mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
feat(agent): add bounded inspection and guarded submission
This commit is contained in:
1 parent
3e06f3c141
commit
a24b0ec7bb
30 files changed
+2462
-36
No files matched your search
@@ -0,0 +1,119 @@
|
||||
/** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */
|
||||
import { afterEach, 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 { 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);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,161 @@
|
||||
/** @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 { 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('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'));
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
/** @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: () =>
|
||||
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 rejects concurrent browser input', async () => {
|
||||
let dispatch: (() => void) | undefined;
|
||||
const writes: string[] = [];
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: () =>
|
||||
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 expected = session.guardedInputSnapshot()!;
|
||||
const sending = session.submitGuardedInput(expected);
|
||||
expect(await session.writeViaMux('other')).toBe(false);
|
||||
dispatch!();
|
||||
expect(await sending).toBe(false);
|
||||
expect(writes).toEqual([]);
|
||||
const next = session.submitGuardedInput(session.guardedInputSnapshot()!);
|
||||
dispatch!();
|
||||
expect(await next).toBe(true);
|
||||
expect(writes).toEqual(['\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(['\r']);
|
||||
const auxiliary = session.submitGuardedInput(session.guardedInputSnapshot()!);
|
||||
await expect(
|
||||
session.performAuxiliaryInput(async () => {
|
||||
throw new Error('must not execute');
|
||||
})
|
||||
).rejects.toThrow('Guarded submission');
|
||||
dispatch!();
|
||||
expect(await auxiliary).toBe(false);
|
||||
expect(writes).toEqual(['\r']);
|
||||
} 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: () =>
|
||||
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: () =>
|
||||
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: () =>
|
||||
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: 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: () =>
|
||||
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.inspectionGeneration;
|
||||
session.resize(80, 24);
|
||||
expect(session.inspectionGeneration).not.toBe(priorResize);
|
||||
} finally {
|
||||
access.ptyProcess = null;
|
||||
await session.stop(false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/** @fileoverview Guarded staging one-shot and content-verification safety. */
|
||||
import { beforeEach, expect, it } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { GuardedStageStore, type GuardedInputTarget } from '../src/agent-staging.js';
|
||||
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;
|
||||
beforeEach(() => {
|
||||
time = 0;
|
||||
generation = 'pane-1';
|
||||
revision = 0;
|
||||
composer = '';
|
||||
writes = [];
|
||||
fail = false;
|
||||
store = new GuardedStageStore(() => time, 2, 1000);
|
||||
target = {
|
||||
id: 's',
|
||||
snapshot: () => (composer === null ? null : { generation, revision, composerSha: sha(composer) }),
|
||||
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 () => {
|
||||
const { readGuardedCodexComposer } = await import('../src/agent-staging.js');
|
||||
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 { readGuardedCodexSnapshot } = await import('../src/agent-staging.js');
|
||||
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);
|
||||
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;
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/** @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);
|
||||
});
|
||||
@@ -107,6 +107,10 @@ 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();
|
||||
}
|
||||
|
||||
private _muxName: string | null = null;
|
||||
|
||||
constructor(id: string = 'mock-session-id') {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
|
||||
let harness: RouteTestHarness;
|
||||
afterEach(async () => {
|
||||
await harness?.app.close();
|
||||
});
|
||||
describe('agent session routes', () => {
|
||||
it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => {
|
||||
harness = await createRouteTestHarness(registerSessionRoutes);
|
||||
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(registerSessionRoutes);
|
||||
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(registerSessionRoutes, { 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(registerSessionRoutes);
|
||||
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(registerSessionRoutes);
|
||||
let composer = '';
|
||||
const writes: string[] = [];
|
||||
const session = new Session({
|
||||
id: 'guarded-session',
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux: {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: () =>
|
||||
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);
|
||||
} 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(registerSessionRoutes, { 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('');
|
||||
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(registerSessionRoutes);
|
||||
(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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user