Files
Codeman/test/routes/agent-session-routes.test.ts
T
Aamer Akhter 2832a037d6 fix(agent): verify staged text on a live codex pane and refuse popups
Stage now re-reads the pane after typing (every 50 ms, up to 1 s) and
issues a receipt only for a frame with the same generation, the input
revision its own write moved, and a composer hash equal to the
message's. Codex repaints 30 to 60 ms after the keys, so the first
capture was often stale and stage refused with the text already typed.
Every refusal after typing now says the message may be in the composer
and points at the Ctrl+U recovery. A visible-frame capture reads the
screen and the caret in one tmux invocation, with the old two calls as
the fallback.

Codex draws its suggestion popups above the composer and keeps the
footer, so a staged /stat verified and submit ran /status. Stage now
refuses a leading / or ! and a last word starting with @ or $ before
typing, and stage and submit refuse a frame with popup rows just above
the composer. The pattern and characters are registry data
(composerPopupLine, composerCommandPrefixes, composerCompletionSigils),
read from the codex 0.162.0 bottom_pane sources.

Submit waits until 250 ms after the staged keys: codex turns an Enter
within 120 ms of a typed burst into a newline. POST .../input answers
409 with its seq returned while a guarded submit holds the input lease,
instead of 200 for bytes it never wrote.

Also align the attribution memo comment with the code (a verified file
whose incremental scan fails is not re-walked, since the full walk
reads a superset), and list stage as a fromUser source in CLAUDE.md.
2026-10-25 09:22:35 -04:00

356 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
AGENT_COORDINATION_DISABLED_MESSAGE,
registerAgentSessionRoutes,
} from '../../src/web/routes/agent-session-routes.js';
import { InspectionReader } from '../../src/agent-inspection.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
let harness: RouteTestHarness;
/** The routes are opt-in (`agentCoordinationEnabled`, default OFF); undefined = key absent. */
function setEnabled(on: boolean | undefined): void {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { agentCoordinationEnabled: on }));
}
beforeEach(() => setEnabled(true));
afterEach(async () => {
await harness?.app.close();
rmSync(SETTINGS_PATH, { force: true });
});
describe('the agentCoordinationEnabled gate', () => {
const requests = (id: string) => [
{ method: 'GET' as const, url: `/api/sessions/${id}/inspect` },
{ method: 'GET' as const, url: `/api/sessions/${id}/inspect?maxBytes=0` },
{ method: 'POST' as const, url: `/api/sessions/${id}/stage`, payload: { message: 'hello' } },
{
method: 'POST' as const,
url: `/api/sessions/${id}/submit`,
payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) },
},
];
it.each([
['absent', undefined],
['false', false],
] as const)('answers 403 naming the setting on every route while it is %s', async (_label, value) => {
setEnabled(value);
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
// A session id that does not exist is refused the same way: the gate runs before lookup.
for (const request of [...requests(session.id), ...requests('no-such-session')]) {
const result = await harness.app.inject(request);
expect(result.statusCode).toBe(403);
expect(result.json()).toEqual({
success: false,
error: AGENT_COORDINATION_DISABLED_MESSAGE,
errorCode: 'FORBIDDEN',
});
}
expect(AGENT_COORDINATION_DISABLED_MESSAGE).toContain('agentCoordinationEnabled');
expect(session.writeBuffer).toEqual([]);
});
it('answers on every route once it is on, with no restart', async () => {
setEnabled(false);
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
expect((await harness.app.inject(requests(session.id)[0])).statusCode).toBe(403);
setEnabled(true);
const [inspect, invalid, stage, submit] = await Promise.all(
requests(session.id).map((request) => harness.app.inject(request))
);
expect(inspect.statusCode).toBe(200);
expect(inspect.json().data.source).toBe('pane-snapshot');
expect(invalid.statusCode).toBe(400);
// The mock session has no characterized composer, so the guards refuse: the route answered.
expect(stage.statusCode).toBe(409);
expect(submit.statusCode).toBe(409);
});
});
it('reports a non-cursor inspection failure as OPERATION_FAILED and logs only the session and error code', async () => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { Session } = await import('../../src/session.js');
const root = await mkdtemp(join(tmpdir(), 'agent-inspect-fail-'));
const home = join(root, 'codex');
await mkdir(join(home, 'sessions'), { recursive: true });
const session = new Session({
id: 'inspect-fail',
workingDir: root,
mode: 'codex',
envOverrides: { CODEX_HOME: home },
});
const id = '11111111-1111-4111-8111-111111111111';
const path = join(home, 'sessions', `rollout-${id}.jsonl`);
await writeFile(
path,
JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n'
);
harness = await createRouteTestHarness(registerAgentSessionRoutes);
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
const read = vi
.spyOn(InspectionReader.prototype, 'read')
.mockRejectedValue(Object.assign(new Error(`EACCES: permission denied, open '${path}'`), { code: 'EACCES' }));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` });
expect(read).toHaveBeenCalled();
expect(result.statusCode).toBe(422);
expect(result.json().errorCode).toBe('OPERATION_FAILED');
expect(result.body).not.toContain(path);
const logged = warn.mock.calls.map((call) => call.join(' ')).join('\n');
expect(logged).toContain(session.id);
expect(logged).toContain('EACCES');
expect(logged).not.toContain(home);
} finally {
read.mockRestore();
warn.mockRestore();
await session.stop(false);
await rm(root, { recursive: true, force: true });
}
});
describe('agent session routes', () => {
it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => {
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=20` });
expect(result.statusCode).toBe(200);
const body = result.json();
expect(body.data.source).toBe('pane-snapshot');
expect(body.data.cursor).toBeNull();
expect(Buffer.byteLength(body.data.evidence)).toBeLessThanOrEqual(20);
});
it('refuses guarded staging for an uncharacterized composer', async () => {
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
const result = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/stage`,
payload: { message: 'hello' },
});
expect(result.statusCode).toBe(409);
});
});
it('checks ownership before inspection, stage and submit', async () => {
process.env.CODEMAN_MULTIUSER = '1';
try {
harness = await createRouteTestHarness(registerAgentSessionRoutes, {
authUser: { username: 'alice', role: 'user' },
});
const session = [...harness.ctx.sessions.values()][0];
session.owner = 'bob';
for (const request of [
{ method: 'GET' as const, url: `/api/sessions/${session.id}/inspect` },
{ method: 'POST' as const, url: `/api/sessions/${session.id}/stage`, payload: { message: 'hello' } },
{
method: 'POST' as const,
url: `/api/sessions/${session.id}/submit`,
payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) },
},
])
expect((await harness.app.inject(request)).statusCode).toBe(404);
expect(session.writeBuffer).toEqual([]);
} finally {
delete process.env.CODEMAN_MULTIUSER;
}
});
it('validates output budgets and refuses to resume a pane fallback', async () => {
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
session.terminalBuffer = '😀é'.repeat(20);
for (const value of ['0', '-1', '65537', '1.5', 'NaN'])
expect(
(await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=${value}` })).statusCode
).toBe(400);
const result = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=7` })).json();
expect(result.data.evidence).not.toContain('�');
expect(Buffer.byteLength(result.data.evidence)).toBeLessThanOrEqual(7);
expect(result.data.truncated).toBe(true);
expect(
(
await harness.app.inject({
url: `/api/sessions/${session.id}/inspect?since=11111111-1111-4111-8111-111111111111`,
})
).statusCode
).toBe(409);
});
it('uses real session writes to stage without Enter and refuses intervening API input', async () => {
const { Session } = await import('../../src/session.js');
const { formatPaneSnapshot } = await import('../../src/tmux-manager.js');
harness = await createRouteTestHarness(registerAgentSessionRoutes);
let composer = '';
const writes: string[] = [];
const session = new Session({
id: 'guarded-session',
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 2 + composer.length,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
writes.push(text);
composer += text;
return true;
},
},
muxSession: { muxName: 'codeman-test', sessionId: 'guarded-session', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
try {
const stage = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/stage`,
payload: { message: 'hello' },
});
expect(stage.statusCode).toBe(200);
expect(writes).toEqual(['hello']);
const { stageId, sha256 } = stage.json().data;
const submit = () =>
harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/submit`,
payload: { stageId, confirmSha: sha256 },
});
expect((await submit()).statusCode).toBe(200);
expect(writes).toEqual(['hello', '\r']);
expect((await submit()).statusCode).toBe(409);
composer = '';
const next = (
await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/stage`,
payload: { message: 'again' },
})
).json().data;
await session.writeViaMux('x');
composer = 'again'; // Even restoring the visible text cannot erase the input revision.
const stale = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/submit`,
payload: { stageId: next.stageId, confirmSha: next.sha256 },
});
expect(stale.statusCode).toBe(409);
expect(writes.filter((s) => s === '\r')).toHaveLength(1);
// Codex's command popup opens on `/`; Enter would run the highlighted command.
composer = '';
writes.length = 0;
const slash = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/stage`,
payload: { message: '/stat' },
});
expect(slash.statusCode).toBe(409);
expect(slash.json().error).toMatch(/starting with "\/".*codeman agent send/);
expect(writes).toEqual([]);
} finally {
await session.stop(false);
}
});
it('reads only a strictly attributed Codex rollout and binds HTTP cursors to its owner', async () => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { Session } = await import('../../src/session.js');
const root = await mkdtemp(join(tmpdir(), 'agent-inspect-route-'));
const home = join(root, 'codex');
await mkdir(join(home, 'sessions'), { recursive: true });
const session = new Session({
id: 'inspect-session',
workingDir: root,
mode: 'codex',
envOverrides: { CODEX_HOME: home },
});
const id = '11111111-1111-4111-8111-111111111111';
const row =
JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n';
await writeFile(join(home, 'sessions', `rollout-${id}.jsonl`), row + '{"text":"hello"}\n');
harness = await createRouteTestHarness(registerAgentSessionRoutes, {
authUser: { username: 'alice', role: 'admin' },
});
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
try {
const first = await harness.app.inject({
url: `/api/sessions/${session.id}/inspect?maxBytes=${Buffer.byteLength(row)}`,
});
expect(first.statusCode).toBe(200);
const data = first.json().data;
expect(data.source).toBe('codex-jsonl');
expect(data.evidence).toBe('{\"text\":\"hello\"}\n');
expect(data.omittedBeforeBytes).toBe(Buffer.byteLength(row));
expect(data.identity).toEqual({ sessionId: session.id, mode: 'codex', conversationId: id });
expect(data.cursor).not.toContain(home);
const next = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` });
expect(next.json().data.evidence).toBe('');
const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null };
access.ptyProcess = { resize: () => {} };
session.resize(80, 24);
access.ptyProcess = null;
expect(
(await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode
).toBe(200);
session.owner = 'changed-owner';
expect(
(await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode
).toBe(409);
} finally {
await session.stop(false);
await rm(root, { recursive: true, force: true });
}
});
it('refuses a stale launch-resume cursor once a new managed rollout conflicts with it', async () => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { Session } = await import('../../src/session.js');
const root = await mkdtemp(join(tmpdir(), 'agent-resume-conflict-'));
const home = join(root, 'codex');
const old = '11111111-1111-4111-8111-111111111111',
fresh = '22222222-2222-4222-8222-222222222222';
await mkdir(join(home, 'sessions'), { recursive: true });
const session = new Session({
id: 'resumed-session',
workingDir: root,
mode: 'codex',
envOverrides: { CODEX_HOME: home },
codexConfig: { resumeSessionId: old },
});
const row = (id: string, originator: string) =>
JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator } }) + '\n';
await writeFile(join(home, 'sessions', `rollout-${old}.jsonl`), row(old, 'original-launch'));
harness = await createRouteTestHarness(registerAgentSessionRoutes);
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
try {
const initial = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data;
expect(initial.source).toBe('codex-jsonl');
await writeFile(join(home, 'sessions', `rollout-${fresh}.jsonl`), row(fresh, `codeman_${session.id}`));
const stale = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${initial.cursor}` });
expect(stale.statusCode).toBe(409);
const fallback = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data;
expect(fallback.source).toBe('pane-snapshot');
expect(fallback.cursor).toBeNull();
} finally {
await session.stop(false);
await rm(root, { recursive: true, force: true });
}
});