feat(agent): make coordination routes opt-in and address review

Gate inspect, stage and submit behind the synced agentCoordinationEnabled
setting (default OFF, App Settings > Cross-session features, zh-CN
strings). While off, all three routes answer 403 FORBIDDEN naming the
setting, checked before session lookup; the CLI verbs print it verbatim.

Remember the attributed Codex rollout per session and re-verify only its
head on later calls, rescanning only date directories not older than it
for a /new conflict. The first walk reads 1 KiB prefixes first, so a
store of 2,100+ unrelated rollouts still attributes.

Answer unexpected inspection errors with 422 OPERATION_FAILED and log
the session id and error code only.

Read the guarded composer glyph and footer from the CLI registry: codex
workDetect.promptGlyph and a new workDetect.composerFooterLine
capability, compiled through compileVersionRegex. Codex footer effort
words are one shared list with modelDetect.screenLine.

Fix the wiki API reference link, frame the versioning policy entry as a
case of the experimental rule, join the CLAUDE.md Agent CLI paragraph,
and update the changeset, skill reference and architecture notes.
This commit is contained in:
Aamer Akhter committed 2026-10-24 22:54:17 -04:00
1 parent 1a2c3be0ea
commit c78635a3d3
32 files changed
+848 -124

No files matched your search

+44 -6
View File
@@ -2,6 +2,11 @@
* @fileoverview Agent-facing bounded inspection and guarded stage/submit endpoints.
* Ownership is checked before any disk or pane read. Native evidence comes only
* from strict conversation attribution; all other modes get labeled snapshots.
*
* Experimental and opt-in: all three routes answer `403 FORBIDDEN`, naming the
* setting, until the synced `agentCoordinationEnabled` is on (default OFF), the
* same shape as `cliManagementEnabled` and `mcpSyncEnabled`. The gate runs first,
* before session lookup, so a disabled install reveals nothing about its sessions.
*/
import type { FastifyInstance } from 'fastify';
import type { Session } from '../../session.js';
@@ -10,10 +15,32 @@ import { getCli } from '../../config/cli-registry/registry.js';
import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js';
import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js';
import { GuardedStageStore, GuardedInputConflict, type GuardedInputTarget } from '../../agent-staging.js';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { findSessionOrFail, getAuthUser, parseBody } from '../route-helpers.js';
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
import { findSessionOrFail, getAuthUser, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js';
/** Default OFF, same shape as `readMcpSyncEnabled`: read fresh so a toggle applies at once. */
export async function readAgentCoordinationEnabled(): Promise<boolean> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
return settings.agentCoordinationEnabled === true;
}
/** The refusal every route sends while the setting is off. Exported for the tests. */
export const AGENT_COORDINATION_DISABLED_MESSAGE =
'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.';
async function gate(): Promise<ApiResponse<never> | null> {
if (await readAgentCoordinationEnabled()) return null;
return createErrorResponse(ApiErrorCode.FORBIDDEN, AGENT_COORDINATION_DISABLED_MESSAGE);
}
/** A Node error code (`EACCES`, `EIO`, ...) or the error's class name; never its message. */
export function errorCode(error: unknown): string {
const code = (error as { code?: unknown } | null)?.code;
if (typeof code === 'string' && /^[A-Z0-9_]{1,40}$/.test(code)) return code;
return error instanceof Error ? error.constructor.name : 'unknown';
}
export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void {
const reader = new InspectionReader();
const stages = new GuardedStageStore();
@@ -33,6 +60,8 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor
submit: (expected) => session.submitGuardedInput(expected),
});
app.get('/api/sessions/:id/inspect', async (req, reply) => {
const refused = await gate();
if (refused) return reply.code(403).send(refused);
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const { maxBytes, since } = parseBody(AgentInspectQuerySchema, req.query);
@@ -99,19 +128,24 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor
} catch (error) {
if (error instanceof InspectionConflict)
return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, error.message));
// Native paths and arbitrary IO errors must never escape into the response.
return reply
.code(409)
.send(createErrorResponse(ApiErrorCode.CONFLICT, 'Inspection source unavailable; retry without a cursor'));
// Anything else (an unreadable rollout, an IO error) is not a retryable cursor
// conflict. Log the session and the error code only: the message can carry the
// native transcript path, which must never reach the log or the response.
console.warn(`[agent-inspect] session ${session.id}: inspection failed (${errorCode(error)})`);
return reply.code(422).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Inspection source unavailable'));
}
});
app.post('/api/sessions/:id/stage', async (req, reply) => {
const refused = await gate();
if (refused) return reply.code(403).send(refused);
const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req);
const { message } = parseBody(AgentStageSchema, req.body);
try {
const receipt = await stages.stage(target(session), getAuthUser(req).username, message);
return { success: true, data: receipt };
} catch (error) {
if (!(error instanceof GuardedInputConflict))
console.warn(`[agent-stage] session ${session.id}: delivery failed (${errorCode(error)})`);
return reply
.code(409)
.send(
@@ -125,6 +159,8 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor
}
});
app.post('/api/sessions/:id/submit', async (req, reply) => {
const refused = await gate();
if (refused) return reply.code(403).send(refused);
const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req);
const { stageId, confirmSha } = parseBody(AgentSubmitSchema, req.body);
try {
@@ -133,6 +169,8 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor
data: await stages.submit(target(session), getAuthUser(req).username, stageId, confirmSha),
};
} catch (error) {
if (!(error instanceof GuardedInputConflict))
console.warn(`[agent-submit] session ${session.id}: delivery failed (${errorCode(error)})`);
return reply
.code(409)
.send(