Merge pull request #694: feat(agent): COD-529 add bounded inspection and guarded submission

Conflicts resolved by keeping both sides:
- CLAUDE.md (Agent CLI): #694's opening (an HTTP client, the experimental
  agent-session-routes behind agentCoordinationEnabled) with #708's clause
  on the directory spawn and its two refusals.
- docs/api-reference.md (codeman agent): #708's spawn paragraph and --json
  shape, with #694's inspect/stage/submit in the verb list and the guarded
  endpoints named before "adds no route for the other verbs".
- src/cli-agent.ts (@fileoverview): #708's quick-start / directory spawn
  routes and #694's experimental routes in one endpoint list; the two
  node:fs/promises imports (#708's realpath+stat, #694's open+stat) merged
  into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-27 02:14:44 +01:00
commit 5908cc51fd
50 files changed
+5408 -116

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
/** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
__clearCodexAttributionMemo,
findExactCodexConversation,
resolveEffectiveCodexHome,
} from '../src/agent-codex-identity.js';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
const A = '11111111-1111-4111-8111-111111111111';
const B = '22222222-2222-4222-8222-222222222222';
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'agent-attribution-'));
roots.push(root);
const home = join(root, 'home');
await mkdir(join(home, 'sessions'), { recursive: true });
const options = { codexHome: home, sessionId: 'managed', workingDir: root };
const put = async (id: string, payload: object = {}, text?: string) => {
const path = join(home, 'sessions', `rollout-${id}.jsonl`);
await writeFile(
path,
text ??
JSON.stringify({
type: 'session_meta',
payload: { id, cwd: root, originator: 'codeman_managed', ...payload },
}) + '\n'
);
return path;
};
return { root, home, options, put };
}
it('requires one complete native session_meta matching the rollout ID, managed originator and canonical workspace', async () => {
const f = await fixture();
const path = await f.put(A);
expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path });
await f.put(A, { id: B });
expect(await findExactCodexConversation(f.options)).toBeUndefined();
});
it('never chooses newest cwd files or an ambiguous managed originator', async () => {
const f = await fixture();
await f.put(A, { originator: 'codex_cli' });
expect(await findExactCodexConversation(f.options)).toBeUndefined();
await f.put(A);
await f.put(B);
expect(await findExactCodexConversation(f.options)).toBeUndefined();
});
it('allows an explicit resume identity but still requires native metadata and the workspace', async () => {
const f = await fixture();
const path = await f.put(A, { originator: 'original-launch' });
expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toEqual({ id: A, path });
await f.put(B);
expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined();
await f.put(A, { cwd: f.home });
expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined();
});
it.each(['partial', 'duplicate', 'oversized'] as const)('refuses %s metadata', async (kind) => {
const f = await fixture();
const row = JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } });
await f.put(
A,
{},
kind === 'partial'
? row
: kind === 'duplicate'
? row + '\n' + row + '\n'
: row.slice(0, -2) + ',"instructions":"' + 'x'.repeat(140000) + '"}}\n'
);
expect(await findExactCodexConversation(f.options)).toBeUndefined();
});
it('enforces traversal/read caps and ignores symlinked directories', async () => {
const f = await fixture();
await f.put(A);
expect(await findExactCodexConversation(f.options, { maxEntries: 0, maxBytes: 1048576 })).toBeUndefined();
expect(await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 128 })).toBeUndefined();
await mkdir(join(f.root, 'elsewhere'));
await symlink(join(f.root, 'elsewhere'), join(f.home, 'sessions', 'alias'));
expect((await findExactCodexConversation(f.options))?.id).toBe(A);
});
it('resolves per-session, process and default CODEX_HOME relative to the session workspace', () => {
expect(resolveEffectiveCodexHome('custom', '/tmp/work', { CODEX_HOME: '/other' })).toBe('/tmp/work/custom');
expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { CODEX_HOME: 'process' })).toBe('/tmp/work/process');
expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { HOME: '/tmp/user' })).toBe('/tmp/user/.codex');
});
it.each([
['trailing-space', 'codex-home ', 'codex-home'],
['whitespace-only', ' ', '.codex'],
])(
'keeps the raw %s CODEX_HOME namespace through the Session getter and attribution helper',
async (_label, configured, wrongHome) => {
const { Session } = await import('../src/session.js');
const f = await fixture();
const expectedHome = join(f.root, configured);
const siblingHome = join(f.root, wrongHome);
const record =
JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } }) + '\n';
for (const home of [expectedHome, siblingHome]) {
await mkdir(join(home, 'sessions'), { recursive: true });
await writeFile(join(home, 'sessions', `rollout-${A}.jsonl`), record);
}
const session = new Session({
id: 'managed',
workingDir: f.root,
mode: 'codex',
envOverrides: { CODEX_HOME: configured },
});
try {
const codexHome = resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir, { HOME: f.root });
expect(await findExactCodexConversation({ ...f.options, codexHome })).toEqual({
id: A,
path: join(expectedHome, 'sessions', `rollout-${A}.jsonl`),
});
expect(session.getCodexHomeOverride()).toBe(configured);
} finally {
await session.stop(false);
}
}
);
it('attributes an owned rollout among 320 unrelated full-size metadata prefixes', async () => {
const f = await fixture();
const path = await f.put(A);
await Promise.all(
Array.from({ length: 320 }, (_, n) => {
const id = `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`;
return f.put(id, { originator: 'codex_cli', instructions: 'x'.repeat(5000) });
})
);
expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path });
expect(
await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 1048576, maxPrefixBytes: 4096 })
).toBeUndefined();
});
it.each(['workspace', 'metadata'] as const)(
'probes %s bounded past the cap before canonical filesystem reads',
async (where) => {
const { vi } = await import('vitest');
const probe = await import('../src/utils/bounded-path-probe.js');
const f = await fixture();
const alias = join(f.root, 'workspace-alias');
await symlink(f.root, alias);
await f.put(A, { cwd: where === 'metadata' ? alias : f.root });
const blocked = where === 'workspace' ? f.root : alias;
const spy = vi
.spyOn(probe, 'probePathKind')
.mockImplementation(async (path) => (path === blocked ? 'unknown' : 'directory'));
try {
expect(await findExactCodexConversation(f.options)).toBeUndefined();
expect(spy).toHaveBeenCalledWith(blocked, { pastCap: true });
} finally {
spy.mockRestore();
}
}
);
describe('remembered attribution on a large, never-pruned store', () => {
beforeEach(() => __clearCodexAttributionMemo());
const OWNED = '33333333-3333-4333-8333-333333333333';
const NEWER = '44444444-4444-4444-8444-444444444444';
/** codex's own layout: sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl */
async function dated(f: Awaited<ReturnType<typeof fixture>>, day: string, id: string, payload: object = {}) {
const dir = join(f.home, 'sessions', ...day.split('/'));
await mkdir(dir, { recursive: true });
const path = join(dir, `rollout-${day.replaceAll('/', '-')}T00-00-00-${id}.jsonl`);
await writeFile(
path,
JSON.stringify({
type: 'session_meta',
payload: { id, cwd: f.root, originator: 'codeman_managed', ...payload },
}) + '\n'
);
return path;
}
/** `count` unrelated 19 KiB rollouts (a real session_meta size) spread over older days. */
async function unrelated(f: Awaited<ReturnType<typeof fixture>>, count: number) {
const instructions = 'x'.repeat(19 * 1024);
for (let start = 0; start < count; start += 200) {
await Promise.all(
Array.from({ length: Math.min(200, count - start) }, (_, k) => {
const n = start + k;
const day = `2026/${String(1 + (n % 9)).padStart(2, '0')}/${String(1 + (n % 28)).padStart(2, '0')}`;
return dated(f, day, `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`, {
originator: 'codex_cli',
instructions,
});
})
);
}
}
const small = { maxEntries: 20, maxBytes: 1048576 };
it('attributes one owned rollout beside 2,100 unrelated full-size rollouts', async () => {
const f = await fixture();
await unrelated(f, 2100);
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
}, 60_000);
it('re-verifies only the remembered file on a later call instead of re-walking the store', async () => {
const f = await fixture();
await unrelated(f, 300);
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
// 20 entries cannot cover 300 rollouts in 9 months of day directories: only the
// remembered head plus the directories not older than it fit.
expect(await findExactCodexConversation(f.options, small)).toEqual({ id: OWNED, path });
__clearCodexAttributionMemo();
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
});
it('still catches a newer owned rollout (codex /new) after remembering one', async () => {
const f = await fixture();
await unrelated(f, 50);
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
await dated(f, '2026/10/25', NEWER);
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
expect(await findExactCodexConversation(f.options)).toBeUndefined();
});
it('answers unattributed, without a full walk, when the remembered file verifies but its incremental scan runs out', async () => {
const f = await fixture();
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
// 30 rollouts in the remembered day itself: the incremental scan reads them all,
// so 20 entries cannot cover it, and a full walk (a superset) could not either.
await Promise.all(
Array.from({ length: 30 }, (_, n) =>
dated(f, '2026/10/24', `20000000-2222-4222-8222-${String(n).padStart(12, '0')}`, { originator: 'codex_cli' })
)
);
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
// The memo was dropped, so a call with room walks the store and attributes again.
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
});
it('drops the remembered file when it is replaced, and walks again', async () => {
const f = await fixture();
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
await rm(path);
await dated(f, '2026/10/24', OWNED, { originator: 'codex_cli' });
expect(await findExactCodexConversation(f.options)).toBeUndefined();
const moved = await dated(f, '2026/10/23', NEWER);
expect(await findExactCodexConversation(f.options)).toEqual({ id: NEWER, path: moved });
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* @fileoverview The synced `agentCoordinationEnabled` setting (default OFF) that gates the
* experimental inspect/stage/submit routes: the strict settings schema admits it, App
* Settings loads and saves it under the same control id, and its row reads in Chinese
* (zh-CN) with no English left beyond the command and product names. The route gate itself
* is pinned in test/routes/agent-session-routes.test.ts.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const SETTINGS_UI = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8');
const ID = 'appSettingsAgentCoordination';
type Api = { configure(o: Record<string, string>): void; start(): void };
/** What may stay Latin: the product, CLI, key and protocol names and the three commands. */
const leftover = (text: string) =>
text.replace(/\b(Codex|Enter|HTTP|inspect|stage|submit)\b/g, '').match(/[A-Za-z]+/g) ?? [];
function rowStrings(doc: Document): { label: string; description: string } {
const row = doc.getElementById(ID)!.closest('.set-row')!;
return {
label: row.querySelector('.set-row-label')!.textContent!.trim(),
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
};
}
const source = new JSDOM(INDEX);
const english = rowStrings(source.window.document);
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
const chinese = rowStrings(dom.window.document);
afterAll(() => {
source.window.close();
dom.window.close();
});
describe('agentCoordinationEnabled', () => {
it('is a boolean the strict settings schema accepts, and nothing else', () => {
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: true }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: false }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: 'yes' }).success).toBe(false);
});
it('has one checkbox in App Settings, loaded only from an explicit true and saved by id', () => {
expect(INDEX.split(`id="${ID}"`)).toHaveLength(2);
expect(source.window.document.getElementById(ID)!.getAttribute('type')).toBe('checkbox');
expect(SETTINGS_UI).toContain(
`document.getElementById('${ID}').checked = settings.agentCoordinationEnabled === true;`
);
expect(SETTINGS_UI).toContain(`agentCoordinationEnabled: document.getElementById('${ID}').checked,`);
});
it('sits in the synced Cross-session features group', () => {
const group = source.window.document.getElementById(ID)!.closest('.set-group')!;
expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Cross-session features');
expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('synced');
});
it('finds the row (the translation check is not vacuous)', () => {
expect(english.label).toBe('Experimental agent coordination');
expect(english.description).toMatch(/^Lets agents read another session's output/);
});
it('translates the label and the description, with no English left', () => {
for (const key of ['label', 'description'] as const) {
expect(chinese[key]).not.toBe(english[key]);
expect(leftover(chinese[key])).toEqual([]);
}
});
it('adds each key to the dictionary once', () => {
for (const text of [english.label, english.description]) {
expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1);
}
});
});
+202
View File
@@ -0,0 +1,202 @@
/** @fileoverview Bounded JSONL inspection: cursors, byte budgets and file generations. */
import { afterEach, beforeEach, expect, it } from 'vitest';
import { mkdtemp, writeFile, appendFile, rename, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { INSPECT_CURSORS_PER_SCOPE, InspectionReader } from '../src/agent-inspection.js';
let dir: string;
let file: string;
let reader: InspectionReader;
const scope = { owner: 'alice', sessionId: 's', generation: 'pane-1', conversationId: 'thread-1' };
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'inspect-'));
file = join(dir, 'rollout.jsonl');
reader = new InspectionReader();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('returns complete UTF8 records within the byte budget and resumes exactly', async () => {
const row = JSON.stringify({ text: 'é😀' }) + '\n';
await writeFile(file, row + row);
const first = await reader.read(file, scope, Buffer.byteLength(row));
expect(first.evidence).toBe(row);
expect(first.truncated).toBe(true);
await appendFile(file, row);
const next = await reader.read(file, scope, 1024, first.cursor);
expect(next.evidence).toBe(row);
expect(next.truncated).toBe(false);
});
it('does not consume partial final records', async () => {
await writeFile(file, '{"text":"hello');
const first = await reader.read(file, scope, 100);
expect(first.evidence).toBe('');
await appendFile(file, '"}\n');
const next = await reader.read(file, scope, 100, first.cursor);
expect(next.evidence).toBe('{"text":"hello"}\n');
});
it('reports an oversized incremental record without splitting it or advancing past it', async () => {
await writeFile(file, '{}\n');
const initial = await reader.read(file, scope, 100);
await appendFile(file, JSON.stringify({ text: 'x'.repeat(300000) }) + '\n');
const result = await reader.read(file, scope, 100, initial.cursor);
expect(result.evidence).toBe('');
expect(result.truncated).toBe(true);
expect(result.blockedReason).toBe('record-exceeds-scan-limit');
expect(result.scannedBytes).toBeLessThanOrEqual(262144);
});
it('rejects malformed, cross-owner and changed-session cursors', async () => {
await writeFile(file, '{}\n');
const first = await reader.read(file, scope, 100);
for (const changed of [
{ ...scope, owner: 'bob' },
{ ...scope, sessionId: 'other' },
{ ...scope, generation: 'new' },
{ ...scope, conversationId: 'new' },
]) {
await expect(reader.read(file, changed, 100, first.cursor)).rejects.toThrow(/cursor/i);
}
await expect(reader.read(file, scope, 100, 'garbage')).rejects.toThrow(/cursor/i);
});
it('rejects truncation, replacement and rewritten consumed evidence', async () => {
await writeFile(file, '{}\n{}\n');
const first = await reader.read(file, scope, 100);
await writeFile(file, '{}\n');
await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i);
await writeFile(file, '{}\n{}\n');
const second = await reader.read(file, scope, 100);
await writeFile(file, '[]\n[]\n');
await expect(reader.read(file, scope, 100, second.cursor)).rejects.toThrow(/cursor/i);
const third = await reader.read(file, scope, 100);
await rename(file, join(dir, 'old'));
await writeFile(file, '[]\n[]\n');
await expect(reader.read(file, scope, 100, third.cursor)).rejects.toThrow(/cursor/i);
});
it('expires cursors and bounds retained state', async () => {
await writeFile(file, '{}\n');
let now = 0;
reader = new InspectionReader(() => now, 2, 1000);
const first = await reader.read(file, scope, 100);
await reader.read(file, scope, 100);
await reader.read(file, scope, 100);
await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i);
const last = await reader.read(file, scope, 100);
now = 1001;
await expect(reader.read(file, scope, 100, last.cursor)).rejects.toThrow(/cursor/i);
});
it('bounds strict attribution itself and refuses an incomplete inventory', async () => {
const { mkdir } = await import('node:fs/promises');
const { findExactCodexConversation } = await import('../src/agent-codex-identity.js');
const home = join(dir, 'codex');
const sessions = join(home, 'sessions');
await mkdir(sessions, { recursive: true });
const id = '11111111-1111-4111-8111-111111111111';
await writeFile(
join(sessions, `rollout-${id}.jsonl`),
JSON.stringify({ type: 'session_meta', payload: { id, cwd: dir, originator: 'codeman_s' } }) + '\n'
);
await writeFile(join(sessions, 'other.txt'), 'other');
const options = { codexHome: home, sessionId: 's', workingDir: dir };
expect((await findExactCodexConversation(options))?.id).toBe(id);
expect(await findExactCodexConversation(options, { maxEntries: 1, maxBytes: 16384 })).toBeUndefined();
});
it('starts at recent complete records past oversized bootstrap evidence, labeling all omission', async () => {
const old = JSON.stringify({ system: 'x'.repeat(300000) }) + '\n';
const latest = JSON.stringify({ assistant: 'latest 😀' }) + '\n';
await writeFile(file, old + latest);
const result = await reader.read(file, scope, Buffer.byteLength(latest));
expect(result.evidence).toBe(latest);
expect(result.omittedBeforeBytes).toBe(Buffer.byteLength(old));
expect(result.truncated).toBe(true);
await appendFile(file, '{"assistant":"next"}\n');
expect((await reader.read(file, scope, 100, result.cursor)).evidence).toBe('{"assistant":"next"}\n');
});
it('attributes a real-sized session_meta row rather than requiring a tiny header', async () => {
const { mkdir } = await import('node:fs/promises');
const { findExactCodexConversation } = await import('../src/agent-codex-identity.js');
const home = join(dir, 'codex');
await mkdir(join(home, 'sessions'), { recursive: true });
const id = '11111111-1111-4111-8111-111111111111';
await writeFile(
join(home, 'sessions', `rollout-${id}.jsonl`),
JSON.stringify({
type: 'session_meta',
payload: { id, cwd: dir, originator: 'codeman_s', instructions: 'x'.repeat(20000) },
}) + '\n'
);
expect(
(
await findExactCodexConversation(
{ codexHome: home, sessionId: 's', workingDir: dir },
{ maxEntries: 8192, maxBytes: 1048576 }
)
)?.id
).toBe(id);
});
it('rejects same-size rewrites outside the bounded cursor anchors', async () => {
const { utimes } = await import('node:fs/promises');
const row = JSON.stringify({ text: 'a'.repeat(20000) }) + '\n';
await writeFile(file, row);
await utimes(file, 1, 1);
const first = await reader.read(file, scope, 30000);
await writeFile(file, row.slice(0, 10000) + 'b' + row.slice(10001));
await expect(reader.read(file, scope, 30000, first.cursor)).rejects.toThrow(/cursor/i);
});
it('keeps cursor capacity bounded across concurrent reads', async () => {
await writeFile(file, '{}\n');
reader = new InspectionReader(Date.now, 2);
const results = await Promise.all(Array.from({ length: 16 }, () => reader.read(file, scope, 100)));
const resumed = await Promise.allSettled(results.map((result) => reader.read(file, scope, 100, result.cursor)));
expect(resumed.filter((result) => result.status === 'fulfilled').length).toBeLessThanOrEqual(2);
});
it('caps cursors per owner and session, so one polling scope cannot evict another', async () => {
await writeFile(file, '{}\n');
const other = { ...scope, owner: 'bob', sessionId: 't' };
const bobs = await reader.read(file, other, 100);
// Alice polls every second for well past the old global FIFO of 1,024.
let last = await reader.read(file, scope, 100);
for (let i = 0; i < 1100; i++) last = await reader.read(file, scope, 100, last.cursor);
// Bob's cursor survives, and so does a retry of Alice's last call.
await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toMatchObject({ evidence: '' });
await expect(reader.read(file, scope, 100, last.cursor)).resolves.toMatchObject({ evidence: '' });
// Alice keeps only her newest cursors: the oldest of one more than the cap is retired.
const issued: string[] = [];
for (let i = 0; i <= INSPECT_CURSORS_PER_SCOPE; i++) issued.push((await reader.read(file, scope, 100)).cursor);
await expect(reader.read(file, scope, 100, issued[0])).rejects.toThrow(/cursor/i);
await expect(reader.read(file, scope, 100, issued[issued.length - 1])).resolves.toBeDefined();
// The cap counts owner and session: a new pane generation of Alice's session shares
// it, Bob's session does not.
const nextPane = { ...scope, generation: 'pane-2' };
for (let i = 0; i < INSPECT_CURSORS_PER_SCOPE; i++) await reader.read(file, nextPane, 100);
await expect(reader.read(file, scope, 100, issued[issued.length - 1])).rejects.toThrow(/cursor/i);
await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toBeDefined();
});
it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete', async () => {
const row = Buffer.from('{"text":"😀"}\n');
const split = row.indexOf(Buffer.from('😀')) + 2;
await writeFile(file, row.subarray(0, split));
const first = await reader.read(file, scope, 100);
expect(first.evidence).toBe('');
await appendFile(file, row.subarray(split));
expect((await reader.read(file, scope, 100, first.cursor)).evidence).toBe(row.toString('utf8'));
});
it('treats a full incremental page as pageable rather than a blocked oversized record', async () => {
await writeFile(file, '{}\n');
const initial = await reader.read(file, scope, 100);
await appendFile(file, '{}\n{}\n');
const page = await reader.read(file, scope, 3, initial.cursor);
expect(page.evidence).toBe('{}\n');
expect(page.truncated).toBe(true);
expect(page.blockedReason).toBeUndefined();
const next = await reader.read(file, scope, 3, page.cursor);
expect(next.evidence).toBe('{}\n');
});
it('still labels the first selected oversized tail record after omitted older bytes', async () => {
await writeFile(file, '{}\n' + JSON.stringify({ text: 'x'.repeat(100) }) + '\n');
const page = await reader.read(file, scope, 10);
expect(page.evidence).toBe('');
expect(page.blockedReason).toBe('record-exceeds-output-budget');
});
+544
View File
@@ -0,0 +1,544 @@
/** @fileoverview Guarded input observes all production write and lifecycle primitives. */
import { expect, it, vi } from 'vitest';
import { formatPaneSnapshot } from '../src/tmux-manager.js';
import { Session } from '../src/session.js';
import { promptSha256 } from '../src/agent-staging.js';
it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => {
let screen = '› \n GPT-6-Luna default · ~/case\n';
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(screen.trimEnd().split('\n'), {
cols: 100,
rows: 20,
cursorX: screen.startsWith('› hello') ? 7 : 2,
cursorY: 0,
});
},
sendInput: async () => true,
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const before = session.guardedInputSnapshot();
expect(before?.composerSha).toBe(promptSha256(''));
await session.writeViaMux('hello');
screen = '› hello\n GPT-6-Luna default · ~/case\n';
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 1);
session.trackUserInput('x');
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 2);
await session.performAuxiliaryInput(async () => {});
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 3);
const generation = session.inspectionGeneration;
await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle(
async () => {
expect(session.guardedInputSnapshot()).toBeNull();
}
);
expect(session.inspectionGeneration).not.toBe(generation);
session.markPromptSubmitted();
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 4);
await session.stop(false);
expect(session.guardedInputSnapshot()).toBeNull();
});
it('checks conditional Enter at the dispatch boundary and holds concurrent input until after it', async () => {
let dispatch: (() => void) | undefined;
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
await new Promise<void>((resolve) => {
dispatch = resolve;
});
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
writes.push(text);
return true;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
const settle = () => new Promise<void>((resolve) => setImmediate(resolve));
const expected = session.guardedInputSnapshot()!;
const sending = session.submitGuardedInput(expected);
// Held behind the lease, not refused: it still invalidates the pending Enter, and
// it goes out once the lease is released.
const other = session.writeViaMux('other');
dispatch!();
expect(await sending).toBe(false);
expect(writes).toEqual([]);
await settle();
dispatch!();
expect(await other).toBe(true);
expect(writes).toEqual(['other']);
const next = session.submitGuardedInput(session.guardedInputSnapshot()!);
dispatch!();
expect(await next).toBe(true);
expect(writes).toEqual(['other', '\r']);
const restarting = session.submitGuardedInput(session.guardedInputSnapshot()!);
await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle(
async () => {}
);
dispatch!();
expect(await restarting).toBe(false);
expect(writes).toEqual(['other', '\r']);
const auxiliary = session.submitGuardedInput(session.guardedInputSnapshot()!);
const key = session.performAuxiliaryInput(async () => {
writes.push('aux');
});
await settle();
// The named key waits for the lease instead of failing, and never runs inside it.
expect(writes).toEqual(['other', '\r']);
dispatch!();
expect(await auxiliary).toBe(false);
await key;
expect(writes).toEqual(['other', '\r', 'aux']);
} finally {
await session.stop(false);
}
});
it('cancels an older automatic submit verifier before staging an identical prompt', async () => {
vi.useFakeTimers();
let composer = '';
const writes: string[] = [];
const 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,
});
},
capturePaneText: () => `› ${composer}\n GPT-6-Luna default · ~/case`,
sendInput: async (_id: string, text: string) => {
writes.push(text);
composer += text;
return true;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
session.verifySubmitted('hello');
expect(await session.stageGuardedInput('hello')).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
expect(writes).toEqual(['hello']);
} finally {
await session.stop(false);
vi.useRealTimers();
}
});
it('routes automatic verifier Enter through the shared guarded input boundary', async () => {
vi.useFakeTimers();
let release!: () => void;
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
capturePaneText: () => '› hello\n GPT-6-Luna default · ~/case',
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
if (options?.beforeDispatch) {
await new Promise<void>((resolve) => {
release = resolve;
});
if (!options.beforeDispatch()) return false;
}
writes.push(text);
return true;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
session.verifySubmitted('hello');
const submission = session.submitGuardedInput(session.guardedInputSnapshot()!);
await vi.advanceTimersByTimeAsync(2000);
release();
expect(await submission).toBe(false);
await vi.advanceTimersByTimeAsync(60000);
expect(writes).toEqual([]);
} finally {
await session.stop(false);
vi.useRealTimers();
}
});
it('refuses legacy one-shot prompt entrypoints during a guarded submission before spawning', async () => {
let release!: () => void;
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
await new Promise<void>((resolve) => {
release = resolve;
});
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
writes.push(text);
return true;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const access = session as unknown as { ptyProcess: { write(data: string): void } | null };
try {
const submission = session.submitGuardedInput(session.guardedInputSnapshot()!);
// This also prevents a real spawn if the lease guard regresses.
access.ptyProcess = {
write: () => {
throw new Error('must not write');
},
};
await expect(session.runPrompt('other')).rejects.toThrow('Guarded submission');
await expect(session.sendInput('other')).rejects.toThrow('Guarded submission');
access.ptyProcess = null;
release();
expect(await submission).toBe(false);
expect(writes).toEqual([]);
} finally {
access.ptyProcess = null;
await session.stop(false);
}
});
it('counts an ordinary transport pending window before any awaited acknowledgement', async () => {
let release!: () => void;
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
sendInput: async () => {
await new Promise<void>((resolve) => {
release = resolve;
});
return true;
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
const writing = session.writeViaMux('hello');
expect(session.guardedInputPendingWrites).toBe(1);
release();
await writing;
expect(session.guardedInputPendingWrites).toBe(0);
} finally {
release?.();
await session.stop(false);
}
});
it('uses fresh normalized pane rows for working/dialog denial before exact composer proof', async () => {
let working = false;
const capture = vi.fn(() =>
formatPaneSnapshot(
[working ? '• Working (1s · esc to interrupt)' : 'ready', '› hello', ' GPT-6-Luna default · ~/case'],
{ cols: 100, rows: 20, cursorX: 7, cursorY: 1 }
)
);
const session = new 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 capture();
},
capturePaneText: () => {
throw Error('must use same frame');
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
const blocked = vi.spyOn(session as unknown as { _paneShowsBlocked(text: string): boolean }, '_paneShowsBlocked');
expect(session.guardedInputSnapshot()).not.toBeNull();
expect(blocked.mock.calls[0][0]).toContain('ready\n› hello\n');
working = true;
expect(session.guardedInputSnapshot()).toBeNull();
blocked.mockReturnValue(true);
working = false;
expect(session.guardedInputSnapshot()).toBeNull();
} finally {
await session.stop(false);
}
});
it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop reports', async () => {
const writes: string[] = [];
const session = new 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(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const access = session as unknown as {
ptyProcess: { write(data: string): void; resize(cols: number, rows: number): void } | null;
};
access.ptyProcess = {
write: (text) => {
writes.push(text);
},
resize: () => {},
};
try {
const before = session.guardedInputSnapshot()!;
for (const data of ['\x1b[I', '\x1b[O', '\x1b[<0;10;5M', '\x1b[M !!', '']) {
expect(session.write(data, { fromUser: true })).toBe(true);
expect(writes.at(-1)).toBe(data);
expect(session.guardedInputSnapshot()?.revision).toBe(before.revision);
}
session.write('\x1b[Oedit');
expect(session.guardedInputSnapshot()?.revision).toBe(before.revision + 1);
const generation = session.inspectionGeneration;
session.owner = 'other';
session.owner = undefined;
expect(session.inspectionGeneration).not.toBe(generation);
const priorResize = session.guardedInputGeneration;
session.resize(80, 24);
expect(session.guardedInputGeneration).not.toBe(priorResize);
} finally {
access.ptyProcess = null;
await session.stop(false);
}
});
it('refuses a message that would wrap using the actual captured indent and width before any write', async () => {
const writes: string[] = [];
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane: string,
opts: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 30, rows: 20 };
return formatPaneSnapshot([' › ', ' GPT-6 default · ~/case'], {
cols: 30,
rows: 20,
cursorX: 6,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string) => {
writes.push(text);
return true;
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js');
const { getCli } = await import('../src/config/cli-registry/registry.js');
try {
const store = new GuardedStageStore();
const target = {
id: session.id,
chrome: () => guardedComposerChrome(getCli('codex')),
snapshot: () => session.guardedInputSnapshot(),
write: (text: string) => session.stageGuardedInput(text),
submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap),
};
await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(/wrap/);
expect(writes).toEqual([]);
} finally {
await session.stop(false);
}
});
it('keeps inspection generations stable across resize but invalidates a staged snapshot', async () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null };
access.ptyProcess = { resize: () => {} };
try {
const inspection = session.inspectionGeneration;
const staging = session.guardedInputGeneration;
session.resize(80, 24);
expect(session.inspectionGeneration).toBe(inspection);
expect(session.guardedInputGeneration).not.toBe(staging);
} finally {
access.ptyProcess = null;
await session.stop(false);
}
});
it('records a verifier re-press as the latest submit time', async () => {
vi.useFakeTimers();
vi.setSystemTime(10000);
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: { isAvailable: () => true, capturePaneText: () => '› hello\n', sendInput: async () => true },
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
await session.writeViaMux('hello\r');
const initial = session.lastSubmitAt;
await vi.advanceTimersByTimeAsync(2000);
expect(session.lastSubmitAt).toBe(initial + 2000);
} finally {
await session.stop(false);
vi.useRealTimers();
}
});
it('stages through a real session whose first post-write capture is the torn frame codex produces', async () => {
// Reproduces the live 0.162.0 race: right after the keys land, the screen still
// shows the empty composer while the caret is already past the new text. That
// frame proves nothing; the repaint a few ms later does.
let composer = '';
let torn = 0;
const writes: string[] = [];
const session = new 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 };
const shown = torn > 0 ? '' : composer;
if (torn > 0) torn--;
return formatPaneSnapshot([`› ${shown}`, ' 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);
if (text !== '\r') {
composer += text;
torn = 2;
}
return true;
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js');
const { getCli } = await import('../src/config/cli-registry/registry.js');
const sleeps: number[] = [];
const store = new GuardedStageStore(Date.now, 1024, 120000, {
sleep: async (ms) => {
sleeps.push(ms);
},
});
const target = {
id: session.id,
chrome: () => guardedComposerChrome(getCli('codex')),
snapshot: () => session.guardedInputSnapshot(),
write: (text: string) => session.stageGuardedInput(text),
submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap),
};
try {
const receipt = await store.stage(target, 'alice', 'hello');
expect(receipt.sha256).toBe(promptSha256('hello'));
expect(sleeps).toHaveLength(2);
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).resolves.toMatchObject({
submitted: true,
});
expect(writes).toEqual(['hello', '\r']);
} finally {
await session.stop(false);
}
});
+380
View File
@@ -0,0 +1,380 @@
/** @fileoverview Guarded staging one-shot and content-verification safety. */
import { beforeEach, describe, expect, it } from 'vitest';
import { createHash } from 'node:crypto';
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
import {
GuardedStageStore,
STAGE_SETTLE_INTERVAL_MS,
STAGE_SETTLE_TIMEOUT_MS,
SUBMIT_QUIET_MS,
composerTriggerRefusal,
guardedComposerChrome,
readGuardedComposer,
readGuardedSnapshot,
type GuardedInputTarget,
} from '../src/agent-staging.js';
import { getCli, listClis } from '../src/config/cli-registry/registry.js';
/** Codex's composer chrome exactly as the registry declares it: no copy of the glyph or footer here. */
const CODEX = guardedComposerChrome(getCli('codex'))!;
const readGuardedCodexComposer = (screen: string) => readGuardedComposer(screen, CODEX);
const readGuardedCodexSnapshot = (snapshot: string) => readGuardedSnapshot(snapshot, CODEX);
const sha = (s: string) => createHash('sha256').update(s).digest('hex');
let store: GuardedStageStore;
let time: number;
let generation: string;
let revision: number;
let composer: string | null;
let writes: string[];
let fail: boolean;
let target: GuardedInputTarget;
/** Every settle-window wait, on the fake clock: no real timer runs in this file. */
let sleeps: number[];
const fakeSleep = async (ms: number) => {
sleeps.push(ms);
time += ms;
};
beforeEach(() => {
sleeps = [];
time = 0;
generation = 'pane-1';
revision = 0;
composer = '';
writes = [];
fail = false;
store = new GuardedStageStore(() => time, 2, 1000, { sleep: fakeSleep });
target = {
id: 's',
chrome: () => CODEX,
snapshot: () =>
composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 },
submit: async () => target.write('\r'),
write: async (text) => {
writes.push(text);
revision++;
if (fail) return false;
composer = text === '\r' ? '' : composer + text;
return true;
},
};
});
it('stages without Enter, stores only a hash, and submits exactly once', async () => {
const receipt = await store.stage(target, 'alice', 'hello');
expect(writes).toEqual(['hello']);
expect(receipt.sha256).toBe(sha('hello'));
expect(JSON.stringify(receipt)).not.toContain('hello');
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
expect(writes).toEqual(['hello', '\r']);
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
});
it('refuses multiline, controls, oversized prompts and nonempty or unprovable composers before writing', async () => {
for (const text of ['', 'one\ntwo', 'one\rtwo', '\u001b', '\u0085', 'x'.repeat(20000), '\ud800'])
await expect(store.stage(target, 'alice', text)).rejects.toThrow();
composer = 'existing';
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
composer = null;
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
expect(writes).toEqual([]);
});
it('refuses altered composer including whitespace-only differences', async () => {
const receipt = await store.stage(target, 'alice', 'hello world');
composer = 'helloworld';
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
expect(writes).toEqual(['hello world']);
});
it('refuses changed generation, intervening input, wrong owner/session/hash and expiry', async () => {
for (const change of [
() => {
generation = 'pane-2';
},
() => {
revision++;
},
() => {
time += 1001;
},
]) {
composer = '';
const receipt = await store.stage(target, 'alice', 'hello');
change();
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
}
composer = '';
const receipt = await store.stage(target, 'alice', 'hello');
await expect(store.submit(target, 'bob', receipt.stageId, receipt.sha256)).rejects.toThrow();
await expect(store.submit({ ...target, id: 'other' }, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
await expect(store.submit(target, 'alice', receipt.stageId, sha('other'))).rejects.toThrow();
expect(writes.filter((s) => s === '\r')).toHaveLength(0);
});
it('consumes before write so concurrent and failed deliveries never replay Enter', async () => {
const receipt = await store.stage(target, 'alice', 'hello');
fail = true;
const results = await Promise.allSettled([
store.submit(target, 'alice', receipt.stageId, receipt.sha256),
store.submit(target, 'alice', receipt.stageId, receipt.sha256),
]);
expect(results.every((r) => r.status === 'rejected')).toBe(true);
expect(writes).toEqual(['hello', '\r']);
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
});
it('does not issue a receipt on failed staging or intervening input during its write', async () => {
fail = true;
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
fail = false;
composer = '';
const altered = {
...target,
write: async (s: string) => {
await target.write(s);
revision++;
return true;
},
};
await expect(store.stage(altered, 'alice', 'hello')).rejects.toThrow();
});
it('reads exact composer spaces and refuses wrapped or modal frames', async () => {
expect(readGuardedCodexComposer('› hello world\n GPT-6-Luna default · ~/case\n')).toBe('hello world');
expect(
readGuardedCodexComposer(
'› \x1b[2mAsk Codex to do anything\x1b[0m\n GPT-6-Luna default · ~/case\n ← for agents · ? for shortcuts'
)
).toBe('');
expect(readGuardedCodexComposer('› wrapped\n more text\n GPT-6-Luna default · ~/case')).toBeNull();
expect(readGuardedCodexComposer('› 1. Yes\n Press enter to confirm')).toBeNull();
expect(readGuardedCodexComposer('› hello\n')).toBeNull();
});
it('requires capture cursor geometry, rejecting invisible trailing edits and moved cursors', async () => {
const { formatPaneSnapshot } = await import('../src/tmux-manager.js');
const frame = (text: string, x: number, y = 0) =>
formatPaneSnapshot([`› ${text}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: x, cursorY: y });
expect(readGuardedCodexSnapshot(frame('hello', 7))).toBe('hello');
expect(readGuardedCodexSnapshot(frame('hello', 8))).toBeNull();
expect(readGuardedCodexSnapshot(frame('hello', 4))).toBeNull();
expect(readGuardedCodexSnapshot(frame('hello', 7, 1))).toBeNull();
expect(readGuardedCodexSnapshot('› hello\n GPT-6-Luna default · ~/case')).toBeNull();
expect(readGuardedCodexSnapshot(frame('你好', 6))).toBe('你好');
expect(readGuardedCodexSnapshot(frame('\x1b[38;2;2;100;100mhello\x1b[0m', 7))).toBe('hello');
});
it('reserves receipt capacity before concurrent stages type', async () => {
store = new GuardedStageStore(() => time, 1, 1000, { sleep: fakeSleep });
let release: (() => void) | undefined;
const firstTarget = {
...target,
write: async (text: string) => {
await new Promise<void>((resolve) => {
release = resolve;
});
return target.write(text);
},
};
const first = store.stage(firstTarget, 'alice', 'hello');
const otherTarget = { ...target, id: 'other' };
await expect(store.stage(otherTarget, 'alice', 'other')).rejects.toThrow(/capacity/i);
expect(writes).toEqual([]);
release!();
await first;
});
it.each([...CODEX_REASONING_EFFORTS, 'default'])(
'accepts the shared Codex effort word %s in the footer',
async (word) => {
expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello');
}
);
it('refuses messages that reach the available pane columns before typing', async () => {
target.snapshot = () => ({ generation, revision, composerSha: sha(composer ?? ''), availableColumns: 4 });
for (const text of ['four', '界界']) await expect(store.stage(target, 'alice', text)).rejects.toThrow();
expect(writes).toEqual([]);
await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false });
});
it('takes the composer glyph and footer from the registry, and only codex declares a footer', () => {
const codex = getCli('codex')!.capabilities.workDetect!;
expect(CODEX.promptGlyph).toBe(codex.promptGlyph);
expect(CODEX.footerLine.source).toBe(codex.composerFooterLine);
// Another glyph drives the same reader: nothing in it is codex-specific.
const other = { promptGlyph: '>>', footerLine: CODEX.footerLine };
expect(readGuardedComposer('>> hello\n GPT-6 default · ~/case\n', other)).toBe('hello');
expect(readGuardedComposer('› hello\n GPT-6 default · ~/case\n', other)).toBeNull();
const declaring = listClis()
.filter((entry) => entry.capabilities.workDetect?.composerFooterLine)
.map((entry) => entry.id);
expect(declaring).toEqual(['codex']);
expect(guardedComposerChrome(getCli('claude'))).toBeNull();
// A pattern the ReDoS guard refuses yields no chrome, so no guarded input.
const unsafe = structuredClone(getCli('codex')!);
unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b';
expect(guardedComposerChrome(unsafe)).toBeNull();
});
describe('the settle window after typing (a live codex pane repaints 30 to 60 ms after the keys)', () => {
/** Serve these post-write frames in order, then the target's own snapshot. */
function afterWrite(frames: Array<(() => ReturnType<GuardedInputTarget['snapshot']>) | null>) {
let written = false;
const own = target.snapshot;
const write = target.write;
target.write = async (text) => {
const ok = await write(text);
written = true;
return ok;
};
target.snapshot = () => {
if (!written || frames.length === 0) return own();
const next = frames.shift()!;
return next ? next() : null;
};
}
const stale = () => ({ generation, revision, composerSha: sha(''), availableColumns: 100 });
it('names its bounds', () => {
expect(STAGE_SETTLE_TIMEOUT_MS).toBe(1000);
expect(STAGE_SETTLE_INTERVAL_MS).toBe(50);
});
it('issues a receipt once a null first frame is followed by a valid one', async () => {
afterWrite([null]);
const receipt = await store.stage(target, 'alice', 'hello');
expect(receipt.sha256).toBe(sha('hello'));
expect(sleeps).toEqual([STAGE_SETTLE_INTERVAL_MS]);
});
it('issues a receipt once a stale frame (the old, empty composer) is followed by a valid one', async () => {
afterWrite([stale, stale]);
await expect(store.stage(target, 'alice', 'hello')).resolves.toMatchObject({ submitted: false });
expect(sleeps).toHaveLength(2);
});
it('refuses, saying the text may be in the composer, when no frame verifies within the window', async () => {
target.snapshot = () => (writes.length ? null : stale());
const refusal = await store.stage(target, 'alice', 'hello').catch((error: Error) => error.message);
expect(refusal).toMatch(/within 1000 ms; no receipt was issued/);
expect(refusal).toMatch(/may already be in the composer.*Ctrl\+U/);
expect(sleeps.length).toBeGreaterThanOrEqual(STAGE_SETTLE_TIMEOUT_MS / STAGE_SETTLE_INTERVAL_MS);
});
it('issues no receipt for a frame whose composer text differs from the message', async () => {
const write = target.write;
target.write = async (text) => {
await write(text);
composer = 'hellp'; // codex shows something else: a transformed paste, a dropped key
return true;
};
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(/did not show the staged message/);
// Nothing to submit: no receipt was stored for the frame that did not match.
composer = 'hello';
await expect(store.submit(target, 'alice', '11111111-1111-4111-8111-111111111111', sha('hello'))).rejects.toThrow(
/Unknown/
);
});
it('refuses at once, without waiting, when another writer moved the input during the window', async () => {
afterWrite([() => ({ generation, revision: revision + 1, composerSha: sha('hello'), availableColumns: 100 })]);
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(
/Input or pane changed while staging; no receipt was issued\. The message may already be in the composer/
);
expect(sleeps).toEqual([]);
});
it('words a failed typing delivery like the submit refusal: it cannot know whether the text landed', async () => {
fail = true;
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(
/^Staging delivery failed or ambiguous; no receipt was issued\. The message may already be in the composer/
);
});
});
describe('popups and commands that change what Enter does (codex rust-v0.162.0 bottom_pane)', () => {
const footer = ' gpt-5.6-terra default · ~/case';
/** A frame as codex lays it out: transcript, popup, the composer's blank top row, composer, blank, footer. */
const frame = (popup: string[], draft: string) =>
['• Earlier answer from the agent.', '', ...popup, '', `› ${draft}`, '', footer].join('\n');
it('reads the idle frame with transcript above it', () => {
expect(readGuardedCodexComposer(frame([], 'hello'))).toBe('hello');
});
it('refuses the slash-command list codex opens above `› /stat` (Enter would run /status)', () => {
const popup = [
'› /status show current session configuration and token usage',
' /statusline configure which items appear in the status line',
];
expect(readGuardedCodexComposer(frame(popup, '/stat'))).toBeNull();
// A wrapped description row at the bottom of the list does not hide it.
expect(readGuardedCodexComposer(frame(['› /tier-one Use faster', ' inference'], '/tier'))).toBeNull();
});
it('refuses the skill, mention and file-search popups', () => {
const skill = ['› 1password [Skill] Example skill used in tests.', '', ' enter insert · esc close'];
const mention = [
' Mentions',
' All Results Filesystem Only Plugins',
' sa',
'› Sample Plugin Plugin with skills and an MCP server Plugin',
' enter/tab insert · esc close · ↑/↓ select · ←/→ filter',
];
expect(readGuardedCodexComposer(frame(skill, '$1p'))).toBeNull();
expect(readGuardedCodexComposer(frame(mention, '@sa'))).toBeNull();
expect(readGuardedCodexComposer(frame([' loading...', ''], '@src'))).toBeNull();
expect(readGuardedCodexComposer(frame(['no matches'], '/zz'))).toBeNull();
});
it('looks only at the block directly above the composer, not the transcript further up', () => {
const screen = [' /status shows the status', '', '• Answer.', '', '', '', '› hello', '', footer].join('\n');
expect(readGuardedCodexComposer(screen)).toBe('hello');
});
it('takes the popup pattern and the trigger characters from the registry', () => {
const codex = getCli('codex')!.capabilities.workDetect!;
expect(CODEX.popupLine?.source).toBe(new RegExp(codex.composerPopupLine!).source);
expect(CODEX.commandPrefixes).toBe('/!');
expect(CODEX.completionSigils).toBe('@$');
// A popup pattern the ReDoS guard refuses yields no chrome, rather than chrome without it.
const unsafe = structuredClone(getCli('codex')!);
unsafe.capabilities.workDetect!.composerPopupLine = '(a+)+b';
expect(guardedComposerChrome(unsafe)).toBeNull();
});
it.each([
['/stat', '/'],
['/status now', '/'],
[' /review', '/'],
['!ls -la', '!'],
['review @src/app', '@'],
['@notes', '@'],
['run $skill', '$'],
['costs $5', '$'],
])('refuses %j before typing (trigger %s)', async (message, trigger) => {
expect(composerTriggerRefusal(message, CODEX)).toContain(`"${trigger}"`);
await expect(store.stage(target, 'alice', message)).rejects.toThrow(
trigger === '/' || trigger === '!' ? /codeman agent send/ : /completion popup/
);
expect(writes).toEqual([]);
});
it.each(['look at @src/app.ts please', 'mail a@b.example', 'pay $5 today', 'a/b and c!'])(
'stages %j: no trigger at the start, and the caret word opens nothing',
async (message) => {
expect(composerTriggerRefusal(message, CODEX)).toBeNull();
await expect(store.stage(target, 'alice', message)).resolves.toMatchObject({ submitted: false });
}
);
});
describe("the quiet time before Enter (codex's 120 ms paste-burst window turns Enter into a newline)", () => {
it('waits out the rest of the window after the staged keys before checking and sending Enter', async () => {
expect(SUBMIT_QUIET_MS).toBeGreaterThan(120);
const receipt = await store.stage(target, 'alice', 'hello');
time += 40;
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
expect(sleeps).toEqual([SUBMIT_QUIET_MS - 40]);
expect(writes).toEqual(['hello', '\r']);
});
it('does not wait once the window has passed', async () => {
const receipt = await store.stage(target, 'alice', 'hello');
time += SUBMIT_QUIET_MS;
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
expect(sleeps).toEqual([]);
});
it('still refuses when a keystroke lands during the wait', async () => {
store = new GuardedStageStore(() => time, 2, 1000, {
sleep: async (ms) => {
await fakeSleep(ms);
revision++; // the user typed while submit waited
},
});
const receipt = await store.stage(target, 'alice', 'hello');
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(
/changed; nothing submitted/
);
expect(writes).toEqual(['hello']);
});
});
+127
View File
@@ -0,0 +1,127 @@
/** @fileoverview Agent inspect/stage/submit transport, refusal and file boundaries. */
import { expect, it } from 'vitest';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
agentInspect,
agentStage,
agentSubmit,
registerAgentCommands,
type AgentDeps,
type RequestOptions,
} from '../src/cli-agent.js';
import { Command } from 'commander';
const SELF = '11111111-1111-4111-8111-111111111111';
const OTHER = '22222222-2222-4222-8222-222222222222';
function deps() {
const calls: RequestOptions[] = [],
output: string[] = [];
const deps: AgentDeps = {
ctx: { apiUrl: 'http://localhost:1', selfId: SELF },
json: true,
now: () => 1,
io: { out: (line) => output.push(line), err: () => {} },
request: async (_ctx, options) => {
calls.push(options);
return {
status: 200,
text: '',
json: { success: true, data: { evidence: 'hello', stageId: OTHER, sha256: 'a'.repeat(64) } },
};
},
};
return { deps, calls, output };
}
it('inspect uses the ownership-checked versioned endpoint with exact bounds and cursor', async () => {
const d = deps();
expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 42, since: 'cursor' })).toBe(0);
expect(d.calls[0]).toMatchObject({
method: 'GET',
path: `/api/v1/sessions/${OTHER}/inspect`,
query: { maxBytes: 42, since: 'cursor' },
});
expect(JSON.parse(d.output[0]).evidence).toBe('hello');
expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 65537 })).toBe(4);
expect(d.calls).toHaveLength(1);
});
it('stage reads a bounded UTF8 file and never adds Enter; submit sends only receipt and hash', async () => {
const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-'));
try {
const path = join(dir, 'message');
await writeFile(path, 'hello');
const d = deps();
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(0);
expect(d.calls[0]).toMatchObject({
method: 'POST',
path: `/api/v1/sessions/${OTHER}/stage`,
body: { message: 'hello' },
});
expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(0);
expect(d.calls[1].body).toEqual({ stageId: OTHER, confirmSha: 'a'.repeat(64) });
await writeFile(path, 'hello\n');
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
await writeFile(path, Buffer.from([0xff]));
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
await writeFile(path, 'x'.repeat(8193));
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
expect(d.calls).toHaveLength(2);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('refuses self-target and malformed submission before transport', async () => {
const d = deps();
expect(await agentStage(d.deps, { id: SELF, messageFile: '/nonexistent' })).toBe(4);
expect(await agentSubmit(d.deps, { id: SELF, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(4);
expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'bad' })).toBe(4);
expect(d.calls).toHaveLength(0);
});
it('registers required file and confirmation options and bounded inspect flags', () => {
const program = new Command();
registerAgentCommands(program);
const commands = program.commands.find((command) => command.name() === 'agent')!.commands;
for (const name of ['inspect', 'stage', 'submit'])
expect(commands.some((command) => command.name() === name)).toBe(true);
expect(
commands.find((command) => command.name() === 'stage')!.options.find((option) => option.long === '--message-file')
?.mandatory
).toBe(true);
expect(
commands.find((command) => command.name() === 'submit')!.options.find((option) => option.long === '--confirm-sha')
?.mandatory
).toBe(true);
});
it('shows the opt-in 403 verbatim on inspect, stage and submit while the setting is off', async () => {
const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-'));
try {
const path = join(dir, 'message');
await writeFile(path, 'hello');
const message =
'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.';
for (const json of [false, true]) {
const lines: string[] = [];
const refusing: AgentDeps = {
ctx: { apiUrl: 'http://localhost:1', selfId: SELF },
json,
now: () => 1,
io: { out: (line) => lines.push(line), err: (line) => lines.push(line) },
request: async () => ({
status: 403,
text: '',
json: { success: false, error: message, errorCode: 'FORBIDDEN' },
}),
};
expect(await agentInspect(refusing, { id: OTHER, maxBytes: 10 })).toBe(1);
expect(await agentStage(refusing, { id: OTHER, messageFile: path })).toBe(1);
expect(await agentSubmit(refusing, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(1);
expect(lines).toHaveLength(3);
for (const line of lines) {
const shown = json ? (JSON.parse(line) as { success: boolean; error: string }).error : line;
expect(shown).toContain(`FORBIDDEN: ${message} (HTTP 403)`);
}
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+63
View File
@@ -184,6 +184,69 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
}
});
it('holds the optional composerFooterLine to the same guard', () => {
// Run over the rows under the composer each time guarded input reads the pane.
for (const composerFooterLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
composerFooterLine,
};
}, 'guarded input tests it against pane rows a clis.json override can supply');
}
});
it('accepts every shipped composerFooterLine', () => {
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.composerFooterLine;
if (!src) continue;
expect(compileVersionRegex(src), `${entry.id} declares a composerFooterLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional composerPopupLine to the same guard, and accepts the shipped one', () => {
// Run over the rows just above the composer each time guarded input reads the pane.
for (const composerPopupLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
composerPopupLine,
};
}, 'guarded input tests it against pane rows a clis.json override can supply');
}
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.composerPopupLine;
if (src)
expect(compileVersionRegex(src), `${entry.id} declares a composerPopupLine the guard refuses`).not.toBeNull();
}
});
it('takes only short runs of ASCII punctuation as composer trigger characters', () => {
for (const field of ['composerCommandPrefixes', 'composerCompletionSigils']) {
for (const value of ['', 'a', '/a', '/ ', '/\t', '\u00a7', '!!!!!!!!!']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
[field]: value,
};
},
`${field} ${JSON.stringify(value)} would refuse letters, spaces or an unbounded set`
);
}
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
[field]: '/!@$',
};
expect(CliEntrySchema.safeParse(entry).success, field).toBe(true);
}
});
it('holds the optional blockedLine to the same guard', () => {
// Run over the foot of a capture at every settle and on a timer while a dialog is
// up, and a clis.json override can set it.
+450
View File
@@ -0,0 +1,450 @@
/**
* @fileoverview Input that lands while a guarded submit holds the input lease is held
* and applied in arrival order once the lease is released, never refused and lost.
*
* Shaped like the review repro: a real `Session`, a mux that runs `beforeDispatch` and
* then awaits the Enter the way `TmuxManager.sendInput` does, and the real WebSocket
* route driven through a fake socket. The browser's WebSocket path does not wait for
* ACKs, so the client model below sends every frame at once and redelivers whatever
* is still unACKed afterwards, in seq order, the way `_redeliverSweep` does.
*/
import { EventEmitter } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { formatPaneSnapshot } from '../src/tmux-manager.js';
import { GuardedInputBusyError, Session } from '../src/session.js';
import { registerWsRoutes } from '../src/web/routes/ws-routes.js';
import { registerSessionRoutes } from '../src/web/routes/session-routes.js';
import type { SessionPort } from '../src/web/ports/session-port.js';
import { createRouteTestHarness } from './routes/_route-test-utils.js';
type Frame = { t: 'i'; d: string; seq?: number; cid?: string };
class FakeSocket extends EventEmitter {
readyState = 1;
sent: Array<Record<string, unknown>> = [];
closedWith: number | null = null;
send(data: string): void {
this.sent.push(JSON.parse(data));
}
close(code?: number): void {
this.closedWith = code ?? 1000;
this.readyState = 3;
}
ping(): void {}
terminate(): void {
this.readyState = 3;
}
frame(msg: Frame): void {
this.emit('message', Buffer.from(JSON.stringify(msg)));
}
acks(): number[] {
return this.sent.filter((m) => m.t === 'ia').map((m) => m.seq as number);
}
}
interface Harness {
session: Session;
pty: string[];
/** Resolve the in-flight Enter (true = tmux accepted it, false = it failed). */
finishEnter(ok?: boolean | Error): void;
enterDispatched(): boolean;
connect(cid: string): FakeSocket;
stop(): Promise<void>;
}
const sockets: FakeSocket[] = [];
function harness(awaitBeforeGuard = false): Harness {
const pty: string[] = [];
let finish: ((ok: boolean | Error) => void) | undefined;
let dispatched = false;
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
// Like TmuxManager.sendInput's Enter-only branch: the guard runs synchronously,
// then the `send-keys Enter` child process is awaited with the lease still held.
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
if (options?.beforeDispatch && text !== '\r') return false;
const settle = () =>
new Promise<boolean | Error>((resolve) => {
finish = resolve;
});
// A transport that awaits before its guard leaves a window in which held
// keystrokes must still invalidate the Enter.
const early = awaitBeforeGuard ? await settle() : undefined;
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
dispatched = true;
const outcome = early ?? (await settle());
if (outcome instanceof Error) throw outcome;
if (outcome) pty.push(text);
return outcome;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const access = session as unknown as { ptyProcess: { write(data: string): void } | null };
access.ptyProcess = { write: (data: string) => pty.push(data) };
let handler: ((socket: FakeSocket, req: unknown) => void) | undefined;
const app = {
get: (_path: string, _opts: unknown, h: (socket: FakeSocket, req: unknown) => void) => {
handler = h;
},
} as unknown as FastifyInstance;
const ctx = { sessions: new Map([[session.id, session]]) } as unknown as SessionPort;
registerWsRoutes(app, ctx, () => ({ bindHost: '127.0.0.1', allowedHosts: [] }));
return {
session,
pty,
finishEnter: (ok = true) => finish!(ok),
enterDispatched: () => dispatched,
connect: (cid: string) => {
const socket = new FakeSocket();
sockets.push(socket);
handler!(socket, { headers: { host: 'localhost:3000' }, params: { id: session.id }, query: { cid } });
return socket;
},
stop: async () => {
access.ptyProcess = null;
await session.stop(false);
},
};
}
/** Redeliver every still-unACKed frame in seq order, as the browser's sweep does. */
function redeliverUnacked(socket: FakeSocket, frames: Frame[]): void {
const acked = new Set(socket.acks());
for (const f of frames) if (f.seq !== undefined && !acked.has(f.seq)) socket.frame(f);
}
afterEach(() => {
for (const s of sockets.splice(0)) s.emit('close', 1000, Buffer.from(''));
});
describe('input during a guarded submit lease', () => {
it('delivers a keystroke that landed during the lease before the one typed after it (review repro)', async () => {
const h = harness();
try {
const socket = h.connect('c1');
const a: Frame = { t: 'i', d: 'a', seq: 1, cid: 'c1' };
const b: Frame = { t: 'i', d: 'b', seq: 2, cid: 'c1' };
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
expect(h.enterDispatched()).toBe(true);
expect(h.session.guardedSubmitInFlight).toBe(true);
socket.frame(a);
// Held, not refused: no ACK yet and nothing written while the lease is held.
expect(socket.acks()).toEqual([]);
expect(h.pty).toEqual([]);
h.finishEnter(true);
await expect(submit).resolves.toBe(true);
socket.frame(b);
redeliverUnacked(socket, [a, b]);
expect(h.pty).toEqual(['\r', 'a', 'b']);
expect(socket.acks()).toEqual([1, 2]);
expect(socket.sent.filter((m) => m.dup)).toEqual([]);
} finally {
await h.stop();
}
});
it('keeps arrival order for several frames during the lease and one after it', async () => {
const h = harness();
try {
const socket = h.connect('c1');
const frames: Frame[] = ['a', 'b', 'c', 'd'].map((d, i) => ({ t: 'i', d, seq: i + 1, cid: 'c1' }));
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
for (const f of frames.slice(0, 3)) socket.frame(f);
expect(h.pty).toEqual([]);
h.finishEnter(true);
await submit;
socket.frame(frames[3]);
redeliverUnacked(socket, frames);
expect(h.pty).toEqual(['\r', 'a', 'b', 'c', 'd']);
expect(socket.acks()).toEqual([1, 2, 3, 4]);
} finally {
await h.stop();
}
});
it('interleaves a second client by arrival, each with its own seq space', async () => {
const h = harness();
try {
const one = h.connect('c1');
const two = h.connect('c2');
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
one.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' });
two.frame({ t: 'i', d: 'x', seq: 1, cid: 'c2' });
one.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' });
h.finishEnter(true);
await submit;
two.frame({ t: 'i', d: 'y', seq: 2, cid: 'c2' });
expect(h.pty).toEqual(['\r', 'a', 'x', 'b', 'y']);
expect(one.acks()).toEqual([1, 2]);
expect(two.acks()).toEqual([1, 2]);
} finally {
await h.stop();
}
});
it('flushes held input when the lease ends in failure, refused Enter or thrown', async () => {
for (const outcome of [false, new Error('tmux send-keys failed')]) {
const h = harness();
try {
const socket = h.connect('c1');
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' });
h.finishEnter(outcome);
if (outcome instanceof Error) await expect(submit).rejects.toThrow('tmux send-keys failed');
else await expect(submit).resolves.toBe(false);
socket.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' });
expect(h.pty).toEqual(['a', 'b']);
expect(socket.acks()).toEqual([1, 2]);
expect(h.session.guardedSubmitInFlight).toBe(false);
} finally {
await h.stop();
}
}
});
it('holds untagged frames too, which have no redelivery to fall back on', async () => {
const h = harness();
try {
const socket = h.connect('c1');
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
socket.frame({ t: 'i', d: 'a' });
h.finishEnter(true);
await submit;
socket.frame({ t: 'i', d: 'b' });
expect(h.pty).toEqual(['\r', 'a', 'b']);
} finally {
await h.stop();
}
});
it('a frame held for a socket that closed is still applied once; its redelivery is a duplicate', async () => {
const h = harness();
try {
const first = h.connect('c1');
const a: Frame = { t: 'i', d: 'a', seq: 1, cid: 'c1' };
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
first.frame(a);
first.close();
first.emit('close', 1006, Buffer.from(''));
h.finishEnter(true);
await submit;
expect(first.sent).toEqual([]);
const second = h.connect('c1');
second.frame(a);
second.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' });
expect(h.pty).toEqual(['\r', 'a', 'b']);
expect(second.sent.filter((m) => m.t === 'ia')).toEqual([
{ t: 'ia', seq: 1, dup: true, last: 1 },
{ t: 'ia', seq: 2 },
]);
} finally {
await h.stop();
}
});
it('closes the socket instead of holding past the cap, and the reconnect resends in order', async () => {
const h = harness();
try {
const socket = h.connect('c1');
const cap = Session.MAX_GUARDED_HELD_INPUTS;
const frames: Frame[] = Array.from({ length: cap + 3 }, (_, i) => ({
t: 'i',
d: `k${i}`,
seq: i + 1,
cid: 'c1',
}));
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
for (const f of frames) socket.frame(f);
// Frames past the cap are not held, not applied and not ACKed; the socket is
// closed so the browser reconnects and resends everything still unACKed.
expect(socket.closedWith).toBe(1013);
h.finishEnter(true);
await submit;
expect(h.pty.length).toBe(1 + cap);
expect(socket.acks()).toEqual([]);
socket.emit('close', 1013, Buffer.from(''));
const again = h.connect('c1');
for (const f of frames) again.frame(f);
expect(h.pty).toEqual(['\r', ...frames.map((f) => f.d)]);
expect(again.acks()).toEqual(frames.map((f) => f.seq));
} finally {
await h.stop();
}
});
it('holds managed writes and named keys behind the lease, and refuses them only when the hold queue is full', async () => {
const h = harness();
try {
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
const order: string[] = [];
const key = h.session.performAuxiliaryInput(async () => {
order.push('key');
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(order).toEqual([]);
for (let i = 1; i < Session.MAX_GUARDED_HELD_INPUTS; i++) {
expect(h.session.holdForGuardedLease(() => order.push('frame'))).toBe('held');
}
expect(h.session.holdForGuardedLease(() => order.push('lost'))).toBe('full');
await expect(h.session.performAuxiliaryInput(async () => order.push('lost'))).rejects.toBeInstanceOf(
GuardedInputBusyError
);
await expect(h.session.writeViaMux('lost')).resolves.toBe(false);
h.finishEnter(true);
await submit;
await key;
expect(order[0]).toBe('key');
expect(order.filter((o) => o === 'frame')).toHaveLength(Session.MAX_GUARDED_HELD_INPUTS - 1);
expect(order).not.toContain('lost');
} finally {
await h.stop();
}
});
it('caps held bytes as well as held frames', async () => {
const h = harness();
try {
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
expect(h.session.holdForGuardedLease(() => {}, Session.MAX_GUARDED_HELD_BYTES)).toBe('held');
expect(h.session.holdForGuardedLease(() => {}, 1)).toBe('full');
h.finishEnter(true);
await submit;
expect(h.session.holdForGuardedLease(() => {}, Session.MAX_GUARDED_HELD_BYTES + 1)).toBe('free');
} finally {
await h.stop();
}
});
it('a frame arriving while a held named key is still being sent waits for it', async () => {
const h = harness();
try {
const socket = h.connect('c1');
let keySent!: () => void;
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
const key = h.session.performAuxiliaryInput(async () => {
await new Promise<void>((resolve) => {
keySent = resolve;
});
h.pty.push('<newline>');
});
socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' });
h.finishEnter(true);
await submit;
// The lease is over, but the held key is still in flight: input stays held.
expect(h.session.guardedSubmitInFlight).toBe(false);
expect(h.session.guardedInputHeld).toBe(true);
socket.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' });
expect(h.pty).toEqual(['\r']);
keySent();
await key;
await new Promise<void>((resolve) => setImmediate(resolve));
expect(h.pty).toEqual(['\r', '<newline>', 'a', 'b']);
expect(socket.acks()).toEqual([1, 2]);
expect(h.session.guardedInputHeld).toBe(false);
} finally {
await h.stop();
}
});
it('holds an untagged raw POST .../input during the lease and writes it after the Enter', async () => {
const h = harness();
const route = await createRouteTestHarness(registerSessionRoutes);
try {
(route.ctx.sessions as unknown as Map<string, Session>).set(h.session.id, h.session);
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
expect(h.session.guardedSubmitInFlight).toBe(true);
// The Stop button's Ctrl+C: an untagged POST whose answer nobody reads.
const res = await route.app.inject({
method: 'POST',
url: `/api/sessions/${h.session.id}/input`,
payload: { input: '\x03' },
});
expect(res.statusCode).toBe(200);
expect(h.pty).toEqual([]);
h.finishEnter(true);
await expect(submit).resolves.toBe(true);
expect(h.pty).toEqual(['\r', '\x03']);
} finally {
await route.app.close();
await h.stop();
}
});
it('a held untagged raw send-and-wait reports the held write as delivered', async () => {
const h = harness();
const route = await createRouteTestHarness(registerSessionRoutes);
try {
(route.ctx.sessions as unknown as Map<string, Session>).set(h.session.id, h.session);
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
const answer = route.app.inject({
method: 'POST',
url: `/api/sessions/${h.session.id}/input`,
payload: { input: '\x03', useMux: false, wait: 'exit', waitTimeout: 1000 },
});
// Private, but the only proof the route reached the hold before the lease ends.
const queue = (h.session as unknown as { _guardedHeld: unknown[] })._guardedHeld;
await vi.waitFor(() => expect(queue).toHaveLength(1));
expect(h.pty).toEqual([]);
h.finishEnter(true);
await submit;
const res = await answer;
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ delivered: true, duplicate: false });
expect(h.pty).toEqual(['\r', '\x03']);
} finally {
await route.app.close();
await h.stop();
}
});
it('a keystroke held before the guard runs still invalidates the Enter, then lands', async () => {
const h = harness(true);
try {
const socket = h.connect('c1');
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' });
h.finishEnter(true);
await expect(submit).resolves.toBe(false);
expect(h.enterDispatched()).toBe(false);
expect(h.pty).toEqual(['a']);
expect(socket.acks()).toEqual([1]);
} finally {
await h.stop();
}
});
});
+28 -3
View File
@@ -124,6 +124,26 @@ export class MockSession extends EventEmitter {
/** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */
trackUserInput(_data: string): void {}
async performAuxiliaryInput(operation: () => Promise<void>): Promise<void> {
await operation();
}
/** Mirrors Session.guardedInputHeld; a route test sets it to stand in for a guarded submit's lease. */
guardedInputHeld = false;
/** Input held behind that lease, in arrival order (see releaseGuardedLease). */
heldGuardedInput: Array<() => void | Promise<unknown>> = [];
/** Mirrors Session.holdForGuardedLease, without the cap (a test replaces it to model a full queue). */
holdForGuardedLease(run: () => void | Promise<unknown>, _bytes = 0, _data?: string): 'free' | 'held' | 'full' {
if (!this.guardedInputHeld) return 'free';
this.heldGuardedInput.push(run);
return 'held';
}
/** Ends the stand-in lease and runs the held input in arrival order, as Session does. */
releaseGuardedLease(): void {
this.guardedInputHeld = false;
for (const run of this.heldGuardedInput.splice(0)) void run();
}
private _muxName: string | null = null;
constructor(id: string = 'mock-session-id') {
@@ -147,9 +167,14 @@ export class MockSession extends EventEmitter {
/** Write via mux (used by respawn controller) */
async writeViaMux(data: string): Promise<boolean> {
if (this.failWrites) return false;
this.writeBuffer.push(data);
return true;
const write = (): boolean => {
if (this.failWrites) return false;
this.writeBuffer.push(data);
return true;
};
// Like Session.writeViaMux, it waits its turn behind a stand-in guarded lease.
if (!this.guardedInputHeld) return write();
return new Promise<boolean>((resolve) => this.heldGuardedInput.push(() => resolve(write())));
}
/**
+355
View File
@@ -0,0 +1,355 @@
/** @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 });
}
});
+115 -1
View File
@@ -25,7 +25,7 @@ import { tmpdir } from 'node:os';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { Session } from '../../src/session.js';
import { GuardedInputBusyError, Session } from '../../src/session.js';
import { agentInbox } from '../../src/web/agent-inbox.js';
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
@@ -265,6 +265,33 @@ describe('session-routes', () => {
});
});
it('answers 409 CONFLICT, without an error log, when a guarded submit cannot hold the key', async () => {
execFile.mockReset();
const session = harness.ctx._session as unknown as {
performAuxiliaryInput: (op: () => Promise<void>) => Promise<void>;
};
const original = session.performAuxiliaryInput;
session.performAuxiliaryInput = async () => {
throw new GuardedInputBusyError();
};
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'S-Enter' },
});
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
expect(res.json().error).toMatch(/guarded submit is in progress.*nothing was written/);
expect(errors).not.toHaveBeenCalled();
expect(execFile).not.toHaveBeenCalled();
} finally {
errors.mockRestore();
session.performAuxiliaryInput = original;
}
});
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
execFile.mockReset();
const res = await harness.app.inject({
@@ -1197,6 +1224,93 @@ describe('session-routes', () => {
expect(session.writeBuffer).toEqual(['prompt', '\r']);
});
it('refuses tagged input with 409 and gives the seq back while a guarded submit holds the input lease', async () => {
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
session.writeBuffer.length = 0;
const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload });
session.guardedInputHeld = true;
for (const payload of [
{ input: 'tagged', seq: 7, clientId: 'cid-guard' },
{ input: 'prompt\r', useMux: true, seq: 1, clientId: 'cid-guard-mux' },
]) {
const refused = await post(payload);
expect(refused.statusCode, JSON.stringify(payload)).toBe(409);
expect(refused.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
expect(refused.json().error).toMatch(/guarded submit is in progress.*nothing was written/);
}
expect(session.writeBuffer).toEqual([]);
expect(session.heldGuardedInput).toEqual([]);
// Once the lease is released the browser's retry of the same seq is applied, not
// rejected as a duplicate.
session.releaseGuardedLease();
expect((await post({ input: 'tagged', seq: 7, clientId: 'cid-guard' })).statusCode).toBe(200);
expect(session.writeBuffer).toEqual(['tagged']);
});
it('holds an untagged raw POST during a guarded submit and writes it once the lease is released', async () => {
// The phone accessory keys and the Stop button's Ctrl+C are untagged, fire-and-forget
// POSTs that never read the answer, so a 409 here silently lost the tap.
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
session.writeBuffer.length = 0;
session.guardedInputHeld = true;
try {
for (const input of ['\x03', '\x1b[A']) {
const res = await harness.app.inject({ method: 'POST', url, payload: { input } });
expect(res.statusCode, JSON.stringify(input)).toBe(200);
expect(res.json()).toMatchObject({ success: true });
}
expect(session.writeBuffer).toEqual([]);
session.releaseGuardedLease();
expect(session.writeBuffer).toEqual(['\x03', '\x1b[A']);
} finally {
session.releaseGuardedLease();
}
});
it('lets untagged mux input wait its own turn behind the lease instead of refusing it', async () => {
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
session.writeBuffer.length = 0;
const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload });
session.guardedInputHeld = true;
try {
// Fire-and-forget `useMux`: answered at once, written in its turn.
expect((await post({ input: 'x', useMux: true })).statusCode).toBe(200);
// A plain prompt's mux write is awaited, so it answers once written.
const prompt = post({ input: 'prompt\r' });
await vi.waitFor(() => expect(session.heldGuardedInput).toHaveLength(2));
expect(session.writeBuffer).toEqual([]);
session.releaseGuardedLease();
expect((await prompt).statusCode).toBe(200);
expect(session.writeBuffer).toEqual(['x', 'prompt\r']);
} finally {
session.releaseGuardedLease();
}
});
it('refuses an untagged raw POST with 409 only when the hold queue is full', async () => {
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
session.writeBuffer.length = 0;
session.guardedInputHeld = true;
session.holdForGuardedLease = () => 'full';
try {
const res = await harness.app.inject({ method: 'POST', url, payload: { input: '\x03' } });
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
expect(session.writeBuffer).toEqual([]);
} finally {
delete session.holdForGuardedLease;
session.releaseGuardedLease();
}
});
it('always applies untagged input (curl/legacy, no dedup)', async () => {
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { isKeyboardTerminalInput } from '../src/terminal-input-classifier.js';
describe('isKeyboardTerminalInput', () => {
it.each([
['focus in', '\x1b[I'],
['focus out', '\x1b[O'],
['SGR press', '\x1b[<0;10;5M'],
['SGR release', '\x1b[<0;10;5m'],
['SGR wheel', '\x1b[<64;999;1000M'],
['X10 mouse', '\x1b[M !!'],
['coalesced reports', '\x1b[<0;10;5M\x1b[O\x1b[M !!'],
])('classifies %s as report-only', (_name, data) => {
expect(isKeyboardTerminalInput(data)).toBe(false);
});
it.each([
['empty', ''],
['text', 'continue'],
['paste', 'git status'],
['Enter', '\r'],
['Escape', '\x1b'],
['arrow', '\x1b[A'],
['control key', '\x03'],
['truncated SGR', '\x1b[<0;10;5'],
['truncated X10', '\x1b[M !'],
['X10 with a control button byte', '\x1b[M\r!!'],
['X10 with a control coordinate byte', '\x1b[M \x1b!'],
['X10-shaped navigation input', '\x1b[M\x1b[A'],
['X10 with a zero coordinate', '\x1b[M !'],
['report then text', '\x1b[<0;10;5Mx'],
['text then report', 'x\x1b[<0;10;5M'],
['report then Enter', '\x1b[O\r'],
])('classifies %s as keyboard-bearing', (_name, data) => {
expect(isKeyboardTerminalInput(data)).toBe(true);
});
});
+89 -2
View File
@@ -10,8 +10,15 @@
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor } from '../src/tmux-manager.js';
import { describe, expect, it, vi } from 'vitest';
import {
formatCursorRestore,
formatPaneSnapshot,
hasVisibleContent,
queryPaneCursor,
splitFrameAndCursor,
TmuxManager,
} from '../src/tmux-manager.js';
describe('tmux full-history pane capture (COD-47)', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
@@ -265,3 +272,83 @@ describe('queryPaneCursor history depth', () => {
expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'");
});
});
describe('the visible frame and its caret come from one tmux invocation', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart));
it('chains the capture and the cursor query with `\;` on the visible path only', () => {
// Two separate execs raced a CLI repaint (codex redraws its composer 30-60 ms
// after input): the frame could predate the caret, and guarded stage refused it.
// Two backslashes in the source: one escaped backslash in the shell command, which hands tmux its `;`.
const chained = '${captureCommand} ' + '\\'.repeat(2) + '; ${cursorQuery}';
expect(methodBody).toContain('exec(`' + chained + '`');
// Output it cannot split falls back to the two calls the capture always made.
expect(methodBody).toContain('combined?.frame ?? exec(captureCommand, execOpts)');
expect(methodBody).toContain('combined\n ? combined.cursor');
});
it('splits the combined output into the rows and the cursor line tmux prints last', () => {
expect(splitFrameAndCursor('› hello\n footer\n\n7 0 100 20 0 0\n')).toEqual({
frame: '› hello\n footer\n\n',
cursor: '7 0 100 20 0 0',
});
expect(splitFrameAndCursor('only\n3 0 80 24\n')).toEqual({ frame: 'only\n', cursor: '3 0 80 24' });
});
it('refuses output whose last line is not the cursor query, so a frame row is never taken for it', () => {
expect(splitFrameAndCursor('› hello\n footer\n')).toBeNull();
expect(splitFrameAndCursor('row\nworld 1 2 3 4\n')).toBeNull();
expect(splitFrameAndCursor('')).toBeNull();
});
});
describe('how many tmux calls one visible-frame capture makes', () => {
type Run = (cmd: string, opts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number }) => string;
class ScriptedTmux extends TmuxManager {
calls: string[] = [];
constructor(private readonly answer: (cmd: string) => string) {
super();
}
protected override captureRunner(): Run {
return (cmd) => {
this.calls.push(cmd.includes(' \\; ') ? 'combined' : cmd.includes('display-message') ? 'cursor' : 'capture');
return this.answer(cmd);
};
}
}
const quiet = () => vi.spyOn(console, 'error').mockImplementation(() => {});
it('makes ONE call when the combined output splits', () => {
const tmux = new ScriptedTmux(() => '› hello\n footer\n7 0 100 20 0 0\n');
expect(tmux.capturePaneBuffer('codeman-12345678')).toContain('hello');
expect(tmux.calls).toEqual(['combined']);
});
it('does not rerun a combined call that threw: a hung tmux costs one timeout, not two', () => {
const errors = quiet();
try {
const tmux = new ScriptedTmux(() => {
throw Object.assign(new Error('spawnSync /bin/sh ETIMEDOUT'), { code: 'ETIMEDOUT' });
});
expect(tmux.capturePaneBuffer('codeman-12345678')).toBeNull();
expect(tmux.calls).toEqual(['combined']);
expect(errors).toHaveBeenCalledTimes(1);
} finally {
errors.mockRestore();
}
});
it('falls back to the two separate calls only for output it cannot split', () => {
const tmux = new ScriptedTmux((cmd) =>
cmd.includes(' \\; ') ? '› hello\n footer\n' : cmd.includes('display-message') ? '7 0 100 20 0 0\n' : '› hello\n'
);
expect(tmux.capturePaneBuffer('codeman-12345678')).toContain('hello');
expect(tmux.calls).toEqual(['combined', 'capture', 'cursor']);
});
it('keeps the full-history path on its two calls', () => {
const tmux = new ScriptedTmux((cmd) => (cmd.includes('display-message') ? '0 0 80 24 0 0\n' : 'row\n'));
tmux.capturePaneBuffer('codeman-12345678', undefined, { fullHistory: true });
expect(tmux.calls).toEqual(['capture', 'cursor']);
});
});
+25
View File
@@ -229,6 +229,21 @@ describe('sendInput line breaks', () => {
return argvOf(setBufferCmd.replace(/^.*? set-buffer /, `${FAKE_TMUX} set-buffer `)).at(-1) as string;
}
it('checks a conditional Enter before the actual inputRunner command', async () => {
const manager = managerWithSession();
try {
expect(await manager.sendInput('s1', '\r', { beforeDispatch: () => false })).toBe(false);
expect(manager.ran).toEqual([]);
expect(await manager.sendInput('s1', '\r', { beforeDispatch: () => true })).toBe(true);
expect(manager.ran).toEqual([expect.stringMatching(/ Enter$/)]);
manager.ran = [];
expect(await manager.sendInput('s1', 'draft\r', { beforeDispatch: () => true })).toBe(false);
expect(manager.ran).toEqual([]);
} finally {
manager.destroy();
}
});
it('submits a multi-line prompt as one paste with its line breaks, then Enter', async () => {
const manager = managerWithSession();
expect(await manager.sendInput('s1', 'line one\nline two\n\nparagraph three\r')).toBe(true);
@@ -311,3 +326,13 @@ describe('sendInput line breaks', () => {
expect(answer.ran[1]).toMatch(/ send-keys -t "codeman-abc12345" Enter$/);
});
});
it('terminates literal operands in both sendInputToPane branches', async () => {
const { readFile } = await import('node:fs/promises');
const src = await readFile(new URL('../src/tmux-manager.ts', import.meta.url), 'utf8');
const start = src.indexOf(' sendInputToPane(');
const body = src.slice(start, src.indexOf('\n }\n', start));
const literal = body.match(/send-keys[^`\n]*-l[^`\n]*/g) ?? [];
expect(literal).toHaveLength(2);
for (const command of literal) expect(command).toContain(' -l -- ');
});
+53
View File
@@ -40,6 +40,59 @@ describe('WebServer on an ephemeral port', () => {
expect(ctx.port).toBe(socketPort());
});
it('registers bounded agent routes with the server route modules', async () => {
const { Session } = await import('../src/session.js');
const session = new Session({ id: 'agent-route-wiring', workingDir: process.env.HOME!, mode: 'shell' });
const sessions = (server as unknown as { sessions: Map<string, typeof session> }).sessions;
sessions.set(session.id, session);
const { mkdirSync, rmSync, writeFileSync } = await import('node:fs');
const { dirname } = await import('node:path');
const { SETTINGS_PATH } = await import('../src/web/route-helpers.js');
const setEnabled = (on: boolean) => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ agentCoordinationEnabled: on }));
};
try {
const prefix = `http://127.0.0.1:${server.boundPort}/api/v1/sessions/${session.id}`;
// Opt-in: off, the /api/v1 alias of every route refuses with 403 FORBIDDEN.
setEnabled(false);
for (const [method, verb] of [
['GET', 'inspect'],
['POST', 'stage'],
['POST', 'submit'],
] as const) {
const refused = await fetch(prefix + '/' + verb, {
method,
...(method === 'POST' ? { headers: { 'Content-Type': 'application/json' }, body: '{}' } : {}),
});
expect(refused.status).toBe(403);
const body = await refused.json();
expect(body.errorCode).toBe('FORBIDDEN');
expect(body.error).toContain('agentCoordinationEnabled');
}
setEnabled(true);
const inspected = await fetch(prefix + '/inspect');
expect(inspected.status).toBe(200);
expect((await inspected.json()).data.source).toBe('pane-snapshot');
for (const [verb, body] of [
['stage', { message: 'hello' }],
['submit', { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) }],
] as const) {
const response = await fetch(prefix + '/' + verb, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
expect(response.status).toBe(409);
expect((await response.json()).errorCode).toBe('CONFLICT');
}
} finally {
rmSync(SETTINGS_PATH, { force: true });
sessions.delete(session.id);
await session.stop(false);
}
});
it('exports the real port to the panes as CODEMAN_API_URL', () => {
expect(process.env.CODEMAN_API_URL).toBe(`http://127.0.0.1:${socketPort()}`);
});