mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
Stage now re-reads the pane after typing (every 50 ms, up to 1 s) and issues a receipt only for a frame with the same generation, the input revision its own write moved, and a composer hash equal to the message's. Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed. Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame capture reads the screen and the caret in one tmux invocation, with the old two calls as the fallback. Codex draws its suggestion popups above the composer and keeps the footer, so a staged /stat verified and submit ran /status. Stage now refuses a leading / or ! and a last word starting with @ or $ before typing, and stage and submit refuse a frame with popup rows just above the composer. The pattern and characters are registry data (composerPopupLine, composerCommandPrefixes, composerCompletionSigils), read from the codex 0.162.0 bottom_pane sources. Submit waits until 250 ms after the staged keys: codex turns an Enter within 120 ms of a typed burst into a newline. POST .../input answers 409 with its seq returned while a guarded submit holds the input lease, instead of 200 for bytes it never wrote. Also align the attribution memo comment with the code (a verified file whose incremental scan fails is not re-walked, since the full walk reads a superset), and list stage as a fromUser source in CLAUDE.md.
381 lines
17 KiB
TypeScript
381 lines
17 KiB
TypeScript
/** @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']);
|
||
});
|
||
});
|