mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
feat(agent): add bounded inspection and guarded submission
This commit is contained in:
1 parent
3e06f3c141
commit
a24b0ec7bb
30 files changed
+2462
-36
No files matched your search
@@ -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'
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user