feat(agent): add bounded inspection and guarded submission

This commit is contained in:
Aamer Akhter committed 2026-10-24 22:37:06 -04:00
1 parent 3e06f3c141
commit a24b0ec7bb
30 files changed
+2462 -36

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
/**
* @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.
*/
import type { FastifyInstance } from 'fastify';
import type { Session } from '../../session.js';
import type { SessionPort, InfraPort } from '../ports/index.js';
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 { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js';
export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void {
const reader = new InspectionReader();
const stages = new GuardedStageStore();
app.addHook('onClose', async () => {
reader.clear();
stages.clear();
});
const generation = (session: Session) => session.inspectionGeneration;
const target = (session: Session): GuardedInputTarget => ({
id: session.id,
snapshot: () => {
if (ctx.sessions.get(session.id) !== session) return null;
const snapshot = session.guardedInputSnapshot?.();
return snapshot ? { ...snapshot, generation: generation(session) } : null;
},
write: (text) => session.stageGuardedInput(text),
submit: (expected) => session.submitGuardedInput(expected),
});
app.get('/api/sessions/:id/inspect', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const { maxBytes, since } = parseBody(AgentInspectQuerySchema, req.query);
const initialGeneration = generation(session);
const identity = { sessionId: session.id, mode: session.mode };
const activity = { status: session.status, lastSubmitAt: session.lastSubmitAt || null, observedAt: Date.now() };
try {
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout' && !session.remote && !session.docker) {
const conversation = await findExactCodexConversation(
{
codexHome: resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir),
sessionId: session.id,
workingDir: session.workingDir,
resumeSessionId: session.codexConfig?.resumeSessionId,
},
{ maxEntries: 8192, maxBytes: 1048576 }
);
if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session)
throw new InspectionConflict('Session changed during inspection');
if (conversation) {
const result = await reader.read(
conversation.path,
{
owner: getAuthUser(req).username,
sessionId: id,
generation: initialGeneration,
conversationId: conversation.id,
},
maxBytes,
since
);
if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session)
throw new InspectionConflict('Session changed during inspection');
return {
success: true,
data: {
source: 'codex-jsonl',
identity: { ...identity, conversationId: conversation.id },
activity,
incremental: true,
maxBytes,
...result,
},
};
}
}
if (since)
throw new InspectionConflict('Attributed transcript unavailable; this cursor cannot resume a pane snapshot');
const frame = session.muxName ? ctx.mux.capturePaneText?.(session.muxName) : null;
const evidence = frame ?? session.terminalBuffer;
return {
success: true,
data: {
source: 'pane-snapshot',
identity,
activity,
incremental: false,
cursor: null,
maxBytes,
evidence: boundedUtf8Tail(evidence, maxBytes),
truncated: evidence.length > maxBytes || Buffer.byteLength(evidence) > maxBytes,
},
};
} 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'));
}
});
app.post('/api/sessions/:id/stage', async (req, reply) => {
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) {
return reply
.code(409)
.send(
createErrorResponse(
ApiErrorCode.CONFLICT,
error instanceof GuardedInputConflict
? error.message
: 'Stage delivery failed; inspect the pane before retrying'
)
);
}
});
app.post('/api/sessions/:id/submit', async (req, reply) => {
const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req);
const { stageId, confirmSha } = parseBody(AgentSubmitSchema, req.body);
try {
return {
success: true,
data: await stages.submit(target(session), getAuthUser(req).username, stageId, confirmSha),
};
} catch (error) {
return reply
.code(409)
.send(
createErrorResponse(
ApiErrorCode.CONFLICT,
error instanceof GuardedInputConflict
? error.message
: 'Submit delivery failed or ambiguous; receipt consumed, inspect the pane'
)
);
}
});
}
+17 -11
View File
@@ -5,6 +5,7 @@
*/
import { claudeProjectDirName } from '../../case-rename.js';
import { registerAgentSessionRoutes } from './agent-session-routes.js';
import { FastifyInstance, type FastifyRequest } from 'fastify';
import { z } from 'zod';
import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path';
@@ -1072,6 +1073,8 @@ export function registerSessionRoutes(
// ========== Logout ==========
registerAgentSessionRoutes(app, ctx);
app.post('/api/logout', async (req, reply) => {
// Invalidate server-side session token (not just the browser cookie)
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
@@ -2428,17 +2431,20 @@ export function registerSessionRoutes(
try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
await new Promise<void>((resolve, reject) => {
execFile(
'tmux',
['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
{ timeout: 5000 },
(err) => {
if (err) reject(err);
else resolve();
}
);
});
await session.performAuxiliaryInput(
() =>
new Promise<void>((resolve, reject) => {
execFile(
'tmux',
['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
{ timeout: 5000 },
(err) => {
if (err) reject(err);
else resolve();
}
);
})
);
} catch (err) {
console.error('[Server] send-key failed:', err);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
+12
View File
@@ -2410,3 +2410,15 @@ export const CustomModelSelectionSchema = z.union([
}),
z.object({ clear: z.literal(true) }),
]);
/** Agent inspection bounds are bytes of evidence, independent of the JSON envelope. */
export const AgentInspectQuerySchema = z
.object({
maxBytes: z.coerce.number().int().min(1).max(65536).default(8192),
since: z.string().uuid().optional(),
})
.strict();
export const AgentStageSchema = z.object({ message: z.string().min(1).max(8192) }).strict();
export const AgentSubmitSchema = z
.object({ stageId: z.string().uuid(), confirmSha: z.string().regex(/^[a-f0-9]{64}$/) })
.strict();