mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +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
@@ -31,6 +31,10 @@
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { open, stat } from 'node:fs/promises';
|
||||
import { isUtf8 } from 'node:buffer';
|
||||
import { STAGE_MAX_BYTES } from './agent-staging.js';
|
||||
import { INSPECT_MAX_BYTES } from './agent-inspection.js';
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
basicAuthHeader,
|
||||
@@ -757,6 +761,103 @@ export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise<
|
||||
return waitExitCode(data?.wait);
|
||||
}
|
||||
|
||||
/** Bounded incremental evidence, with an opaque server-owned cursor. */
|
||||
export async function agentInspect(
|
||||
deps: AgentDeps,
|
||||
options: { id: string; maxBytes: number; since?: string }
|
||||
): Promise<number> {
|
||||
if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1 || options.maxBytes > INSPECT_MAX_BYTES)
|
||||
return fail(deps, '--max-bytes must be between 1 and 65536', EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const response = await deps.request(deps.ctx, {
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inspect`,
|
||||
query: { maxBytes: options.maxBytes, since: options.since },
|
||||
});
|
||||
if (!response.json?.success) return fail(deps, describeFailure(response));
|
||||
if (deps.json) emitJson(deps, response.json.data);
|
||||
else {
|
||||
const data = response.json.data as {
|
||||
source?: string;
|
||||
evidence?: string;
|
||||
truncated?: boolean;
|
||||
cursor?: string;
|
||||
blockedReason?: string;
|
||||
};
|
||||
deps.io.out(
|
||||
`[${data.source ?? 'unknown'}]${data.truncated ? ' truncated' : ''}${data.blockedReason ? ` (${data.blockedReason})` : ''}`
|
||||
);
|
||||
deps.io.out(data.evidence ?? '');
|
||||
if (data.cursor) deps.io.out(`cursor: ${data.cursor}`);
|
||||
}
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** Stage exactly the printable UTF8 file contents; never sends Enter. */
|
||||
export async function agentStage(deps: AgentDeps, options: { id: string; messageFile: string }): Promise<number> {
|
||||
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
|
||||
let message: string;
|
||||
try {
|
||||
const info = await stat(options.messageFile);
|
||||
if (!info.isFile() || info.size > STAGE_MAX_BYTES)
|
||||
return fail(deps, 'message file must be a regular file within 8192 UTF8 bytes', EXIT.refused);
|
||||
const handle = await open(options.messageFile, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(STAGE_MAX_BYTES + 1);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
const bytes = buffer.subarray(0, bytesRead);
|
||||
if (bytesRead > STAGE_MAX_BYTES || !isUtf8(bytes))
|
||||
return fail(deps, 'message file must contain at most 8192 bytes of UTF8', EXIT.refused);
|
||||
message = bytes.toString('utf8');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
return fail(deps, `cannot read message file: ${getErrorMessage(error)}`, EXIT.refused);
|
||||
}
|
||||
const refusal = inputRefusal(message);
|
||||
if (refusal || message !== message.trimEnd())
|
||||
return fail(deps, refusal ?? 'stage refuses trailing whitespace', EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const response = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/stage`,
|
||||
body: { message },
|
||||
});
|
||||
if (!response.json?.success) return fail(deps, describeFailure(response));
|
||||
if (deps.json) emitJson(deps, response.json.data);
|
||||
else {
|
||||
const data = response.json.data as { stageId: string; sha256: string; expiresAt: number };
|
||||
deps.io.out(
|
||||
`staged (no Enter): ${data.stageId}\nsha256: ${data.sha256}\nexpires: ${new Date(data.expiresAt).toISOString()}`
|
||||
);
|
||||
}
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** Submit one verified staged composer; failure never automatically replays Enter. */
|
||||
export async function agentSubmit(
|
||||
deps: AgentDeps,
|
||||
options: { id: string; stageId: string; confirmSha: string }
|
||||
): Promise<number> {
|
||||
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
|
||||
if (!/^[a-f0-9]{64}$/.test(options.confirmSha) || !/^[a-f0-9-]{36}$/.test(options.stageId))
|
||||
return fail(deps, 'submit requires a stage UUID and its exact SHA-256', EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const response = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/submit`,
|
||||
body: { stageId: options.stageId, confirmSha: options.confirmSha },
|
||||
});
|
||||
if (!response.json?.success) return fail(deps, describeFailure(response));
|
||||
if (deps.json) emitJson(deps, response.json.data);
|
||||
else deps.io.out('Enter delivered once; inspect or wait for acceptance (no automatic retry).');
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
export interface ReadOptions {
|
||||
id: string;
|
||||
/** Bytes of raw terminal to fetch; ANSI is stripped for humans. */
|
||||
@@ -1169,6 +1270,43 @@ export function registerAgentCommands(program: Command): Command {
|
||||
)
|
||||
);
|
||||
|
||||
agent
|
||||
.command('inspect <id>')
|
||||
.description('Bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors')
|
||||
.option('--max-bytes <n>', 'UTF8 evidence budget (1–65536)', '8192')
|
||||
.option('--since <cursor>', 'Resume a cursor from the same owner, pane and transcript')
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((id: string, options: { maxBytes: string; since?: string; json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) =>
|
||||
agentInspect(deps, {
|
||||
id,
|
||||
maxBytes: parsePositiveInt(options.maxBytes, 8192, '--max-bytes'),
|
||||
since: options.since,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
agent
|
||||
.command('stage <id>')
|
||||
.description('Type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer')
|
||||
.requiredOption('--message-file <path>', 'Regular UTF8 file, at most 8192 bytes, no trailing newline')
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((id: string, options: { messageFile: string; json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) => agentStage(deps, { id, messageFile: options.messageFile }))
|
||||
);
|
||||
|
||||
agent
|
||||
.command('submit <id>')
|
||||
.description('Send Enter once only when the staged composer, pane and hash still match')
|
||||
.requiredOption('--stage-id <receipt>', 'One-shot stage receipt')
|
||||
.requiredOption('--confirm-sha <hash>', 'Exact SHA-256 returned by stage')
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((id: string, options: { stageId: string; confirmSha: string; json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) =>
|
||||
agentSubmit(deps, { id, stageId: options.stageId, confirmSha: options.confirmSha })
|
||||
)
|
||||
);
|
||||
|
||||
agent
|
||||
.command('read <id>')
|
||||
.description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal")
|
||||
|
||||
Reference in new issue
Block a user