mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
commit
5908cc51fd
50 files changed
+5408
-116
No files matched your search
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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())));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 -- ');
|
||||
});
|
||||
@@ -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()}`);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user