From 41947c8b3120adf2942d43d1b7e1ffda4b5adf6c Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 09:27:50 +0900 Subject: [PATCH 01/18] feat(observation): add bounded metadata-only Codex reader --- src/handoff-observer-probe.ts | 101 ++++++++++++++++++ src/metadata-observation.ts | 163 ++++++++++++++++++++++++++++ test/metadata-observation.test.ts | 172 ++++++++++++++++++++++++++++++ 3 files changed, 436 insertions(+) create mode 100644 src/handoff-observer-probe.ts create mode 100644 src/metadata-observation.ts create mode 100644 test/metadata-observation.test.ts diff --git a/src/handoff-observer-probe.ts b/src/handoff-observer-probe.ts new file mode 100644 index 0000000..3d9feca --- /dev/null +++ b/src/handoff-observer-probe.ts @@ -0,0 +1,101 @@ +// Owned one-shot worker. node:sqlite is synchronous, so it never runs on the +// caller's event loop. Its supervisor kills only this process group at deadline. +// Codex rust-v0.160.1 / d27764b82f7118f674371e6d6e76271d9d606edb: +// state/thread_history_migrations/0001_thread_history.sql, 0002_thread_items_item_type.sql; +// app-server-protocol/src/protocol/v2/item.rs (userMessage.clientId); +// thread-store/src/local/thread_history.rs (turn_status, projection lag). +import { DatabaseSync } from 'node:sqlite'; +import { lstatSync, realpathSync } from 'node:fs'; +import { basename, dirname, isAbsolute } from 'node:path'; + +type Reason = 'metadata_storage_untrusted' | 'metadata_schema_unsupported' | 'metadata_history_unavailable' | + 'metadata_probe_failed' | 'metadata_ambiguous'; +type SqlRow = Record; +const fail = (reason: Reason) => ({ supported: false, injectionObserved: false, reason }); +const safe = (value: unknown, bytes: number): value is string => typeof value === 'string' && value.length > 0 && + Buffer.byteLength(value) <= bytes && !/[\x00-\x1f\x7f-\x9f]/.test(value) && + !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? !['mode', 'database', 'threadId', 'clientUserMessageId'].includes(key)) || + (request.mode === 'observe' && !safe(request.clientUserMessageId, 128))) return fail('metadata_probe_failed'); + } catch { return fail('metadata_probe_failed'); } + let db: DatabaseSync | undefined; + try { + const uid = process.getuid?.(), home = lstatSync(dirname(request.database)), info = lstatSync(request.database); + if (uid === undefined || !home.isDirectory() || home.isSymbolicLink() || home.uid !== uid || (home.mode & 0o022) || + realpathSync(request.database) !== request.database || !info.isFile() || info.isSymbolicLink() || + info.uid !== uid || info.nlink !== 1 || (info.mode & 0o022)) return fail('metadata_storage_untrusted'); + const sides = new Set(); + for (const suffix of ['-wal', '-shm', '-journal']) { + try { const side = lstatSync(request.database + suffix); + if (suffix === '-journal' || !side.isFile() || side.isSymbolicLink() || side.uid !== uid || side.nlink !== 1 || (side.mode & 0o022)) return fail('metadata_storage_untrusted'); + sides.add(suffix); + } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return fail('metadata_storage_untrusted'); } + } + if (sides.has('-wal') !== sides.has('-shm')) return fail('metadata_storage_untrusted'); + db = new DatabaseSync(request.database, { readOnly: true }); + db.exec('PRAGMA query_only=ON; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=40;'); + const opened = lstatSync(request.database); + if (opened.dev !== info.dev || opened.ino !== info.ino) return fail('metadata_storage_untrusted'); + const tables = db.prepare("SELECT name,type FROM sqlite_schema WHERE name IN ('thread_items','thread_turns','thread_history_projection_state')").all() as SqlRow[]; + if (tables.length !== 3 || tables.some(row => row.type !== 'table')) return fail('metadata_schema_unsupported'); + const columns: Record> = { + thread_items: { thread_id: 'TEXT', turn_id: 'TEXT', item_id: 'TEXT', rollout_ordinal: 'INTEGER', item_json: 'TEXT', item_type: 'TEXT' }, + thread_turns: { thread_id: 'TEXT', turn_id: 'TEXT', status: 'TEXT' }, + thread_history_projection_state: { thread_id: 'TEXT', next_rollout_byte_offset: 'INTEGER', next_rollout_ordinal: 'INTEGER' }, + }; + for (const [table, required] of Object.entries(columns)) { + const actual = db.prepare('PRAGMA table_info(' + table + ')').all() as SqlRow[]; + if (Object.entries(required).some(([name, type]) => !actual.some(row => row.name === name && row.type === type))) return fail('metadata_schema_unsupported'); + const keys = table === 'thread_items' ? ['thread_id', 'turn_id', 'item_id'] : table === 'thread_turns' ? ['thread_id', 'turn_id'] : ['thread_id']; + if (actual.filter(row => typeof row.pk === 'number' && row.pk > 0).length !== keys.length || + keys.some((name, i) => !actual.some(row => row.name === name && row.pk === i + 1))) return fail('metadata_schema_unsupported'); + } + const indexes = db.prepare('PRAGMA index_list(thread_items)').all() as SqlRow[]; + const userIndex = indexes.find(row => row.name === 'idx_thread_items_user_messages' && row.partial === 1); + const indexColumns = db.prepare('PRAGMA index_info(idx_thread_items_user_messages)').all() as SqlRow[]; + if (!userIndex || indexColumns.length !== 2 || indexColumns[0]?.name !== 'thread_id' || indexColumns[1]?.name !== 'rollout_ordinal') return fail('metadata_schema_unsupported'); + const projection = db.prepare('SELECT next_rollout_byte_offset,next_rollout_ordinal FROM thread_history_projection_state WHERE thread_id=?').get(request.threadId) as SqlRow | undefined; + if (!projection || !Object.values(projection).every(value => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)) return fail('metadata_history_unavailable'); + // Preparing this query also verifies the partial index's actual predicate. + // LIMIT is applied inside a materialized CTE before any JSON parsing. Raw + // item_json stays INSIDE SQLite; no body-bearing column crosses into JS. + const query = db.prepare(`WITH recent AS MATERIALIZED ( + SELECT thread_id,turn_id,item_json FROM thread_items INDEXED BY idx_thread_items_user_messages + WHERE thread_id=? AND item_type='userMessage' ORDER BY rollout_ordinal DESC LIMIT 256 + ), projected AS MATERIALIZED ( + SELECT thread_id,turn_id,CASE WHEN typeof(item_json)='text' THEN + CASE WHEN length(CAST(item_json AS BLOB))<=262144 THEN + CASE WHEN json_valid(item_json) THEN + CASE WHEN json_extract(item_json,'$.type')='userMessage' THEN json_extract(item_json,'$.clientId') END + END + END + END AS client_id FROM recent + ) SELECT projected.thread_id AS threadId,projected.turn_id AS turnId, + projected.client_id AS clientUserMessageId,thread_turns.status AS status + FROM projected LEFT JOIN thread_turns ON thread_turns.thread_id=projected.thread_id AND thread_turns.turn_id=projected.turn_id + WHERE typeof(projected.client_id)='text' AND projected.client_id=? LIMIT 2`); + if (request.mode === 'capability') return { supported: true, injectionObserved: false }; + const rows = query.all(request.threadId, request.clientUserMessageId!) as SqlRow[]; + if (rows.length > 1) return fail('metadata_ambiguous'); + if (!rows.length) return { supported: true, injectionObserved: false }; + const row = rows[0]!; + if (row.threadId !== request.threadId || row.clientUserMessageId !== request.clientUserMessageId || !safe(row.turnId, 128)) return fail('metadata_probe_failed'); + const status = row.status === 'inProgress' ? 'running' : ['completed', 'failed', 'interrupted'].includes(row.status as string) ? row.status : 'unknown'; + return { supported: true, injectionObserved: true, clientUserMessageId: request.clientUserMessageId, turn: { id: row.turnId, status } }; + } catch { return fail('metadata_probe_failed'); } + finally { try { db?.close(); } catch { /* No diagnostics or storage mutation. */ } } +} +let bytes = 0, oversized = false; const parts: Buffer[] = []; +process.stdin.on('data', (part: Buffer) => { bytes += part.length; if (bytes > 4096) { oversized = true; parts.length = 0; } else if (!oversized) parts.push(part); }); +process.stdin.once('end', () => { + const result = oversized ? fail('metadata_probe_failed') : main(Buffer.concat(parts)); + process.stdout.write(JSON.stringify(result) + '\n'); +}); +process.stdin.once('error', () => { process.stdout.write(JSON.stringify(fail('metadata_probe_failed')) + '\n'); }); diff --git a/src/metadata-observation.ts b/src/metadata-observation.ts new file mode 100644 index 0000000..69af2d8 --- /dev/null +++ b/src/metadata-observation.ts @@ -0,0 +1,163 @@ +// Opt-in, metadata-only observation for the exact qualified Codex store below. +// No transcript, native client, warming, migration or receipt bootstrap is used. +import childProcess, { type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { lstatSync, realpathSync } from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const METADATA_SOURCE = Object.freeze({ version: '0.160.1', + commit: 'd27764b82f7118f674371e6d6e76271d9d606edb', + database: 'thread_history_1.sqlite', recentUsers: 256, maxCellBytes: 256 * 1024, childMs: 1000 }); +export type MetadataScope = Readonly<{ home: string; threadId: string; generation: string | null }>; +export type MetadataBudget = { observationRemainingMs(): number }; +// The caller owns any resources used by this verifier and must honor its signal. +// A true result proves the ORIGINAL generation, rather than selecting a new one. +export type VerifyMetadataOwner = (scope: MetadataScope, signal: AbortSignal) => boolean | Promise; +export type MetadataReason = 'metadata_version_unsupported' | 'metadata_platform_unsupported' | + 'metadata_scope_invalid' | 'metadata_storage_untrusted' | 'metadata_history_unavailable' | + 'metadata_schema_unsupported' | 'metadata_owner_changed' | 'metadata_deadline' | + 'metadata_probe_failed' | 'metadata_probe_stopped' | 'metadata_ambiguous'; +export type MetadataCapability = { supported: boolean; reason?: MetadataReason }; +export type MetadataObservation = MetadataCapability & { injectionObserved: boolean; clientUserMessageId?: string; + turn?: { id: string; status: 'running' | 'completed' | 'failed' | 'interrupted' | 'unknown' } }; +type Stamp = { path: string; dev: number; ino: number }; +type ProbeRequest = { mode: 'capability' | 'observe'; database: string; threadId: string; clientUserMessageId?: string }; +const reasons: readonly MetadataReason[] = ['metadata_version_unsupported', 'metadata_platform_unsupported', + 'metadata_scope_invalid', 'metadata_storage_untrusted', 'metadata_history_unavailable', 'metadata_schema_unsupported', + 'metadata_owner_changed', 'metadata_deadline', 'metadata_probe_failed', 'metadata_probe_stopped', 'metadata_ambiguous']; +const safe = (value: unknown, limit: number): value is string => typeof value === 'string' && value.length > 0 && + Buffer.byteLength(value) <= limit && !/[\x00-\x1f\x7f-\x9f]/.test(value) && + !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i.test(value); +const unsupported = (reason: MetadataReason): MetadataObservation => ({ supported: false, injectionObserved: false, reason }); +function remaining(budget?: MetadataBudget): number { + const value = budget?.observationRemainingMs() ?? 3000; + return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} +function trusted(scope: MetadataScope): Stamp[] { + const uid = process.getuid?.(); + if (uid === undefined || !isAbsolute(scope.home) || resolve(scope.home) !== scope.home || + realpathSync(scope.home) !== scope.home) throw new Error(); + const home = lstatSync(scope.home); + if (!home.isDirectory() || home.isSymbolicLink() || home.uid !== uid || (home.mode & 0o022)) throw new Error(); + const database = join(scope.home, METADATA_SOURCE.database); + const stamps: Stamp[] = []; + for (const path of [database, database + '-wal', database + '-shm', database + '-journal']) { + let info; + try { info = lstatSync(path); } catch (error) { + if (path !== database && (error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + if (!info.isFile() || info.isSymbolicLink() || info.uid !== uid || info.nlink !== 1 || (info.mode & 0o022)) throw new Error(); + // A rollback journal can require recovery. Never let this reader recover it. + if (path.endsWith('-journal')) throw new Error(); + stamps.push({ path, dev: info.dev, ino: info.ino }); + } + // Read-only WAL access requires a pre-existing shared-memory sidecar. SQLite + // may participate in transient SHM reader locks; it cannot create sidecars here. + if (stamps.some(item => item.path.endsWith('-wal')) !== stamps.some(item => item.path.endsWith('-shm'))) throw new Error(); + return stamps; +} +function sameFiles(before: Stamp[], after: Stamp[]): boolean { + return before.length === after.length && before.every((item, i) => item.path === after[i]?.path && + item.dev === after[i]?.dev && item.ino === after[i]?.ino); +} +async function owner(scope: MetadataScope, verify: VerifyMetadataOwner, budget?: MetadataBudget): Promise { + const duration = Math.min(METADATA_SOURCE.childMs, remaining(budget)); + if (duration < 1) return false; + const controller = new AbortController(); let timer: ReturnType | undefined; + try { + return await Promise.race([Promise.resolve().then(() => verify(scope, controller.signal)).then(value => value === true), + new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve(false); }, duration); })]); + } catch { return false; } + finally { if (timer) clearTimeout(timer); controller.abort(); } +} +function probe(request: ProbeRequest, budget?: MetadataBudget): Promise { + const duration = Math.min(METADATA_SOURCE.childMs, remaining(budget)); + if (duration < 1) return Promise.resolve(unsupported('metadata_deadline')); + const extension = import.meta.url.endsWith('.ts') ? '.ts' : '.js'; + const worker = fileURLToPath(new URL('./handoff-observer-probe' + extension, import.meta.url)); + return new Promise(resolve => { + let child: ChildProcessWithoutNullStreams; + try { child = childProcess.spawn(process.execPath, [...(extension === '.ts' ? ['--experimental-strip-types'] : []), worker], { + detached: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, NODE_OPTIONS: undefined, NODE_PATH: undefined }, + }) as ChildProcessWithoutNullStreams; } catch { resolve(unsupported('metadata_probe_failed')); return; } + let finished = false, stopped = false, total = 0, output = '', stopReason: MetadataReason = 'metadata_probe_failed'; + let grace: ReturnType | undefined; + const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const; + const handlers = new Map void>(); + const reap = () => { if (child.pid) try { process.kill(-child.pid, 'SIGKILL'); } catch { /* Already gone. */ } }; + const finish = (result: MetadataObservation) => { + if (finished) return; finished = true; clearTimeout(timer); if (grace) clearTimeout(grace); + for (const signal of signals) process.removeListener(signal, handlers.get(signal)!); + resolve(result); + }; + const stop = (reason: MetadataReason) => { + if (stopped || finished) return; stopped = true; stopReason = reason; reap(); + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + // Cleanup grace stays inside the available original observation budget. + grace = setTimeout(() => finish(unsupported(reason)), Math.min(100, remaining(budget))); + }; + const interrupted = (signal: NodeJS.Signals) => { + const sole = process.listenerCount(signal) === 1; stop('metadata_probe_stopped'); + if (sole) { for (const name of signals) process.removeListener(name, handlers.get(name)!); process.kill(process.pid, signal); } + }; + const timer = setTimeout(() => stop('metadata_deadline'), duration); + for (const signal of signals) { const handler = () => interrupted(signal); handlers.set(signal, handler); process.on(signal, handler); } + child.stdin.on('error', () => {}); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (part: string) => { total += Buffer.byteLength(part); if (total > 4096) stop('metadata_probe_failed'); else output += part; }); + // Drain without retaining or logging SQLite/native diagnostics. + child.stderr.on('data', (part: Buffer) => { total += part.length; if (total > 8192) stop('metadata_probe_failed'); }); + child.once('error', () => stop('metadata_probe_failed')); + child.once('close', code => { + if (stopped) { finish(unsupported(stopReason)); return; } + if (code !== 0) { finish(unsupported('metadata_probe_failed')); return; } + try { + const value = JSON.parse(output) as MetadataObservation; + if (!value || typeof value.supported !== 'boolean' || typeof value.injectionObserved !== 'boolean' || + Object.keys(value).some(key => !['supported', 'injectionObserved', 'reason', 'clientUserMessageId', 'turn'].includes(key))) throw new Error(); + if (!value.supported) { + if (value.injectionObserved || !reasons.includes(value.reason!) || value.clientUserMessageId !== undefined || value.turn !== undefined) throw new Error(); + } else if (value.reason !== undefined) throw new Error(); + if (value.injectionObserved) { + if (value.clientUserMessageId !== request.clientUserMessageId || !safe(value.clientUserMessageId, 128) || !value.turn || + Object.keys(value.turn).some(key => !['id', 'status'].includes(key)) || !safe(value.turn.id, 128) || + !['running', 'completed', 'failed', 'interrupted', 'unknown'].includes(value.turn.status)) throw new Error(); + } else if (value.clientUserMessageId !== undefined || value.turn !== undefined) throw new Error(); + finish(value); + } catch { finish(unsupported('metadata_probe_failed')); } + }); + child.stdin.end(JSON.stringify(request)); + }); +} +async function inspect(scope: MetadataScope, mode: ProbeRequest['mode'], budget: MetadataBudget | undefined, + verify: VerifyMetadataOwner, clientUserMessageId?: string): Promise { + if (!['darwin', 'linux'].includes(process.platform) || process.getuid?.() === undefined) return unsupported('metadata_platform_unsupported'); + if (!scope || !safe(scope.home, 2048) || !safe(scope.threadId, 128) || !uuid(scope.threadId) || + !safe(scope.generation, 256) || (mode === 'observe' && !safe(clientUserMessageId, 128))) return unsupported('metadata_scope_invalid'); + const original = Object.freeze({ home: scope.home, threadId: scope.threadId, generation: scope.generation }); + if (remaining(budget) < 1) return unsupported('metadata_deadline'); + let before: Stamp[]; + try { before = trusted(original); } catch { return unsupported('metadata_storage_untrusted'); } + if (!(await owner(original, verify, budget))) return unsupported(remaining(budget) < 1 ? 'metadata_deadline' : 'metadata_owner_changed'); + const result = await probe({ mode, database: join(original.home, METADATA_SOURCE.database), + threadId: original.threadId.toLowerCase(), ...(clientUserMessageId === undefined ? {} : { clientUserMessageId }) }, budget); + if (!result.supported) return result; + if (!(await owner(original, verify, budget))) return unsupported(remaining(budget) < 1 ? 'metadata_deadline' : 'metadata_owner_changed'); + try { if (!sameFiles(before, trusted(original))) return unsupported('metadata_storage_untrusted'); } + catch { return unsupported('metadata_storage_untrusted'); } + if (remaining(budget) < 1) return unsupported('metadata_deadline'); + return result; +} +export async function metadataCapability(scope: MetadataScope, version: string, budget: MetadataBudget | undefined, + verifyOwner: VerifyMetadataOwner): Promise { + if (!/^(?:codex-cli )?0\.160\.1$/.test(version)) return { supported: false, reason: 'metadata_version_unsupported' }; + const result = await inspect(scope, 'capability', budget, verifyOwner); + return { supported: result.supported, ...(result.reason === undefined ? {} : { reason: result.reason }) }; +} +export function observeMetadata(scope: MetadataScope & { clientUserMessageId: string }, budget: MetadataBudget, + verifyOwner: VerifyMetadataOwner): Promise { + return inspect(scope, 'observe', budget, verifyOwner, scope.clientUserMessageId); +} diff --git a/test/metadata-observation.test.ts b/test/metadata-observation.test.ts new file mode 100644 index 0000000..21b1fbc --- /dev/null +++ b/test/metadata-observation.test.ts @@ -0,0 +1,172 @@ +import { test, mock, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import childProcess from 'node:child_process'; +import { DatabaseSync } from 'node:sqlite'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { performance } from 'node:perf_hooks'; +import { metadataCapability, observeMetadata, METADATA_SOURCE, type MetadataScope } from '../dist/metadata-observation.js'; + +const posix = { skip: process.platform === 'win32' }; +const threadId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const clientId = 'client-message-fixture-1'; +// Structural fixture from the official 0.160.1 migrations 0001/0002. Later +// migrations add unrelated columns/indexes; no transcript or live home is used. +const schema = `CREATE TABLE thread_turns(thread_id TEXT NOT NULL,turn_id TEXT NOT NULL, + rollout_ordinal INTEGER NOT NULL,status TEXT NOT NULL,error_json TEXT,started_at INTEGER,completed_at INTEGER, + duration_ms INTEGER,first_user_item_id TEXT,final_agent_item_id TEXT,PRIMARY KEY(thread_id,turn_id)); +CREATE TABLE thread_items(thread_id TEXT NOT NULL,turn_id TEXT NOT NULL,item_id TEXT NOT NULL, + rollout_ordinal INTEGER NOT NULL,created_at_ms INTEGER NOT NULL,item_json TEXT NOT NULL, + item_type TEXT NOT NULL DEFAULT '',PRIMARY KEY(thread_id,turn_id,item_id)); +CREATE UNIQUE INDEX idx_thread_items_page ON thread_items(thread_id,rollout_ordinal); +CREATE INDEX idx_thread_items_user_messages ON thread_items(thread_id,rollout_ordinal) WHERE item_type='userMessage'; +CREATE TABLE thread_history_projection_state(thread_id TEXT PRIMARY KEY,next_rollout_byte_offset INTEGER NOT NULL,next_rollout_ordinal INTEGER NOT NULL);`; +function fixture(t: TestContext, wal = false) { + const home = realpathSync(mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-metadata-'))); + chmodSync(home, 0o700); + const file = join(home, METADATA_SOURCE.database), db = new DatabaseSync(file); + chmodSync(file, 0o600); if (wal) db.exec('PRAGMA journal_mode=WAL;'); db.exec(schema); + db.prepare('INSERT INTO thread_history_projection_state VALUES(?,?,?)').run(threadId, 100, 100); + db.prepare('INSERT INTO thread_turns(thread_id,turn_id,rollout_ordinal,status,error_json) VALUES(?,?,?,?,?)') + .run(threadId, 'turn-fixture-1', 1, 'inProgress', '{"message":"ERROR_BODY_SENTINEL"}'); + const scope: MetadataScope = { home, threadId, generation: 'fixture-original-writer-generation' }; + t.after(() => { db.close(); rmSync(home, { recursive: true, force: true }); }); + const item = (ordinal: number, client: string | null = clientId, body = 'PRIVATE_BODY_SENTINEL', turn = 'turn-fixture-1') => + db.prepare('INSERT INTO thread_items VALUES(?,?,?,?,?,?,?)').run(threadId, turn, 'native-item-' + ordinal, ordinal, + 1000, JSON.stringify({ type: 'userMessage', id: 'native-item-' + ordinal, clientId: client, + content: [{ type: 'text', text: body }] }), 'userMessage'); + return { home, file, db, scope, item }; +} +const budget = () => ({ observationRemainingMs: () => 5000 }); +const owner = () => true; +const observe = (scope: MetadataScope, clientUserMessageId = clientId) => observeMetadata({ ...scope, clientUserMessageId }, budget(), owner); + +test('qualified warm metadata is projected without body/error output and without DB/WAL writes', posix, async t => { + const f = fixture(t, true); f.item(1); + const files = readdirSync(f.home).sort(), before = new Map(files.filter(name => !name.endsWith('-shm')).map(name => [name, readFileSync(join(f.home, name))])); + const spawn = childProcess.spawn; let stdout = '', stderr = ''; + mock.method(childProcess, 'spawn', (...args: Parameters) => { + const child = spawn(...args); child.stdout?.on('data', part => { stdout += part; }); + child.stderr?.on('data', part => { stderr += part; }); return child; + }); t.after(() => mock.restoreAll()); + assert.deepEqual(await metadataCapability(f.scope, '0.160.1', budget(), owner), { supported: true }); + assert.deepEqual(await observe(f.scope), { supported: true, injectionObserved: true, + clientUserMessageId: clientId, turn: { id: 'turn-fixture-1', status: 'running' } }); + for (const sentinel of ['PRIVATE_BODY_SENTINEL', 'ERROR_BODY_SENTINEL']) { + assert.equal(stdout.includes(sentinel), false); assert.equal(stderr.includes(sentinel), false); + } + assert.deepEqual(readdirSync(f.home).sort(), files); + for (const [name, bytes] of before) assert.deepEqual(readFileSync(join(f.home, name)), bytes, name); +}); +test('native clientId, not item_id or correlation body, controls injection evidence', posix, async t => { + const f = fixture(t); f.item(1, 'unrelated-client', 'Handoff: ' + clientId); + assert.deepEqual(await observe(f.scope), { supported: true, injectionObserved: false }); + assert.deepEqual(await observe(f.scope, 'native-item-1'), { supported: true, injectionObserved: false }); + f.item(2); assert.equal((await observe(f.scope)).injectionObserved, true); +}); +test('queue deletion, missing row and missing turn completion never manufacture delivery or ACK', posix, async t => { + const f = fixture(t); + f.db.exec('CREATE TABLE unrelated_queue(client_id TEXT);'); + f.db.prepare('INSERT INTO unrelated_queue VALUES(?)').run(clientId); + f.db.exec("UPDATE thread_turns SET status='completed'; DELETE FROM unrelated_queue;"); + assert.deepEqual(await observe(f.scope), { supported: true, injectionObserved: false }); + f.item(1); assert.equal((await observe(f.scope)).turn?.status, 'completed'); + f.db.exec('DELETE FROM thread_items;'); + assert.deepEqual(await observe(f.scope), { supported: true, injectionObserved: false }); +}); +test('stored status allowlist leaves unknown statuses unknown and adds no receipt fields', posix, async t => { + const f = fixture(t); f.item(1); + for (const [native, expected] of [['inProgress', 'running'], ['completed', 'completed'], ['failed', 'failed'], + ['interrupted', 'interrupted'], ['future-complete-ish', 'unknown']]) { + f.db.prepare('UPDATE thread_turns SET status=?').run(native!); + const result = await observe(f.scope); assert.equal(result.turn?.status, expected); + assert.deepEqual(Object.keys(result).sort(), ['clientUserMessageId', 'injectionObserved', 'supported', 'turn']); + } +}); +test('old client outside the bounded recent-user window, oversized and malformed cells do not become delivery', posix, async t => { + const f = fixture(t); f.item(1); + for (let n = 2; n <= 257; n++) f.item(n, 'other-' + n); + assert.deepEqual(await observe(f.scope), { supported: true, injectionObserved: false }); + f.item(258, clientId, 'x'.repeat(METADATA_SOURCE.maxCellBytes)); + assert.deepEqual(await observe(f.scope), { supported: true, injectionObserved: false }); + f.db.prepare('UPDATE thread_items SET item_json=? WHERE item_id=?').run('{invalid PRIVATE_BODY_SENTINEL', 'native-item-258'); + assert.deepEqual(await observe(f.scope), { supported: true, injectionObserved: false }); +}); +test('duplicate native client IDs fail closed instead of choosing a turn', posix, async t => { + const f = fixture(t); f.item(1); f.item(2); + assert.deepEqual(await observe(f.scope), { supported: false, injectionObserved: false, reason: 'metadata_ambiguous' }); +}); +test('version/platform scope and unknown generation refuse before starting any probe', posix, async t => { + const f = fixture(t); let called = 0; + const verify = () => { called++; return true; }; + for (const version of ['0.159.0', '0.160.2', '0.160.1+unknown', 'garbage']) { + assert.deepEqual(await metadataCapability(f.scope, version, budget(), verify), { supported: false, reason: 'metadata_version_unsupported' }); + } + assert.deepEqual(await metadataCapability({ ...f.scope, generation: null }, '0.160.1', budget(), verify), + { supported: false, reason: 'metadata_scope_invalid' }); + assert.equal(called, 0); +}); +test('schema drift and missing warm projection refuse capability without warming storage', posix, async t => { + const f = fixture(t); f.db.exec('ALTER TABLE thread_turns RENAME COLUMN status TO future_status;'); + assert.deepEqual(await metadataCapability(f.scope, '0.160.1', budget(), owner), { supported: false, reason: 'metadata_schema_unsupported' }); + const g = fixture(t); g.db.exec('DELETE FROM thread_history_projection_state;'); + const before = readFileSync(g.file); + assert.deepEqual(await metadataCapability(g.scope, '0.160.1', budget(), owner), { supported: false, reason: 'metadata_history_unavailable' }); + assert.deepEqual(readFileSync(g.file), before); + const h = fixture(t); h.db.exec('DROP INDEX idx_thread_items_user_messages;'); + assert.deepEqual(await metadataCapability(h.scope, '0.160.1', budget(), owner), { supported: false, reason: 'metadata_schema_unsupported' }); +}); +test('views, symlink files, unsafe permissions and absent DB cannot become supported', posix, async t => { + const f = fixture(t); f.db.exec('ALTER TABLE thread_items RENAME TO original_items; CREATE VIEW thread_items AS SELECT * FROM original_items;'); + assert.equal((await metadataCapability(f.scope, '0.160.1', budget(), owner)).reason, 'metadata_schema_unsupported'); + const g = fixture(t); renameSync(g.file, g.file + '.original'); symlinkSync(g.file + '.original', g.file); + assert.equal((await metadataCapability(g.scope, '0.160.1', budget(), owner)).reason, 'metadata_storage_untrusted'); + const h = fixture(t); chmodSync(h.file, 0o666); + assert.equal((await metadataCapability(h.scope, '0.160.1', budget(), owner)).reason, 'metadata_storage_untrusted'); + const empty = join(h.home, 'empty'); mkdirSync(empty, { mode: 0o700 }); + assert.equal((await metadataCapability({ ...h.scope, home: empty }, '0.160.1', budget(), owner)).reason, 'metadata_storage_untrusted'); + assert.deepEqual(readdirSync(empty), []); +}); +test('owner must prove the immutable original generation both before and after the read', posix, async t => { + const f = fixture(t); f.item(1); let calls = 0; + const result = await observeMetadata({ ...f.scope, clientUserMessageId: clientId }, budget(), (scope, signal) => { + assert.equal(scope.generation, f.scope.generation); assert.equal(Object.isFrozen(scope), true); + assert.equal(signal.aborted, false); return ++calls === 1; + }); + assert.equal(calls, 2); assert.deepEqual(result, { supported: false, injectionObserved: false, reason: 'metadata_owner_changed' }); + calls = 0; assert.equal((await metadataCapability(f.scope, '0.160.1', budget(), () => { calls++; return false; })).reason, 'metadata_owner_changed'); + assert.equal(calls, 1); +}); +test('replaced database inode after read invalidates otherwise matching metadata', posix, async t => { + const f = fixture(t); f.item(1); const g = fixture(t); g.item(1); let calls = 0; + const result = await observeMetadata({ ...f.scope, clientUserMessageId: clientId }, budget(), () => { + if (++calls === 2) { renameSync(f.file, f.file + '.old'); renameSync(g.file, f.file); } + return true; + }); + assert.deepEqual(result, { supported: false, injectionObserved: false, reason: 'metadata_storage_untrusted' }); +}); +test('expired caller budget starts no process and aborts a hanging owner verifier', posix, async t => { + const f = fixture(t); let calls = 0; + assert.equal((await metadataCapability(f.scope, '0.160.1', { observationRemainingMs: () => 0 }, () => { calls++; return true; })).reason, 'metadata_deadline'); + assert.equal(calls, 0); + const deadline = performance.now() + 35; let aborted = false; + const result = await metadataCapability(f.scope, '0.160.1', { observationRemainingMs: () => deadline - performance.now() }, (_scope, signal) => + new Promise(resolve => signal.addEventListener('abort', () => { aborted = true; resolve(false); }, { once: true }))); + assert.equal(result.supported, false); assert.equal(aborted, true); +}); +test('deadline kills only the owned probe process group including its descendant', posix, async t => { + const f = fixture(t), marker = join(f.home, 'owned-pids.json'); + const realSpawn = childProcess.spawn; + mock.method(childProcess, 'spawn', (binary: string, _args: readonly string[], options: any) => realSpawn(binary, ['-e', + `const cp=require('node:child_process');const fs=require('node:fs');const child=cp.spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});fs.writeFileSync(${JSON.stringify(marker)},JSON.stringify([process.pid,child.pid]));setInterval(()=>{},1000);`], options)); + t.after(() => mock.restoreAll()); + const started = performance.now(), deadline = started + 500; + const result = await observeMetadata({ ...f.scope, clientUserMessageId: clientId }, { observationRemainingMs: () => deadline - performance.now() }, owner); + assert.deepEqual(result, { supported: false, injectionObserved: false, reason: 'metadata_deadline' }); + assert.ok(performance.now() - started < 1500); assert.equal(existsSync(marker), true); + const pids: number[] = JSON.parse(readFileSync(marker, 'utf8')); + for (let n = 0; n < 50 && pids.some(pid => { try { process.kill(pid, 0); return true; } catch { return false; } }); n++) await new Promise(resolve => setTimeout(resolve, 10)); + for (const pid of pids) assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }); + assert.doesNotThrow(() => process.kill(process.pid, 0)); +}); From b503fb9de052787477cccd9d47dc8e5bda6e7178 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 09:33:25 +0900 Subject: [PATCH 02/18] feat(observation): bound original writer identity verification --- src/handoff-writer-probe.ts | 95 +++++++++++++++++++++++++++++++ test/handoff-writer-probe.test.ts | 78 +++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/handoff-writer-probe.ts create mode 100644 test/handoff-writer-probe.test.ts diff --git a/src/handoff-writer-probe.ts b/src/handoff-writer-probe.ts new file mode 100644 index 0000000..46c435a --- /dev/null +++ b/src/handoff-writer-probe.ts @@ -0,0 +1,95 @@ +// Private owner verifier entry point. The caller MUST spawn this in an owned +// POSIX process group and enforce the original remaining deadline (at most 1s). +// lsof/ps inherit that group; using process.run() here would detach them into +// separate groups and defeat the supervisor's cleanup. +// Keep the sample/identity algorithm aligned with writer.ts inspectWriter. +// Its byte-for-byte identity SHA is covered by a real-lock synthetic fixture. +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstatSync, realpathSync } from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +type Owner = { pid: number; uid?: number; command?: string; start?: string }; +type Output = { schemaVersion: 1; ok: true; generation: string } | + { schemaVersion: 1; ok: false; error: 'writer_probe_invalid' | 'writer_probe_unsupported' | 'writer_probe_unverified' }; +const safe = (value: unknown, bytes: number): value is string => typeof value === 'string' && value.length > 0 && + Buffer.byteLength(value) <= bytes && !/[\x00-\x1f\x7f-\x9f]/.test(value) && + !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + return new Promise(resolve => { + let child; + try { child = spawn(binary, args, { shell: false, detached: false, stdio: ['ignore', 'pipe', 'pipe'], + env: { ...env, NODE_OPTIONS: undefined, NODE_PATH: undefined } }); } + catch { resolve({ valid: false, stdout: '' }); return; } + let bytes = 0, output = '', overflow = false, failed = false; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (part: string) => { + bytes += Buffer.byteLength(part); + if (bytes > 4096) { overflow = true; output = ''; } else if (!overflow) output += part; + }); + // Drain metadata tool errors without retaining paths, command text or bodies. + child.stderr.on('data', (part: Buffer) => { bytes += part.length; if (bytes > 8192) { overflow = true; output = ''; } }); + child.once('error', () => { failed = true; }); + // Do not return while a command still runs. A hung/overflowing command and + // all its inherited-group descendants are killed by the owning supervisor. + child.once('close', code => resolve({ valid: code === 0 && !failed && !overflow, stdout: overflow ? '' : output })); + }); +} +async function main(args: string[]): Promise { + const refusal = (error: 'writer_probe_invalid' | 'writer_probe_unsupported' | 'writer_probe_unverified'): Output => ({ schemaVersion: 1, ok: false, error }); + if (!['darwin', 'linux'].includes(process.platform) || process.getuid?.() === undefined) return refusal('writer_probe_unsupported'); + const [home, rawId] = args; + if (args.length !== 2 || !safe(home, 2048) || !safe(rawId, 128) || !isAbsolute(home) || resolve(home) !== home || + !/^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i.test(rawId)) return refusal('writer_probe_invalid'); + try { + const id = rawId.toLowerCase(), directory = join(home, 'thread-writer-locks'), path = join(directory, id + '.lock'); + if (realpathSync(home) !== home || !owned(home, true) || !owned(directory, true) || !owned(path, false)) return refusal('writer_probe_unverified'); + // Dynamic import keeps dependency failures inside the sanitized boundary. + const { probeLock } = await import('./writer.js'); + async function sample() { + const state = await probeLock(path); + if (state !== 'held') return { state, fingerprint: state === 'free' ? fingerprint(path) : '', owners: [] as Owner[], valid: true }; + const stamp = fingerprint(path); + const listed = await command('lsof', ['-nP', '-F0pcu', '--', path]); + const owners: Owner[] = []; let current: Owner | undefined; + // Preserve assignment order: inspectWriter's original JSON serialization + // is the generation source, not a newly canonicalized owner object. + for (const token of listed.stdout.split(/[\0\n]/)) { + const value = token.slice(1).trim(); + if (token[0] === 'p') { current = { pid: Number(value) }; owners.push(current); } + if (current && token[0] === 'u') { const uid = /^\d+$/.test(value) ? Number(value) : NaN; current.uid = Number.isSafeInteger(uid) ? uid : undefined; } + if (current && token[0] === 'c') current.command = value; + } + // Ambiguous owners need no further tools and cannot produce a generation. + if (owners.length !== 1) return { state, fingerprint: stamp, owners, valid: false }; + for (const owner of owners) { + if (!Number.isSafeInteger(owner.pid) || owner.pid <= 1) continue; + const started = await command('ps', ['-p', String(owner.pid), '-o', 'lstart='], { ...process.env, LC_ALL: 'C' }); + if (started.valid) owner.start = started.stdout.trim(); + } + return { state, fingerprint: stamp, owners, valid: listed.valid }; + } + const before = await sample(); + if (before.state !== 'held') return refusal('writer_probe_unverified'); + await delay(250); + const after = await sample(), owner = after.owners[0], uid = process.getuid?.(); + if (!before.valid || !after.valid || before.state !== after.state || before.fingerprint !== after.fingerprint || + before.owners.length !== 1 || after.owners.length !== 1 || JSON.stringify(before.owners) !== JSON.stringify(after.owners) || + !owner?.start || !safe(owner.start, 256) || uid === undefined || owner.uid !== uid || + !/^codex(?:\.exe|-|$)/i.test(owner.command ?? '') || !owned(path, false)) return refusal('writer_probe_unverified'); + const identity = JSON.stringify({ fingerprint: after.fingerprint, ...owner }); + return { schemaVersion: 1, ok: true, generation: createHash('sha256').update(identity).digest('hex') }; + } catch { return refusal('writer_probe_unverified'); } +} +void main(process.argv.slice(2)).then(result => { process.stdout.write(JSON.stringify(result) + '\n'); }, + () => { process.stdout.write('{"schemaVersion":1,"ok":false,"error":"writer_probe_unverified"}\n'); }); diff --git a/test/handoff-writer-probe.test.ts b/test/handoff-writer-probe.test.ts new file mode 100644 index 0000000..5850e6f --- /dev/null +++ b/test/handoff-writer-probe.test.ts @@ -0,0 +1,78 @@ +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { createRequire } from 'node:module'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { delimiter, join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { inspectWriter, probeLock } from '../dist/writer.js'; +import { run } from '../dist/process.js'; + +const posix = { skip: process.platform === 'win32' }; +const id = '11111111-1111-4111-8111-111111111111'; +const nativeLocks = createRequire(import.meta.url).resolve('fs-ext-extra-prebuilt'); +const probe = resolve('dist/handoff-writer-probe.js'); +async function fixture(t: TestContext, uid: string = String(process.getuid?.()), order = 'command-first') { + const root = realpathSync(mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-writer-probe-'))); + chmodSync(root, 0o700); const home = join(root, 'home'), bin = join(root, 'bin'); + mkdirSync(join(home, 'thread-writer-locks'), { recursive: true, mode: 0o700 }); mkdirSync(bin, { mode: 0o700 }); + t.after(() => rmSync(root, { recursive: true, force: true })); + const lock = join(home, 'thread-writer-locks', id + '.lock'); + const held = spawn(process.execPath, ['-e', `const fs=require('node:fs'),locks=require(${JSON.stringify(nativeLocks)});const fd=fs.openSync(process.argv[1],'w',0o600);locks.flockSync(fd,'exnb');process.stdout.write('ready');setInterval(()=>{},1000);`, lock], + { stdio: ['ignore', 'pipe', 'pipe'] }); + held.stderr.resume(); await once(held.stdout, 'data'); assert.equal(await probeLock(lock), 'held'); + t.after(async () => { if (held.exitCode === null && held.signalCode === null) { held.kill('SIGKILL'); await once(held, 'close'); } }); + const owner = `p${held.pid}\0` + (order === 'command-first' ? `ccodex-fixture\0u${uid}\0` : `u${uid}\0ccodex-fixture\0`); + writeFileSync(join(bin, 'lsof'), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(owner)});\n`, { mode: 0o700 }); + writeFileSync(join(bin, 'ps'), `#!${process.execPath}\nconsole.log('Fixture stable start time');\n`, { mode: 0o700 }); + const env = { ...process.env, PATH: bin + delimiter + (process.env.PATH ?? ''), NODE_OPTIONS: undefined, NODE_PATH: undefined }; + return { root, home, bin, held, env }; +} +test('bounded probe hashes exactly the existing inspectWriter identity for either owner token order', posix, async t => { + for (const order of ['command-first', 'uid-first']) { + const f = await fixture(t, String(process.getuid?.()), order), saved = process.env.PATH; + process.env.PATH = f.env.PATH; + let identity: string; try { identity = (await inspectWriter(f.home, id)).identity; } + finally { if (saved === undefined) delete process.env.PATH; else process.env.PATH = saved; } + const result = await run(process.execPath, [probe, f.home, id], { env: f.env, timeout: 1000, limit: 4096 }); + assert.equal(result.code, 0); assert.equal(result.interrupted, false); + assert.deepEqual(JSON.parse(result.stdout), { schemaVersion: 1, ok: true, + generation: createHash('sha256').update(identity!).digest('hex') }); + assert.equal(result.stderr, ''); assert.equal(result.stdout.includes(f.home), false); + } +}); +test('invalid/missing/inactive/foreign owner results are closed sanitized failures without paths', posix, async t => { + const f = await fixture(t, String((process.getuid?.() ?? 0) + 1)); + for (const args of [[f.home, id], [f.home, 'invalid'], ['relative', id], [f.home, id, 'extra'], [join(f.root, 'PRIVATE_PATH_SENTINEL'), id]]) { + const done = await run(process.execPath, [probe, ...args], { env: f.env, timeout: 1000, limit: 4096 }); + const value = JSON.parse(done.stdout); assert.equal(value.ok, false); + assert.deepEqual(Object.keys(value).sort(), ['error', 'ok', 'schemaVersion']); + assert.ok(['writer_probe_invalid', 'writer_probe_unverified'].includes(value.error)); + assert.equal(done.stdout.includes(f.root), false); assert.equal(done.stderr.includes('PRIVATE_PATH_SENTINEL'), false); + } + f.held.kill('SIGKILL'); await once(f.held, 'close'); + const inactive = await run(process.execPath, [probe, f.home, id], { env: f.env, timeout: 1000 }); + assert.deepEqual(JSON.parse(inactive.stdout), { schemaVersion: 1, ok: false, error: 'writer_probe_unverified' }); +}); +test('tool stderr and malformed owner metadata never appear in probe output', posix, async t => { + const f = await fixture(t); + writeFileSync(join(f.bin, 'lsof'), `#!${process.execPath}\nprocess.stderr.write('PRIVATE_BODY_SENTINEL');process.stdout.write('pbad\\0cother\\0u0\\0');\n`, { mode: 0o700 }); + const result = await run(process.execPath, [probe, f.home, id], { env: f.env, timeout: 1000 }); + assert.deepEqual(JSON.parse(result.stdout), { schemaVersion: 1, ok: false, error: 'writer_probe_unverified' }); + assert.equal(result.stderr, ''); assert.equal(result.stdout.includes('PRIVATE_BODY_SENTINEL'), false); +}); +test('supervisor deadline reaps stalled lsof/ps and their descendants while retaining the unrelated writer', posix, async t => { + for (const tool of ['lsof', 'ps']) { + const f = await fixture(t), marker = join(f.root, tool + '-pids.json'); + writeFileSync(join(f.bin, tool), `#!${process.execPath}\nconst cp=require('node:child_process'),fs=require('node:fs');const child=cp.spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});fs.writeFileSync(${JSON.stringify(marker)},JSON.stringify([process.pid,child.pid]));setInterval(()=>{},1000);\n`, { mode: 0o700 }); + const started = performance.now(); + const result = await run(process.execPath, [probe, f.home, id], { env: f.env, timeout: 1000, limit: 4096 }); + assert.equal(result.interrupted, true); assert.ok(performance.now() - started < 1600); assert.equal(existsSync(marker), true); + const pids: number[] = JSON.parse(readFileSync(marker, 'utf8')); + for (let n = 0; n < 50 && pids.some(pid => { try { process.kill(pid, 0); return true; } catch { return false; } }); n++) await new Promise(resolve => setTimeout(resolve, 10)); + for (const pid of pids) assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }); + assert.doesNotThrow(() => process.kill(f.held.pid!, 0)); assert.equal(await probeLock(join(f.home, 'thread-writer-locks', id + '.lock')), 'held'); + } +}); From 293c578ebeb5058f7ed6271432fc73bf9d71a356 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 09:44:24 +0900 Subject: [PATCH 03/18] fix(observation): bound filesystem checks and SQLite temporary storage --- src/handoff-observer-probe.ts | 110 +++++++++++++++++++----------- src/metadata-observation.ts | 70 +++++++------------ test/metadata-observation.test.ts | 53 ++++++++++++++ 3 files changed, 147 insertions(+), 86 deletions(-) diff --git a/src/handoff-observer-probe.ts b/src/handoff-observer-probe.ts index 3d9feca..a8a4f87 100644 --- a/src/handoff-observer-probe.ts +++ b/src/handoff-observer-probe.ts @@ -5,44 +5,47 @@ // app-server-protocol/src/protocol/v2/item.rs (userMessage.clientId); // thread-store/src/local/thread_history.rs (turn_status, projection lag). import { DatabaseSync } from 'node:sqlite'; +import { createHash } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; import { basename, dirname, isAbsolute } from 'node:path'; type Reason = 'metadata_storage_untrusted' | 'metadata_schema_unsupported' | 'metadata_history_unavailable' | 'metadata_probe_failed' | 'metadata_ambiguous'; type SqlRow = Record; +type ProbeRequest = { mode: 'snapshot' | 'capability' | 'observe'; database: string; threadId: string; + clientUserMessageId?: string; expectedStorageStamp?: string }; const fail = (reason: Reason) => ({ supported: false, injectionObserved: false, reason }); const safe = (value: unknown, bytes: number): value is string => typeof value === 'string' && value.length > 0 && Buffer.byteLength(value) <= bytes && !/[\x00-\x1f\x7f-\x9f]/.test(value) && !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? !['mode', 'database', 'threadId', 'clientUserMessageId'].includes(key)) || - (request.mode === 'observe' && !safe(request.clientUserMessageId, 128))) return fail('metadata_probe_failed'); - } catch { return fail('metadata_probe_failed'); } +function storageStamp(database: string): string { + const uid = process.getuid?.(), home = lstatSync(dirname(database)); + if (uid === undefined || !home.isDirectory() || home.isSymbolicLink() || home.uid !== uid || (home.mode & 0o022) || + realpathSync(database) !== database) throw new Error(); + const stamps: { suffix: string; dev: string; ino: string }[] = []; + for (const suffix of ['', '-wal', '-shm', '-journal']) { + try { + const side = lstatSync(database + suffix, { bigint: true }); + if (suffix === '-journal' || !side.isFile() || side.isSymbolicLink() || side.uid !== BigInt(uid) || + side.nlink !== 1n || (side.mode & 0o022n)) throw new Error(); + stamps.push({ suffix, dev: side.dev.toString(), ino: side.ino.toString() }); + } catch (error) { if (!suffix || (error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + } + // Never create a WAL shared-memory sidecar or recover a rollback journal. + // Existing SHM reader locks are transient; DB/WAL bytes remain read-only. + if (stamps.some(item => item.suffix === '-wal') !== stamps.some(item => item.suffix === '-shm')) throw new Error(); + return createHash('sha256').update(JSON.stringify(stamps)).digest('hex'); +} +function queryDatabase(request: ProbeRequest): { supported: boolean; injectionObserved: boolean; reason?: Reason; + clientUserMessageId?: string; turn?: { id: string; status: string } } { let db: DatabaseSync | undefined; try { - const uid = process.getuid?.(), home = lstatSync(dirname(request.database)), info = lstatSync(request.database); - if (uid === undefined || !home.isDirectory() || home.isSymbolicLink() || home.uid !== uid || (home.mode & 0o022) || - realpathSync(request.database) !== request.database || !info.isFile() || info.isSymbolicLink() || - info.uid !== uid || info.nlink !== 1 || (info.mode & 0o022)) return fail('metadata_storage_untrusted'); - const sides = new Set(); - for (const suffix of ['-wal', '-shm', '-journal']) { - try { const side = lstatSync(request.database + suffix); - if (suffix === '-journal' || !side.isFile() || side.isSymbolicLink() || side.uid !== uid || side.nlink !== 1 || (side.mode & 0o022)) return fail('metadata_storage_untrusted'); - sides.add(suffix); - } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return fail('metadata_storage_untrusted'); } - } - if (sides.has('-wal') !== sides.has('-shm')) return fail('metadata_storage_untrusted'); + const before = storageStamp(request.database); db = new DatabaseSync(request.database, { readOnly: true }); - db.exec('PRAGMA query_only=ON; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=40;'); - const opened = lstatSync(request.database); - if (opened.dev !== info.dev || opened.ino !== info.ino) return fail('metadata_storage_untrusted'); + db.exec('PRAGMA query_only=ON; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=40; PRAGMA temp_store=MEMORY;'); + if ((db.prepare('PRAGMA temp_store').get() as SqlRow).temp_store !== 2 || + (db.prepare('PRAGMA encoding').get() as SqlRow).encoding !== 'UTF-8') return fail('metadata_probe_failed'); + if (storageStamp(request.database) !== before) return fail('metadata_storage_untrusted'); const tables = db.prepare("SELECT name,type FROM sqlite_schema WHERE name IN ('thread_items','thread_turns','thread_history_projection_state')").all() as SqlRow[]; if (tables.length !== 3 || tables.some(row => row.type !== 'table')) return fail('metadata_schema_unsupported'); const columns: Record> = { @@ -64,34 +67,61 @@ function main(raw: Buffer): unknown { const projection = db.prepare('SELECT next_rollout_byte_offset,next_rollout_ordinal FROM thread_history_projection_state WHERE thread_id=?').get(request.threadId) as SqlRow | undefined; if (!projection || !Object.values(projection).every(value => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)) return fail('metadata_history_unavailable'); // Preparing this query also verifies the partial index's actual predicate. - // LIMIT is applied inside a materialized CTE before any JSON parsing. Raw - // item_json stays INSIDE SQLite; no body-bearing column crosses into JS. + // Materialize only row keys before JSON parsing. Never copy item_json into + // a CTE/temp table: even an oversized cell must stay in its original table. + // Projected temporary rows contain only bounded IDs, never body values. const query = db.prepare(`WITH recent AS MATERIALIZED ( - SELECT thread_id,turn_id,item_json FROM thread_items INDEXED BY idx_thread_items_user_messages + SELECT rowid AS item_rowid FROM thread_items INDEXED BY idx_thread_items_user_messages WHERE thread_id=? AND item_type='userMessage' ORDER BY rollout_ordinal DESC LIMIT 256 ), projected AS MATERIALIZED ( - SELECT thread_id,turn_id,CASE WHEN typeof(item_json)='text' THEN - CASE WHEN length(CAST(item_json AS BLOB))<=262144 THEN + SELECT CASE WHEN typeof(turn_id)='text' AND octet_length(turn_id)<=128 THEN turn_id END AS turn_id, + CASE WHEN typeof(item_json)='text' THEN + CASE WHEN octet_length(item_json)<=262144 THEN CASE WHEN json_valid(item_json) THEN - CASE WHEN json_extract(item_json,'$.type')='userMessage' THEN json_extract(item_json,'$.clientId') END + CASE WHEN json_extract(item_json,'$.type')='userMessage' AND json_type(item_json,'$.clientId')='text' THEN + CASE WHEN length(CAST(json_extract(item_json,'$.clientId') AS BLOB))<=128 THEN json_extract(item_json,'$.clientId') END + END END END - END AS client_id FROM recent - ) SELECT projected.thread_id AS threadId,projected.turn_id AS turnId, - projected.client_id AS clientUserMessageId,thread_turns.status AS status - FROM projected LEFT JOIN thread_turns ON thread_turns.thread_id=projected.thread_id AND thread_turns.turn_id=projected.turn_id - WHERE typeof(projected.client_id)='text' AND projected.client_id=? LIMIT 2`); + END AS client_id FROM recent JOIN thread_items ON thread_items.rowid=recent.item_rowid + ) SELECT projected.turn_id AS turnId,projected.client_id AS clientUserMessageId, + CASE WHEN typeof(thread_turns.status)='text' AND octet_length(thread_turns.status)<=11 THEN + CASE thread_turns.status WHEN 'inProgress' THEN 'running' WHEN 'completed' THEN 'completed' + WHEN 'failed' THEN 'failed' WHEN 'interrupted' THEN 'interrupted' ELSE 'unknown' END + ELSE 'unknown' END AS status + FROM projected LEFT JOIN thread_turns ON thread_turns.thread_id=? AND thread_turns.turn_id=projected.turn_id + WHERE projected.client_id=? LIMIT 2`); if (request.mode === 'capability') return { supported: true, injectionObserved: false }; - const rows = query.all(request.threadId, request.clientUserMessageId!) as SqlRow[]; + const rows = query.all(request.threadId, request.threadId, request.clientUserMessageId!) as SqlRow[]; if (rows.length > 1) return fail('metadata_ambiguous'); if (!rows.length) return { supported: true, injectionObserved: false }; const row = rows[0]!; - if (row.threadId !== request.threadId || row.clientUserMessageId !== request.clientUserMessageId || !safe(row.turnId, 128)) return fail('metadata_probe_failed'); - const status = row.status === 'inProgress' ? 'running' : ['completed', 'failed', 'interrupted'].includes(row.status as string) ? row.status : 'unknown'; - return { supported: true, injectionObserved: true, clientUserMessageId: request.clientUserMessageId, turn: { id: row.turnId, status } }; + if (row.clientUserMessageId !== request.clientUserMessageId || !safe(row.turnId, 128) || + !['running', 'completed', 'failed', 'interrupted', 'unknown'].includes(row.status as string)) return fail('metadata_probe_failed'); + return { supported: true, injectionObserved: true, clientUserMessageId: request.clientUserMessageId, turn: { id: row.turnId, status: row.status as string } }; } catch { return fail('metadata_probe_failed'); } finally { try { db?.close(); } catch { /* No diagnostics or storage mutation. */ } } } +function main(raw: Buffer): unknown { + let request: ProbeRequest; + try { + request = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)); + if (!request || !['snapshot', 'capability', 'observe'].includes(request.mode) || !safe(request.database, 2200) || + !isAbsolute(request.database) || basename(request.database) !== 'thread_history_1.sqlite' || + !/^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/.test(request.threadId) || + Object.keys(request).some(key => !['mode', 'database', 'threadId', 'clientUserMessageId', 'expectedStorageStamp'].includes(key)) || + (request.expectedStorageStamp !== undefined && !/^[a-f0-9]{64}$/.test(request.expectedStorageStamp)) || + (request.mode === 'observe' && !safe(request.clientUserMessageId, 128))) return fail('metadata_probe_failed'); + } catch { return fail('metadata_probe_failed'); } + try { + const before = storageStamp(request.database); + if (request.expectedStorageStamp !== undefined && before !== request.expectedStorageStamp) return fail('metadata_storage_untrusted'); + const result = request.mode === 'snapshot' ? { supported: true, injectionObserved: false } : queryDatabase(request); + if (!result.supported) return result; + if (storageStamp(request.database) !== before) return fail('metadata_storage_untrusted'); + return { ...result, storageStamp: before }; + } catch { return fail('metadata_storage_untrusted'); } +} let bytes = 0, oversized = false; const parts: Buffer[] = []; process.stdin.on('data', (part: Buffer) => { bytes += part.length; if (bytes > 4096) { oversized = true; parts.length = 0; } else if (!oversized) parts.push(part); }); process.stdin.once('end', () => { diff --git a/src/metadata-observation.ts b/src/metadata-observation.ts index 69af2d8..60cccad 100644 --- a/src/metadata-observation.ts +++ b/src/metadata-observation.ts @@ -1,8 +1,7 @@ // Opt-in, metadata-only observation for the exact qualified Codex store below. // No transcript, native client, warming, migration or receipt bootstrap is used. import childProcess, { type ChildProcessWithoutNullStreams } from 'node:child_process'; -import { lstatSync, realpathSync } from 'node:fs'; -import { isAbsolute, join, resolve } from 'node:path'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; export const METADATA_SOURCE = Object.freeze({ version: '0.160.1', @@ -20,8 +19,9 @@ export type MetadataReason = 'metadata_version_unsupported' | 'metadata_platform export type MetadataCapability = { supported: boolean; reason?: MetadataReason }; export type MetadataObservation = MetadataCapability & { injectionObserved: boolean; clientUserMessageId?: string; turn?: { id: string; status: 'running' | 'completed' | 'failed' | 'interrupted' | 'unknown' } }; -type Stamp = { path: string; dev: number; ino: number }; -type ProbeRequest = { mode: 'capability' | 'observe'; database: string; threadId: string; clientUserMessageId?: string }; +type ProbeResult = MetadataObservation & { storageStamp?: string }; +type ProbeRequest = { mode: 'snapshot' | 'capability' | 'observe'; database: string; threadId: string; + clientUserMessageId?: string; expectedStorageStamp?: string }; const reasons: readonly MetadataReason[] = ['metadata_version_unsupported', 'metadata_platform_unsupported', 'metadata_scope_invalid', 'metadata_storage_untrusted', 'metadata_history_unavailable', 'metadata_schema_unsupported', 'metadata_owner_changed', 'metadata_deadline', 'metadata_probe_failed', 'metadata_probe_stopped', 'metadata_ambiguous']; @@ -34,34 +34,6 @@ function remaining(budget?: MetadataBudget): number { const value = budget?.observationRemainingMs() ?? 3000; return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; } -function trusted(scope: MetadataScope): Stamp[] { - const uid = process.getuid?.(); - if (uid === undefined || !isAbsolute(scope.home) || resolve(scope.home) !== scope.home || - realpathSync(scope.home) !== scope.home) throw new Error(); - const home = lstatSync(scope.home); - if (!home.isDirectory() || home.isSymbolicLink() || home.uid !== uid || (home.mode & 0o022)) throw new Error(); - const database = join(scope.home, METADATA_SOURCE.database); - const stamps: Stamp[] = []; - for (const path of [database, database + '-wal', database + '-shm', database + '-journal']) { - let info; - try { info = lstatSync(path); } catch (error) { - if (path !== database && (error as NodeJS.ErrnoException).code === 'ENOENT') continue; - throw error; - } - if (!info.isFile() || info.isSymbolicLink() || info.uid !== uid || info.nlink !== 1 || (info.mode & 0o022)) throw new Error(); - // A rollback journal can require recovery. Never let this reader recover it. - if (path.endsWith('-journal')) throw new Error(); - stamps.push({ path, dev: info.dev, ino: info.ino }); - } - // Read-only WAL access requires a pre-existing shared-memory sidecar. SQLite - // may participate in transient SHM reader locks; it cannot create sidecars here. - if (stamps.some(item => item.path.endsWith('-wal')) !== stamps.some(item => item.path.endsWith('-shm'))) throw new Error(); - return stamps; -} -function sameFiles(before: Stamp[], after: Stamp[]): boolean { - return before.length === after.length && before.every((item, i) => item.path === after[i]?.path && - item.dev === after[i]?.dev && item.ino === after[i]?.ino); -} async function owner(scope: MetadataScope, verify: VerifyMetadataOwner, budget?: MetadataBudget): Promise { const duration = Math.min(METADATA_SOURCE.childMs, remaining(budget)); if (duration < 1) return false; @@ -72,7 +44,7 @@ async function owner(scope: MetadataScope, verify: VerifyMetadataOwner, budget?: } catch { return false; } finally { if (timer) clearTimeout(timer); controller.abort(); } } -function probe(request: ProbeRequest, budget?: MetadataBudget): Promise { +function probe(request: ProbeRequest, budget?: MetadataBudget): Promise { const duration = Math.min(METADATA_SOURCE.childMs, remaining(budget)); if (duration < 1) return Promise.resolve(unsupported('metadata_deadline')); const extension = import.meta.url.endsWith('.ts') ? '.ts' : '.js'; @@ -88,7 +60,7 @@ function probe(request: ProbeRequest, budget?: MetadataBudget): Promise void>(); const reap = () => { if (child.pid) try { process.kill(-child.pid, 'SIGKILL'); } catch { /* Already gone. */ } }; - const finish = (result: MetadataObservation) => { + const finish = (result: ProbeResult) => { if (finished) return; finished = true; clearTimeout(timer); if (grace) clearTimeout(grace); for (const signal of signals) process.removeListener(signal, handlers.get(signal)!); resolve(result); @@ -115,12 +87,14 @@ function probe(request: ProbeRequest, budget?: MetadataBudget): Promise !['supported', 'injectionObserved', 'reason', 'clientUserMessageId', 'turn'].includes(key))) throw new Error(); + Object.keys(value).some(key => !['supported', 'injectionObserved', 'reason', 'clientUserMessageId', 'turn', 'storageStamp'].includes(key))) throw new Error(); if (!value.supported) { - if (value.injectionObserved || !reasons.includes(value.reason!) || value.clientUserMessageId !== undefined || value.turn !== undefined) throw new Error(); - } else if (value.reason !== undefined) throw new Error(); + if (value.injectionObserved || !reasons.includes(value.reason!) || value.clientUserMessageId !== undefined || + value.turn !== undefined || value.storageStamp !== undefined) throw new Error(); + } else if (value.reason !== undefined || !/^[a-f0-9]{64}$/.test(value.storageStamp ?? '') || + (request.expectedStorageStamp !== undefined && value.storageStamp !== request.expectedStorageStamp)) throw new Error(); if (value.injectionObserved) { if (value.clientUserMessageId !== request.clientUserMessageId || !safe(value.clientUserMessageId, 128) || !value.turn || Object.keys(value.turn).some(key => !['id', 'status'].includes(key)) || !safe(value.turn.id, 128) || @@ -132,24 +106,28 @@ function probe(request: ProbeRequest, budget?: MetadataBudget): Promise { if (!['darwin', 'linux'].includes(process.platform) || process.getuid?.() === undefined) return unsupported('metadata_platform_unsupported'); if (!scope || !safe(scope.home, 2048) || !safe(scope.threadId, 128) || !uuid(scope.threadId) || !safe(scope.generation, 256) || (mode === 'observe' && !safe(clientUserMessageId, 128))) return unsupported('metadata_scope_invalid'); const original = Object.freeze({ home: scope.home, threadId: scope.threadId, generation: scope.generation }); if (remaining(budget) < 1) return unsupported('metadata_deadline'); - let before: Stamp[]; - try { before = trusted(original); } catch { return unsupported('metadata_storage_untrusted'); } + // Filesystem calls can block on a disconnected volume. Every validation and + // inode comparison is performed inside a deadline-owned worker, never here. + const request = { database: join(original.home, METADATA_SOURCE.database), threadId: original.threadId.toLowerCase() }; + const before = await probe({ ...request, mode: 'snapshot' }, budget); + if (!before.supported) return before; if (!(await owner(original, verify, budget))) return unsupported(remaining(budget) < 1 ? 'metadata_deadline' : 'metadata_owner_changed'); - const result = await probe({ mode, database: join(original.home, METADATA_SOURCE.database), - threadId: original.threadId.toLowerCase(), ...(clientUserMessageId === undefined ? {} : { clientUserMessageId }) }, budget); + const result = await probe({ ...request, mode, expectedStorageStamp: before.storageStamp, + ...(clientUserMessageId === undefined ? {} : { clientUserMessageId }) }, budget); if (!result.supported) return result; if (!(await owner(original, verify, budget))) return unsupported(remaining(budget) < 1 ? 'metadata_deadline' : 'metadata_owner_changed'); - try { if (!sameFiles(before, trusted(original))) return unsupported('metadata_storage_untrusted'); } - catch { return unsupported('metadata_storage_untrusted'); } + const after = await probe({ ...request, mode: 'snapshot', expectedStorageStamp: before.storageStamp }, budget); + if (!after.supported) return after; if (remaining(budget) < 1) return unsupported('metadata_deadline'); - return result; + const { storageStamp: _privateStamp, ...publicResult } = result; + return publicResult; } export async function metadataCapability(scope: MetadataScope, version: string, budget: MetadataBudget | undefined, verifyOwner: VerifyMetadataOwner): Promise { diff --git a/test/metadata-observation.test.ts b/test/metadata-observation.test.ts index 21b1fbc..2eb7e1c 100644 --- a/test/metadata-observation.test.ts +++ b/test/metadata-observation.test.ts @@ -1,6 +1,8 @@ import { test, mock, type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import childProcess from 'node:child_process'; +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; import { DatabaseSync } from 'node:sqlite'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync } from 'node:fs'; import { join } from 'node:path'; @@ -170,3 +172,54 @@ test('deadline kills only the owned probe process group including its descendant for (const pid of pids) assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }); assert.doesNotThrow(() => process.kill(process.pid, 0)); }); +test('actual SQLite uses memory-only temp storage and materializes keys and bounded scalars only', posix, async t => { + const f = fixture(t), marker = join(f.home, 'sql-policy.json'), temp = join(f.home, 'sqlite-temp'); + mkdirSync(temp, { mode: 0o700 }); + f.item(1, clientId, 'PRIVATE_OVERSIZED_BODY_SENTINEL'.repeat(80000)); + f.item(2, 'other-client'); + f.db.prepare('UPDATE thread_items SET item_json=? WHERE item_id=?').run(JSON.stringify({ type: 'userMessage', + clientId: { content: 'PRIVATE_OBJECT_BODY_SENTINEL' } }), 'native-item-2'); + f.item(3, 'x'.repeat(129)); + const preload = `import sqlite from 'node:sqlite';import fs from 'node:fs'; +const prepare=sqlite.DatabaseSync.prototype.prepare;sqlite.DatabaseSync.prototype.prepare=function(sql){ + if(sql.startsWith('WITH recent')) fs.writeFileSync(${JSON.stringify(marker)},JSON.stringify({ + tempStore:prepare.call(this,'PRAGMA temp_store').get().temp_store, + recentProjection:sql.match(/WITH recent AS MATERIALIZED \\(\\s*SELECT ([\\s\\S]*?) FROM thread_items/)[1],sql + }));return prepare.call(this,sql);};`; + const realSpawn = childProcess.spawn; + mock.method(childProcess, 'spawn', (binary: string, args: readonly string[], options: any) => realSpawn(binary, + ['--import', 'data:text/javascript,' + encodeURIComponent(preload), ...args], { ...options, env: { ...options.env, SQLITE_TMPDIR: temp } })); + t.after(() => mock.restoreAll()); + const result = await observe(f.scope); + assert.deepEqual(result, { supported: true, injectionObserved: false }); + const policy = JSON.parse(readFileSync(marker, 'utf8')); + assert.equal(policy.tempStore, 2); + assert.equal(policy.recentProjection, 'rowid AS item_rowid'); + assert.ok(policy.sql.includes("json_type(item_json,'$.clientId')='text'")); + assert.deepEqual(readdirSync(temp), []); + assert.equal(JSON.stringify(result).includes('PRIVATE_'), false); +}); +test('blocking realpath/lstat validation remains in a killable worker and never blocks the supervisor', posix, async t => { + for (const method of ['realpathSync', 'lstatSync']) { + const f = fixture(t), marker = join(f.home, method + '-started'); let parentCalls = 0, ticks = 0; + const preload = `import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module'; +fs.writeFileSync(${JSON.stringify(marker)},'ready');const original=fs.${method};const blocked=()=>{Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,5000);throw new Error('PRIVATE_FS_SENTINEL');};blocked.native=original.native;fs.${method}=blocked;syncBuiltinESMExports();`; + const realSpawn = childProcess.spawn; let pid: number | undefined; + mock.method(childProcess, 'spawn', (binary: string, args: readonly string[], options: any) => { + const child = realSpawn(binary, ['--import', 'data:text/javascript,' + encodeURIComponent(preload), ...args], options); + pid = child.pid; return child; + }); + mock.method(fs, method as 'realpathSync', () => { parentCalls++; throw new Error('supervisor synchronous filesystem validation'); }); + syncBuiltinESMExports(); + const timer = setInterval(() => { ticks++; }, 20), start = performance.now(), deadline = start + 400; + let result; + try { result = await observeMetadata({ ...f.scope, clientUserMessageId: clientId }, + { observationRemainingMs: () => deadline - performance.now() }, owner); } + finally { clearInterval(timer); mock.restoreAll(); syncBuiltinESMExports(); } + assert.deepEqual(result, { supported: false, injectionObserved: false, reason: 'metadata_deadline' }); + assert.equal(parentCalls, 0); assert.ok(ticks >= 3); assert.equal(existsSync(marker), true); + assert.ok(performance.now() - start < 1200); + for (let n = 0; n < 50; n++) { try { process.kill(pid!, 0); } catch { break; } await new Promise(resolve => setTimeout(resolve, 10)); } + assert.throws(() => process.kill(pid!, 0), { code: 'ESRCH' }); + } +}); From ac10b968b8b96f353805aed941412a161b3fe828 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 10:04:39 +0900 Subject: [PATCH 04/18] feat(observation): integrate qualified local queue evidence and bounded waits --- PARITY.md | 17 ++++++++ docs/api.md | 48 ++++++++++++++++++++ docs/guide.ja.md | 5 +++ docs/guide.ko.md | 5 +++ docs/guide.md | 5 +++ docs/guide.zh-CN.md | 5 +++ package.json | 2 +- scripts/release.mjs | 2 +- src/handoff-ledger.ts | 49 +++++++++++++-------- src/handoff-operations.ts | 82 ++++++++++++++++++++++++++++++----- src/send.ts | 11 ++++- test/handoff-core.test.ts | 43 ++++++++++++++++++ test/handoff-delivery.test.ts | 63 +++++++++++++++++++++++++++ test/package-smoke.ts | 2 +- 14 files changed, 305 insertions(+), 34 deletions(-) create mode 100644 test/handoff-delivery.test.ts diff --git a/PARITY.md b/PARITY.md index 881cb15..e327ac0 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1102,3 +1102,20 @@ fixture refused the durable effect guard and left zero queue rows. No version range, Linux/Windows qualification, live delivery or ACK follows from these queue-only fixtures. Initial harness failures are recorded separately and were never retried with an already-attempted intent. + +## Unreleased local observation integration — #72 + +[Source-only runtime details](docs/api.md#unreleased-local-delivery-observation--72) +cover bounded one-shot/explicit-wait observation, native CID queueing, original +writer generation checks, per-operation terminal waits and positive native fact +preservation. Generated schema/native queue mechanics are distinct from +synthetic metadata/wait integration and live consumption evidence. The combined +CLI path is restricted to Codex 0.160.1/macOS arm64; POSIX SQL fixtures alone do +not qualify other native platforms. No transcript is read, no ACK is inferred +and no post-attempt fallback/resend occurs. + +Open release gates include real local/platform qualification, SSH originating +ledger coordination, Windows ACL support, receipt producer/collector bootstrap, +restart observation and bounded cancellation of all initial setup operations. +#69/#70/#71/#72 remain together in 0.4.0 and open. Ordinary runtime parity and +published version markers are unchanged. diff --git a/docs/api.md b/docs/api.md index 059ab45..a52e071 100644 --- a/docs/api.md +++ b/docs/api.md @@ -126,3 +126,51 @@ native binaries are qualified; launcher wrappers without this PID/storage proof remain unsupported. Native bootstrap may create state/cache files according to managed policy before binding refusal; zero queue is the guarantee, not zero bootstrap filesystem activity. Observers still open only read-only warm stores. + + +## Unreleased local delivery observation — #72 + +A narrower qualified source path combines #71 queue mechanics with a warm +Codex 0.160.1 metadata store on macOS arm64. `--observe-delivery` checks once +(best effort); `--wait-for delivered` polls within the original remaining +budget. The native client ID is the reserved correlation ID. A positive native +receipt remains `status: queued`, `submitted: true`, `consumptionConfirmed: +false` even when the explicit wait fails. The separate handoff describes the +requested evidence goal. After any possible RPC write, no legacy fallback or +second submission occurs. + +Before an opted-in native effect, original saved thread/home/writer evidence, +default SQLite storage, exact native version and a warm supported metadata +schema are checked. Required unsupported observation refuses before effect. +Best-effort chooses legacy queue only if qualification failed before any native +app-server attempt. The sender fences its intent durably before queue/add. +A fresh queue transport is not treated as the owner of another process's thread. + +The observer never reads rollouts or calls history, queue/list, thread/resume, +turn/start or full item/turn notification APIs. SQLite itself projects only +`clientId`, thread/turn IDs and an allowlisted status. Recent row keys are bounded +to 256; cells over 256 KiB are excluded before JSON extraction. Temporary SQLite +storage is memory-only and materializes keys/bounded scalars. Body/error columns +are never returned to JavaScript. Only an already-warm original store is used; +there is no migration, warm-up/backfill, new model turn or wake. Existing WAL/SHM +must already be trusted; SQLite read-only readers can participate in transient +SHM read locks. DB/WAL bytes and sidecar creation remain unchanged in fixtures. + +File/schema/inode checks and queries run in owned deadline-bounded children. +An inherited-group owner verifier compares the ORIGINAL selected fingerprint +before/after each read. Wrong client ID, schema drift, owner restart or an +ambiguous row cannot advance delivery. Unknown native turn statuses remain +unknown. Queue deletion or turn completion alone never creates injection/ACK. +Each wait keeps its own operation ID/cutoff and immutable terminal record; +concurrent later waits do not replace the originating send's result. An explicit +SIGINT-stopped wait returns exit 130 when its matching stopped record is proven, +retaining independently known submission facts and never retracting/resending. + +Qualification boundaries: combined CLI route is macOS arm64/Codex 0.160.1; +portable SQL/owner fixtures run on POSIX. End-to-end tests use synthetic writer, +SSH/process metadata and native RPC fixtures, not real user TUIs. Actual live +injection/ACK and Linux/Windows native queue integration are not qualified. +Windows ledger, sender-ledger SSH coordination, automated receipt bootstrap, +restart-resumed observation, full setup/filesystem cancellation and other native +versions remain open. Required ACK remains unsupported. The 0.4.0 milestone is +not complete and no package/version publication is implied. diff --git a/docs/guide.ja.md b/docs/guide.ja.md index c89accb..e142f74 100644 --- a/docs/guide.ja.md +++ b/docs/guide.ja.md @@ -345,3 +345,8 @@ npm `--ignore-scripts` は利用でき、postinstall は Skills CLI を呼び出 開発中の 0.4.0 ソースには opt-in private POSIX handoff ledger が追加されます。公開 0.3.1 にこのコマンドはありません。必須の証拠が unsupported の場合は提出前に拒否し、best-effort の提出は ACK を証明しません。自動 receipt bootstrap、Windows ledger、SSH handoff は未対応です。 [CLI](api.md#unreleased-opt-in-handoff-cli--040-work) + + +ソース専用の `--wait-for delivered` は Codex 0.160.1/macOS arm64 と既存 metadata store に限定されます。本文は返さず、wait 失敗でも queued/submitted の証拠を保ちます。各 wait の operation ID と終了結果は独立です。合成テストは live ACK の証拠ではなく、SSH/Windows handoff と自動 ACK は未対応です。 + +[Observation](api.md#unreleased-local-delivery-observation--72) diff --git a/docs/guide.ko.md b/docs/guide.ko.md index 2da46aa..4bc10c7 100644 --- a/docs/guide.ko.md +++ b/docs/guide.ko.md @@ -598,3 +598,8 @@ npm `--ignore-scripts`로 설치해도 되며, 설치 후 Skills CLI를 호출 개발 중인 0.4.0 소스에는 opt-in private POSIX handoff ledger가 추가됩니다. 공개 0.3.1에는 이 명령이 없습니다. 필수 증거 채널이 unsupported이면 제출 전에 거절하며, best-effort 제출은 ACK를 증명하지 않습니다. 자동 receipt bootstrap, Windows ledger 및 SSH handoff는 아직 미지원입니다. [CLI](api.md#unreleased-opt-in-handoff-cli--040-work) + + +소스 전용 `--wait-for delivered` 경로는 Codex 0.160.1/macOS arm64 queue mechanics와 이미 생성된 metadata store에 한정됩니다. 본문을 반환하지 않으며 wait 실패도 queued/submitted 증거를 지우지 않습니다. 각 wait의 operation ID·종료 결과는 별개입니다. 합성 통합 검증은 live ACK 증거가 아니며 SSH/Windows handoff와 자동 ACK는 미지원입니다. + +[Observation](api.md#unreleased-local-delivery-observation--72) diff --git a/docs/guide.md b/docs/guide.md index 36a41f6..0745616 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -625,3 +625,8 @@ never enabled automatically. See [explicit activation and collisions](shorthand. Unreleased 0.4.0 source adds an opt-in private POSIX handoff ledger. Published 0.3.1 has no handoff commands. Required unsupported evidence refuses before submission; best-effort unsupported evidence never proves ACK. Automated receipt bootstrap, Windows ledger and SSH handoff remain unsupported. [CLI](api.md#unreleased-opt-in-handoff-cli--040-work) + + +The source-only `--wait-for delivered` path is narrowly qualified for Codex 0.160.1/macOS arm64 queue mechanics and a warm body-free metadata projection. Failed waits retain queued/submitted facts; every wait has its own immutable operation ID. Synthetic integration is not live ACK proof. SSH/Windows handoff and automatic ACK remain unsupported. + +[Observation](api.md#unreleased-local-delivery-observation--72) diff --git a/docs/guide.zh-CN.md b/docs/guide.zh-CN.md index 86c1814..e495805 100644 --- a/docs/guide.zh-CN.md +++ b/docs/guide.zh-CN.md @@ -329,3 +329,8 @@ postinstall 不会调用 Skills CLI,安装此技能不会覆盖 Python 技能 开发中的 0.4.0 源码加入 opt-in private POSIX handoff ledger。已发布的 0.3.1 不含这些命令。所需证据通道 unsupported 时在提交前拒绝;best-effort 提交并不证明 ACK。自动 receipt bootstrap、Windows ledger 和 SSH handoff 尚不支持。 [CLI](api.md#unreleased-opt-in-handoff-cli--040-work) + + +源码专用的 `--wait-for delivered` 限于 Codex 0.160.1/macOS arm64 与已有 metadata store。不返回正文,wait 失败仍保留 queued/submitted 事实。每个 wait 的 operation ID 和终态独立。合成集成测试不是 live ACK 证据;SSH/Windows handoff 与自动 ACK 仍未支持。 + +[Observation](api.md#unreleased-local-delivery-observation--72) diff --git a/package.json b/package.json index 4706223..dae4f25 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "build": "tsc -p tsconfig.json", "test": "npm run build && npm run test:types && node --import ./test/fixtures/isolate-env.mjs --experimental-strip-types --test test/*.test.ts", "test:types": "tsc -p tsconfig.test.json", - "test:windows": "npm run build && npm run test:types && node --import ./test/fixtures/isolate-env.mjs --import ./test/fixtures/windows-inspection-diagnostic.mjs --experimental-strip-types --test --test-concurrency=1 test/app-server.test.ts test/writer-uid.test.ts test/claude-records.test.ts test/reply-agent-binding.test.ts test/windows.test.ts test/windows-contract.test.ts test/windows-inspection.test.ts test/input-boundaries.test.ts test/discovery.test.ts test/codex-homes.test.ts test/diagnostics.test.ts test/cli-usability.test.ts test/skill-guidance.test.ts test/skill-metadata.test.ts test/ssh-hosts.test.ts test/ssh-response.test.ts test/replies.test.ts test/updates.test.ts", + "test:windows": "npm run build && npm run test:types && node --import ./test/fixtures/isolate-env.mjs --import ./test/fixtures/windows-inspection-diagnostic.mjs --experimental-strip-types --test --test-concurrency=1 test/app-server.test.ts test/handoff-core.test.ts test/handoff-cli.test.ts test/handoff-delivery.test.ts test/metadata-observation.test.ts test/handoff-writer-probe.test.ts test/writer-uid.test.ts test/claude-records.test.ts test/reply-agent-binding.test.ts test/windows.test.ts test/windows-contract.test.ts test/windows-inspection.test.ts test/input-boundaries.test.ts test/discovery.test.ts test/codex-homes.test.ts test/diagnostics.test.ts test/cli-usability.test.ts test/skill-guidance.test.ts test/skill-metadata.test.ts test/ssh-hosts.test.ts test/ssh-response.test.ts test/replies.test.ts test/updates.test.ts", "test:package": "npm run build && node --experimental-strip-types test/package-smoke.ts" }, "devDependencies": { diff --git a/scripts/release.mjs b/scripts/release.mjs index a93d71d..3e1dff2 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -19,7 +19,7 @@ export const packageFiles = ['UNICODE-LICENSE.txt', 'dist/casefold.js', 'dist/ca 'dist/cli.js', 'dist/discovery.js', 'dist/diagnostics.js', 'dist/skill-metadata.js', 'dist/process.js', 'dist/protocol.js', 'dist/replies.js', 'dist/send.js', 'dist/ssh.js', 'dist/ssh-response.js', 'dist/windows.js', 'dist/writer.js', 'dist/index.js', 'dist/index.d.ts', 'dist/cli.d.ts', 'dist/discovery.d.ts', 'dist/diagnostics.d.ts', 'dist/skill-metadata.d.ts', 'dist/process.d.ts', 'dist/protocol.d.ts', - 'dist/replies.d.ts', 'dist/send.d.ts', 'dist/ssh.d.ts', 'dist/ssh-response.d.ts', 'dist/windows.d.ts', 'dist/writer.d.ts', 'dist/updates.js', 'dist/updates.d.ts', 'dist/handoff.js', 'dist/handoff.d.ts', 'dist/handoff-ledger.js', 'dist/handoff-ledger.d.ts', 'dist/handoff-operations.js', 'dist/handoff-operations.d.ts', 'dist/app-server.js', 'dist/app-server.d.ts', 'package.json'].sort(); + 'dist/replies.d.ts', 'dist/send.d.ts', 'dist/ssh.d.ts', 'dist/ssh-response.d.ts', 'dist/windows.d.ts', 'dist/writer.d.ts', 'dist/updates.js', 'dist/updates.d.ts', 'dist/handoff.js', 'dist/handoff.d.ts', 'dist/handoff-ledger.js', 'dist/handoff-ledger.d.ts', 'dist/handoff-operations.js', 'dist/handoff-operations.d.ts', 'dist/app-server.js', 'dist/app-server.d.ts', 'dist/metadata-observation.js', 'dist/metadata-observation.d.ts', 'dist/handoff-observer-probe.js', 'dist/handoff-observer-probe.d.ts', 'dist/handoff-writer-probe.js', 'dist/handoff-writer-probe.d.ts', 'package.json'].sort(); const json = path => JSON.parse(readFileSync(path, 'utf8')); const digest = (bytes, algorithm, encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding); const npm = args => execFileSync('npm', args, { encoding: 'utf8', timeout: 120000 }); diff --git a/src/handoff-ledger.ts b/src/handoff-ledger.ts index 6c6b8c0..fb8b4db 100644 --- a/src/handoff-ledger.ts +++ b/src/handoff-ledger.ts @@ -215,6 +215,21 @@ export class HandoffLedger { } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') context = 'ledger_missing'; } return { reason: 'handoff_history_unavailable', handoffQuery: { schemaVersion: 1, correlationId: id, status: 'unknown', context, retry: { allowed: false, reason: 'history_unavailable' } } }; } + private selectWait(item: RecordIntent, operationId?: string): StoredWait { + if (operationId !== undefined && !canonicalId(operationId)) fail('invalid_wait_operation_id', 2); + const wait = operationId === undefined ? item.waits.at(-1) : item.waits.find(value => value.public.operationId === operationId); + if (!wait) fail('handoff_wait_unknown'); + return wait; + } + private operationSnapshot(item: RecordIntent, wait: StoredWait): Handoff { + const h = this.snapshot(item); h.wait = structuredClone(wait.public); + // Actions from the latest wait cannot be projected onto a different operation. + h.nextActions = ['reconcile']; return validateHandoff(h); + } + operationStatus(id: string, operationId: string): Handoff { + const data = this.read(), item = this.usable(data, id); + return this.operationSnapshot(item, this.selectWait(item, operationId)); + } prepare(binding: HandoffBinding, options: PrepareOptions = {}): Handoff { binding = checkBinding(binding); if (options.dryRun && (options.requestAck || options.observeDelivery || (options.waitFor && options.waitFor !== 'none'))) fail('incompatible_handoff_dry_run', 2); @@ -345,6 +360,19 @@ export class HandoffLedger { h.nextActions = ['reconcile']; this.write(data); return this.snapshot(item); }); } + recordObservationFailure(id: string, originalBinding: HandoffBinding, status: 'unsupported' | 'failed', operationId?: string): Handoff { + originalBinding = checkBinding(originalBinding); + if (!['unsupported','failed'].includes(status)) fail('invalid_handoff_observation'); + return this.lock(() => { + const data = this.read(), item = this.usable(data,id), h = item.handoff; + if (!sameBinding(item.binding,originalBinding) || !item.deliverySupported || h.submission.status !== 'submitted') fail('handoff_observation_unqualified'); + const wait = operationId !== undefined ? this.selectWait(item, operationId) : item.waits.at(-1); + if (!h.observation.injectionObserved) h.observation = {status,injectionObserved:false}; + if(wait?.public.status === 'pending') { wait.public.status = status; wait.public.reason = status === 'unsupported' ? 'evidence_unsupported' : 'evidence_failed'; } + if(item.waits.length) h.wait=structuredClone(item.waits.at(-1)!.public); + h.nextActions=['reconcile']; this.write(data); return operationId !== undefined ? this.operationSnapshot(item,wait!) : this.snapshot(item); + }); + } private addWait(item: RecordIntent, goal: Exclude, budget: HandoffBudget): void { if (item.waits.length >= HANDOFF_LIMITS.waits) fail('handoff_wait_quota'); item.handoff.wait = { for: goal, status: 'pending', operationId: randomUUID(), deadlineAtUtcMs: budget.deadlineAtUtcMs }; @@ -369,32 +397,17 @@ export class HandoffLedger { this.write(data); return this.snapshot(item); }); } - private selectWait(item: RecordIntent, operationId?: string): StoredWait { - if (operationId !== undefined && !canonicalId(operationId)) fail('invalid_wait_operation_id', 2); - const wait = operationId === undefined ? item.waits.at(-1) : item.waits.find(value => value.public.operationId === operationId); - if (!wait) fail('handoff_wait_unknown'); - return wait; - } - private operationSnapshot(item: RecordIntent, wait: StoredWait): Handoff { - const h = this.snapshot(item); h.wait = structuredClone(wait.public); - // Actions of the newest operation cannot authorize an older operation. - h.nextActions = ['reconcile']; return validateHandoff(h); - } - operationStatus(id: string, operationId: string): Handoff { - const data = this.read(), item = this.usable(data, id); - return this.operationSnapshot(item, this.selectWait(item, operationId)); - } recordWait(id: string, status: Exclude, reason?: WaitReason, operationId?: string): Handoff { return this.lock(() => { - const data = this.read(), item = this.usable(data, id), wait = this.selectWait(item, operationId); + const data = this.read(), item = this.usable(data, id), wait = this.selectWait(item,operationId); if (wait.public.status !== 'pending') fail('handoff_wait_terminal'); if (status === 'timed_out_unknown' && (wait.clockId !== this.clockId || this.clock.monotonic() < wait.cutoff)) fail('receipt_order_unprovable'); wait.public.status = status; if (reason) wait.public.reason = reason; if (status === 'timed_out_unknown' && !item.handoff.observation.injectionObserved && item.handoff.ack.status !== 'acknowledged') item.handoff.state = 'timed_out_unknown'; item.handoff.nextActions = ['reconcile']; item.handoff.wait = structuredClone(item.waits.at(-1)!.public); - validateHandoff({ ...item.handoff, wait: wait.public }); - this.write(data); return operationId !== undefined ? this.operationSnapshot(item, wait) : this.snapshot(item); + validateHandoff({...item.handoff,wait:wait.public}); + this.write(data); return operationId !== undefined ? this.operationSnapshot(item,wait) : this.snapshot(item); }); } private commitAck(item: RecordIntent, assurance: 'operator_confirmed' | 'token_possession'): void { diff --git a/src/handoff-operations.ts b/src/handoff-operations.ts index aa49b4e..0644c53 100644 --- a/src/handoff-operations.ts +++ b/src/handoff-operations.ts @@ -5,9 +5,12 @@ import { dirname, isAbsolute, join, parse, resolve } from 'node:path'; import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync } from 'node:fs'; import { HandoffLedger, payloadDigest, type HandoffBinding } from './handoff-ledger.js'; import { canonicalId, HANDOFF_LIMITS, HandoffBudget, parseWaitTimeout, type Handoff, type WaitFor } from './handoff.js'; -import { checkMessage, send, type SendContext, type SendOptions } from './send.js'; +import { fileURLToPath } from 'node:url'; +import { nativeQueueCapability, nativeQueueOnce, AppServerFault, type AppServerScope } from './app-server.js'; +import { metadataCapability, observeMetadata, type MetadataScope } from './metadata-observation.js'; +import { checkMessage, queueId, send, type SendContext, type SendOptions } from './send.js'; import { Refusal } from './discovery.js'; -import { UnknownOutcome } from './process.js'; +import { executable, run, UnknownOutcome } from './process.js'; export type HandoffOptions = { correlationId?: string; requestAck?: boolean; observeDelivery?: boolean; waitFor?: Exclude; seconds?: number; ledgerPath?: string; payload?: string; budget?: HandoffBudget; resultOverhead?: number }; @@ -53,51 +56,108 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO const ledger = new HandoffLedger(handoffOptions.ledgerPath ?? ledgerPath()); const budget = handoffOptions.budget ?? new HandoffBudget(handoffOptions.seconds ?? 30); let handoff: Handoff | undefined, snapshot: Record | undefined; + let scope: MetadataScope | undefined, queueScope: AppServerScope | undefined, deliverySupported = false, stopped = false, operationId: string | undefined; let original: HandoffBinding | undefined, fenced = false, knownNative: Record | undefined; + const ownWait = () => { if (handoff && operationId) handoff.wait = ledger.operationStatus(handoff.correlationId,operationId).wait; }; + const stop = () => { stopped = true; if (handoff?.wait.status === 'pending') { try { handoff = ledger.recordWait(handoff.correlationId, 'stopped', 'stopped_by_operator', operationId); ownWait(); } catch { /* No fabricated terminal evidence after persistence failure. */ } } }; + const authorize = () => { if (stopped) throw new Refusal('stopped_by_operator', 130); budget.beforeEffect(); handoff = ledger.commitEffect(handoff!.correlationId, original!, budget); fenced = true; }; + const verifyOwner = async (target: MetadataScope, signal?: AbortSignal) => { + if (stopped || signal?.aborted || budget.observationRemainingMs() < 1) return false; + const worker = fileURLToPath(new URL('./handoff-writer-probe.js', import.meta.url)); + const result = await run(process.execPath, [worker, target.home, target.threadId], { timeout: Math.min(800, budget.observationRemainingMs()), limit: 4096, env: { ...process.env, NODE_OPTIONS: undefined, NODE_PATH: undefined } }); + if (signal?.aborted || stopped || result.code !== 0 || result.interrupted) return false; + try { const value = JSON.parse(result.stdout); return value.schemaVersion === 1 && value.ok === true && Object.keys(value).every(key => ['schemaVersion','ok','generation'].includes(key)) && /^[0-9a-f]{64}$/.test(value.generation) && value.generation === target.generation; } catch { return false; } + }; const finish = (value: Record, exitCode: number): OperationResult => ({ value: { ...value, ...(handoff ? { handoff } : {}) }, exitCode }); try { const value = await send({ ...options, hooks: { - resolved(context, native) { + async resolved(context, native) { snapshot = native; original = binding(context, handoffOptions.payload ?? options.message); if (Buffer.byteLength(JSON.stringify(native)) + HANDOFF_LIMITS.publicFrame + (handoffOptions.resultOverhead ?? 1024) > HANDOFF_LIMITS.outerFrame) throw new Refusal('handoff_result_too_large',1); // Account for the fixed correlation envelope before allocating an intent. checkMessage(correlationEnvelope(options.message, '00000000-0000-4000-8000-000000000000'), context.agent === 'codex'); + const wantsDelivery = handoffOptions.observeDelivery || handoffOptions.waitFor === 'delivered'; + if (wantsDelivery && context.agent === 'codex' && context.home && context.generation && budget.observationRemainingMs() > 0) { + scope = { home: context.home, threadId: context.target, generation: context.generation }; + queueScope = { codexHome: context.home, threadId: context.target, generation: context.generation, ownerIdentity: context.writerIdentity! }; + const version = await run(executable(options.codexBin ?? 'codex'), ['--version'], { timeout: Math.min(1000, budget.observationRemainingMs()), limit: 4096 }); + const nativeVersion = !version.interrupted && version.code === 0 ? version.stdout.trim().replace(/^codex-cli /, '') : ''; + if (nativeQueueCapability({version:nativeVersion,platform:`${process.platform}-${process.arch}`,scope:queueScope}).queueSupported) deliverySupported = (await metadataCapability(scope, nativeVersion, budget, verifyOwner)).supported; + } handoff = ledger.prepare(original, { correlationId: handoffOptions.correlationId, requestAck: handoffOptions.requestAck || handoffOptions.waitFor === 'acknowledged', observeDelivery: handoffOptions.observeDelivery || handoffOptions.waitFor === 'delivered', - waitFor: handoffOptions.waitFor ?? 'none', budget, dryRun: options.dryRun }); + waitFor: handoffOptions.waitFor ?? 'none', budget, dryRun: options.dryRun, observationSupported: deliverySupported }); + operationId = handoff.wait.operationId; + if (handoffOptions.waitFor && handoff.wait.status === 'pending') process.on('SIGINT', stop); if (handoff.state === 'refused') throw new BeforeEffectRefused(); return correlationEnvelope(options.message, handoff.correlationId); }, - beforeEffect() { budget.beforeEffect(); handoff = ledger.commitEffect(handoff!.correlationId, original!, budget); fenced = true; }, + beforeEffect: authorize, + async submitCodex(context, message) { + if (!deliverySupported) { + // Best-effort unsupported is chosen before ANY app-server attempt. + authorize(); + const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toUpperCase() !== 'CODEX_SQLITE_HOME')); + const done = await run(executable(options.codexBin ?? 'codex'), ['queue','--thread',context.target,`--message=${message}`], {env:{...inherited,CODEX_HOME:context.home},timeout:Math.max(1,budget.observationRemainingMs())}); + if (!done.spawned) throw new Refusal('native_spawn_failed',1); + if (done.interrupted || done.code !== 0) throw new UnknownOutcome(); + const receipt = queueId(done.stdout,context.target); + return {queueId:receipt,clientUserMessageId:handoff!.correlationId}; + } + try { return await nativeQueueOnce({binary:executable(options.codexBin ?? 'codex'),scope:queueScope!,message,clientUserMessageId:handoff!.correlationId,deadlineMs:budget.cutoff,verifyOwner: async () => verifyOwner(scope!),beforeEffect: async () => authorize()}); } + catch(error) { if (error instanceof AppServerFault && error.attempted) throw new UnknownOutcome(); if(error instanceof AppServerFault) throw new Refusal(`app_server_${error.code}`,1); throw error; } + }, timeoutMs() { budget.beforeEffect(); return Math.max(1, Math.floor(budget.observationRemainingMs())); }, } }); knownNative = value; - if (!options.dryRun) handoff = ledger.recordSubmission(handoff!.correlationId, 'submitted'); + if (!options.dryRun) { handoff = ledger.recordSubmission(handoff!.correlationId, 'submitted'); ownWait(); } + if (!options.dryRun && deliverySupported && scope && handoff) { + do { + if (stopped) break; + const observed = await observeMetadata({...scope,clientUserMessageId:handoff.correlationId}, budget, verifyOwner); + if (!observed.supported) { + if (budget.observationRemainingMs() <= 0) break; + handoff = ledger.recordObservationFailure(handoff.correlationId, original!, observed.reason === 'metadata_owner_changed' ? 'unsupported' : 'failed', operationId); ownWait(); + break; + } + if (observed.injectionObserved) { handoff = ledger.recordObservation(handoff.correlationId,{clientUserMessageId:observed.clientUserMessageId!,injectionObserved:true,...(observed.turn ? {turn:observed.turn} : {})},original!); ownWait(); break; } + if (!handoffOptions.waitFor) break; + await new Promise(resolve => setTimeout(resolve, Math.min(100,budget.observationRemainingMs()))); + } while(budget.observationRemainingMs() > 0); + if (handoffOptions.waitFor && handoff.wait.status === 'pending') handoff = stopped ? ledger.recordWait(handoff.correlationId,'stopped','stopped_by_operator',operationId) : ledger.recordWait(handoff.correlationId,'timed_out_unknown',undefined,operationId); + ownWait(); + if (handoffOptions.waitFor && handoff.wait.status !== 'satisfied') return finish({...value,ok:false,error:handoff.wait.reason ?? 'timed_out_unknown',retryAllowed:false},handoff.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator' ? 130 : 1); + } return finish(value, 0); } catch (error) { if (!handoff || !snapshot) throw error; if (knownNative) { // An I/O failure after positive native acceptance cannot erase that fact. - handoff = { ...handoff, state: 'submitted', submission: { status: 'submitted' }, nextActions: ['reconcile'] }; + handoff = { ...handoff, state: handoff.ack.status === 'acknowledged' ? 'acknowledged' : handoff.observation.injectionObserved ? 'delivered' : 'submitted', submission: { status: 'submitted' }, nextActions: ['reconcile'] }; if (handoffOptions.waitFor) { if (handoff.wait.status === 'pending') handoff.wait = { ...handoff.wait, status: 'failed', reason: 'evidence_failed' }; - return finish({ ...knownNative, ok: false, error: 'handoff_persistence_failed', retryAllowed: false }, 1); + const interrupted = handoff.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator'; + return finish({ ...knownNative, ok: false, error: interrupted ? 'stopped_by_operator' : 'handoff_persistence_failed', retryAllowed: false }, interrupted ? 130 : 1); } return finish({ ...knownNative, handoffWarning: 'persistence_failed', retryAllowed: false }, 0); } const uncertain = error instanceof UnknownOutcome; if (fenced) { - try { handoff = ledger.recordSubmission(handoff.correlationId, uncertain ? 'unknown' : 'refused'); } + try { handoff = ledger.recordSubmission(handoff.correlationId, uncertain ? 'unknown' : 'refused'); ownWait(); } catch { const state = ledger.status(handoff.correlationId); if ('handoff' in state) handoff = state.handoff; } } + if (handoffOptions.waitFor && handoff.wait.status === 'pending') { + try { handoff = ledger.recordWait(handoff.correlationId, budget.observationRemainingMs() <= 0 ? 'timed_out_unknown' : 'failed', budget.observationRemainingMs() <= 0 ? undefined : 'evidence_failed', operationId); ownWait(); } + catch { handoff.wait = {...handoff.wait,status:'failed',reason:'evidence_failed'}; } + } if (!fenced && original && handoff.state !== 'refused') { try { handoff = ledger.refusePrepared(handoff.correlationId, original); } catch { /* Keep the last verified facts; never retry. */ } } const code = error instanceof BeforeEffectRefused ? handoff.wait.reason ?? 'evidence_unsupported' : error instanceof Refusal ? error.code : uncertain ? 'outcome_unknown' : 'handoff_operation_failed'; return finish({ ...snapshot, ok: false, error: code, status: uncertain ? 'unknown' : 'refused', submitted: uncertain ? null : false, - consumptionConfirmed: false, retryAllowed: false }, error instanceof Refusal ? error.exitCode : 1); - } + consumptionConfirmed: false, retryAllowed: false }, handoffOptions.waitFor && handoff.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator' ? 130 : error instanceof Refusal && error.exitCode !== 130 ? error.exitCode : 1); + } finally { process.removeListener('SIGINT',stop); } } // New commands are local owner operations; forwarding them through native send diff --git a/src/send.ts b/src/send.ts index ffede82..0e1cdf1 100644 --- a/src/send.ts +++ b/src/send.ts @@ -16,6 +16,7 @@ export type SendHooks = { resolved: (context: SendContext, snapshot: Record) => Promise | string | void; beforeEffect?: () => Promise | void; timeoutMs?: () => number; + submitCodex?: (context: SendContext, message: string) => Promise<{ queueId?: string; clientUserMessageId: string }>; }; export type SendOptions = { to: string; home?: string; codexBin?: string; message: string; dryRun?: boolean; allowInactive?: boolean; hooks?: SendHooks }; export class CodexUnknownOutcome extends UnknownOutcome { @@ -95,6 +96,7 @@ export async function send(options: SendOptions): Promise B -> A. @@ -104,10 +106,11 @@ export async function send(options: SendOptions): Promise item.codexHome === home)?.activity === 'live_writer'; if (!identity) throw new HomeRefusal('active_writer_unverified', selection.resolution); const generation = live ? createHash('sha256').update(identity).digest('hex') : null; - const body = await options.hooks.resolved({ agent: 'codex', target: id, home, generation, + context = { agent: 'codex', target: id, home, generation, // Inactive evidence still binds a prepared intent across invocations. This // private fingerprint is not a native incarnation or public generation. - writerIdentity: generation ?? createHash('sha256').update(JSON.stringify(selected)).digest('hex') }, result); + writerIdentity: generation ?? createHash('sha256').update(JSON.stringify(selected)).digest('hex') }; + const body = await options.hooks.resolved(context, result); if (body !== undefined) { checkMessage(body, true); options = { ...options, message: body }; result.chars = [...body].length; } } if (options.dryRun) return result; @@ -119,6 +122,10 @@ export async function send(options: SendOptions): Promise key.toUpperCase() !== 'CODEX_SQLITE_HOME')); await options.hooks?.beforeEffect?.(); const done = await run(binary, ['queue', '--thread', id, `--message=${options.message}`], diff --git a/test/handoff-core.test.ts b/test/handoff-core.test.ts index 8452d28..334e05d 100644 --- a/test/handoff-core.test.ts +++ b/test/handoff-core.test.ts @@ -426,6 +426,49 @@ test('wait quota exhaustion and insufficient new-wait budget preserve native sub assert.throws(() => f.ledger.beginWait(h.correlationId, 'acknowledged'), /handoff_wait_quota/); assert.equal(handoff(f.ledger, h.correlationId).state, 'submitted'); }); +test('an original wait can stop after another process adds a newer terminal wait', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding, { waitFor:'delivered',observationSupported:true }); submit(f,h); + const originalId=h.wait.operationId!; + const other=new HandoffLedger(f.ledger.path,{clock:f.clock}); + const newer=other.beginWait(h.correlationId,'delivered',new HandoffBudget(6,f.clock)); + assert.equal(newer.wait.status,'unsupported'); + const stopped=f.ledger.recordWait(h.correlationId,'stopped','stopped_by_operator',originalId); + assert.equal(stopped.wait.operationId,originalId); assert.equal(stopped.wait.status,'stopped'); + assert.equal(f.ledger.operationStatus(h.correlationId,newer.wait.operationId!).wait.status,'unsupported'); + assert.equal(handoff(f.ledger,h.correlationId).wait.operationId,newer.wait.operationId); + assert.throws(()=>f.ledger.recordWait(h.correlationId,'timed_out_unknown',undefined,originalId),/handoff_wait_terminal/); +}); +test('delivery classifies every same-clock pending operation by its own original cutoff', posix, t => { + const f=fixtureLedger(t);f.ledger.init();const h=f.ledger.prepare(f.binding,{waitFor:'delivered',observationSupported:true,budget:new HandoffBudget(6,f.clock)});submit(f,h); + const newer=f.ledger.beginWait(h.correlationId,'delivered',new HandoffBudget(7,f.clock));f.advance(1000); + f.ledger.recordObservation(h.correlationId,{clientUserMessageId:h.correlationId,injectionObserved:true},f.binding); + assert.equal(f.ledger.operationStatus(h.correlationId,h.wait.operationId!).wait.status,'timed_out_unknown'); + assert.equal(f.ledger.operationStatus(h.correlationId,newer.wait.operationId!).wait.status,'satisfied'); + assert.equal(handoff(f.ledger,h.correlationId).wait.operationId,newer.wait.operationId); +}); +test('targeted observation failure preserves a newer operation and rejects unknown IDs without writes', posix, t => { + const f=fixtureLedger(t);f.ledger.init();const h=f.ledger.prepare(f.binding,{waitFor:'delivered',observationSupported:true});submit(f,h); + const newer=f.ledger.beginWait(h.correlationId,'delivered',new HandoffBudget(6,f.clock)); + const failed=f.ledger.recordObservationFailure(h.correlationId,f.binding,'failed',h.wait.operationId); + assert.equal(failed.wait.status,'failed');assert.equal(failed.wait.operationId,h.wait.operationId); + assert.equal(f.ledger.operationStatus(h.correlationId,newer.wait.operationId!).wait.status,'pending'); + const file=join(f.ledger.path,'ledger.json'),before=readFileSync(file,'utf8'),unknown=randomUUID(); + assert.throws(()=>f.ledger.operationStatus(h.correlationId,unknown),/handoff_wait_unknown/); + assert.throws(()=>f.ledger.recordWait(h.correlationId,'stopped','stopped_by_operator',unknown),/handoff_wait_unknown/); + assert.throws(()=>f.ledger.recordObservationFailure(h.correlationId,f.binding,'unsupported',unknown),/handoff_wait_unknown/); + assert.equal(readFileSync(file,'utf8'),before); +}); +test('delivery leaves terminal operations intact and does not guess a different clock deadline', posix, t => { + const f=fixtureLedger(t);f.ledger.init();const h=f.ledger.prepare(f.binding,{waitFor:'delivered',observationSupported:true});submit(f,h); + const stopped=f.ledger.recordWait(h.correlationId,'stopped','stopped_by_operator',h.wait.operationId); + const pending=f.ledger.beginWait(h.correlationId,'delivered',new HandoffBudget(6,f.clock)); + const restarted=new HandoffLedger(f.ledger.path,{clock:{monotonic:()=>0,utc:f.clock.utc}}); + restarted.recordObservation(h.correlationId,{clientUserMessageId:h.correlationId,injectionObserved:true},f.binding); + assert.deepEqual(restarted.operationStatus(h.correlationId,h.wait.operationId!).wait,stopped.wait); + const foreign=restarted.operationStatus(h.correlationId,pending.wait.operationId!); + assert.equal(foreign.wait.status,'failed');assert.equal(foreign.wait.reason,'history_unavailable'); + assert.equal(foreign.observation.injectionObserved,true);assert.equal(foreign.ack.status,'not_requested'); +}); test('expired details compact to an epoch-bound fence, never restoring send or receipt authority', posix, t => { const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); const capability = f.ledger.mintCapability(h.correlationId, bootstrap); submit(f, h); diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts new file mode 100644 index 0000000..b8bf1d7 --- /dev/null +++ b/test/handoff-delivery.test.ts @@ -0,0 +1,63 @@ +import {test,type TestContext} from 'node:test'; +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {once} from 'node:events'; +import {createRequire} from 'node:module'; +import {DatabaseSync} from 'node:sqlite'; +import {chmodSync,existsSync,mkdirSync,mkdtempSync,readFileSync,realpathSync,rmSync,writeFileSync,watch} from 'node:fs'; +import {join,delimiter,resolve} from 'node:path'; +import {tmpdir} from 'node:os'; +import {validateHandoff} from '../dist/handoff.js'; +const qualified={skip:process.platform!=='darwin'||process.arch!=='arm64'}; +const thread='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',cli=resolve('dist/cli.js'); +const schema=`CREATE TABLE thread_turns(thread_id TEXT NOT NULL,turn_id TEXT NOT NULL,status TEXT NOT NULL,PRIMARY KEY(thread_id,turn_id)); +CREATE TABLE thread_items(thread_id TEXT NOT NULL,turn_id TEXT NOT NULL,item_id TEXT NOT NULL,rollout_ordinal INTEGER NOT NULL,item_json TEXT NOT NULL,item_type TEXT NOT NULL,PRIMARY KEY(thread_id,turn_id,item_id)); +CREATE INDEX idx_thread_items_user_messages ON thread_items(thread_id,rollout_ordinal) WHERE item_type='userMessage'; +CREATE TABLE thread_history_projection_state(thread_id TEXT PRIMARY KEY,next_rollout_byte_offset INTEGER NOT NULL,next_rollout_ordinal INTEGER NOT NULL);`; +async function fixture(t:TestContext,mode='inject',version='0.160.1',warm=true){ + const root=realpathSync(mkdtempSync(join(process.env.TASK_TEMP??tmpdir(),'codex-hd-'))),home=join(root,'home'),bin=join(root,'bin');chmodSync(root,0o700);mkdirSync(home,{mode:0o700});mkdirSync(bin,{mode:0o700});mkdirSync(join(home,'thread-writer-locks'),{mode:0o700}); + t.after(()=>rmSync(root,{recursive:true,force:true})); + const state=new DatabaseSync(join(home,'state_5.sqlite'));state.exec('CREATE TABLE threads(id TEXT)');state.prepare('INSERT INTO threads VALUES(?)').run(thread);state.close(); + if(warm){const db=new DatabaseSync(join(home,'thread_history_1.sqlite'));db.exec(schema);db.prepare('INSERT INTO thread_turns VALUES(?,?,?)').run(thread,'turn-fixture','completed');db.prepare('INSERT INTO thread_history_projection_state VALUES(?,?,?)').run(thread,100,100);db.close();} + const lock=join(home,'thread-writer-locks',thread+'.lock'),locks=createRequire(import.meta.url).resolve('fs-ext-extra-prebuilt'); + const held=spawn(process.execPath,['-e',`const f=require('fs').openSync(process.argv[1],'w',0o600);require(${JSON.stringify(locks)}).flockSync(f,'exnb');console.log('ready');setInterval(()=>{},1000)`,lock]);held.stderr.resume();await once(held.stdout,'data');t.after(async()=>{if(held.exitCode===null&&held.signalCode===null){held.kill('SIGKILL');await once(held,'close');}}); + writeFileSync(join(bin,'lsof'),`#!${process.execPath}\nprocess.stdout.write(${JSON.stringify('p'+held.pid+'\0ccodex-fixture\0u'+process.getuid?.()+'\0')});`,{mode:0o700}); + writeFileSync(join(bin,'ps'),`#!${process.execPath}\nconsole.log('Fixture stable start');`,{mode:0o700}); + const log=join(root,'queue-log');writeFileSync(log,'');const codex=join(bin,'codex'); + writeFileSync(codex,`#!${process.execPath} +const fs=require('fs'),readline=require('readline'),path=require('path'); +if(process.argv[2]==='--version'){console.log('codex-cli '+${JSON.stringify(version)});process.exit(0);} +if(process.argv[2]==='queue'){fs.appendFileSync(${JSON.stringify(log)},JSON.stringify({method:'legacy'})+'\\n');console.log('Queued message legacy-1 for thread '+process.argv[4]+'.');process.exit(0);} +readline.createInterface({input:process.stdin}).on('line',line=>{const r=JSON.parse(line);if(r.method==='initialize')console.log(JSON.stringify({id:r.id,result:{codexHome:process.env.CODEX_HOME,platformFamily:'unix',platformOs:'macos',userAgent:'fixture'}}));else if(r.method==='thread/queue/add'){ +fs.appendFileSync(${JSON.stringify(log)},JSON.stringify({method:r.method,clientId:r.params.clientUserMessageId})+'\\n'); +if(${JSON.stringify(mode)}==='loss'){process.exit(0);return;} +if(${JSON.stringify(mode)}==='inject'){const {DatabaseSync}=require('node:sqlite');const db=new DatabaseSync(path.join(process.env.CODEX_HOME,'thread_history_1.sqlite'));db.prepare('INSERT INTO thread_items VALUES(?,?,?,?,?,?)').run(r.params.threadId,'turn-fixture','native-item-not-client-id',1,JSON.stringify({type:'userMessage',id:'native-item-not-client-id',clientId:r.params.clientUserMessageId,content:[{text:'PRIVATE_BODY_SENTINEL'}]}),'userMessage');db.close();} +console.log(JSON.stringify({id:r.id,result:{queuedSubmission:{id:'queue-fixture',clientUserMessageId:r.params.clientUserMessageId,input:r.params.input}}})); +}});`,{mode:0o700}); + const env={...process.env,HOME:root,USERPROFILE:root,CODEX_HOME:home,SESSION_PEER_CODEX_HOMES:'[]',CLAUDE_CONFIG_DIR:join(root,'.claude'),SESSION_PEER_TAILSCALE:'off',SESSION_PEER_UPDATE_NOTICE:'',CODEX_THREAD_ID:'',CODEX_SESSION_ID:'',PATH:bin+delimiter+(process.env.PATH??''),SESSION_PEER_HANDOFF_HOME:join(root,'state','handoff')}; + const args=['send','--to','codex:'+thread,'--codex-home',home,'--codex-bin',codex,'--message','fixture request','--no-from','--no-reply-to']; + function start(options:string[]){const child=spawn(process.execPath,[cli,...options,'--json'],{env});let stdout='',stderr='';child.stdout.on('data',c=>stdout+=c);child.stderr.on('data',c=>stderr+=c);child.stdin.end();const timer=setTimeout(()=>child.kill('SIGKILL'),15000);const result=once(child,'close').then(([code,signal])=>{clearTimeout(timer);assert.equal(signal,null,stderr);return{code,value:JSON.parse(stdout),stdout,stderr};});return{child,result};} + async function invoke(options:string[]){return start(options).result;} + assert.equal((await invoke(['handoff','init'])).code,0); + const calls=()=>readFileSync(log,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)); + async function queued(){if(calls().length)return;await new Promise((ok,fail)=>{const watcher=watch(root,()=>{if(calls().length){clearTimeout(timer);watcher.close();ok();}});const timer=setTimeout(()=>{watcher.close();fail(new Error('queue fixture not called'));},10000);if(calls().length){clearTimeout(timer);watcher.close();ok();}});} + return{root,home,calls,args,invoke,start,queued}; +} +test('qualified local opt-in waits for exact client ID injection, not completed turn/ACK',qualified,async t=>{ + const f=await fixture(t);const r=await f.invoke([...f.args,'--wait-for','delivered']);assert.equal(r.code,0,r.stdout);assert.equal(r.value.status,'queued');assert.equal(r.value.submitted,true);assert.equal(r.value.consumptionConfirmed,false);assert.equal(r.value.queueId,'queue-fixture');const h=validateHandoff(r.value.handoff);assert.equal(h.state,'delivered');assert.equal(h.wait.status,'satisfied');assert.equal(h.observation.clientUserMessageId,h.correlationId);assert.equal(h.observation.turn?.status,'completed');assert.equal(h.ack.status,'not_requested');assert.equal(f.calls().length,1);assert.equal(f.calls()[0].method,'thread/queue/add');assert.equal(f.calls()[0].clientId,h.correlationId);assert.equal(r.stdout.includes('PRIVATE_BODY_SENTINEL'),false); + const status=await f.invoke(['handoff','status','--correlation-id',h.correlationId]);assert.equal(status.code,0);assert.equal(status.value.handoff.observation.injectionObserved,true);assert.equal(f.calls().length,1); +}); +test('unknown after possible native write never falls back to legacy queue',qualified,async t=>{ + const f=await fixture(t,'loss');const r=await f.invoke([...f.args,'--wait-for','delivered']);assert.equal(r.code,1,r.stdout);assert.equal(r.value.status,'unknown');assert.equal(r.value.submitted,null);assert.equal(r.value.handoff.submission.status,'unknown');assert.equal(r.value.handoff.wait.status,'failed');assert.equal(f.calls().length,1);assert.equal(f.calls()[0].method,'thread/queue/add'); +}); +test('unsupported version/absent warm store refuse required goals, best-effort fallback is chosen before app-server effect',qualified,async t=>{ + const f=await fixture(t,'inject','0.159.0');const r=await f.invoke([...f.args,'--wait-for','delivered']);assert.equal(r.code,1);assert.equal(r.value.submitted,false);assert.equal(r.value.handoff.wait.status,'unsupported');assert.equal(f.calls().length,0); + const best=await f.invoke([...f.args,'--observe-delivery']);assert.equal(best.code,0);assert.equal(best.value.handoff.observation.status,'unsupported');assert.deepEqual(f.calls(),[{method:'legacy'}]); + const missing=await fixture(t,'inject','0.160.1',false);const blocked=await missing.invoke([...missing.args,'--wait-for','delivered']);assert.equal(blocked.code,1);assert.equal(blocked.value.submitted,false);assert.equal(missing.calls().length,0); +}); +test('deadline without matching injection preserves native queued facts and immutable timed-out wait',qualified,async t=>{ + const f=await fixture(t,'none');const r=await f.invoke([...f.args,'--wait-for','delivered','--wait-timeout','10']);assert.equal(r.code,1,r.stdout);assert.equal(r.value.status,'queued');assert.equal(r.value.submitted,true);assert.equal(r.value.handoff.submission.status,'submitted');assert.equal(r.value.handoff.wait.status,'timed_out_unknown');assert.equal(r.value.handoff.observation.injectionObserved,false);assert.equal(r.value.handoff.ack.status,'not_requested');assert.equal(f.calls().length,1); +}); +test('SIGINT stops an explicit wait without retracting or submitting again',qualified,async t=>{ + const f=await fixture(t,'none');const active=f.start([...f.args,'--wait-for','delivered']);await f.queued();active.child.kill('SIGINT');const r=await active.result;assert.equal(r.code,130,r.stdout);assert.equal(r.value.handoff.wait.status,'stopped');assert.equal(r.value.handoff.wait.reason,'stopped_by_operator');assert.equal(r.value.handoff.retry.allowed,false);assert.equal(f.calls().length,1);assert.notEqual(r.value.submitted,false); +}); diff --git a/test/package-smoke.ts b/test/package-smoke.ts index 798bb78..ce8522c 100644 --- a/test/package-smoke.ts +++ b/test/package-smoke.ts @@ -24,7 +24,7 @@ try { let hash; for (let index = 0; index < 2; index++) { const [packed] = JSON.parse(runNpm(['pack', '--ignore-scripts', '--json', '--pack-destination', task])); - assert.deepEqual(packed.files.map((file: {path: string}) => file.path).sort(), ['UNICODE-LICENSE.txt', 'dist/casefold.js', 'dist/casefold.d.ts', 'dist/help.js', 'dist/help.d.ts', 'dist/output.js', 'dist/output.d.ts', 'CONTRIBUTING.md', 'LICENSE', 'PARITY.md', 'README.ja.md', 'README.ko.md', 'README.md', 'README.zh-CN.md', 'RELEASING.md', 'SECURITY.md', 'VALIDATION.md', 'docs/guide.md', 'docs/guide.ko.md', 'docs/guide.ja.md', 'docs/guide.zh-CN.md', 'docs/api.md', 'docs/design/remote-deployment.md', 'docs/design/ordinary-ssh-responses.md', 'docs/design/typescript-skill-compatibility.md', 'docs/shorthand.md', 'shorthand/sp.sh', 'shorthand/sp.ps1', 'dist/cli.js', 'dist/discovery.js', 'dist/diagnostics.js', 'dist/skill-metadata.js', 'dist/process.js', 'dist/protocol.js', 'dist/replies.js', 'dist/send.js', 'dist/ssh.js', 'dist/ssh-response.js', 'dist/windows.js', 'dist/writer.js', 'dist/index.js', 'dist/index.d.ts', 'dist/cli.d.ts', 'dist/discovery.d.ts', 'dist/diagnostics.d.ts', 'dist/skill-metadata.d.ts', 'dist/process.d.ts', 'dist/protocol.d.ts', 'dist/replies.d.ts', 'dist/send.d.ts', 'dist/ssh.d.ts', 'dist/ssh-response.d.ts', 'dist/windows.d.ts', 'dist/writer.d.ts', 'dist/updates.js', 'dist/updates.d.ts', 'dist/handoff.js', 'dist/handoff.d.ts', 'dist/handoff-ledger.js', 'dist/handoff-ledger.d.ts', 'dist/handoff-operations.js', 'dist/handoff-operations.d.ts', 'dist/app-server.js', 'dist/app-server.d.ts', 'package.json'].sort()); + assert.deepEqual(packed.files.map((file: {path: string}) => file.path).sort(), ['UNICODE-LICENSE.txt', 'dist/casefold.js', 'dist/casefold.d.ts', 'dist/help.js', 'dist/help.d.ts', 'dist/output.js', 'dist/output.d.ts', 'CONTRIBUTING.md', 'LICENSE', 'PARITY.md', 'README.ja.md', 'README.ko.md', 'README.md', 'README.zh-CN.md', 'RELEASING.md', 'SECURITY.md', 'VALIDATION.md', 'docs/guide.md', 'docs/guide.ko.md', 'docs/guide.ja.md', 'docs/guide.zh-CN.md', 'docs/api.md', 'docs/design/remote-deployment.md', 'docs/design/ordinary-ssh-responses.md', 'docs/design/typescript-skill-compatibility.md', 'docs/shorthand.md', 'shorthand/sp.sh', 'shorthand/sp.ps1', 'dist/cli.js', 'dist/discovery.js', 'dist/diagnostics.js', 'dist/skill-metadata.js', 'dist/process.js', 'dist/protocol.js', 'dist/replies.js', 'dist/send.js', 'dist/ssh.js', 'dist/ssh-response.js', 'dist/windows.js', 'dist/writer.js', 'dist/index.js', 'dist/index.d.ts', 'dist/cli.d.ts', 'dist/discovery.d.ts', 'dist/diagnostics.d.ts', 'dist/skill-metadata.d.ts', 'dist/process.d.ts', 'dist/protocol.d.ts', 'dist/replies.d.ts', 'dist/send.d.ts', 'dist/ssh.d.ts', 'dist/ssh-response.d.ts', 'dist/windows.d.ts', 'dist/writer.d.ts', 'dist/updates.js', 'dist/updates.d.ts', 'dist/handoff.js', 'dist/handoff.d.ts', 'dist/handoff-ledger.js', 'dist/handoff-ledger.d.ts', 'dist/handoff-operations.js', 'dist/handoff-operations.d.ts', 'dist/app-server.js', 'dist/app-server.d.ts', 'dist/metadata-observation.js', 'dist/metadata-observation.d.ts', 'dist/handoff-observer-probe.js', 'dist/handoff-observer-probe.d.ts', 'dist/handoff-writer-probe.js', 'dist/handoff-writer-probe.d.ts', 'package.json'].sort()); const next = createHash('sha256').update(readFileSync(join(task, packed.filename))).digest('hex'); if (hash) assert.equal(next, hash, 'same build must produce identical tarball'); hash = next; From cd08b6b482e81e82f54bd047a752b775f752579a Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 10:16:23 +0900 Subject: [PATCH 05/18] fix(observation): keep original refusal evidence and reject unsolicited peer claims --- src/handoff-operations.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/handoff-operations.ts b/src/handoff-operations.ts index 0644c53..98ba216 100644 --- a/src/handoff-operations.ts +++ b/src/handoff-operations.ts @@ -10,6 +10,7 @@ import { nativeQueueCapability, nativeQueueOnce, AppServerFault, type AppServerS import { metadataCapability, observeMetadata, type MetadataScope } from './metadata-observation.js'; import { checkMessage, queueId, send, type SendContext, type SendOptions } from './send.js'; import { Refusal } from './discovery.js'; +import { HomeRefusal } from './writer.js'; import { executable, run, UnknownOutcome } from './process.js'; export type HandoffOptions = { correlationId?: string; requestAck?: boolean; observeDelivery?: boolean; @@ -156,7 +157,7 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO } const code = error instanceof BeforeEffectRefused ? handoff.wait.reason ?? 'evidence_unsupported' : error instanceof Refusal ? error.code : uncertain ? 'outcome_unknown' : 'handoff_operation_failed'; return finish({ ...snapshot, ok: false, error: code, status: uncertain ? 'unknown' : 'refused', submitted: uncertain ? null : false, - consumptionConfirmed: false, retryAllowed: false }, handoffOptions.waitFor && handoff.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator' ? 130 : error instanceof Refusal && error.exitCode !== 130 ? error.exitCode : 1); + consumptionConfirmed: false, retryAllowed: false, ...(error instanceof HomeRefusal ? {codexHomeResolution:error.codexHomeResolution} : {}) }, handoffOptions.waitFor && handoff.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator' ? 130 : error instanceof Refusal && error.exitCode !== 130 ? error.exitCode : 1); } finally { process.removeListener('SIGINT',stop); } } From 802aab05f8123739f3d14858189c7bcecac17503 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 10:50:11 +0900 Subject: [PATCH 06/18] test(observation): model native storage descriptors in integration fixtures --- test/handoff-delivery.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index b8bf1d7..f8f0065 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -28,6 +28,7 @@ async function fixture(t:TestContext,mode='inject',version='0.160.1',warm=true){ const fs=require('fs'),readline=require('readline'),path=require('path'); if(process.argv[2]==='--version'){console.log('codex-cli '+${JSON.stringify(version)});process.exit(0);} if(process.argv[2]==='queue'){fs.appendFileSync(${JSON.stringify(log)},JSON.stringify({method:'legacy'})+'\\n');console.log('Queued message legacy-1 for thread '+process.argv[4]+'.');process.exit(0);} +const {DatabaseSync:Sql}=require('node:sqlite');globalThis.keptDatabases=[new Sql(path.join(process.env.CODEX_HOME,'state_5.sqlite'),{readOnly:true}),new Sql(path.join(process.env.CODEX_SQLITE_HOME,'queue_1.sqlite'))]; readline.createInterface({input:process.stdin}).on('line',line=>{const r=JSON.parse(line);if(r.method==='initialize')console.log(JSON.stringify({id:r.id,result:{codexHome:process.env.CODEX_HOME,platformFamily:'unix',platformOs:'macos',userAgent:'fixture'}}));else if(r.method==='thread/queue/add'){ fs.appendFileSync(${JSON.stringify(log)},JSON.stringify({method:r.method,clientId:r.params.clientUserMessageId})+'\\n'); if(${JSON.stringify(mode)}==='loss'){process.exit(0);return;} From 14ccee1193e9158a385c3fadfbbe21dad2be8cb0 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 10:57:29 +0900 Subject: [PATCH 07/18] test(observation): separate snapshot startup from verifier cancellation budget --- test/metadata-observation.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/metadata-observation.test.ts b/test/metadata-observation.test.ts index 2eb7e1c..9cebe0f 100644 --- a/test/metadata-observation.test.ts +++ b/test/metadata-observation.test.ts @@ -152,7 +152,10 @@ test('expired caller budget starts no process and aborts a hanging owner verifie const f = fixture(t); let calls = 0; assert.equal((await metadataCapability(f.scope, '0.160.1', { observationRemainingMs: () => 0 }, () => { calls++; return true; })).reason, 'metadata_deadline'); assert.equal(calls, 0); - const deadline = performance.now() + 35; let aborted = false; + // Budget includes the separate snapshot worker's cold start. The verifier + // itself remains bounded by METADATA_SOURCE.childMs (1 s); a 35 ms total + // budget can correctly expire before the verifier is ever invoked on CI. + const deadline = performance.now() + 5000; let aborted = false; const result = await metadataCapability(f.scope, '0.160.1', { observationRemainingMs: () => deadline - performance.now() }, (_scope, signal) => new Promise(resolve => signal.addEventListener('abort', () => { aborted = true; resolve(false); }, { once: true }))); assert.equal(result.supported, false); assert.equal(aborted, true); From 3b0339b766242999428e418cd33807cf996a327e Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 11:21:04 +0900 Subject: [PATCH 08/18] fix(handoff): keep SIGINT bound to the originating wait operation --- src/handoff-operations.ts | 32 +++++++++++++++++++++++++++----- test/handoff-delivery.test.ts | 28 ++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/handoff-operations.ts b/src/handoff-operations.ts index 98ba216..24ee9b3 100644 --- a/src/handoff-operations.ts +++ b/src/handoff-operations.ts @@ -59,9 +59,23 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO let handoff: Handoff | undefined, snapshot: Record | undefined; let scope: MetadataScope | undefined, queueScope: AppServerScope | undefined, deliverySupported = false, stopped = false, operationId: string | undefined; let original: HandoffBinding | undefined, fenced = false, knownNative: Record | undefined; - const ownWait = () => { if (handoff && operationId) handoff.wait = ledger.operationStatus(handoff.correlationId,operationId).wait; }; - const stop = () => { stopped = true; if (handoff?.wait.status === 'pending') { try { handoff = ledger.recordWait(handoff.correlationId, 'stopped', 'stopped_by_operator', operationId); ownWait(); } catch { /* No fabricated terminal evidence after persistence failure. */ } } }; - const authorize = () => { if (stopped) throw new Refusal('stopped_by_operator', 130); budget.beforeEffect(); handoff = ledger.commitEffect(handoff!.correlationId, original!, budget); fenced = true; }; + const ownWait = () => { + if (handoff && operationId) { + const operation = ledger.operationStatus(handoff.correlationId, operationId); + // Keep independently known native facts, while projecting actions and the + // exact originating wait rather than the ledger's most recently added wait. + handoff = { ...handoff, wait: operation.wait, nextActions: operation.nextActions }; + } else if (handoff) handoff = { ...handoff, wait: { for: 'none', status: 'not_requested' }, nextActions: ['reconcile'] }; + }; + const stop = () => { + stopped = true; + try { + ownWait(); + if (handoff?.wait.status === 'pending') handoff = ledger.recordWait(handoff.correlationId, 'stopped', 'stopped_by_operator', operationId); + ownWait(); + } catch { /* No fabricated terminal evidence after persistence failure. */ } + }; + const authorize = () => { if (stopped) throw new Refusal('stopped_by_operator', 130); budget.beforeEffect(); handoff = ledger.commitEffect(handoff!.correlationId, original!, budget); fenced = true; ownWait(); }; const verifyOwner = async (target: MetadataScope, signal?: AbortSignal) => { if (stopped || signal?.aborted || budget.observationRemainingMs() < 1) return false; const worker = fileURLToPath(new URL('./handoff-writer-probe.js', import.meta.url)); @@ -133,6 +147,7 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO return finish(value, 0); } catch (error) { if (!handoff || !snapshot) throw error; + try { ownWait(); } catch { /* Preserve the last verified original operation. */ } if (knownNative) { // An I/O failure after positive native acceptance cannot erase that fact. handoff = { ...handoff, state: handoff.ack.status === 'acknowledged' ? 'acknowledged' : handoff.observation.injectionObserved ? 'delivered' : 'submitted', submission: { status: 'submitted' }, nextActions: ['reconcile'] }; @@ -146,14 +161,21 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO const uncertain = error instanceof UnknownOutcome; if (fenced) { try { handoff = ledger.recordSubmission(handoff.correlationId, uncertain ? 'unknown' : 'refused'); ownWait(); } - catch { const state = ledger.status(handoff.correlationId); if ('handoff' in state) handoff = state.handoff; } + catch { + try { + const state = operationId ? { handoff: ledger.operationStatus(handoff.correlationId, operationId) } : ledger.status(handoff.correlationId); + if ('handoff' in state) handoff = state.handoff; + ownWait(); + } catch { /* Keep the last verified original wait, never another operation's terminal outcome. */ } + } } + try { ownWait(); } catch { /* A latest-operation snapshot is never proof of this wait's terminal outcome. */ } if (handoffOptions.waitFor && handoff.wait.status === 'pending') { try { handoff = ledger.recordWait(handoff.correlationId, budget.observationRemainingMs() <= 0 ? 'timed_out_unknown' : 'failed', budget.observationRemainingMs() <= 0 ? undefined : 'evidence_failed', operationId); ownWait(); } catch { handoff.wait = {...handoff.wait,status:'failed',reason:'evidence_failed'}; } } if (!fenced && original && handoff.state !== 'refused') { - try { handoff = ledger.refusePrepared(handoff.correlationId, original); } catch { /* Keep the last verified facts; never retry. */ } + try { handoff = ledger.refusePrepared(handoff.correlationId, original); ownWait(); } catch { /* Keep the last verified facts; never retry. */ } } const code = error instanceof BeforeEffectRefused ? handoff.wait.reason ?? 'evidence_unsupported' : error instanceof Refusal ? error.code : uncertain ? 'outcome_unknown' : 'handoff_operation_failed'; return finish({ ...snapshot, ok: false, error: code, status: uncertain ? 'unknown' : 'refused', submitted: uncertain ? null : false, diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index f8f0065..cf20a13 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -24,13 +24,17 @@ async function fixture(t:TestContext,mode='inject',version='0.160.1',warm=true){ writeFileSync(join(bin,'lsof'),`#!${process.execPath}\nprocess.stdout.write(${JSON.stringify('p'+held.pid+'\0ccodex-fixture\0u'+process.getuid?.()+'\0')});`,{mode:0o700}); writeFileSync(join(bin,'ps'),`#!${process.execPath}\nconsole.log('Fixture stable start');`,{mode:0o700}); const log=join(root,'queue-log');writeFileSync(log,'');const codex=join(bin,'codex'); + const initializeReady=join(root,'initialize-ready'),initializeRelease=join(root,'initialize-release'); writeFileSync(codex,`#!${process.execPath} const fs=require('fs'),readline=require('readline'),path=require('path'); if(process.argv[2]==='--version'){console.log('codex-cli '+${JSON.stringify(version)});process.exit(0);} if(process.argv[2]==='queue'){fs.appendFileSync(${JSON.stringify(log)},JSON.stringify({method:'legacy'})+'\\n');console.log('Queued message legacy-1 for thread '+process.argv[4]+'.');process.exit(0);} const {DatabaseSync:Sql}=require('node:sqlite');globalThis.keptDatabases=[new Sql(path.join(process.env.CODEX_HOME,'state_5.sqlite'),{readOnly:true}),new Sql(path.join(process.env.CODEX_SQLITE_HOME,'queue_1.sqlite'))]; -readline.createInterface({input:process.stdin}).on('line',line=>{const r=JSON.parse(line);if(r.method==='initialize')console.log(JSON.stringify({id:r.id,result:{codexHome:process.env.CODEX_HOME,platformFamily:'unix',platformOs:'macos',userAgent:'fixture'}}));else if(r.method==='thread/queue/add'){ +readline.createInterface({input:process.stdin}).on('line',async line=>{const r=JSON.parse(line);if(r.method==='initialize'){ +if(${JSON.stringify(mode)}==='concurrent-stop'){fs.writeFileSync(${JSON.stringify(initializeReady)},'ready');await new Promise(resolve=>{const timer=setInterval(()=>{if(fs.existsSync(${JSON.stringify(initializeRelease)})){clearInterval(timer);resolve();}},10);});} +console.log(JSON.stringify({id:r.id,result:{codexHome:process.env.CODEX_HOME,platformFamily:'unix',platformOs:'macos',userAgent:'fixture'}}));}else if(r.method==='thread/queue/add'){ fs.appendFileSync(${JSON.stringify(log)},JSON.stringify({method:r.method,clientId:r.params.clientUserMessageId})+'\\n'); +if(${JSON.stringify(mode)}==='concurrent-stop')return; if(${JSON.stringify(mode)}==='loss'){process.exit(0);return;} if(${JSON.stringify(mode)}==='inject'){const {DatabaseSync}=require('node:sqlite');const db=new DatabaseSync(path.join(process.env.CODEX_HOME,'thread_history_1.sqlite'));db.prepare('INSERT INTO thread_items VALUES(?,?,?,?,?,?)').run(r.params.threadId,'turn-fixture','native-item-not-client-id',1,JSON.stringify({type:'userMessage',id:'native-item-not-client-id',clientId:r.params.clientUserMessageId,content:[{text:'PRIVATE_BODY_SENTINEL'}]}),'userMessage');db.close();} console.log(JSON.stringify({id:r.id,result:{queuedSubmission:{id:'queue-fixture',clientUserMessageId:r.params.clientUserMessageId,input:r.params.input}}})); @@ -42,7 +46,8 @@ console.log(JSON.stringify({id:r.id,result:{queuedSubmission:{id:'queue-fixture' assert.equal((await invoke(['handoff','init'])).code,0); const calls=()=>readFileSync(log,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)); async function queued(){if(calls().length)return;await new Promise((ok,fail)=>{const watcher=watch(root,()=>{if(calls().length){clearTimeout(timer);watcher.close();ok();}});const timer=setTimeout(()=>{watcher.close();fail(new Error('queue fixture not called'));},10000);if(calls().length){clearTimeout(timer);watcher.close();ok();}});} - return{root,home,calls,args,invoke,start,queued}; + async function initialized(){if(existsSync(initializeReady))return;await new Promise((ok,fail)=>{const watcher=watch(root,()=>{if(existsSync(initializeReady)){clearTimeout(timer);watcher.close();ok();}});const timer=setTimeout(()=>{watcher.close();fail(new Error('initialize fixture not reached'));},10000);if(existsSync(initializeReady)){clearTimeout(timer);watcher.close();ok();}});} + return{root,home,calls,args,invoke,start,queued,initialized,releaseInitialize:()=>writeFileSync(initializeRelease,'release')}; } test('qualified local opt-in waits for exact client ID injection, not completed turn/ACK',qualified,async t=>{ const f=await fixture(t);const r=await f.invoke([...f.args,'--wait-for','delivered']);assert.equal(r.code,0,r.stdout);assert.equal(r.value.status,'queued');assert.equal(r.value.submitted,true);assert.equal(r.value.consumptionConfirmed,false);assert.equal(r.value.queueId,'queue-fixture');const h=validateHandoff(r.value.handoff);assert.equal(h.state,'delivered');assert.equal(h.wait.status,'satisfied');assert.equal(h.observation.clientUserMessageId,h.correlationId);assert.equal(h.observation.turn?.status,'completed');assert.equal(h.ack.status,'not_requested');assert.equal(f.calls().length,1);assert.equal(f.calls()[0].method,'thread/queue/add');assert.equal(f.calls()[0].clientId,h.correlationId);assert.equal(r.stdout.includes('PRIVATE_BODY_SENTINEL'),false); @@ -62,3 +67,22 @@ test('deadline without matching injection preserves native queued facts and immu test('SIGINT stops an explicit wait without retracting or submitting again',qualified,async t=>{ const f=await fixture(t,'none');const active=f.start([...f.args,'--wait-for','delivered']);await f.queued();active.child.kill('SIGINT');const r=await active.result;assert.equal(r.code,130,r.stdout);assert.equal(r.value.handoff.wait.status,'stopped');assert.equal(r.value.handoff.wait.reason,'stopped_by_operator');assert.equal(r.value.handoff.retry.allowed,false);assert.equal(f.calls().length,1);assert.notEqual(r.value.submitted,false); }); +test('concurrent wait before effect cannot hide the original pending operation from SIGINT',qualified,async t=>{ + const f=await fixture(t,'concurrent-stop'); + const active=f.start([...f.args,'--wait-for','delivered']);await f.initialized(); + const wrapper=JSON.parse(readFileSync(join(f.root,'state','handoff','ledger.json'),'utf8')); + const id=Object.keys(wrapper.data.records)[0]!; + const original=await f.invoke(['handoff','status','--correlation-id',id]);const originalOperation=original.value.handoff.wait.operationId; + assert.equal(original.value.handoff.wait.status,'pending'); + const newer=await f.invoke(['handoff','wait','--correlation-id',id,'--wait-for','acknowledged']); + assert.equal(newer.code,1);assert.equal(newer.value.handoff.wait.status,'unsupported'); + assert.notEqual(newer.value.handoff.wait.operationId,originalOperation); + f.releaseInitialize();await f.queued();active.child.kill('SIGINT');const r=await active.result; + assert.equal(r.code,130,r.stdout);const h=validateHandoff(r.value.handoff); + assert.equal(h.wait.operationId,originalOperation);assert.equal(h.wait.status,'stopped');assert.equal(h.wait.reason,'stopped_by_operator'); + assert.notEqual(r.value.submitted,false);assert.ok(r.value.submitted===null||r.value.submitted===true); + assert.equal(h.retry.allowed,false);assert.equal(f.calls().length,1);assert.equal(f.calls()[0].method,'thread/queue/add'); + const latest=await f.invoke(['handoff','status','--correlation-id',id]); + assert.deepEqual(latest.value.handoff.wait,newer.value.handoff.wait); + const duplicate=await f.invoke([...f.args,'--correlation-id',id]);assert.equal(duplicate.code,1);assert.equal(duplicate.value.error,'handoff_already_attempted');assert.equal(f.calls().length,1); +}); From 2ceb7112a45b9b08138da1d05e0c4dd7e5722211 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 02:36:35 +0000 Subject: [PATCH 09/18] test(security): pass observation fixture configuration as data --- test/handoff-delivery.test.ts | 37 +++++++++++++++++++++---------- test/metadata-observation.test.ts | 4 ++-- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index cf20a13..f9ef45d 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -1,6 +1,6 @@ import {test,type TestContext} from 'node:test'; import assert from 'node:assert/strict'; -import {spawn} from 'node:child_process'; +import {spawn,spawnSync} from 'node:child_process'; import {once} from 'node:events'; import {createRequire} from 'node:module'; import {DatabaseSync} from 'node:sqlite'; @@ -20,23 +20,27 @@ async function fixture(t:TestContext,mode='inject',version='0.160.1',warm=true){ const state=new DatabaseSync(join(home,'state_5.sqlite'));state.exec('CREATE TABLE threads(id TEXT)');state.prepare('INSERT INTO threads VALUES(?)').run(thread);state.close(); if(warm){const db=new DatabaseSync(join(home,'thread_history_1.sqlite'));db.exec(schema);db.prepare('INSERT INTO thread_turns VALUES(?,?,?)').run(thread,'turn-fixture','completed');db.prepare('INSERT INTO thread_history_projection_state VALUES(?,?,?)').run(thread,100,100);db.close();} const lock=join(home,'thread-writer-locks',thread+'.lock'),locks=createRequire(import.meta.url).resolve('fs-ext-extra-prebuilt'); - const held=spawn(process.execPath,['-e',`const f=require('fs').openSync(process.argv[1],'w',0o600);require(${JSON.stringify(locks)}).flockSync(f,'exnb');console.log('ready');setInterval(()=>{},1000)`,lock]);held.stderr.resume();await once(held.stdout,'data');t.after(async()=>{if(held.exitCode===null&&held.signalCode===null){held.kill('SIGKILL');await once(held,'close');}}); - writeFileSync(join(bin,'lsof'),`#!${process.execPath}\nprocess.stdout.write(${JSON.stringify('p'+held.pid+'\0ccodex-fixture\0u'+process.getuid?.()+'\0')});`,{mode:0o700}); + const held=spawn(process.execPath,['-e',"const f=require('fs').openSync(process.argv[1],'w',0o600);require(process.argv[2]).flockSync(f,'exnb');console.log('ready');setInterval(()=>{},1000)",lock,locks]);held.stderr.resume();await once(held.stdout,'data');t.after(async()=>{if(held.exitCode===null&&held.signalCode===null){held.kill('SIGKILL');await once(held,'close');}}); + const lsof=join(bin,'lsof');writeFileSync(lsof+'.json',JSON.stringify('p'+held.pid+'\0ccodex-fixture\0u'+process.getuid?.()+'\0'),{mode:0o600}); + writeFileSync(lsof,`#!${process.execPath}\nprocess.stdout.write(JSON.parse(require('fs').readFileSync(__filename+'.json','utf8')));`,{mode:0o700}); writeFileSync(join(bin,'ps'),`#!${process.execPath}\nconsole.log('Fixture stable start');`,{mode:0o700}); - const log=join(root,'queue-log');writeFileSync(log,'');const codex=join(bin,'codex'); + const log=join(root,'queue-log-\'"\\\n\u2028\u2029');writeFileSync(log,'');const codex=join(bin,'codex'); const initializeReady=join(root,'initialize-ready'),initializeRelease=join(root,'initialize-release'); + // Runtime configuration is JSON data; no fixture path/mode/version is code. + writeFileSync(codex+'.json',JSON.stringify({mode,version,log,initializeReady,initializeRelease}),{mode:0o600}); writeFileSync(codex,`#!${process.execPath} const fs=require('fs'),readline=require('readline'),path=require('path'); -if(process.argv[2]==='--version'){console.log('codex-cli '+${JSON.stringify(version)});process.exit(0);} -if(process.argv[2]==='queue'){fs.appendFileSync(${JSON.stringify(log)},JSON.stringify({method:'legacy'})+'\\n');console.log('Queued message legacy-1 for thread '+process.argv[4]+'.');process.exit(0);} +const {mode,version,log,initializeReady,initializeRelease}=JSON.parse(fs.readFileSync(__filename+'.json','utf8')); +if(process.argv[2]==='--version'){console.log('codex-cli '+version);process.exit(0);} +if(process.argv[2]==='queue'){fs.appendFileSync(log,JSON.stringify({method:'legacy'})+'\\n');console.log('Queued message legacy-1 for thread '+process.argv[4]+'.');process.exit(0);} const {DatabaseSync:Sql}=require('node:sqlite');globalThis.keptDatabases=[new Sql(path.join(process.env.CODEX_HOME,'state_5.sqlite'),{readOnly:true}),new Sql(path.join(process.env.CODEX_SQLITE_HOME,'queue_1.sqlite'))]; readline.createInterface({input:process.stdin}).on('line',async line=>{const r=JSON.parse(line);if(r.method==='initialize'){ -if(${JSON.stringify(mode)}==='concurrent-stop'){fs.writeFileSync(${JSON.stringify(initializeReady)},'ready');await new Promise(resolve=>{const timer=setInterval(()=>{if(fs.existsSync(${JSON.stringify(initializeRelease)})){clearInterval(timer);resolve();}},10);});} +if(mode==='concurrent-stop'){fs.writeFileSync(initializeReady,'ready');await new Promise(resolve=>{const timer=setInterval(()=>{if(fs.existsSync(initializeRelease)){clearInterval(timer);resolve();}},10);});} console.log(JSON.stringify({id:r.id,result:{codexHome:process.env.CODEX_HOME,platformFamily:'unix',platformOs:'macos',userAgent:'fixture'}}));}else if(r.method==='thread/queue/add'){ -fs.appendFileSync(${JSON.stringify(log)},JSON.stringify({method:r.method,clientId:r.params.clientUserMessageId})+'\\n'); -if(${JSON.stringify(mode)}==='concurrent-stop')return; -if(${JSON.stringify(mode)}==='loss'){process.exit(0);return;} -if(${JSON.stringify(mode)}==='inject'){const {DatabaseSync}=require('node:sqlite');const db=new DatabaseSync(path.join(process.env.CODEX_HOME,'thread_history_1.sqlite'));db.prepare('INSERT INTO thread_items VALUES(?,?,?,?,?,?)').run(r.params.threadId,'turn-fixture','native-item-not-client-id',1,JSON.stringify({type:'userMessage',id:'native-item-not-client-id',clientId:r.params.clientUserMessageId,content:[{text:'PRIVATE_BODY_SENTINEL'}]}),'userMessage');db.close();} +fs.appendFileSync(log,JSON.stringify({method:r.method,clientId:r.params.clientUserMessageId})+'\\n'); +if(mode==='concurrent-stop')return; +if(mode==='loss'){process.exit(0);return;} +if(mode==='inject'){const {DatabaseSync}=require('node:sqlite');const db=new DatabaseSync(path.join(process.env.CODEX_HOME,'thread_history_1.sqlite'));db.prepare('INSERT INTO thread_items VALUES(?,?,?,?,?,?)').run(r.params.threadId,'turn-fixture','native-item-not-client-id',1,JSON.stringify({type:'userMessage',id:'native-item-not-client-id',clientId:r.params.clientUserMessageId,content:[{text:'PRIVATE_BODY_SENTINEL'}]}),'userMessage');db.close();} console.log(JSON.stringify({id:r.id,result:{queuedSubmission:{id:'queue-fixture',clientUserMessageId:r.params.clientUserMessageId,input:r.params.input}}})); }});`,{mode:0o700}); const env={...process.env,HOME:root,USERPROFILE:root,CODEX_HOME:home,SESSION_PEER_CODEX_HOMES:'[]',CLAUDE_CONFIG_DIR:join(root,'.claude'),SESSION_PEER_TAILSCALE:'off',SESSION_PEER_UPDATE_NOTICE:'',CODEX_THREAD_ID:'',CODEX_SESSION_ID:'',PATH:bin+delimiter+(process.env.PATH??''),SESSION_PEER_HANDOFF_HOME:join(root,'state','handoff')}; @@ -47,8 +51,17 @@ console.log(JSON.stringify({id:r.id,result:{queuedSubmission:{id:'queue-fixture' const calls=()=>readFileSync(log,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)); async function queued(){if(calls().length)return;await new Promise((ok,fail)=>{const watcher=watch(root,()=>{if(calls().length){clearTimeout(timer);watcher.close();ok();}});const timer=setTimeout(()=>{watcher.close();fail(new Error('queue fixture not called'));},10000);if(calls().length){clearTimeout(timer);watcher.close();ok();}});} async function initialized(){if(existsSync(initializeReady))return;await new Promise((ok,fail)=>{const watcher=watch(root,()=>{if(existsSync(initializeReady)){clearTimeout(timer);watcher.close();ok();}});const timer=setTimeout(()=>{watcher.close();fail(new Error('initialize fixture not reached'));},10000);if(existsSync(initializeReady)){clearTimeout(timer);watcher.close();ok();}});} - return{root,home,calls,args,invoke,start,queued,initialized,releaseInitialize:()=>writeFileSync(initializeRelease,'release')}; + return{root,home,codex,env,calls,args,invoke,start,queued,initialized,releaseInitialize:()=>writeFileSync(initializeRelease,'release')}; } +test('scripted fixture paths, modes and versions remain literal data', {skip:process.platform==='win32'}, async t=>{ + const payload='\');throw new Error("fixture data executed");//\n\u2028\u2029'; + const f=await fixture(t,payload,payload); + const version=spawnSync(process.execPath,[f.codex,'--version'],{env:f.env,encoding:'utf8'}); + assert.equal(version.status,0,version.stderr);assert.equal(version.stdout,'codex-cli '+payload+'\n'); + const queue=spawnSync(process.execPath,[f.codex,'queue','add',thread],{env:f.env,encoding:'utf8'}); + assert.equal(queue.status,0,queue.stderr);assert.deepEqual(f.calls(),[{method:'legacy'}]); + assert.equal(queue.stdout,'Queued message legacy-1 for thread '+thread+'.\n'); +}); test('qualified local opt-in waits for exact client ID injection, not completed turn/ACK',qualified,async t=>{ const f=await fixture(t);const r=await f.invoke([...f.args,'--wait-for','delivered']);assert.equal(r.code,0,r.stdout);assert.equal(r.value.status,'queued');assert.equal(r.value.submitted,true);assert.equal(r.value.consumptionConfirmed,false);assert.equal(r.value.queueId,'queue-fixture');const h=validateHandoff(r.value.handoff);assert.equal(h.state,'delivered');assert.equal(h.wait.status,'satisfied');assert.equal(h.observation.clientUserMessageId,h.correlationId);assert.equal(h.observation.turn?.status,'completed');assert.equal(h.ack.status,'not_requested');assert.equal(f.calls().length,1);assert.equal(f.calls()[0].method,'thread/queue/add');assert.equal(f.calls()[0].clientId,h.correlationId);assert.equal(r.stdout.includes('PRIVATE_BODY_SENTINEL'),false); const status=await f.invoke(['handoff','status','--correlation-id',h.correlationId]);assert.equal(status.code,0);assert.equal(status.value.handoff.observation.injectionObserved,true);assert.equal(f.calls().length,1); diff --git a/test/metadata-observation.test.ts b/test/metadata-observation.test.ts index 9cebe0f..883f2b7 100644 --- a/test/metadata-observation.test.ts +++ b/test/metadata-observation.test.ts @@ -161,10 +161,10 @@ test('expired caller budget starts no process and aborts a hanging owner verifie assert.equal(result.supported, false); assert.equal(aborted, true); }); test('deadline kills only the owned probe process group including its descendant', posix, async t => { - const f = fixture(t), marker = join(f.home, 'owned-pids.json'); + const f = fixture(t), marker = join(f.home, 'owned-pids-\'"\\\n\u2028\u2029.json'); const realSpawn = childProcess.spawn; mock.method(childProcess, 'spawn', (binary: string, _args: readonly string[], options: any) => realSpawn(binary, ['-e', - `const cp=require('node:child_process');const fs=require('node:fs');const child=cp.spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});fs.writeFileSync(${JSON.stringify(marker)},JSON.stringify([process.pid,child.pid]));setInterval(()=>{},1000);`], options)); + "const cp=require('node:child_process');const fs=require('node:fs');const child=cp.spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});fs.writeFileSync(process.argv[1],JSON.stringify([process.pid,child.pid]));setInterval(()=>{},1000);", marker], options)); t.after(() => mock.restoreAll()); const started = performance.now(), deadline = started + 500; const result = await observeMetadata({ ...f.scope, clientUserMessageId: clientId }, { observationRemainingMs: () => deadline - performance.now() }, owner); From 518d0a8982adf281c07af11cdbb5c8a67648a48e Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 11 Oct 2026 09:28:42 +0900 Subject: [PATCH 10/18] test(observation): preserve submission facts across ledger failure --- test/handoff-delivery.test.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index f9ef45d..2a1228a 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -40,6 +40,7 @@ console.log(JSON.stringify({id:r.id,result:{codexHome:process.env.CODEX_HOME,pla fs.appendFileSync(log,JSON.stringify({method:r.method,clientId:r.params.clientUserMessageId})+'\\n'); if(mode==='concurrent-stop')return; if(mode==='loss'){process.exit(0);return;} +if(mode==='ledger-failure')fs.chmodSync(path.join(process.env.SESSION_PEER_HANDOFF_HOME,'ledger.json'),0o644); if(mode==='inject'){const {DatabaseSync}=require('node:sqlite');const db=new DatabaseSync(path.join(process.env.CODEX_HOME,'thread_history_1.sqlite'));db.prepare('INSERT INTO thread_items VALUES(?,?,?,?,?,?)').run(r.params.threadId,'turn-fixture','native-item-not-client-id',1,JSON.stringify({type:'userMessage',id:'native-item-not-client-id',clientId:r.params.clientUserMessageId,content:[{text:'PRIVATE_BODY_SENTINEL'}]}),'userMessage');db.close();} console.log(JSON.stringify({id:r.id,result:{queuedSubmission:{id:'queue-fixture',clientUserMessageId:r.params.clientUserMessageId,input:r.params.input}}})); }});`,{mode:0o700}); @@ -69,6 +70,39 @@ test('qualified local opt-in waits for exact client ID injection, not completed test('unknown after possible native write never falls back to legacy queue',qualified,async t=>{ const f=await fixture(t,'loss');const r=await f.invoke([...f.args,'--wait-for','delivered']);assert.equal(r.code,1,r.stdout);assert.equal(r.value.status,'unknown');assert.equal(r.value.submitted,null);assert.equal(r.value.handoff.submission.status,'unknown');assert.equal(r.value.handoff.wait.status,'failed');assert.equal(f.calls().length,1);assert.equal(f.calls()[0].method,'thread/queue/add'); }); +test('positive native acceptance survives a subsequent ledger failure without another submission',qualified,async t=>{ + const f=await fixture(t,'ledger-failure'); + const r=await f.invoke([...f.args,'--wait-for','delivered']); + assert.equal(r.code,1,r.stdout);assert.equal(r.value.ok,false); + assert.equal(r.value.error,'handoff_persistence_failed'); + assert.equal(r.value.status,'queued');assert.equal(r.value.submitted,true); + assert.equal(r.value.queueId,'queue-fixture');assert.equal(r.value.consumptionConfirmed,false); + const h=validateHandoff(r.value.handoff); + assert.equal(h.submission.status,'submitted');assert.equal(h.state,'submitted'); + assert.equal(h.wait.status,'failed');assert.equal(h.wait.reason,'evidence_failed'); + assert.equal(h.observation.injectionObserved,false);assert.equal(h.ack.status,'not_requested'); + assert.equal(h.retry.allowed,false);assert.deepEqual(f.calls().map(call=>call.method),['thread/queue/add']); + // The accepted result is an in-memory fact. Persistence failure did not + // magically commit it: a different invocation must reconcile the retained + // pre-effect fence as unknown and cannot submit this intent a second time. + chmodSync(join(f.root,'state','handoff','ledger.json'),0o600); + const recovered=await f.invoke(['handoff','status','--correlation-id',h.correlationId]); + assert.equal(recovered.code,0);assert.equal(recovered.value.handoff.submission.status,'unknown'); + const duplicate=await f.invoke([...f.args,'--correlation-id',h.correlationId]); + assert.equal(duplicate.code,1);assert.equal(duplicate.value.error,'handoff_already_attempted'); + assert.equal(f.calls().length,1); +}); +test('current unqualified native version cannot enter the app-server or required delivery path',qualified,async t=>{ + const f=await fixture(t,'inject','0.162.1'); + const required=await f.invoke([...f.args,'--wait-for','delivered']); + assert.equal(required.code,1);assert.equal(required.value.submitted,false); + assert.equal(required.value.handoff.wait.status,'unsupported');assert.equal(f.calls().length,0); + const best=await f.invoke([...f.args,'--observe-delivery']); + assert.equal(best.code,0);assert.equal(best.value.status,'queued');assert.equal(best.value.submitted,true); + assert.equal(best.value.handoff.observation.status,'unsupported'); + assert.equal(best.value.handoff.observation.injectionObserved,false); + assert.deepEqual(f.calls(),[{method:'legacy'}]); +}); test('unsupported version/absent warm store refuse required goals, best-effort fallback is chosen before app-server effect',qualified,async t=>{ const f=await fixture(t,'inject','0.159.0');const r=await f.invoke([...f.args,'--wait-for','delivered']);assert.equal(r.code,1);assert.equal(r.value.submitted,false);assert.equal(r.value.handoff.wait.status,'unsupported');assert.equal(f.calls().length,0); const best=await f.invoke([...f.args,'--observe-delivery']);assert.equal(best.code,0);assert.equal(best.value.handoff.observation.status,'unsupported');assert.deepEqual(f.calls(),[{method:'legacy'}]); From e4869e051a60a6d6eb8077a4825712abc8564964 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 11 Oct 2026 12:29:13 +0900 Subject: [PATCH 11/18] feat(observation): qualify native evidence and bound resumed SSH waits --- PARITY.md | 48 +++- VALIDATION.md | 155 ++++++++++ docs/api.md | 92 +++++- docs/guide.ja.md | 6 +- docs/guide.ko.md | 6 +- docs/guide.md | 6 +- docs/guide.zh-CN.md | 6 +- package.json | 2 +- src/cli.ts | 131 +++++++-- src/handoff-ledger.ts | 48 +++- src/handoff-observer-probe.ts | 4 + src/handoff-operations.ts | 137 +++++++-- src/handoff.ts | 10 + src/metadata-observation.ts | 52 +++- src/ssh-response.ts | 60 +++- .../metadata-observation-native-evidence.json | 128 +++++++++ test/handoff-cli.test.ts | 4 +- test/handoff-core.test.ts | 107 ++++++- test/handoff-delivery.test.ts | 154 +++++++++- test/metadata-observation.test.ts | 108 ++++++- test/ssh-handoff.test.ts | 270 ++++++++++++++++++ 21 files changed, 1434 insertions(+), 100 deletions(-) create mode 100644 test/fixtures/metadata-observation-native-evidence.json create mode 100644 test/ssh-handoff.test.ts diff --git a/PARITY.md b/PARITY.md index e327ac0..d31d316 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1072,16 +1072,54 @@ The client bounds frames, RPCs, traffic, owner verification, monotonic deadlines and owned process-group cleanup. An authorized caller supplies a durable before-effect fence; at most one queue RPC follows it. After a possible write, disconnect, timeout, schema error or remote refusal never enables fallback or -resend. initialize opts out all 83 notifications in the exact qualified native -schema; queue/add's own input echo is validated but only IDs are returned. +resend. initialize opts out the exact profile notification set (83 for 0.160.1, +84 for 0.162.1); queue/add's own input echo is validated but only IDs are returned. queue/list, history, full item/turn notifications, resume/start and model turns are excluded. The separate synthetic metadata protocol is explicitly fixture -only. Other platforms/versions and native observation remain unsupported. +only. Other platforms/versions and native app-server observation remain unsupported. Generated schema hashes and the fixed official source commit are recorded in `test/fixtures/app-server-schema-evidence.json`; tests use hermetic streams and -owned Node children. These facts do not re-pin the shipped Python runtime parity -reference or implement automatic ACK. +owned Node children. These mechanics do not establish Python runtime delivery parity +or implement automatic ACK. + + +## Unreleased local observation integration — #72 + +[Source-only runtime details](docs/api.md#unreleased-local-delivery-observation--72) +cover bounded one-shot/explicit-wait observation, native CID queueing, original +writer generation checks, per-operation terminal waits and positive native fact +preservation. Generated schema/native queue mechanics are distinct from +synthetic metadata/wait integration and live consumption evidence. The combined +CLI path is restricted to Codex 0.160.1 or 0.162.1/macOS arm64; POSIX SQL fixtures alone do +not qualify other native platforms. No rollout/transcript API is queried, no ACK is inferred +and no post-attempt fallback/resend occurs. + +Dedicated 0.162.1 Codex and Claude Code 2.1.296 test sessions supplied additional +local evidence; native client ID injection is separate from plain model response +or ACK. Retained qualified intents support independent read-only waits with no +new native submission. Each process owns its wait cutoff; foreign pending and +old terminal waits are preserved. A separate voluntary matching reply was +explicitly confirmed before any wait with `operator_confirmed`; ACK status and +a later acknowledged wait succeeded. Confirmation after an earlier independent +wait correctly refused `receipt_order_unprovable`, without replay. This is manual +operator attestation, not an automated token-possession collector. + +POSIX SSH opt-in now prepares an unattempted intent at an explicitly initialized +destination ledger, pins resolved target/home/generation/epoch/CID, then sends +once. Strict request-aware native facts survive complete failed-transport +responses; unsolicited/malformed handoff cannot create ACK. Source deadlines +are not renewed; destination remaining-duration watchdogs reap owned groups. +Transit delay cannot be synchronized across independent monotonic clocks. +Actual destination-handler/recording-socket fixtures use fake SSH; real remote +network/native-agent qualification is not implied. See [SSH details](docs/api.md#unreleased-destination-side-ssh-handoff). + +Open capabilities include original-sender SSH collector coordination, Windows +ACL/native observation and automated receipt producer/collector bootstrap. Other +platform/version tuples and arbitrary blocking-filesystem deadlines are not +qualified by these tests. +#69/#70/#71/#72 remain together in 0.4.0 and open. Ordinary runtime parity and +published version markers are unchanged. Storage binding is checked on the owned queue transport before its effect: diff --git a/VALIDATION.md b/VALIDATION.md index e30a08f..b4c3786 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -142,6 +142,161 @@ request-count assertions are preserved. This was test isolation, not a runtime failure or a CI rerun treated as an unchanged-head success. Final PR-head CI results are recorded in the PR after the correction. +## 0.4.0 integrated SSH review checkpoint — 2026-10-11 KST + +Independent read-only review used Claude Code 2.1.296 with tools disabled and +no session persistence. The first 163,540-byte input timed out after 240 seconds +(exit 143, no response), so it supplied no review evidence. One narrower +104,671-byte retry completed in 268.508 seconds with exit 0, `is_error=false` +and actual model `claude-opus-5-5` (first-party). Its input SHA-256 was +`69169aacf2c7f02d6606aebbdb23961e2694a0845e86875f2848469ea97a1718`, +unchanged before/after the call. Neither call edited files or ran tests. + +The review identified two required corrections: a complete successful +preparation followed by SSH failure must refuse the actual send, and a +post-submission persistence failure must preserve a verified pending wait +instead of losing its identifiers or inventing a terminal record. A stopped +wait with the wrong process exit is also discarded without discarding separate +native acceptance facts. These fixes and the earlier partial/no-EOF input +deadline correction have focused regression tests; subsequent integrated +test results are recorded separately. The review input predates those final +corrections and is not a whole-final-commit external verification. + +A subsequent internal source audit found that failed-wait normalization could +discard the original malformed/missing `error` before envelope validation. The +regression now rejects that handoff while retaining independently valid native +acceptance facts. The first integrated full run recorded 337 total / 331 pass / +1 failure / 5 skips: a sub-millisecond early probe cutoff attempted to commit a +timeout before its exact monotonic deadline and therefore retained pending +state. The fix waits out only that fractional tail within the existing budget; +it neither extends the deadline nor invents a persisted terminal result. The +corrected run is recorded below. + +The corrected integrated macOS arm64 / Node 24.16.0 run used the exact published +Python 1.1.0 export as `SESSION_PEER_PYTHON_ROOT` and +`PYTHONDONTWRITEBYTECODE=1`: `npm test` (build, test:types and all test files) +passed **337 total / 332 passed / zero failed / five Windows-only skipped**. +The preceding focused SSH/delivery run passed 27/27. Repository/link/anchor and +diff checks passed; `npm audit` reported zero vulnerabilities. These fixture +results do not widen the native/platform or actual Windows evidence above. + +The first packaging invocation failed its reproducibility check because this +validation document was edited between its two packs. That invocation is not a +successful package check. Source/document edits were then stopped before the +repeat packaging check; its result and the exact-commit artifact are reported +in the PR checkpoint separately. + +The apparent fanout deadline extension follows the frozen **per-destination** +budget, rather than one aggregate invocation deadline. Each destination keeps +one setup/preparation/effect/observation budget with its cleanup reserve; +sequential fanout can take their sum. Independent clocks cannot measure SSH +transit delay, and an unknown source result may precede a delayed destination +effect. No retry is authorized. A rejected dry-run timeout example lacked the +required prepared correlation and already fails option validation; it did not +require a runtime change. + +## 0.4.0 additional native verification — 2026-10-11 KST + +These are unpublished development checks on isolated, newly created test +sessions. They do not establish final whole-commit/full-suite CI or authorize +publication. Existing working sessions received no test message. Evidence is +bounded to the tested compiled components; later integrated checks are separate. + +### Codex: exact metadata observation and manual confirmation + +On macOS arm64 / Node 24.16.0, native Codex **0.162.1**, upstream source +`092d3acd6bec3e3a14bdc7e7a2810ab628ab759d`, was checked against the +version/platform/source and live evidence in +[`metadata-observation-native-evidence.json`](test/fixtures/metadata-observation-native-evidence.json). +The production reader matched the original exact client message ID, original +thread and generation before/after observation, and a positively linked +completed turn. Native item ID differed from client ID; neither that turn +completion nor the queue receipt was treated as ACK. The probe returned +bounded IDs/status only, with no body or history API output. SQLite internally +projects metadata from a body-bearing cell; this is not a promise that SQLite +never accesses body bytes. + +Actual CLI checks used two additional fresh test threads, **one native queue +attempt per thread**, with no fallback or resubmission. Both observe-send +commands exited 0 with `status=queued`, `submitted=true`, +`consumptionConfirmed=false` and retry disallowed. A separate delivered wait +on the first thread was satisfied with exact injection evidence; status query +exited 0. Starting that wait before manual confirmation lost provable ordering: +confirmation correctly exited 1 with `receipt_order_unprovable`, retaining +original delivery facts and performing no requeue. + +On the second thread, the operator verified the test-only voluntary response +against the original correlation, then confirmed **before starting another +wait**. Confirmation and status exited 0 with `state=acknowledged`, +`assurance=operator_confirmed`, `late=false`; a subsequent acknowledged wait +was satisfied and exited 0. This is an explicit operator attestation, not an +automated receipt collector or model identity proof. Automated receipt +capability remains unsupported. All owned test terminals were closed. Native +home test records were retained; no existing native record was deleted. + +The fixture records exact compiled component hashes, including +`dist/metadata-observation.js` SHA-256 +`4d01dae9a90f4cda3c7b45bf0c126067bff403f345d0d23bc593cc57363e0f83` +and `dist/handoff-ledger.js` +`efb46bcd1892ba9eb843a4ae603987f15ce535d942938dfe2c0e98a1343edcb9`. +These identify the tested components, not a final integrated commit. + +### Claude: correlated submission and unsupported required ACK + +A separate test-only Claude Code **2.1.296 / Opus 5.5** session on macOS +arm64 / Node 24.16.0 produced a READY response. Its launch identity, PID and +fresh private working directory were checked before each submission. Native +Claude assigned a non-null name; nullable names remain synthetic fixture +coverage. No tools, commands, file tasks or external messages were requested. + +A prepared-ID correlated send and a separate best-effort `--request-ack` +send each submitted exactly once and exited 0 with `status=posted`, +`submitted=true`, `consumptionConfirmed=false`, handoff state `submitted` +and retry disallowed. Each produced its own identification-marker response +in the owned terminal. Subsequent status queries retained state `submitted`, +`injectionObserved=false` and ACK `not_requested`/`unsupported`: a plain +assistant response was not promoted to machine ACK or native observation. +Required `--wait-for acknowledged` instead exited 1 before effect with +`evidence_unsupported`, `status=refused`, `submitted=false` and unsupported +ACK/wait. Its marker never appeared in the owned terminal. Zero effect is +supported by the pre-effect code/result contract and terminal check, not socket +instrumentation. The terminal closed; its PID and native registry record were +absent. Private workspace, message files and sender ledger were removed. + +The tested Claude compiled `dist/cli.js` SHA-256 was +`7195f5bd049bdba7ffbffd1d69473d9b90f9a42646851697e1df149054dc5d1f`; +`dist/handoff-operations.js` was +`99e15fbd8ad04e5f28490512b61bf0296b21d96ee787d63d1b8293e905f8665e`. +This earlier build differs from the subsequent Codex build; these results +must not be presented as a single final whole-commit run. Default native CLI +authentication was used without reading, copying or recording credentials. + +### Windows: separate failed real-host fixture gate + +The actual Windows 10 x64 / Node 24.16.0 suite recorded **266 total, +155 passed, 2 failed, 109 skipped**. The child failure and enclosing failure +refer to one underlying fake-SSH path, not two independent runtime defects. +The unsupported native-platform qualification guard selection separately +passed 3/3; it does not qualify observation on Windows. + +Additional strict/batch SSH diagnostics compiled the repository C# fixture. +Twelve minimal Node/PowerShell child-launch controls passed across standard +stream redirection and window settings; encoded PowerShell `exit 0` also +passed. The owned test `.cmd --version` command instead reproduced +`Win32Exception: Access is denied` in `Process.StartWithCreateProcess`, with +both ordinary and isolated HOME/USERPROFILE, including a failure followed by +a successful `exit 0` control in the same executable. General process creation +worked; the exact host cause remains unverified. A recent Defender event query +with no matches neither identifies nor excludes a security product/policy. +The **real-host SSH fixture gate remains unpassed**; fixture CI is distinct +and cannot erase this result. No policy, permission, service, guard or execution +bypass was applied. No real agent/native queue was invoked by these Windows +fixtures. The marked remote diagnostic directory was deleted and its absence +confirmed. No private hostnames, account paths or session identifiers are +included here. Windows native observation, SSH handoff, automated ACK, +restart/crash continuity and other native versions/platforms remain outside +these successful macOS checks. + ## Python 1.1.0 ordinary comparison — 2026-10-11 KST Unpublished 0.4.0 development checkpoint: an isolated `git archive` export of diff --git a/docs/api.md b/docs/api.md index a52e071..88b843d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -84,6 +84,52 @@ explicitly labeled and must match retained original context. No arbitrary message/transcript scan manufactures ACK. Ordinary generated Reply-To stays v1. +### Unreleased destination-side SSH handoff + +POSIX opt-in `send --host HOST` performs an exact-version preflight and a +no-effect preparation before one destination-side send. Both ends must contain +this reviewed candidate: the public 0.3.3 banner alone does not establish new +capability. Initialize the destination's private ledger explicitly; no command +auto-installs a handler or initializes missing history. The origin does not +create a local receipt collector. Reconcile with `handoff status`/`wait` on the +destination that owns the retained intent, without another send. + +Preparation fixes the canonical destination target/home, original generation, +epoch and correlation ID. A supplied ID must already be unattempted/prepared +there. Fanout uses a distinct ID per destination, is limited to 32 destinations, +and rejects one caller-supplied ID for multiple hosts. An incomplete preparation +refuses before effect. After a possible effect, partial/malformed/oversized +responses remain unknown and are never replayed. Complete request-matched native +facts survive SSH 255/timeout; malformed optional handoff cannot create ACK or +erase separately valid submission facts. Ordinary opt-out discards unsolicited +handoff/query/warning fields. + +The origin retains its original monotonic deadline. Each private request carries +only remaining duration, repeated in a private numeric argv selector. The +destination establishes its input timeout and owned-process cleanup watchdog +before reading the frame, requires the two durations to match, and charges +normal Node bootstrap/frame-read time to the same budget. An indefinitely blocked +static import before handler startup is outside this in-process watchdog; the +origin still has its own hard transport deadline. Transit delay is +not measurable across independent monotonic clocks: this is not a synchronized +cross-host cutoff guarantee. The source deadline is never renewed, and 5 seconds +of cleanup reserve remain inside each per-destination budget. Fake-SSH fixtures +exercise the actual destination handler and a recording socket, including the +watchdog's group cleanup; they do not constitute an actual network SSH or remote +native-agent qualification. + +Each fanout destination starts its own total budget; sequential fanout can take +up to the sum of those budgets (at most 32 × 60 seconds), rather than one shared +invocation deadline. No setup, preparation or wait renews a destination's +deadline. Transport loss can return unknown before a delayed destination effect +has finished; it is not evidence of non-delivery and never authorizes a retry. + +Original-sender ACK collector/bootstrap and Windows SSH handoff remain +unsupported. Required acknowledged wait refuses before submission; best-effort +request-ack reports unsupported. Destination-native observation retains the +same exact Codex version/platform/home/generation gates as local observation. + + ## Unreleased bounded app-server mechanics — 0.4.0 work `dist/app-server.js` is an internal opt-in module, not a new public package @@ -131,7 +177,7 @@ bootstrap filesystem activity. Observers still open only read-only warm stores. ## Unreleased local delivery observation — #72 A narrower qualified source path combines #71 queue mechanics with a warm -Codex 0.160.1 metadata store on macOS arm64. `--observe-delivery` checks once +Codex 0.160.1 or 0.162.1 metadata store on macOS arm64. `--observe-delivery` checks once (best effort); `--wait-for delivered` polls within the original remaining budget. The native client ID is the reserved correlation ID. A positive native receipt remains `status: queued`, `submitted: true`, `consumptionConfirmed: @@ -166,11 +212,39 @@ concurrent later waits do not replace the originating send's result. An explicit SIGINT-stopped wait returns exit 130 when its matching stopped record is proven, retaining independently known submission facts and never retracting/resending. -Qualification boundaries: combined CLI route is macOS arm64/Codex 0.160.1; -portable SQL/owner fixtures run on POSIX. End-to-end tests use synthetic writer, -SSH/process metadata and native RPC fixtures, not real user TUIs. Actual live -injection/ACK and Linux/Windows native queue integration are not qualified. -Windows ledger, sender-ledger SSH coordination, automated receipt bootstrap, -restart-resumed observation, full setup/filesystem cancellation and other native -versions remain open. Required ACK remains unsupported. The 0.4.0 milestone is -not complete and no package/version publication is implied. +`handoff wait --correlation-id UUID --wait-for delivered` starts a new read-only +operation over a retained, positively submitted intent. It preserves the original +home/thread/writer generation and private, exact version/source/platform +qualification. Missing qualification is unsupported; today's PATH or matching +schema never reconstructs it. A new wait has its own cutoff and never rewrites +an old terminal wait. Other processes' pending waits remain untouched and each +owner classifies its own deadline. No native executable, queue request, transcript +API or model turn is started by status/wait. Ledger contention is retried only +inside the existing total budget. A persistence failure returns the last verified +operation and known submission facts with `handoff_persistence_failed`; it does +not claim that a terminal record was written. + +Qualification boundaries: the combined native CLI route is macOS arm64/Codex +0.160.1 or 0.162.1, each with an exact pinned source profile. Dedicated new +0.162.1 TUI sessions demonstrated one native queue attempt and exact client-ID +injection in the original thread/home using bounded metadata projections. +Synthetic tests separately cover independent-process resume, timeout, SIGINT, +concurrent waits, owner change and persistence failure. Neither completion nor +visible response establishes machine ACK. A separate voluntary matching reply +was explicitly recorded with `handoff confirm --receipt -` before any wait: +`operator_confirmed`, retained ACK and a later acknowledged wait succeeded. +A prior test that started a delivered wait before confirmation correctly refused +unprovable cross-process ordering; its intent was not resubmitted. This is manual +operator attestation, not automatic token-possession receipt collection. Dedicated Claude Code 2.1.296 tests +confirmed correlated socket delivery and best-effort ACK remaining unsupported; +a required unsupported ACK refused before posting. + +Linux/Windows native queue/observation, Windows private ledger and automated +receipt collector/bootstrap remain unsupported. Other native versions are not +inferred from a minor range. The original-sender SSH collector is not provided. +No package/version publication or completed milestone is implied by these +source-only checks. Exact evidence and limits are in [VALIDATION](../VALIDATION.md). + + + + diff --git a/docs/guide.ja.md b/docs/guide.ja.md index e142f74..d3edd37 100644 --- a/docs/guide.ja.md +++ b/docs/guide.ja.md @@ -22,7 +22,7 @@ - npm、Python、リモートホスト、スキルは別管理です。TS は自己更新やリモート更新を提供しません。通知は既定で無効で、無効化指定が優先します。明示的な `update --check` はレジストリを照会します。公開 TS 0.3.5 は公開スキル `0.2.1` と旧 `0.1.0`・`0.2.0` の厳密なプロファイルを受け入れます。公開 TS 0.3.2・0.3.3・0.3.4 は `0.2.1` を拒否します。導入の節には独立して公開されたスキルのソースを固定しています。 - Windows の検証は公開 0.3.1、Windows 10 x64、管理者アカウント、PowerShell 5.1、Node 24.16.0 で行いました。オフライン導入、ラッパー・ネイティブロック、運用者の doctor・存在しない宛先の dry-run、ローカル fixture 31 件を検証しました。Node 22 はバージョン表示とネイティブロックの確認のみです。非管理者、他のアーキテクチャ、F6 のランタイム検査、以前の Linux 実験の最新規則は未検証です。0.3.2 の実機 ACK やデプロイ補助ツールの検証ではありません。 -[移行の詳細](../PARITY.md#ordinary-cli-migration-notes--117)と [Windows の検証記録](../VALIDATION.md#windows-ssh-destination-f5--2026-10-07-kst)を参照してください。上の日付付き移行比較は当時の公開版を対象にしています。未公開の 0.4.0 ソース候補は開発時の比較基準を Python 1.1.0 に更新しますが、公開 npm 0.3.3 の従来の 1.0.2 表示は変わりません。Python 1.1.0 には実験的なローカル Claude の受領確認専用 ACK 経路があります。これは TS 候補の自動 ACK、SSH handoff、Windows handoff 対応を意味しません。どちらの基準もエンドツーエンドの消費を保証せず、Side Session は実験段階です。Relay/MCP は比較対象外です。 +[移行の詳細](../PARITY.md#ordinary-cli-migration-notes--117)と [Windows の検証記録](../VALIDATION.md#windows-ssh-destination-f5--2026-10-07-kst)を参照してください。上の日付付き移行比較は当時の公開版を対象にしています。未公開の 0.4.0 ソース候補は開発時の比較基準を Python 1.1.0 に更新しますが、公開 npm 0.3.3 の従来の 1.0.2 表示は変わりません。Python 1.1.0 には実験的なローカル Claude の受領確認専用 ACK 経路があります。これは TS 候補の自動 ACK、元の送信者側の SSH 受領確認収集、Windows handoff 対応を意味しません。どちらの基準もエンドツーエンドの消費を保証せず、Side Session は実験段階です。Relay/MCP は比較対象外です。 [0.4.0 handoff 設計(共同合意・未実装)](../PARITY.md#handoff-v1-design-candidate--69) @@ -342,11 +342,11 @@ npm `--ignore-scripts` は利用でき、postinstall は Skills CLI を呼び出 ## Unreleased handoff -開発中の 0.4.0 ソースには opt-in private POSIX handoff ledger が追加されます。公開 0.3.1 にこのコマンドはありません。必須の証拠が unsupported の場合は提出前に拒否し、best-effort の提出は ACK を証明しません。自動 receipt bootstrap、Windows ledger、SSH handoff は未対応です。 +開発中の 0.4.0 ソースには opt-in private POSIX handoff ledger が追加されます。公開 0.3.3 にこのコマンドはありません。必須の証拠が unsupported の場合は提出前に拒否し、best-effort の提出は ACK を証明しません。自動受領確認の接続設定、Windows ledger、元の送信者側の SSH 受領確認収集は未対応です。POSIX SSH opt-in は明示的に初期化した宛先 ledger で送信しない準備を行い、対象・home・generation を固定して一度だけ送信します。[宛先側 SSH の説明](api.md#unreleased-destination-side-ssh-handoff)を参照してください。模擬 SSH fixture と実ネットワーク検証は区別します。 [CLI](api.md#unreleased-opt-in-handoff-cli--040-work) -ソース専用の `--wait-for delivered` は Codex 0.160.1/macOS arm64 と既存 metadata store に限定されます。本文は返さず、wait 失敗でも queued/submitted の証拠を保ちます。各 wait の operation ID と終了結果は独立です。合成テストは live ACK の証拠ではなく、SSH/Windows handoff と自動 ACK は未対応です。 +未公開のソース候補では、macOS arm64 の厳密な Codex 0.160.1/0.162.1 と既存のメタデータ保存先に限って配送を観測します。検証根拠が保存された提出は、別の `handoff wait` から読み取り専用で観測を再開でき、native 提出は繰り返しません。各待機は自分の期限だけで判定し、他の待機と過去の終了結果を保持します。専用の新規 Codex 0.162.1・Claude Code 2.1.296 セッションによる実機根拠と合成テストを区別します。queued/posted、注入、ターン終了、通常の返信は受領確認 ACK ではありません。自動 ACK と Windows handoff は未対応です。 [Observation](api.md#unreleased-local-delivery-observation--72) diff --git a/docs/guide.ko.md b/docs/guide.ko.md index 4bc10c7..dc4223a 100644 --- a/docs/guide.ko.md +++ b/docs/guide.ko.md @@ -22,7 +22,7 @@ - npm, Python, 원격 호스트, 스킬은 각각 별도로 관리합니다. TS는 자체·원격 업데이트를 제공하지 않습니다. 알림은 기본 꺼짐이며 비활성화 설정이 우선합니다. 명시적 `update --check`는 레지스트리를 조회합니다. 공개 TS 0.3.5는 공개 스킬 `0.2.1`과 기존 `0.1.0`·`0.2.0`의 정확한 프로필을 허용하며, 공개 TS 0.3.2·0.3.3·0.3.4는 `0.2.1`을 거부합니다. 설치 절에는 별도로 공개된 스킬 소스를 고정했습니다. - Windows 검증은 공개 0.3.1, Windows 10 x64, 관리자 계정, PowerShell 5.1, Node 24.16.0에서 수행했습니다. 오프라인 설치, 래퍼·네이티브 잠금, 운영자의 doctor·없는 대상 dry-run과 로컬 fixture 31개가 검증됐습니다. Node 22는 버전 표시·네이티브 잠금 확인만 수행했습니다. 비관리자, 다른 아키텍처, F6 런타임 검사, 이전 Linux 실험의 최신 규칙 검증은 남아 있습니다. 이는 0.3.2 실기 ACK나 배포 도우미 검증이 아닙니다. -[마이그레이션 상세](../PARITY.md#ordinary-cli-migration-notes--117)와 [Windows 검증 기록](../VALIDATION.md#windows-ssh-destination-f5--2026-10-07-kst)을 참고하세요. 위 날짜별 마이그레이션 비교는 당시 공개 버전을 기준으로 합니다. 미발행 0.4.0 소스 후보는 개발 비교 기준을 Python 1.1.0으로 갱신하며, 공개 npm 0.3.3의 기존 1.0.2 표시는 바뀌지 않습니다. Python 1.1.0에는 실험적인 로컬 Claude 수신 확인 전용 ACK 경로가 있습니다. 이것이 TS 후보의 자동 ACK·SSH handoff·Windows handoff 지원을 뜻하지는 않습니다. 어느 기준도 종단 간 소비를 보장하지 않으며 Side Session은 실험 범위입니다. Relay/MCP는 이 비교에서 제외합니다. +[마이그레이션 상세](../PARITY.md#ordinary-cli-migration-notes--117)와 [Windows 검증 기록](../VALIDATION.md#windows-ssh-destination-f5--2026-10-07-kst)을 참고하세요. 위 날짜별 마이그레이션 비교는 당시 공개 버전을 기준으로 합니다. 미발행 0.4.0 소스 후보는 개발 비교 기준을 Python 1.1.0으로 갱신하며, 공개 npm 0.3.3의 기존 1.0.2 표시는 바뀌지 않습니다. Python 1.1.0에는 실험적인 로컬 Claude 수신 확인 전용 ACK 경로가 있습니다. 이것이 TS 후보의 자동 ACK·원래 발신자의 SSH 수신 확인 수집기·Windows handoff 지원을 뜻하지는 않습니다. 어느 기준도 종단 간 소비를 보장하지 않으며 Side Session은 실험 범위입니다. Relay/MCP는 이 비교에서 제외합니다. [0.4.0 handoff 설계(공동 확정·미구현)](../PARITY.md#handoff-v1-design-candidate--69) @@ -595,11 +595,11 @@ npm `--ignore-scripts`로 설치해도 되며, 설치 후 Skills CLI를 호출 ## Unreleased handoff -개발 중인 0.4.0 소스에는 opt-in private POSIX handoff ledger가 추가됩니다. 공개 0.3.1에는 이 명령이 없습니다. 필수 증거 채널이 unsupported이면 제출 전에 거절하며, best-effort 제출은 ACK를 증명하지 않습니다. 자동 receipt bootstrap, Windows ledger 및 SSH handoff는 아직 미지원입니다. +개발 중인 0.4.0 소스에는 opt-in private POSIX handoff ledger가 추가됩니다. 공개 0.3.3에는 이 명령이 없습니다. 필수 증거 채널이 unsupported이면 제출 전에 거절하며, best-effort 제출은 ACK를 증명하지 않습니다. 자동 수신 확인 연결 설정, Windows ledger 및 원래 발신자의 SSH 수신 확인 수집기는 미지원입니다. POSIX SSH opt-in은 명시적으로 초기화한 목적지 ledger에서 전송 없는 준비 단계로 대상·home·generation을 고정한 뒤 한 번만 전송합니다. [목적지 SSH 설명](api.md#unreleased-destination-side-ssh-handoff)을 참고하세요. 가짜 SSH fixture는 실제 네트워크 검증과 구분합니다. [CLI](api.md#unreleased-opt-in-handoff-cli--040-work) -소스 전용 `--wait-for delivered` 경로는 Codex 0.160.1/macOS arm64 queue mechanics와 이미 생성된 metadata store에 한정됩니다. 본문을 반환하지 않으며 wait 실패도 queued/submitted 증거를 지우지 않습니다. 각 wait의 operation ID·종료 결과는 별개입니다. 합성 통합 검증은 live ACK 증거가 아니며 SSH/Windows handoff와 자동 ACK는 미지원입니다. +미발행 소스 후보의 전송 관찰은 macOS arm64의 정확한 Codex 0.160.1/0.162.1과 이미 생성된 메타데이터 저장소에 한정됩니다. 검증 근거가 보존된 전송은 별도 `handoff wait`로 읽기 전용 관찰을 다시 시작할 수 있으며, native 전송을 반복하지 않습니다. 각 대기는 자신의 마감 시각으로만 판단하고 다른 대기와 기존 종료 결과를 보존합니다. 새로 만든 Codex 0.162.1·Claude Code 2.1.296 테스트 세션의 실기 근거와 합성 테스트는 구분합니다. queued/posted, 실제 주입, 턴 종료, 일반 응답은 수령 확인 ACK와 다릅니다. 자동 ACK와 Windows handoff는 미지원입니다. [Observation](api.md#unreleased-local-delivery-observation--72) diff --git a/docs/guide.md b/docs/guide.md index 0745616..01c7d27 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -54,7 +54,7 @@ The dated migration comparison above uses the historical public versions. The unpublished 0.4.0 source candidate now uses Python 1.1.0 as its development comparison baseline; public npm 0.3.3 retains its historical 1.0.2 marker. Python 1.1.0 includes an experimental local Claude receipt-only ACK path. -That does not provide automated ACK, SSH handoff or Windows handoff in this TS +That does not establish automated ACK or original-sender SSH collectors in this TS candidate. Neither baseline provides an end-to-end consumption guarantee; Side Session remains experimental. Relay/MCP are outside this comparison. @@ -622,11 +622,11 @@ never enabled automatically. See [explicit activation and collisions](shorthand. ## Unreleased handoff -Unreleased 0.4.0 source adds an opt-in private POSIX handoff ledger. Published 0.3.1 has no handoff commands. Required unsupported evidence refuses before submission; best-effort unsupported evidence never proves ACK. Automated receipt bootstrap, Windows ledger and SSH handoff remain unsupported. +Unreleased 0.4.0 source adds an opt-in private POSIX handoff ledger. Published 0.3.3 has no handoff commands. Required unsupported evidence refuses before submission; best-effort unsupported evidence never proves ACK. Automated receipt bootstrap, Windows ledger and original-sender SSH collectors remain unsupported. POSIX SSH opt-in uses an explicitly initialized destination ledger: no-effect preparation pins target/home/generation before one send. See [destination-side SSH details](api.md#unreleased-destination-side-ssh-handoff); fake-SSH fixtures are distinct from real network qualification. [CLI](api.md#unreleased-opt-in-handoff-cli--040-work) -The source-only `--wait-for delivered` path is narrowly qualified for Codex 0.160.1/macOS arm64 queue mechanics and a warm body-free metadata projection. Failed waits retain queued/submitted facts; every wait has its own immutable operation ID. Synthetic integration is not live ACK proof. SSH/Windows handoff and automatic ACK remain unsupported. +The source candidate supports exact Codex 0.160.1/0.162.1 on macOS arm64 with an already-warm metadata store. Retained qualified intents can start an independent read-only `handoff wait`; no native submission is repeated. Each operation owns its cutoff and preserves other pending or terminal waits. Dedicated new 0.162.1 Codex and Claude Code 2.1.296 sessions supplied local evidence; synthetic tests remain separate. Queued/posted, injection, turn completion and plain reply are distinct from receipt ACK. Automated ACK and Windows handoff remain unsupported. [Observation](api.md#unreleased-local-delivery-observation--72) diff --git a/docs/guide.zh-CN.md b/docs/guide.zh-CN.md index e495805..b4b3ab2 100644 --- a/docs/guide.zh-CN.md +++ b/docs/guide.zh-CN.md @@ -22,7 +22,7 @@ - npm、Python、远程主机、技能分别管理。TS 不提供自身更新或远程更新。通知默认关闭,禁用设置优先。显式 `update --check` 仍会查询注册表。公开 TS 0.3.5 接受公开技能 `0.2.1` 及旧 `0.1.0`、`0.2.0` 的精确配置;公开 TS 0.3.2、0.3.3 和 0.3.4 拒绝 `0.2.1`。安装部分固定了独立发布的技能源码。 - Windows 验证使用公开 0.3.1、Windows 10 x64、管理员账号、PowerShell 5.1 和 Node 24.16.0。验证范围包括离线安装、包装器与原生锁、操作端 doctor 和不存在目标的 dry-run,以及 31 项本地 fixture。Node 22 仅确认版本标识和原生锁。非管理员、其他架构、F6 运行时校验、此前 Linux 实验的最新规则仍未验证。这不是 0.3.2 实机 ACK 或部署辅助工具的验证。 -请参阅[迁移详情](../PARITY.md#ordinary-cli-migration-notes--117)和 [Windows 验证记录](../VALIDATION.md#windows-ssh-destination-f5--2026-10-07-kst)。上述带日期的迁移比较针对当时的公开版本。未发布的 0.4.0 源码候选将开发比较基准更新为 Python 1.1.0;公开 npm 0.3.3 原有的 1.0.2 标识保持不变。Python 1.1.0 包含实验性的本地 Claude 收件确认专用 ACK 路径。这不表示 TS 候选支持自动 ACK、SSH handoff 或 Windows handoff。两种基准均不保证端到端消费,Side Session 仍属实验功能。Relay/MCP 不在比较范围内。 +请参阅[迁移详情](../PARITY.md#ordinary-cli-migration-notes--117)和 [Windows 验证记录](../VALIDATION.md#windows-ssh-destination-f5--2026-10-07-kst)。上述带日期的迁移比较针对当时的公开版本。未发布的 0.4.0 源码候选将开发比较基准更新为 Python 1.1.0;公开 npm 0.3.3 原有的 1.0.2 标识保持不变。Python 1.1.0 包含实验性的本地 Claude 收件确认专用 ACK 路径。这不表示 TS 候选支持自动 ACK、原发送方的 SSH 收件确认收集器或 Windows handoff。两种基准均不保证端到端消费,Side Session 仍属实验功能。Relay/MCP 不在比较范围内。 [0.4.0 handoff 设计(联合确认、未实现)](../PARITY.md#handoff-v1-design-candidate--69) @@ -326,11 +326,11 @@ postinstall 不会调用 Skills CLI,安装此技能不会覆盖 Python 技能 ## Unreleased handoff -开发中的 0.4.0 源码加入 opt-in private POSIX handoff ledger。已发布的 0.3.1 不含这些命令。所需证据通道 unsupported 时在提交前拒绝;best-effort 提交并不证明 ACK。自动 receipt bootstrap、Windows ledger 和 SSH handoff 尚不支持。 +开发中的 0.4.0 源码加入 opt-in private POSIX handoff ledger。已发布的 0.3.3 不含这些命令。所需证据通道 unsupported 时在提交前拒绝;best-effort 提交并不证明 ACK。自动收件确认连接设置、Windows ledger 和原发送方的 SSH 收件确认收集器尚不支持。POSIX SSH opt-in 在已明确初始化的目标 ledger 上进行无发送的准备,固定目标、home、generation 后只发送一次。请参阅[目标端 SSH 说明](api.md#unreleased-destination-side-ssh-handoff)。模拟 SSH fixture 与实际网络验证分别记录。 [CLI](api.md#unreleased-opt-in-handoff-cli--040-work) -源码专用的 `--wait-for delivered` 限于 Codex 0.160.1/macOS arm64 与已有 metadata store。不返回正文,wait 失败仍保留 queued/submitted 事实。每个 wait 的 operation ID 和终态独立。合成集成测试不是 live ACK 证据;SSH/Windows handoff 与自动 ACK 仍未支持。 +未发布的源码候选仅支持 macOS arm64 上的精确 Codex 0.160.1/0.162.1 和已有的元数据存储。保留验证依据的提交可通过独立的 `handoff wait` 继续只读观察,不会再次进行 native 提交。每项等待只按自己的截止时间判断,并保留其他等待及过去的终态。新建专用 Codex 0.162.1、Claude Code 2.1.296 会话的实机依据与合成测试分开记录。queued/posted、注入、轮次结束及普通回复均不等于收件确认 ACK。自动 ACK 和 Windows handoff 仍不支持。 [Observation](api.md#unreleased-local-delivery-observation--72) diff --git a/package.json b/package.json index dae4f25..07baf09 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "build": "tsc -p tsconfig.json", "test": "npm run build && npm run test:types && node --import ./test/fixtures/isolate-env.mjs --experimental-strip-types --test test/*.test.ts", "test:types": "tsc -p tsconfig.test.json", - "test:windows": "npm run build && npm run test:types && node --import ./test/fixtures/isolate-env.mjs --import ./test/fixtures/windows-inspection-diagnostic.mjs --experimental-strip-types --test --test-concurrency=1 test/app-server.test.ts test/handoff-core.test.ts test/handoff-cli.test.ts test/handoff-delivery.test.ts test/metadata-observation.test.ts test/handoff-writer-probe.test.ts test/writer-uid.test.ts test/claude-records.test.ts test/reply-agent-binding.test.ts test/windows.test.ts test/windows-contract.test.ts test/windows-inspection.test.ts test/input-boundaries.test.ts test/discovery.test.ts test/codex-homes.test.ts test/diagnostics.test.ts test/cli-usability.test.ts test/skill-guidance.test.ts test/skill-metadata.test.ts test/ssh-hosts.test.ts test/ssh-response.test.ts test/replies.test.ts test/updates.test.ts", + "test:windows": "npm run build && npm run test:types && node --import ./test/fixtures/isolate-env.mjs --import ./test/fixtures/windows-inspection-diagnostic.mjs --experimental-strip-types --test --test-concurrency=1 test/handoff-core.test.ts test/handoff-cli.test.ts test/handoff-delivery.test.ts test/metadata-observation.test.ts test/handoff-writer-probe.test.ts test/app-server.test.ts test/writer-uid.test.ts test/claude-records.test.ts test/reply-agent-binding.test.ts test/windows.test.ts test/windows-contract.test.ts test/windows-inspection.test.ts test/input-boundaries.test.ts test/discovery.test.ts test/codex-homes.test.ts test/diagnostics.test.ts test/cli-usability.test.ts test/skill-guidance.test.ts test/skill-metadata.test.ts test/ssh-hosts.test.ts test/ssh-response.test.ts test/ssh-handoff.test.ts test/replies.test.ts test/updates.test.ts", "test:package": "npm run build && node --experimental-strip-types test/package-smoke.ts" }, "devDependencies": { diff --git a/src/cli.ts b/src/cli.ts index a8c97f5..8efbb5d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { handoffCommand, handoffSend, privateMessage } from './handoff-operations.js'; -import { canonicalId, HandoffBudget, parseWaitTimeout } from './handoff.js'; +import { handoffCommand, handoffSend, prepareHandoff, privateMessage } from './handoff-operations.js'; +import { canonicalId, closed, HANDOFF_LIMITS, HandoffBudget, parseStrictJson, parseWaitTimeout, safeInteger, systemClock, type Handoff } from './handoff.js'; import { doctor } from './diagnostics.js'; import { help } from './help.js'; import { renderOutput, type OutputFormat } from './output.js'; @@ -80,8 +80,6 @@ export function parse(args: string[]): Options { if (!values.has('--correlation-id') && !values.has('--wait-for') && !flags.has('--request-ack') && !flags.has('--observe-delivery')) throw new Refusal('inapplicable_wait_timeout'); } if (flags.has('--dry-run') && (flags.has('--request-ack') || flags.has('--observe-delivery') || values.has('--wait-for'))) throw new Refusal('incompatible_handoff_dry_run'); - // The original-sender ledger/receipt route is not yet qualified over SSH. - if (hosts.length) throw new Refusal('remote_handoff_unsupported', 1); } if (values.has('--message-file') && (command !== 'send' || values.has('--message'))) throw new Refusal('conflicting_message_sources'); if (command === 'update') { @@ -118,9 +116,12 @@ export function parse(args: string[]): Options { values.set('--to', route.to); } } - // Reply-To URI parsing can introduce an SSH destination after the early - // flag check. Never let that route bypass the original sender's ledger. - if (handoff && hosts.length) throw new Refusal('remote_handoff_unsupported', 1); + if (handoff && hosts.length) { + if (hosts.length > HANDOFF_LIMITS.fanout) throw new Refusal('handoff_fanout_too_large'); + if (values.has('--correlation-id') && hosts.length > 1) throw new Refusal('handoff_correlation_fanout_forbidden'); + // Owned SSH descendant cleanup has only been qualified on POSIX origins. + if (process.platform === 'win32' || values.get('--remote-platform') === 'win32') throw new Refusal('remote_handoff_unsupported', 1); + } if (command === 'send' && flags.has('--allow-inactive-codex-home')) { if (!values.get('--to')!.startsWith('codex:')) throw new Refusal('inapplicable_option'); if (!values.get('--codex-home')) throw new Refusal('inactive_opt_in_requires_explicit_home'); @@ -196,7 +197,7 @@ export function sshPreflightFailure(result: Done, expected: string): string | un if (result.code !== 0) return 'ssh_preflight_failed'; return undefined; } -async function remote(options: Options, ssh: string, target: string, resolved: { canonical: string; options: string[] }, message?: string, returnTo?: string): Promise<{ value: Record; exitCode: number }> { +async function remote(options: Options, ssh: string, target: string, resolved: { canonical: string; options: string[] }, message?: string, returnTo?: string, budget?: HandoffBudget): Promise<{ value: Record; exitCode: number }> { const { values, flags, command } = options; const binary = values.get('--remote-bin') ?? 'session-peer'; const windows = values.get('--remote-platform') === 'win32'; @@ -213,7 +214,8 @@ async function remote(options: Options, ssh: string, target: string, resolved: { const base = ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10', ...(options.jump ? jumpOptions(ssh, options.jump) : []), ...(values.has('--ssh-control-path') ? ['-S', values.get('--ssh-control-path')!] : []), ...resolved.options, ...options.ssh.args, '--', target]; - const preflight = await run(ssh, [...base, invoke('--version')], { timeout: 15000 }); + budget?.beforeEffect(); + const preflight = await run(ssh, [...base, invoke('--version')], { timeout: budget ? Math.max(1, Math.min(15000, budget.observationRemainingMs())) : 15000 }); const preflightError = sshPreflightFailure(preflight, VERSION_LINE); if (preflightError) throw new Refusal(preflightError, 1); const args = [command, '--json']; @@ -224,19 +226,55 @@ async function remote(options: Options, ssh: string, target: string, resolved: { if (command === 'send') args.push(`--message=${message!}`, '--no-from', '--no-reply-to'); // The destination probes back to this machine; the request carries only the host. if (returnTo) args.push('--return-route-host', returnTo); - const done = await run(ssh, [...base, invoke('--stdio-request')], { - timeout: 90000, rawStdout: true, input: JSON.stringify({ schemaVersion: 1, args }) - }); + const context = budget ? { correlationId: values.get('--correlation-id'), requestAck: flags.has('--request-ack'), + observeDelivery: flags.has('--observe-delivery'), waitFor: values.get('--wait-for') as 'delivered' | 'acknowledged' | undefined } : undefined; + if (context) { + for (const key of ['--correlation-id', '--wait-for', '--wait-timeout']) if (values.has(key)) args.push(key, values.get(key)!); + for (const key of ['--request-ack', '--observe-delivery']) if (flags.has(key)) args.push(key); + } + const execute = (requestArgs: string[], preparation = false) => { + budget?.beforeEffect(); + const remainingMs = budget ? Math.floor(budget.remainingMs()) : undefined; + if (remainingMs !== undefined && (remainingMs < 1 || remainingMs > 60000)) throw new Refusal('invalid_handoff_budget', 1); + return run(ssh, [...base, invoke(`--stdio-request${remainingMs === undefined ? '' : ` --handoff-remaining-ms=${remainingMs}`}`)], { + timeout: budget ? Math.max(1, budget.remainingMs() - 200) : 90000, rawStdout: true, + input: JSON.stringify({ schemaVersion: 1, args: requestArgs, + ...(remainingMs !== undefined ? { handoff: { schemaVersion: 1, remainingMs, ...(preparation ? { preparation: true } : {}) } } : {}) }) + }); + }; + let prepared: Pick | undefined; + let destinationHost: string | undefined; + let boundTo = values.get('--to'), boundHome = values.get('--codex-home'); + if (budget) { + // This round reserves a destination-owned intent and performs zero native + // submissions. An incomplete response is never replayed or guessed into send. + const preparation = await execute(args, true); + let checked: ReturnType; + try { checked = responseOutcome(preparation, { command, to: boundTo, home: boundHome, dryRun: true, + allowInactive: flags.has('--allow-inactive-codex-home'), handoff: { ...context, preparation: true } }); } + catch { throw new Refusal('remote_preparation_unverified', 1); } + if (checked.value.ok === true && checked.exitCode !== 0) throw new Refusal('remote_preparation_unverified', 1); + if (checked.exitCode !== 0 || checked.value.ok !== true) return { value: { ...checked.value, host: resolved.canonical, sshHost: target }, exitCode: checked.exitCode || 1 }; + const h = checked.value.handoff as Handoff | undefined; + if (!h) throw new Refusal('remote_preparation_unverified', 1); + prepared = { correlationId: h.correlationId, ledgerEpoch: h.ledgerEpoch, targetGeneration: h.targetGeneration }; + destinationHost = checked.value.host as string; + const nativeTarget = checked.value.target as { id?: string; pid?: number }; + boundTo = boundTo!.startsWith('codex:') ? `codex:${nativeTarget.id!}` : `claude:${nativeTarget.pid!}`; + if (boundTo.startsWith('codex:')) boundHome = checked.value.codexHome as string; + const replace = (key: string, value: string) => { const index = args.indexOf(key); if (index >= 0) args[index + 1] = value; else args.push(key, value); }; + replace('--to', boundTo); replace('--correlation-id', h.correlationId); + if (boundHome !== undefined) replace('--codex-home', boundHome); + } + const done = await execute(args); const uncertain = () => { if (command === 'send' && !flags.has('--dry-run') && done.spawned) throw new UnknownOutcome(); throw new Refusal('remote_response_unverified', 1); }; let verified: ReturnType; - try { verified = responseOutcome(done, { command, to: values.get('--to'), dryRun: flags.has('--dry-run'), returnTo, - home: values.get('--codex-home'), allowInactive: flags.has('--allow-inactive-codex-home') }); } + try { verified = responseOutcome(done, { command, to: boundTo, dryRun: flags.has('--dry-run'), returnTo, + home: boundHome, allowInactive: flags.has('--allow-inactive-codex-home'), ...(context ? { handoff: { ...context, correlationId: prepared!.correlationId, prepared, destinationHost } } : {}) }); } catch { return uncertain(); } const { value } = verified; // `clientUpdate` is client-local: a remote-supplied value is never trusted or shown. delete value.clientUpdate; - // Ordinary SSH responses cannot opt into peer-supplied handoff evidence. - delete value.handoff; delete value.handoffQuery; delete value.handoffWarning; return { value: { ...value, host: resolved.canonical, sshHost: target }, exitCode: verified.exitCode }; } function failure(error: unknown, command: string, where: string): { value: Record; exitCode: number } { @@ -251,13 +289,15 @@ function failure(error: unknown, command: string, where: string): { value: Recor } // Destinations run in order, one attempt each. A failure or unknown outcome is // reported for that destination only; it is never retried or failed over. -async function remotes(options: Options, status: Tailnet | undefined, message?: string, returnTo?: string): Promise<{ value: Record[]; exitCode: number }> { +async function remotes(options: Options, status: Tailnet | undefined, message?: string, returnTo?: string, firstBudget?: HandoffBudget): Promise<{ value: Record[]; exitCode: number }> { const ssh = executable('ssh'), results: Record[] = []; let exitCode = 0; for (const requestedHost of options.hosts) { const target = host(requestedHost); let outcome: { value: Record; exitCode: number }, user: Partial>> = {}, canonical = target; try { + const budget = firstBudget ? results.length === 0 ? firstBudget : new HandoffBudget(parseWaitTimeout(options.values.get('--wait-timeout') ?? '30')) : undefined; + budget?.beforeEffect(); // A known offline or ambiguous tailnet peer is refused before any SSH process. const resolved = route(target, status); canonical = resolved.canonical; const hostName = resolved.hostName ? ['-o', `HostName=${resolved.hostName}`] : []; @@ -271,7 +311,7 @@ async function remotes(options: Options, status: Tailnet | undefined, message?: if (resolved.hostKeyAlias && config.read && !config.hostKeyAlias) alias = ['-o', `HostKeyAlias=${resolved.hostKeyAlias}`]; } catch { user = { sshUser: null, sshUserSource: 'unknown' }; } // Metadata never blocks or aborts a destination; earlier results are kept. - outcome = await remote(options, ssh, target, { canonical, options: [...hostName, ...alias] }, message, returnTo); + outcome = await remote(options, ssh, target, { canonical, options: [...hostName, ...alias] }, message, returnTo, budget); } // One destination keeps the existing flat local-host failure shape. catch (error) { outcome = failure(error, options.command, options.hosts.length === 1 ? hostname() : canonical !== target ? canonical : requestedHost); } @@ -285,6 +325,7 @@ async function remotes(options: Options, status: Tailnet | undefined, message?: let command = 'unknown'; let format: OutputFormat = 'json'; let wire = false; +let wireWatchdog: ReturnType | undefined; try { const [major, minor] = process.versions.node.split('.').map(Number); if (!((major === 22 && minor! >= 13) || major === 24)) throw new Refusal('unsupported_node_version'); @@ -301,18 +342,56 @@ try { process.exitCode = operation.exitCode; } else { - wire = args.length === 1 && args[0] === '--stdio-request'; + wire = args[0] === '--stdio-request'; + let wireContext: { remainingMs: number; preparation?: true } | undefined; + let wireBudget: HandoffBudget | undefined; + let wireRemainingMs: number | undefined; if (wire) { + if (args.length !== 1) { + if (args.length !== 2 || !/^--handoff-remaining-ms=[1-9][0-9]{0,4}$/.test(args[1]!)) throw new Refusal('invalid_remote_request'); + wireRemainingMs = Number(args[1]!.slice('--handoff-remaining-ms='.length)); + if (wireRemainingMs > 60000) throw new Refusal('invalid_remote_request'); + // Charge Node startup and imports before module execution. The same + // duration then covers input, preparation/effect and owned cleanup. + const wireStartedAt = systemClock.monotonic() - process.uptime() * 1000; + wireBudget = HandoffBudget.fromRemainingMs(wireRemainingMs, systemClock, wireStartedAt); + wireBudget.beforeEffect(); + wireWatchdog = setTimeout(() => process.kill(process.pid, 'SIGTERM'), Math.max(1, wireBudget.remainingMs())); + } let request: unknown; - try { request = JSON.parse(await input()); } catch { throw new Refusal('invalid_remote_request'); } - const record = request as { schemaVersion?: unknown; args?: unknown }; + let raw: string; + try { raw = await input(4_100_000, false, wireBudget?.observationRemainingMs()); request = JSON.parse(raw); } + catch (error) { + if (wireBudget && error instanceof Refusal && error.code === 'deadline_before_effect') throw error; + throw new Refusal('invalid_remote_request'); + } + const record = request as { schemaVersion?: unknown; args?: unknown; handoff?: unknown }; if (!record || record.schemaVersion !== 1 || !Array.isArray(record.args) || record.args.some(a => typeof a !== 'string')) throw new Refusal('invalid_remote_request'); + if (record.handoff !== undefined) { + try { + parseStrictJson(raw, { scope: 'outer', maxBytes: 4_100_000 }); + closed(record, ['schemaVersion', 'args', 'handoff']); + const context = closed(record.handoff, ['schemaVersion', 'remainingMs'], ['preparation']); + if (context.schemaVersion !== 1 || !safeInteger(context.remainingMs) || context.remainingMs < 1 || context.remainingMs > 60000 || + (context.preparation !== undefined && context.preparation !== true)) throw new Error('invalid'); + wireContext = { remainingMs: context.remainingMs, ...(context.preparation ? { preparation: true } : {}) }; + if (wireContext.remainingMs !== wireRemainingMs) throw new Error('duration_mismatch'); + } catch { throw new Refusal('invalid_remote_request'); } + } args = record.args; } command = ['list', 'send', 'doctor', 'update'].includes(args[0] ?? '') ? args[0]! : 'unknown'; const options = parse(args); const handoffEnabled = options.flags.has('--request-ack') || options.flags.has('--observe-delivery') || options.values.has('--correlation-id') || options.values.has('--wait-for') || options.values.has('--wait-timeout'); - const handoffBudget = handoffEnabled ? new HandoffBudget(parseWaitTimeout(options.values.get('--wait-timeout') ?? '30')) : undefined; + if (wire && (handoffEnabled !== !!wireContext || handoffEnabled !== !!wireBudget)) throw new Refusal('remote_handoff_context_required', 1); + if (wireContext && (command !== 'send' || process.platform === 'win32')) throw new Refusal('remote_handoff_unsupported', 1); + if (wireContext && wireContext.remainingMs > parseWaitTimeout(options.values.get('--wait-timeout') ?? '30') * 1000) throw new Refusal('invalid_remote_request'); + if (wireContext && !wireContext.preparation && !options.values.has('--correlation-id')) throw new Refusal('remote_preparation_required', 1); + const handoffBudget = handoffEnabled ? wireContext ? wireBudget : new HandoffBudget(parseWaitTimeout(options.values.get('--wait-timeout') ?? '30')) : undefined; + if (wireContext && handoffBudget) { + handoffBudget.beforeEffect(); + // The input-side watchdog remains installed; do not renew it here. + } format = !wire && options.values.get('--output-format') === 'text' ? 'text' : 'json'; if (wire && options.values.get('--output-format') === 'text') throw new Refusal('remote_json_required'); if (wire && command === 'update') throw new Refusal('remote_update_unsupported'); @@ -377,12 +456,12 @@ try { } let result: Record = {}, exitCode: number | undefined, results: Record[] | undefined; // Propagate the verified remote exit code so SSH and local refusals match. - if (options.hosts.length) ({ value: results, exitCode } = await remotes(options, await tailnetStatus(), message, returnTo)); + if (options.hosts.length) ({ value: results, exitCode } = await remotes(options, await tailnetStatus(), message, returnTo, handoffBudget)); else if (command === 'send') { const settings = { to: options.values.get('--to')!, home: options.values.get('--codex-home'), codexBin: options.values.get('--codex-bin'), message: message!, dryRun: options.flags.has('--dry-run'), allowInactive: options.flags.has('--allow-inactive-codex-home') }; const enabled = options.flags.has('--request-ack') || options.flags.has('--observe-delivery') || options.values.has('--correlation-id') || options.values.has('--wait-for') || options.values.has('--wait-timeout'); - if (wire && enabled) throw new Refusal('remote_handoff_unsupported', 1); - if (enabled) ({ value: result, exitCode } = await handoffSend(settings, { correlationId: options.values.get('--correlation-id'), requestAck: options.flags.has('--request-ack'), observeDelivery: options.flags.has('--observe-delivery'), waitFor: options.values.get('--wait-for') as 'delivered' | 'acknowledged' | undefined, seconds: parseWaitTimeout(options.values.get('--wait-timeout') ?? '30'), payload: rawMessage, budget: handoffBudget, resultOverhead: Buffer.byteLength(JSON.stringify(routing)) + 1024 })); + if (wireContext?.preparation) ({ value: result, exitCode } = await prepareHandoff(settings, undefined, options.values.get('--correlation-id'), handoffBudget)); + else if (enabled) ({ value: result, exitCode } = await handoffSend(settings, { correlationId: options.values.get('--correlation-id'), requestAck: options.flags.has('--request-ack'), observeDelivery: options.flags.has('--observe-delivery'), waitFor: options.values.get('--wait-for') as 'delivered' | 'acknowledged' | undefined, seconds: parseWaitTimeout(options.values.get('--wait-timeout') ?? '30'), payload: rawMessage, budget: handoffBudget, resultOverhead: Buffer.byteLength(JSON.stringify(routing)) + 1024 })); else result = await send(settings); } else if (command === 'update') result = options.flags.has('--check') ? await checkUpdate(options.values.get('--channel')) : refuseSelfUpdate(options.values.get('--channel')); @@ -422,4 +501,4 @@ try { console.log(renderOutput(!wire && requested.length > 1 ? requested.map(item => ({ ...value, host: item })) : value, format)); process.exitCode = exitCode; } -} +} finally { if (wireWatchdog) clearTimeout(wireWatchdog); } diff --git a/src/handoff-ledger.ts b/src/handoff-ledger.ts index fb8b4db..56be0b7 100644 --- a/src/handoff-ledger.ts +++ b/src/handoff-ledger.ts @@ -5,6 +5,7 @@ import { randomBytes, randomUUID, createHash, timingSafeEqual, createCipheriv, c import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { isAbsolute, join, resolve } from 'node:path'; import { createRequire } from 'node:module'; +import type { MetadataQualification } from './metadata-observation.js'; import { canonicalId, closed, fail, HANDOFF_LIMITS, HandoffBudget, parseStrictJson, safeId, safeInteger, systemClock, validateConfirmation, validateHandoff, validateReceipt, type Clock, type Handoff, type WaitFor, type WaitReason, type WaitStatus } from './handoff.js'; @@ -12,13 +13,14 @@ export type HandoffBinding = Readonly<{ agent: 'claude' | 'codex'; destination: home?: string; writerIdentity?: string; generation: string | null; payloadDigest: string }>; export type PrepareOptions = { correlationId?: string; requestAck?: boolean; observeDelivery?: boolean; waitFor?: WaitFor; budget?: HandoffBudget; dryRun?: boolean; channels?: { delivery: boolean; receipt?: boolean }; - observationSupported?: boolean; clientUserMessageId?: string }; + observationSupported?: boolean; clientUserMessageId?: string; observationQualification?: MetadataQualification }; +export type ObservationContext = Readonly<{ binding: HandoffBinding; clientUserMessageId: string; qualification: MetadataQualification }>; export type HandoffObservation = { clientUserMessageId: string; injectionObserved: boolean; turn?: Handoff['observation']['turn'] }; type StoredWait = { public: Handoff['wait']; clockId: string; cutoff: number }; type RecordIntent = { binding: HandoffBinding; preparedUtc: number; clockId: string; preparedMono: number; phase: 'prepared' | 'attempted' | 'finished' | 'refused'; handoff: Handoff; waits: StoredWait[]; reserve: number; capabilityHash?: string; capabilityClockId?: string; receiptId?: string; revoked?: boolean; expired?: boolean; - deliverySupported?: boolean; clientUserMessageId?: string }; + deliverySupported?: boolean; clientUserMessageId?: string; observationQualification?: MetadataQualification }; type Ledger = { version: 1; epoch: string; keyId: string; quarantined: boolean; records: Record }; export type HandoffQueryError = { schemaVersion: 1; correlationId: string; status: 'unknown'; context: 'ledger_missing' | 'ledger_corrupt' | 'id_unknown'; retry: { allowed: false; reason: 'history_unavailable' } }; @@ -36,6 +38,11 @@ function checkBinding(value: HandoffBinding): HandoffBinding { function sameBinding(a: HandoffBinding, b: HandoffBinding): boolean { return ['agent', 'destination', 'target', 'home', 'writerIdentity', 'generation', 'payloadDigest'].every(key => a[key as keyof HandoffBinding] === b[key as keyof HandoffBinding]); } +function checkQualification(value: MetadataQualification): MetadataQualification { + const row = closed(value, ['version', 'commit', 'platform']); + if (!safeId(row.version, 64) || !safeId(row.platform, 64) || typeof row.commit !== 'string' || !/^[0-9a-f]{40}$/.test(row.commit)) fail('invalid_observation_qualification', 2); + return structuredClone(value); +} function privateStat(path: string, directory: boolean) { const uid = process.getuid?.(); if (uid === undefined || process.platform === 'win32') fail('handoff_storage_unsupported'); const stat = lstatSync(path); @@ -123,10 +130,11 @@ export class HandoffLedger { const records = data.records as Record; if (Object.keys(records).length > HANDOFF_LIMITS.intents) fail(); for (const [id, item] of Object.entries(records)) { - closed(item, ['binding', 'preparedUtc', 'clockId', 'preparedMono', 'phase', 'handoff', 'waits', 'reserve'], ['capabilityHash', 'capabilityClockId', 'receiptId', 'revoked', 'expired', 'deliverySupported', 'clientUserMessageId']); + closed(item, ['binding', 'preparedUtc', 'clockId', 'preparedMono', 'phase', 'handoff', 'waits', 'reserve'], ['capabilityHash', 'capabilityClockId', 'receiptId', 'revoked', 'expired', 'deliverySupported', 'clientUserMessageId', 'observationQualification']); if (!canonicalId(id)) fail(); item.binding = { ...item.binding, payloadDigest: this.revealDigest(item.binding?.payloadDigest, key, data.epoch as string, id) }; checkBinding(item.binding); + if (item.observationQualification !== undefined) checkQualification(item.observationQualification); if (!canonicalId(id) || !canonicalId(item.clockId) || !safeInteger(item.preparedUtc) || typeof item.preparedMono !== 'number' || item.preparedMono < 0 || !['prepared', 'attempted', 'finished', 'refused'].includes(item.phase) || !Array.isArray(item.waits) || item.waits.length > HANDOFF_LIMITS.waits || !safeInteger(item.reserve) || item.reserve > reserveBytes || (item.capabilityHash !== undefined && !/^[a-f0-9]{64}$/.test(item.capabilityHash)) || @@ -191,7 +199,7 @@ export class HandoffLedger { if (item.expired || this.clock.utc() - item.preparedUtc >= HANDOFF_LIMITS.detailMs) fail('handoff_history_unavailable'); return item; } - private snapshot(item: RecordIntent, quarantine = false): Handoff { + private snapshot(item: RecordIntent, quarantine = false, currentWait = item.waits.at(-1)): Handoff { const h = structuredClone(item.handoff); if (quarantine || item.expired || this.clock.utc() - item.preparedUtc >= HANDOFF_LIMITS.detailMs) { h.state = 'unknown'; h.submission = { status: 'unknown' }; h.observation = { status: 'not_requested', injectionObserved: false }; @@ -200,7 +208,8 @@ export class HandoffLedger { if ((item.capabilityClockId !== this.clockId || item.revoked || this.clock.monotonic() - item.preparedMono >= HANDOFF_LIMITS.capabilityMs) && h.ack.status === 'pending') { h.ack = { status: 'unsupported' }; h.nextActions = ['reconcile']; } - if (item.clockId !== this.clockId && h.observation.status === 'pending') { + const resumed = item.observationQualification !== undefined && currentWait?.clockId === this.clockId && currentWait.public.for === 'delivered' && currentWait.public.status === 'pending'; + if (item.clockId !== this.clockId && h.observation.status === 'pending' && !resumed) { h.observation = { status: 'unsupported', injectionObserved: false }; h.nextActions = ['reconcile']; } return validateHandoff(h); @@ -222,7 +231,7 @@ export class HandoffLedger { return wait; } private operationSnapshot(item: RecordIntent, wait: StoredWait): Handoff { - const h = this.snapshot(item); h.wait = structuredClone(wait.public); + const h = this.snapshot(item, false, wait); h.wait = structuredClone(wait.public); // Actions from the latest wait cannot be projected onto a different operation. h.nextActions = ['reconcile']; return validateHandoff(h); } @@ -230,6 +239,16 @@ export class HandoffLedger { const data = this.read(), item = this.usable(data, id); return this.operationSnapshot(item, this.selectWait(item, operationId)); } + // Private projection only. Do not return this binding/digest or qualification + // in ordinary JSON. Missing historical provenance cannot be reconstructed + // from today's native executable or a matching database schema. + observationContext(id: string): ObservationContext | undefined { + const data = this.read(), item = this.usable(data, id); + if (item.phase !== 'finished' || item.handoff.submission.status !== 'submitted' || !item.deliverySupported || + !item.observationQualification || item.binding.agent !== 'codex' || !item.binding.home || + !item.binding.generation || item.binding.writerIdentity !== item.binding.generation || !item.clientUserMessageId) return undefined; + return structuredClone({ binding: item.binding, clientUserMessageId: item.clientUserMessageId, qualification: item.observationQualification }); + } prepare(binding: HandoffBinding, options: PrepareOptions = {}): Handoff { binding = checkBinding(binding); if (options.dryRun && (options.requestAck || options.observeDelivery || (options.waitFor && options.waitFor !== 'none'))) fail('incompatible_handoff_dry_run', 2); @@ -271,6 +290,17 @@ export class HandoffLedger { if (options.observeDelivery || goal === 'delivered') { item.deliverySupported = delivery; item.clientUserMessageId = clientId; h.observation.status = delivery ? 'pending' : 'unsupported'; + if (delivery && options.observationQualification !== undefined) { + const qualification = checkQualification(options.observationQualification); + if (item.observationQualification && JSON.stringify(item.observationQualification) !== JSON.stringify(qualification)) fail('handoff_binding_conflict'); + item.observationQualification = qualification; + } else delete item.observationQualification; + } else { + // A prepared reservation may be reused by a non-observing submission. + // Its old native qualification must not describe that different path. + item.deliverySupported = false; + delete item.observationQualification; + h.observation = { status: 'not_requested', injectionObserved: false }; } if (goal !== 'none') { const budget = options.budget ?? new HandoffBudget(30, this.clock); this.addWait(item, goal, budget); @@ -378,7 +408,7 @@ export class HandoffLedger { item.handoff.wait = { for: goal, status: 'pending', operationId: randomUUID(), deadlineAtUtcMs: budget.deadlineAtUtcMs }; item.waits.push({ public: structuredClone(item.handoff.wait), clockId: this.clockId, cutoff: budget.cutoff }); } - beginWait(id: string, goal: Exclude, budget = new HandoffBudget(30, this.clock)): Handoff { + beginWait(id: string, goal: Exclude, budget = new HandoffBudget(30, this.clock), options: { observationSupported?: boolean } = {}): Handoff { if (!['delivered', 'acknowledged'].includes(goal)) fail('invalid_wait_for', 2); return this.lock(() => { const data = this.read(), item = this.usable(data, id); this.addWait(item, goal, budget); @@ -386,7 +416,9 @@ export class HandoffLedger { h.nextActions = ['reconcile']; if ((goal === 'acknowledged' && h.ack.status === 'acknowledged') || (goal === 'delivered' && h.observation.injectionObserved)) h.wait.status = 'satisfied'; else if (budget.seconds <= 5) { h.wait.status = 'failed'; h.wait.reason = 'insufficient_budget'; } - else if (goal === 'delivered' && item.deliverySupported && item.binding.generation !== null && item.clockId === this.clockId && h.submission.status === 'submitted') { + else if (goal === 'delivered' && item.deliverySupported && item.binding.generation !== null && h.submission.status === 'submitted' && + (options.observationSupported === true ? item.observationQualification !== undefined : options.observationSupported === undefined && item.clockId === this.clockId)) { + h.observation = { status: 'pending', injectionObserved: false }; h.nextActions = ['keep_waiting', 'reconcile', 'stop_waiting']; } else if (goal === 'acknowledged' && item.capabilityHash && !item.revoked && item.capabilityClockId === this.clockId && diff --git a/src/handoff-observer-probe.ts b/src/handoff-observer-probe.ts index a8a4f87..13ebd1b 100644 --- a/src/handoff-observer-probe.ts +++ b/src/handoff-observer-probe.ts @@ -4,6 +4,10 @@ // state/thread_history_migrations/0001_thread_history.sql, 0002_thread_items_item_type.sql; // app-server-protocol/src/protocol/v2/item.rs (userMessage.clientId); // thread-store/src/local/thread_history.rs (turn_status, projection lag). +// Also checked against rust-v0.162.1 / 092d3acd6bec3e3a14bdc7e7a2810ab628ab759d: +// the same migrations, camelCase userMessage.clientId mapping and bounded +// metadata column projection. Exact source hashes/live native evidence are in +// test/fixtures/metadata-observation-native-evidence.json. No history/body API. import { DatabaseSync } from 'node:sqlite'; import { createHash } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; diff --git a/src/handoff-operations.ts b/src/handoff-operations.ts index 24ee9b3..25ea893 100644 --- a/src/handoff-operations.ts +++ b/src/handoff-operations.ts @@ -7,7 +7,7 @@ import { HandoffLedger, payloadDigest, type HandoffBinding } from './handoff-led import { canonicalId, HANDOFF_LIMITS, HandoffBudget, parseWaitTimeout, type Handoff, type WaitFor } from './handoff.js'; import { fileURLToPath } from 'node:url'; import { nativeQueueCapability, nativeQueueOnce, AppServerFault, type AppServerScope } from './app-server.js'; -import { metadataCapability, observeMetadata, type MetadataScope } from './metadata-observation.js'; +import { metadataCapability, observeMetadata, resumeMetadataObservation, metadataQualification, type MetadataQualification, type MetadataScope } from './metadata-observation.js'; import { checkMessage, queueId, send, type SendContext, type SendOptions } from './send.js'; import { Refusal } from './discovery.js'; import { HomeRefusal } from './writer.js'; @@ -45,11 +45,30 @@ export function correlationEnvelope(message: string, id: string): string { // A dedicated inert metadata line, not an executable Reply-To extension. return message + '\n\n---\nHandoff: ' + JSON.stringify({ schemaVersion: 1, correlationId: id }); } +// Nonblocking ledger locks can contend across independent waiters. Retry only +// within the caller's existing total budget; never replay a native operation. +async function persistWithinBudget(budget: HandoffBudget, action: () => T): Promise { + for (;;) { + try { return action(); } catch (error) { + if (!(error instanceof Refusal) || error.code !== 'handoff_ledger_busy' || budget.remainingMs() < 1) throw error; + await new Promise(resolve => setTimeout(resolve, Math.min(20, budget.remainingMs()))); + } + } +} +// Probes stop when less than one millisecond remains, but a durable timeout +// still requires reaching the exact monotonic cutoff. Charge this fractional +// tail to the existing budget, never label an early wake as expiry. +async function settleCutoffEdge(budget: HandoffBudget): Promise { + while (budget.observationRemainingMs() > 0 && budget.observationRemainingMs() < 1) { + await new Promise(resolve => setTimeout(resolve, 1)); + } +} class BeforeEffectRefused extends Error {} -export async function prepareHandoff(options: SendOptions, path = ledgerPath()): Promise { +export async function prepareHandoff(options: SendOptions, path = ledgerPath(), correlationId?: string, budget?: HandoffBudget): Promise { const ledger = new HandoffLedger(path); let handoff: Handoff | undefined; const value = await send({ ...options, dryRun: true, hooks: { resolved(context) { - handoff = ledger.prepare(binding(context, options.message)); + budget?.beforeEffect(); + handoff = ledger.prepare(binding(context, options.message), { correlationId }); } } }); return { value: { ...value, handoff }, exitCode: 0 }; } @@ -58,6 +77,7 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO const budget = handoffOptions.budget ?? new HandoffBudget(handoffOptions.seconds ?? 30); let handoff: Handoff | undefined, snapshot: Record | undefined; let scope: MetadataScope | undefined, queueScope: AppServerScope | undefined, deliverySupported = false, stopped = false, operationId: string | undefined; + let qualification: MetadataQualification | undefined; let original: HandoffBinding | undefined, fenced = false, knownNative: Record | undefined; const ownWait = () => { if (handoff && operationId) { @@ -75,7 +95,8 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO ownWait(); } catch { /* No fabricated terminal evidence after persistence failure. */ } }; - const authorize = () => { if (stopped) throw new Refusal('stopped_by_operator', 130); budget.beforeEffect(); handoff = ledger.commitEffect(handoff!.correlationId, original!, budget); fenced = true; ownWait(); }; + const persist = (action: () => T) => persistWithinBudget(budget, action); + const authorize = async () => { if (stopped) throw new Refusal('stopped_by_operator', 130); budget.beforeEffect(); handoff = await persist(() => { if (stopped) throw new Refusal('stopped_by_operator', 130); return ledger.commitEffect(handoff!.correlationId, original!, budget); }); fenced = true; ownWait(); }; const verifyOwner = async (target: MetadataScope, signal?: AbortSignal) => { if (stopped || signal?.aborted || budget.observationRemainingMs() < 1) return false; const worker = fileURLToPath(new URL('./handoff-writer-probe.js', import.meta.url)); @@ -84,6 +105,7 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO try { const value = JSON.parse(result.stdout); return value.schemaVersion === 1 && value.ok === true && Object.keys(value).every(key => ['schemaVersion','ok','generation'].includes(key)) && /^[0-9a-f]{64}$/.test(value.generation) && value.generation === target.generation; } catch { return false; } }; const finish = (value: Record, exitCode: number): OperationResult => ({ value: { ...value, ...(handoff ? { handoff } : {}) }, exitCode }); + if (handoffOptions.waitFor) process.on('SIGINT', stop); try { const value = await send({ ...options, hooks: { async resolved(context, native) { @@ -97,14 +119,15 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO queueScope = { codexHome: context.home, threadId: context.target, generation: context.generation, ownerIdentity: context.writerIdentity! }; const version = await run(executable(options.codexBin ?? 'codex'), ['--version'], { timeout: Math.min(1000, budget.observationRemainingMs()), limit: 4096 }); const nativeVersion = !version.interrupted && version.code === 0 ? version.stdout.trim().replace(/^codex-cli /, '') : ''; - if (nativeQueueCapability({version:nativeVersion,platform:`${process.platform}-${process.arch}`,scope:queueScope}).queueSupported) deliverySupported = (await metadataCapability(scope, nativeVersion, budget, verifyOwner)).supported; + qualification = metadataQualification(nativeVersion); + if (qualification && nativeQueueCapability({version:nativeVersion,platform:`${process.platform}-${process.arch}`,scope:queueScope}).queueSupported) deliverySupported = (await metadataCapability(scope, nativeVersion, budget, verifyOwner)).supported; } - handoff = ledger.prepare(original, { correlationId: handoffOptions.correlationId, + handoff = await persist(() => ledger.prepare(original!, { correlationId: handoffOptions.correlationId, requestAck: handoffOptions.requestAck || handoffOptions.waitFor === 'acknowledged', observeDelivery: handoffOptions.observeDelivery || handoffOptions.waitFor === 'delivered', - waitFor: handoffOptions.waitFor ?? 'none', budget, dryRun: options.dryRun, observationSupported: deliverySupported }); + waitFor: handoffOptions.waitFor ?? 'none', budget, dryRun: options.dryRun, observationSupported: deliverySupported, + ...(deliverySupported ? { observationQualification: qualification! } : {}) })); operationId = handoff.wait.operationId; - if (handoffOptions.waitFor && handoff.wait.status === 'pending') process.on('SIGINT', stop); if (handoff.state === 'refused') throw new BeforeEffectRefused(); return correlationEnvelope(options.message, handoff.correlationId); }, @@ -112,7 +135,7 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO async submitCodex(context, message) { if (!deliverySupported) { // Best-effort unsupported is chosen before ANY app-server attempt. - authorize(); + await authorize(); const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toUpperCase() !== 'CODEX_SQLITE_HOME')); const done = await run(executable(options.codexBin ?? 'codex'), ['queue','--thread',context.target,`--message=${message}`], {env:{...inherited,CODEX_HOME:context.home},timeout:Math.max(1,budget.observationRemainingMs())}); if (!done.spawned) throw new Refusal('native_spawn_failed',1); @@ -120,27 +143,34 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO const receipt = queueId(done.stdout,context.target); return {queueId:receipt,clientUserMessageId:handoff!.correlationId}; } - try { return await nativeQueueOnce({binary:executable(options.codexBin ?? 'codex'),scope:queueScope!,message,clientUserMessageId:handoff!.correlationId,deadlineMs:budget.cutoff,verifyOwner: async () => verifyOwner(scope!),beforeEffect: async () => authorize()}); } + try { return await nativeQueueOnce({binary:executable(options.codexBin ?? 'codex'),scope:queueScope!,message,clientUserMessageId:handoff!.correlationId,deadlineMs:budget.cutoff,verifyOwner: async () => verifyOwner(scope!),beforeEffect: authorize}); } catch(error) { if (error instanceof AppServerFault && error.attempted) throw new UnknownOutcome(); if(error instanceof AppServerFault) throw new Refusal(`app_server_${error.code}`,1); throw error; } }, timeoutMs() { budget.beforeEffect(); return Math.max(1, Math.floor(budget.observationRemainingMs())); }, } }); knownNative = value; - if (!options.dryRun) { handoff = ledger.recordSubmission(handoff!.correlationId, 'submitted'); ownWait(); } + if (!options.dryRun) { handoff = await persist(() => ledger.recordSubmission(handoff!.correlationId, 'submitted')); ownWait(); } if (!options.dryRun && deliverySupported && scope && handoff) { do { if (stopped) break; const observed = await observeMetadata({...scope,clientUserMessageId:handoff.correlationId}, budget, verifyOwner); if (!observed.supported) { - if (budget.observationRemainingMs() <= 0) break; - handoff = ledger.recordObservationFailure(handoff.correlationId, original!, observed.reason === 'metadata_owner_changed' ? 'unsupported' : 'failed', operationId); ownWait(); + if (budget.observationRemainingMs() < 1) break; + if (['metadata_deadline', 'metadata_probe_failed'].includes(observed.reason!)) { + await new Promise(resolve => setTimeout(resolve, Math.min(100, budget.observationRemainingMs()))); + continue; + } + handoff = await persist(() => ledger.recordObservationFailure(handoff!.correlationId, original!, observed.reason === 'metadata_owner_changed' ? 'unsupported' : 'failed', operationId)); ownWait(); break; } - if (observed.injectionObserved) { handoff = ledger.recordObservation(handoff.correlationId,{clientUserMessageId:observed.clientUserMessageId!,injectionObserved:true,...(observed.turn ? {turn:observed.turn} : {})},original!); ownWait(); break; } + if (observed.injectionObserved) { handoff = await persist(() => ledger.recordObservation(handoff!.correlationId,{clientUserMessageId:observed.clientUserMessageId!,injectionObserved:true,...(observed.turn ? {turn:observed.turn} : {})},original!)); ownWait(); break; } if (!handoffOptions.waitFor) break; await new Promise(resolve => setTimeout(resolve, Math.min(100,budget.observationRemainingMs()))); } while(budget.observationRemainingMs() > 0); - if (handoffOptions.waitFor && handoff.wait.status === 'pending') handoff = stopped ? ledger.recordWait(handoff.correlationId,'stopped','stopped_by_operator',operationId) : ledger.recordWait(handoff.correlationId,'timed_out_unknown',undefined,operationId); + if (handoffOptions.waitFor && handoff.wait.status === 'pending') { + if (!stopped) await settleCutoffEdge(budget); + handoff = await persist(() => stopped ? ledger.recordWait(handoff!.correlationId,'stopped','stopped_by_operator',operationId) : ledger.recordWait(handoff!.correlationId,'timed_out_unknown',undefined,operationId)); + } ownWait(); if (handoffOptions.waitFor && handoff.wait.status !== 'satisfied') return finish({...value,ok:false,error:handoff.wait.reason ?? 'timed_out_unknown',retryAllowed:false},handoff.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator' ? 130 : 1); } @@ -152,9 +182,10 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO // An I/O failure after positive native acceptance cannot erase that fact. handoff = { ...handoff, state: handoff.ack.status === 'acknowledged' ? 'acknowledged' : handoff.observation.injectionObserved ? 'delivered' : 'submitted', submission: { status: 'submitted' }, nextActions: ['reconcile'] }; if (handoffOptions.waitFor) { - if (handoff.wait.status === 'pending') handoff.wait = { ...handoff.wait, status: 'failed', reason: 'evidence_failed' }; + // Preserve the last verified operation; a failed write cannot create + // a durable terminal wait in the returned evidence. const interrupted = handoff.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator'; - return finish({ ...knownNative, ok: false, error: interrupted ? 'stopped_by_operator' : 'handoff_persistence_failed', retryAllowed: false }, interrupted ? 130 : 1); + return finish({ ...knownNative, ok: false, error: interrupted ? 'stopped_by_operator' : 'handoff_persistence_failed', handoffWarning: 'persistence_failed', retryAllowed: false }, interrupted ? 130 : 1); } return finish({ ...knownNative, handoffWarning: 'persistence_failed', retryAllowed: false }, 0); } @@ -183,6 +214,71 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO } finally { process.removeListener('SIGINT',stop); } } +// Start a new read-only operation on retained original submission evidence. +// Never reopen a queue client, adopt a successor owner or reset an old deadline. +export async function waitForHandoff(ledger: HandoffLedger, id: string, goal: 'delivered' | 'acknowledged', budget: HandoffBudget): Promise { + const retained = goal === 'delivered' ? ledger.observationContext(id) : undefined; + const context = retained?.binding.destination === hostname() ? retained : undefined; + // Register before the first durable wait write; the signal handler only marks + // intent. Persistence remains serialized in this operation, never its callback. + let stopped = false, handoff: Handoff | undefined, operationId: string | undefined; + const stop = () => { stopped = true; }; + const persist = (action: () => T) => persistWithinBudget(budget, action); + const ownWait = () => { if (operationId) handoff = ledger.operationStatus(id, operationId); }; + const verifyOwner = async (scope: MetadataScope, signal: AbortSignal) => { + if (stopped || signal.aborted || budget.observationRemainingMs() < 1) return false; + const worker = fileURLToPath(new URL('./handoff-writer-probe.js', import.meta.url)); + const result = await run(process.execPath, [worker, scope.home, scope.threadId], { timeout: Math.min(800, budget.observationRemainingMs()), limit: 4096, env: { ...process.env, NODE_OPTIONS: undefined, NODE_PATH: undefined } }); + if (stopped || signal.aborted || result.code !== 0 || result.interrupted) return false; + try { + const value = JSON.parse(result.stdout); + return value.schemaVersion === 1 && value.ok === true && Object.keys(value).every(key => ['schemaVersion','ok','generation'].includes(key)) && + /^[0-9a-f]{64}$/.test(value.generation) && value.generation === scope.generation; + } catch { return false; } + }; + process.on('SIGINT', stop); + try { + handoff = await persist(() => ledger.beginWait(id, goal, budget, { observationSupported: context !== undefined })); + operationId = handoff.wait.operationId!; + while (context && handoff.wait.status === 'pending' && !stopped && budget.observationRemainingMs() >= 1) { + const observed = await resumeMetadataObservation({ home: context.binding.home!, threadId: context.binding.target, + generation: context.binding.generation, clientUserMessageId: context.clientUserMessageId }, context.qualification, budget, verifyOwner); + ownWait(); + if (stopped || handoff.wait.status !== 'pending') break; + if (!observed.supported) { + if (budget.observationRemainingMs() < 1) break; + if (!['metadata_deadline', 'metadata_probe_failed'].includes(observed.reason!)) { + handoff = await persist(() => ledger.recordObservationFailure(id, context.binding, + ['metadata_owner_changed', 'metadata_version_unsupported', 'metadata_platform_unsupported'].includes(observed.reason!) ? 'unsupported' : 'failed', operationId)); + break; + } + } else if (observed.injectionObserved) { + await persist(() => ledger.recordObservation(id, { clientUserMessageId: observed.clientUserMessageId!, injectionObserved: true, + ...(observed.turn ? { turn: observed.turn } : {}) }, context.binding)); + ownWait(); break; + } + await new Promise(resolve => setTimeout(resolve, Math.min(100, budget.observationRemainingMs()))); + ownWait(); + } + ownWait(); + if (handoff.wait.status === 'pending') { + if (!stopped) await settleCutoffEdge(budget); + handoff = await persist(() => stopped ? ledger.recordWait(id, 'stopped', 'stopped_by_operator', operationId) : + budget.observationRemainingMs() < 1 ? ledger.recordWait(id, 'timed_out_unknown', undefined, operationId) : + ledger.recordWait(id, 'unsupported', 'evidence_unsupported', operationId)); + } + const success = handoff.wait.status === 'satisfied', interrupted = handoff.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator'; + return { value: { handoff, ok: success }, exitCode: success ? 0 : interrupted ? 130 : 1 }; + } catch (error) { + if (!handoff) throw error; + // Failure to write is not a committed terminal result. Keep the last valid + // original-operation snapshot and its known submission/injection evidence. + try { ownWait(); } catch { /* Retain independently verified facts. */ } + const interrupted = handoff.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator'; + return { value: { handoff, ok: false, error: 'handoff_persistence_failed', handoffWarning: 'persistence_failed', retryAllowed: false }, exitCode: interrupted ? 130 : 1 }; + } finally { process.removeListener('SIGINT', stop); } +} + // New commands are local owner operations; forwarding them through native send // or interpreting a receipt as a model prompt is never allowed. export async function handoffCommand(args: string[], readInput: (limit: number) => Promise): Promise { @@ -231,13 +327,10 @@ export async function handoffCommand(args: string[], readInput: (limit: number) return { value: { handoff: operation === 'confirm' ? ledger.confirm(raw) : ledger.acceptReceipt(raw) }, exitCode: 0 }; } const id = values.get('--correlation-id'); if (!canonicalId(id)) throw new Refusal('invalid_correlation_id', 2); + const budget = operation === 'wait' ? new HandoffBudget(parseWaitTimeout(values.get('--wait-timeout') ?? '30')) : undefined; const result = ledger.status(id); if ('handoffQuery' in result) return { value: { ok: false, ...result }, exitCode: 1 }; if (operation === 'status') return { value: result, exitCode: 0 }; const goal = values.get('--wait-for'); if (!['delivered', 'acknowledged'].includes(goal ?? '')) throw new Refusal('invalid_wait_for', 2); - const budget = new HandoffBudget(parseWaitTimeout(values.get('--wait-timeout') ?? '30')); - const handoff = ledger.beginWait(id, goal as 'delivered' | 'acknowledged', budget); - // No qualified observer/receipt bootstrap exists yet. This path never polls - // transcripts, opens a native client or repeats a prior submission. - return { value: { handoff, ok: handoff.wait.status === 'satisfied' }, exitCode: handoff.wait.status === 'satisfied' ? 0 : 1 }; + return waitForHandoff(ledger, id, goal as 'delivered' | 'acknowledged', budget!); } diff --git a/src/handoff.ts b/src/handoff.ts index 9876db5..d1e2ea4 100644 --- a/src/handoff.ts +++ b/src/handoff.ts @@ -43,6 +43,16 @@ export class HandoffBudget { this.cutoff = this.deadline - HANDOFF_LIMITS.cleanupMs; this.deadlineAtUtcMs = Math.max(0, Math.floor(clock.utc() + Math.max(0, seconds * 1000 - HANDOFF_LIMITS.cleanupMs))); } + // Private SSH frames carry a remaining duration, never a renewed CLI budget + // or a UTC authorization deadline. Charge frame-reading time to that duration. + static fromRemainingMs(remainingMs: number, clock: Clock = systemClock, startedAt = clock.monotonic()): HandoffBudget { + if (!safeInteger(remainingMs) || remainingMs < 1 || remainingMs > 60000 || !Number.isFinite(startedAt) || startedAt > clock.monotonic()) fail('invalid_handoff_budget', 2); + const budget = new HandoffBudget(Math.ceil(remainingMs / 1000), clock); + const deadline = startedAt + remainingMs, cutoff = deadline - HANDOFF_LIMITS.cleanupMs; + Object.assign(budget, { startedAt, deadline, cutoff, + deadlineAtUtcMs: Math.max(0, Math.floor(clock.utc() + Math.max(0, cutoff - clock.monotonic()))) }); + return budget; + } remainingMs(): number { return Math.max(0, this.deadline - this.clock.monotonic()); } observationRemainingMs(): number { return Math.max(0, this.cutoff - this.clock.monotonic()); } beforeEffect(): void { diff --git a/src/metadata-observation.ts b/src/metadata-observation.ts index 60cccad..ccfcc37 100644 --- a/src/metadata-observation.ts +++ b/src/metadata-observation.ts @@ -1,13 +1,31 @@ // Opt-in, metadata-only observation for the exact qualified Codex store below. // No transcript, native client, warming, migration or receipt bootstrap is used. import childProcess, { type ChildProcessWithoutNullStreams } from 'node:child_process'; -import { join } from 'node:path'; +import { isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; export const METADATA_SOURCE = Object.freeze({ version: '0.160.1', commit: 'd27764b82f7118f674371e6d6e76271d9d606edb', database: 'thread_history_1.sqlite', recentUsers: 256, maxCellBytes: 256 * 1024, childMs: 1000 }); export type MetadataScope = Readonly<{ home: string; threadId: string; generation: string | null }>; +// Persist this private qualification only after the original native queue and +// metadata preflight have both selected this exact source. A version read from +// today's PATH, a caller-supplied flag or the metadata schema alone cannot +// establish how an older intent was submitted. +export const METADATA_RESUME_QUALIFICATION = Object.freeze({ version: METADATA_SOURCE.version, + commit: METADATA_SOURCE.commit, platform: 'darwin-arm64' }); +export type MetadataQualification = Readonly<{ version: string; commit: string; platform: string }>; +// Exact native qualification is separate from POSIX structural-store fixtures. +// No version range, current PATH inference or schema-only upgrade is permitted. +const metadataQualifications: readonly MetadataQualification[] = Object.freeze([ + METADATA_RESUME_QUALIFICATION, + Object.freeze({ version: '0.162.1', commit: '092d3acd6bec3e3a14bdc7e7a2810ab628ab759d', platform: 'darwin-arm64' }), +]); +export function metadataQualification(version: string): MetadataQualification | undefined { + if (typeof version !== 'string') return undefined; + const exact = version.startsWith('codex-cli ') ? version.slice('codex-cli '.length) : version; + return metadataQualifications.find(profile => profile.version === exact); +} export type MetadataBudget = { observationRemainingMs(): number }; // The caller owns any resources used by this verifier and must honor its signal. // A true result proves the ORIGINAL generation, rather than selecting a new one. @@ -131,7 +149,7 @@ async function inspect(scope: MetadataScope, mode: 'capability' | 'observe', bud } export async function metadataCapability(scope: MetadataScope, version: string, budget: MetadataBudget | undefined, verifyOwner: VerifyMetadataOwner): Promise { - if (!/^(?:codex-cli )?0\.160\.1$/.test(version)) return { supported: false, reason: 'metadata_version_unsupported' }; + if (!metadataQualification(version)) return { supported: false, reason: 'metadata_version_unsupported' }; const result = await inspect(scope, 'capability', budget, verifyOwner); return { supported: result.supported, ...(result.reason === undefined ? {} : { reason: result.reason }) }; } @@ -139,3 +157,33 @@ export function observeMetadata(scope: MetadataScope & { clientUserMessageId: st verifyOwner: VerifyMetadataOwner): Promise { return inspect(scope, 'observe', budget, verifyOwner, scope.clientUserMessageId); } +// Read-only reconciliation of a retained, positively submitted intent. The +// ledger caller supplies the ORIGINAL binding/qualification and must neither +// infer missing submission evidence nor replace the generation with today's +// owner. This API never finds or invokes Codex and never submits or warms data. +export function resumeMetadataObservation(scope: MetadataScope & { clientUserMessageId: string }, + qualification: MetadataQualification, budget: MetadataBudget, + verifyOwner: VerifyMetadataOwner): Promise { + if (!qualification || typeof qualification !== 'object' || Array.isArray(qualification) || + Object.keys(qualification).length !== 3 || + Object.keys(qualification).some(key => !['version', 'commit', 'platform'].includes(key))) { + return Promise.resolve(unsupported('metadata_scope_invalid')); + } + const expected = metadataQualification(qualification.version); + // Retained records use the canonical native version, not a CLI banner. + if (!expected || qualification.version !== expected.version) { + return Promise.resolve(unsupported('metadata_version_unsupported')); + } + if (qualification.commit !== expected.commit) { + return Promise.resolve(unsupported('metadata_schema_unsupported')); + } + if (qualification.platform !== expected.platform || + qualification.platform !== `${process.platform}-${process.arch}`) { + return Promise.resolve(unsupported('metadata_platform_unsupported')); + } + if (!scope || !safe(scope.home, 2048) || !isAbsolute(scope.home) || resolve(scope.home) !== scope.home || + typeof scope.generation !== 'string' || !/^[a-f0-9]{64}$/.test(scope.generation)) { + return Promise.resolve(unsupported('metadata_scope_invalid')); + } + return inspect(scope, 'observe', budget, verifyOwner, scope.clientUserMessageId); +} diff --git a/src/ssh-response.ts b/src/ssh-response.ts index 64d740c..c13fc85 100644 --- a/src/ssh-response.ts +++ b/src/ssh-response.ts @@ -2,6 +2,7 @@ import { casefold } from './casefold.js'; import { uuid } from './writer.js'; import type { Done } from './process.js'; +import { parseStrictJson, validateHandoff, type Handoff } from './handoff.js'; export const RESPONSE_BYTES = 1024 * 1024; export const RESPONSE_DEPTH = 64; @@ -62,7 +63,10 @@ export function parseResponse(bytes: Uint8Array): Record { try { return JSON.parse(text) as Record; } catch { return invalid(); } } -export type ResponseRequest = { command: string; to?: string; dryRun?: boolean; returnTo?: string; home?: string; allowInactive?: boolean }; +export type PreparedResponse = Pick; +export type ResponseRequest = { command: string; to?: string; dryRun?: boolean; returnTo?: string; home?: string; allowInactive?: boolean; + handoff?: { preparation?: boolean; correlationId?: string; requestAck?: boolean; observeDelivery?: boolean; + waitFor?: 'delivered' | 'acknowledged'; prepared?: PreparedResponse; destinationHost?: string } }; const record = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); // Bind supported native facts to the original command, dry-run and exact @@ -114,11 +118,63 @@ export function validateResponse(value: Record, request: Respon // failure. Overflow never establishes a complete frame, even with a prefix. export function responseOutcome(done: Done, request: ResponseRequest): { value: Record; exitCode: number } { if (!done.spawned || done.stopReason === 'output_limit' || !done.stdoutBytes) return invalid(); - const value = parseResponse(done.stdoutBytes); validateResponse(value, request); + const value = parseResponse(done.stdoutBytes), context = request.handoff; + if (context?.destinationHost !== undefined && value.host !== context.destinationHost) invalid(); + // The native profile is selected by the caller, never by returned metadata. + // Validate a positive native snapshot independently of a failed explicit wait. + const positive = request.command === 'send' && !request.dryRun && value.submitted === true; + const waitException = context?.waitFor !== undefined && positive && value.ok === false; + const native = waitException ? { ...value, ok: true } : value; + if (waitException) delete native.error; + validateResponse(native, request); + let accepted: Handoff | undefined; + if (context) { + try { + // The independently valid positive native snapshot does not make a + // malformed failed-wait envelope valid. Reject only its handoff evidence. + if (waitException && typeof value.error !== 'string') invalid(); + // The outer scanner preserved native facts; this second parser applies + // integer lexemes only to protocol subtrees before any lossy JSON values. + parseStrictJson(done.stdoutBytes, { scope: 'outer' }); + const h = validateHandoff(value.handoff); + if (context.correlationId !== undefined && h.correlationId !== context.correlationId) invalid(); + if (context.preparation) { + if (!request.dryRun || !value.ok || h.state !== 'validated' || h.submission.status !== 'not_attempted' || + h.wait.for !== 'none' || h.ack.status !== 'not_requested' || h.observation.status !== 'not_requested') invalid(); + } else { + const expected = context.prepared; + if (!expected || (context.destinationHost !== undefined && value.host !== context.destinationHost) || h.correlationId !== expected.correlationId || h.ledgerEpoch !== expected.ledgerEpoch || h.targetGeneration !== expected.targetGeneration || + h.wait.for !== (context.waitFor ?? 'none') || h.ack.status !== (context.requestAck || context.waitFor === 'acknowledged' ? 'unsupported' : 'not_requested') || + (h.observation.injectionObserved && h.observation.clientUserMessageId !== expected.correlationId)) invalid(); + if (positive && h.submission.status !== 'submitted') invalid(); + if (request.dryRun && h.submission.status !== 'not_attempted') invalid(); + if (!positive && !request.dryRun && h.submission.status !== (value.submitted === null ? 'unknown' : 'refused')) invalid(); + if (context.waitFor) { + const success = h.wait.status === 'satisfied'; + const pendingPersistence = positive && h.wait.status === 'pending' && + value.error === 'handoff_persistence_failed' && value.handoffWarning === 'persistence_failed'; + if (value.ok !== success || (!success && !pendingPersistence && !['timed_out_unknown', 'unsupported', 'failed', 'stopped'].includes(h.wait.status)) || + (h.wait.status === 'stopped' && (h.wait.reason !== 'stopped_by_operator' || done.interrupted || done.code !== 130))) invalid(); + } + } + accepted = h; + } catch { /* Retain the separately validated native snapshot below. */ } + } + const persistenceWarning = value.handoffWarning === 'persistence_failed'; + delete value.handoff; delete value.handoffQuery; delete value.handoffWarning; + if (accepted) { value.handoff = accepted; if (persistenceWarning) value.handoffWarning = 'persistence_failed'; } + else if (context && positive) { + value.handoffWarning = 'invalid_handoff'; + if (context.waitFor) { value.ok = false; value.error = 'invalid_handoff'; } + } // Reserved client-owned diagnosis; retain unrelated additive native fields. delete value.sshTransport; delete value.requestedCodexHome; const ordinary = !done.interrupted && [0, 1, 2].includes(done.code ?? -1); + const stopped = accepted?.wait.status === 'stopped' && !!context?.waitFor && positive && value.ok === false; + if (!done.interrupted && done.code === 130 && stopped) return { value, exitCode: 130 }; if (ordinary) { + if (context && !accepted && positive && context.waitFor) return { value, exitCode: 1 }; + if (accepted?.wait.status === 'stopped') return invalid(); if (value.ok !== (done.code === 0)) return invalid(); return { value, exitCode: done.code! }; } diff --git a/test/fixtures/metadata-observation-native-evidence.json b/test/fixtures/metadata-observation-native-evidence.json new file mode 100644 index 0000000..8e8a9e6 --- /dev/null +++ b/test/fixtures/metadata-observation-native-evidence.json @@ -0,0 +1,128 @@ +{ + "schemaVersion": 1, + "nativeProfile": { + "version": "0.162.1", + "commit": "092d3acd6bec3e3a14bdc7e7a2810ab628ab759d", + "platform": "darwin-arm64" + }, + "qualificationScope": "Exact native version/platform with a newly created dedicated test thread; no existing working session received a message.", + "officialSources": [ + { + "path": "codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs", + "sha256": "164f88345aebc0922b30243f6df26414bc1a8c19da8663d609332ef2ba8dc756" + }, + { + "path": "codex-rs/thread-store/src/local/thread_history_materialization.rs", + "sha256": "07ff19e7aa152dd3e80640526bdae3d7227238bdada8a8bb1ffa51f10e84a70e" + }, + { + "path": "codex-rs/thread-store/src/local/thread_history.rs", + "sha256": "3dbb26bde497ce88068c83e936bb638614cda1c5e40222af91e3bc83c2474a1e" + }, + { + "path": "codex-rs/state/thread_history_migrations/0001_thread_history.sql", + "sha256": "9e92130a3cefcaa4e8350241f4b92e9433314560ade6dc78a8cecf9e5c17f2a6" + }, + { + "path": "codex-rs/app-server-protocol/src/protocol/v2/item.rs", + "sha256": "e3ca6b9b880d815a16ff956b701beb9690abc222fb70a5b7e697e1c0ff91e099" + }, + { + "path": "codex-rs/protocol/src/items.rs", + "sha256": "5ca7f804a77dc8f132bb1eda4045e693d2d9b595cf70fc0877ca0cc98c5b621c" + } + ], + "unchangedMigration0002": true, + "metadataMapping": { + "client": "ThreadItem::UserMessage.client_id serializes as clientId; conversion preserves CoreTurnItem::UserMessage.client_id independently of item.id", + "storage": "thread_items item_type=userMessage; thread_turns joined by original thread_id+turn_id; projection SQL returns bounded IDs/status only", + "sourceFilesNotByteIdenticalAcrossVersions": [ + "app-server-protocol/src/protocol/v2/item.rs", + "thread-store/src/local/thread_history.rs" + ] + }, + "liveFixture": { + "date": "2026-10-11", + "nativeVersion": "0.162.1", + "platform": "darwin-arm64", + "newDedicatedThread": true, + "oneQueueAttempt": true, + "ownerChecks": 4, + "beforeEffectCalls": 1, + "positiveNativeReceipt": true, + "matchingClientIdRows": 1, + "nativeItemIdEqualsClientId": false, + "generationUnchanged": true, + "injectionObserved": true, + "linkedTurnStatus": "completed", + "acknowledged": false, + "terminalCleanup": "owned test terminal closed; native writer subsequently inactive/free", + "bodyOrTranscriptReturned": false, + "nativeHomeTestFilesDeleted": false + }, + "limits": [ + "One controlled macOS arm64 native test is not cross-platform qualification.", + "Queue receipt alone, queue deletion, turn completion and visible reply are not ACK.", + "Observation projects metadata inside SQLite from the body-bearing item_json cell; it does not promise that body bytes are never accessed internally by SQLite.", + "Existing user authentication was used by the native CLI without reading, copying or recording credentials.", + "Production reader qualification does not authorize automatic retry, new turns, native history APIs or schema warming." + ], + "unchangedMigration0001": true, + "productionReader": { + "exactQualification": { + "version": "0.162.1", + "commit": "092d3acd6bec3e3a14bdc7e7a2810ab628ab759d", + "platform": "darwin-arm64" + }, + "capabilitySupported": true, + "injectionObserved": true, + "linkedTurnStatus": "completed", + "ownerChecks": 4, + "originalGenerationProvedBeforeAndAfter": true, + "noBodyOrHistoryApiOutput": true + }, + "actualCli": { + "freshThreads": 2, + "queueAttemptsPerThread": 1, + "observeSendExitCodes": [ + 0, + 0 + ], + "submitted": true, + "nativeStatus": "queued", + "consumptionConfirmed": false, + "retryAllowed": false, + "deliveredWaitOnFirstThread": { + "operationStatus": "satisfied", + "statusQueryExitCode": 0, + "injectionObserved": true + }, + "harnessOrderDeviation": { + "description": "A separate delivered wait was started before manual confirmation; lost clock ordering correctly refused confirmation. No requeue.", + "manualConfirmationExitCode": 1, + "error": "receipt_order_unprovable", + "originalDeliveryFactsPreserved": true + }, + "secondThreadOperatorConfirmation": { + "ownVoluntaryReplyMatchedCorrelationId": true, + "confirmExitCode": 0, + "assurance": "operator_confirmed", + "state": "acknowledged", + "late": false, + "statusExitCode": 0, + "acknowledgedWaitExitCode": 0, + "waitStatus": "satisfied" + }, + "automatedReceiptCapability": false, + "allOwnedTestTerminalsClosed": true, + "nativeHomeFilesDeleted": false + }, + "testedCompiledComponentHashes": { + "dist/app-server.js": "4207937bb25fb1f42d97922d55ebdadcc9cb295dbe0a92bebd9d35f171204b6e", + "dist/metadata-observation.js": "4d01dae9a90f4cda3c7b45bf0c126067bff403f345d0d23bc593cc57363e0f83", + "dist/handoff-observer-probe.js": "ac43875de9ceb06cd6f4ce9ca9e836c97a83a396f2a301d0aa78ee43d606ce34", + "dist/handoff-operations.js": "60d201351269da031785a6c6c5fbe14120b132c514372b5b3921f528b96e82ea", + "dist/handoff-ledger.js": "efb46bcd1892ba9eb843a4ae603987f15ce535d942938dfe2c0e98a1343edcb9", + "dist/cli.js": "05c83306e9418acfdd2e64dad905a9a9797500e01914a0ef6b76ec145ed2ab23" + } +} diff --git a/test/handoff-cli.test.ts b/test/handoff-cli.test.ts index 71d54d3..ab5df0d 100644 --- a/test/handoff-cli.test.ts +++ b/test/handoff-cli.test.ts @@ -42,8 +42,8 @@ test('syntax/private inputs reject without minting authority or submitting', { s const f = await fixture(t); await f.invoke(['handoff','init']); for (const seconds of ['01','1.0','1e0','+1','61']) { const r = await f.invoke([...f.send,'--wait-timeout',seconds]); assert.equal(r.code,2); assert.equal(r.value.error,'invalid_wait_timeout'); } const small = await f.invoke([...f.send,'--request-ack','--wait-timeout','5']); assert.equal(small.code,1); assert.equal(small.value.submitted,false); - const remote = await f.invoke([...f.send,'--request-ack','--host','fixture']); assert.equal(remote.code,1); assert.equal(remote.value.error,'remote_handoff_unsupported'); - const uri = await f.invoke(['send','--to','session-peer://v1/reply?agent=claude&session=fixture&transport=ssh&host=worker','--request-ack','--message','fixture','--no-from','--no-reply-to']); assert.equal(uri.code,1); assert.equal(uri.value.error,'remote_handoff_unsupported'); + const remote = await f.invoke([...f.send,'--request-ack','--host','fixture','--remote-platform','win32']); assert.equal(remote.code,1); assert.equal(remote.value.error,'remote_handoff_unsupported'); + const uri = await f.invoke(['send','--to','session-peer://v1/reply?agent=claude&session=fixture&transport=ssh&host=worker','--request-ack','--remote-platform','win32','--message','fixture','--no-from','--no-reply-to']); assert.equal(uri.code,1); assert.equal(uri.value.error,'remote_handoff_unsupported'); const receipt = await f.invoke(['ack','--receipt','-'], ' '.repeat(4097)); assert.equal(receipt.code,2); assert.equal(receipt.value.error,'input_too_large'); assert.equal(receipt.value.submitted,undefined); const bomReceipt = {schemaVersion:1,kind:'receipt',ledgerEpoch:'11111111-1111-4111-8111-111111111111',correlationId:'11111111-1111-4111-8111-111111111111',targetGeneration:'fixture',receiptId:'22222222-2222-4222-8222-222222222222',capability:Buffer.alloc(32).toString('base64url')}; const bom = await f.invoke(['ack','--receipt','-'], '\ufeff'+JSON.stringify(bomReceipt));assert.equal(bom.value.error,'invalid_handoff_json'); diff --git a/test/handoff-core.test.ts b/test/handoff-core.test.ts index 334e05d..5a68c86 100644 --- a/test/handoff-core.test.ts +++ b/test/handoff-core.test.ts @@ -241,7 +241,7 @@ test('exact injection evidence survives observer restart without inventing old w const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding, { waitFor: 'delivered', observationSupported: true }); submit(f, h); const restarted = new HandoffLedger(f.ledger.path, { clock: f.clock }); const observed = restarted.recordObservation(h.correlationId, { clientUserMessageId: h.correlationId, injectionObserved: true }, f.binding); - assert.equal(observed.state, 'delivered'); assert.equal(observed.wait.status, 'failed'); assert.equal(observed.wait.reason, 'history_unavailable'); + assert.equal(observed.state, 'delivered'); assert.deepEqual(observed.wait, h.wait); assert.equal(observed.ack.status, 'not_requested'); assert.equal(observed.observation.injectionObserved, true); }); test('late final resolution refusal consumes only a valid bound prepared reservation and fences restart', posix, t => { @@ -466,7 +466,7 @@ test('delivery leaves terminal operations intact and does not guess a different restarted.recordObservation(h.correlationId,{clientUserMessageId:h.correlationId,injectionObserved:true},f.binding); assert.deepEqual(restarted.operationStatus(h.correlationId,h.wait.operationId!).wait,stopped.wait); const foreign=restarted.operationStatus(h.correlationId,pending.wait.operationId!); - assert.equal(foreign.wait.status,'failed');assert.equal(foreign.wait.reason,'history_unavailable'); + assert.deepEqual(foreign.wait,pending.wait); assert.equal(foreign.observation.injectionObserved,true);assert.equal(foreign.ack.status,'not_requested'); }); test('expired details compact to an epoch-bound fence, never restoring send or receipt authority', posix, t => { @@ -524,3 +524,106 @@ test('private receipt rejects incorrect capability encoding, extra fields and le assert.throws(() => validateReceipt(JSON.stringify({ ...sample, receivedAtUtcMs: 1 }))); assert.throws(() => validateReceipt(JSON.stringify(sample).replace('"schemaVersion":1', '"schemaVersion":1.0'))); }); + + +test('resumed delivery requires retained original qualification and never rewrites old terminal waits', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); + const binding = { ...f.binding, generation: 'a'.repeat(64), writerIdentity: 'a'.repeat(64) }; + const qualification = { version: '0.160.1', commit: 'd27764b82f7118f674371e6d6e76271d9d606edb', platform: 'darwin-arm64' }; + const original = f.ledger.prepare(binding, { waitFor: 'delivered', observationSupported: true, + observationQualification: qualification, budget: new HandoffBudget(6, f.clock) }); + f.ledger.commitEffect(original.correlationId, binding); f.ledger.recordSubmission(original.correlationId, 'submitted'); + f.advance(1000); f.ledger.recordWait(original.correlationId, 'timed_out_unknown', undefined, original.wait.operationId); + const restarted = new HandoffLedger(f.ledger.path, { clock: f.clock }); + const context = restarted.observationContext(original.correlationId)!; + assert.deepEqual(context, { binding, clientUserMessageId: original.correlationId, qualification }); + (context.binding as {home:string}).home = '/changed'; + assert.equal(restarted.observationContext(original.correlationId)!.binding.home, binding.home); + const prior = restarted.operationStatus(original.correlationId, original.wait.operationId!); + const pending = restarted.beginWait(original.correlationId, 'delivered', new HandoffBudget(6,f.clock), { observationSupported:true }); + assert.equal(pending.wait.status,'pending'); assert.equal(pending.observation.status,'pending'); + assert.notEqual(pending.wait.operationId,original.wait.operationId); + const observed = restarted.recordObservation(original.correlationId, + {clientUserMessageId:original.correlationId,injectionObserved:true},binding); + assert.equal(observed.state,'delivered'); assert.equal(observed.ack.status,'not_requested'); + assert.equal(restarted.operationStatus(original.correlationId,pending.wait.operationId!).wait.status,'satisfied'); + assert.deepEqual(restarted.operationStatus(original.correlationId,original.wait.operationId!).wait,prior.wait); + assert.equal(JSON.stringify(observed).includes('observationQualification'),false); + assert.equal(JSON.stringify(observed).includes('payloadDigest'),false); + assert.throws(()=>restarted.commitEffect(original.correlationId,binding),/handoff_already_attempted/); +}); + +test('restart cannot invent observation qualification for historical, attempted or refused intents', posix, t => { + const f=fixtureLedger(t);f.ledger.init(); + const binding={...f.binding,generation:'a'.repeat(64),writerIdentity:'a'.repeat(64)}; + const old=f.ledger.prepare(binding,{observeDelivery:true,observationSupported:true}); + f.ledger.commitEffect(old.correlationId,binding);f.ledger.recordSubmission(old.correlationId,'submitted'); + const restarted=new HandoffLedger(f.ledger.path,{clock:f.clock}); + assert.equal(restarted.observationContext(old.correlationId),undefined); + assert.equal(restarted.beginWait(old.correlationId,'delivered',new HandoffBudget(6,f.clock),{observationSupported:true}).wait.status,'unsupported'); + const qualification={version:'0.160.1',commit:'d27764b82f7118f674371e6d6e76271d9d606edb',platform:'darwin-arm64'}; + const pending=f.ledger.prepare(binding,{observeDelivery:true,observationSupported:true,observationQualification:qualification}); + assert.equal(restarted.observationContext(pending.correlationId),undefined); + f.ledger.commitEffect(pending.correlationId,binding); + assert.equal(restarted.observationContext(pending.correlationId),undefined); + f.ledger.recordSubmission(pending.correlationId,'refused'); + assert.equal(restarted.observationContext(pending.correlationId),undefined); + assert.throws(()=>f.ledger.prepare(binding,{observeDelivery:true,observationSupported:true, + observationQualification:{...qualification,commit:'bad'}}),/invalid_observation_qualification/); +}); + +test('independent delivery observers preserve foreign operations and project their own pending wait', posix, t => { + for (const firstObserver of ['original', 'resumed'] as const) { + const f = fixtureLedger(t); f.ledger.init(); + const binding = { ...f.binding, generation: 'a'.repeat(64), writerIdentity: 'a'.repeat(64) }; + const qualification = { version: '0.160.1', commit: 'd27764b82f7118f674371e6d6e76271d9d606edb', platform: 'darwin-arm64' }; + const original = f.ledger.prepare(binding, { waitFor: 'delivered', observationSupported: true, + observationQualification: qualification, budget: new HandoffBudget(6, f.clock) }); + f.ledger.commitEffect(original.correlationId, binding); f.ledger.recordSubmission(original.correlationId, 'submitted'); + const resumed = new HandoffLedger(f.ledger.path, { clock: f.clock }); + const resumedWait = resumed.beginWait(original.correlationId, 'delivered', new HandoffBudget(6, f.clock), { observationSupported: true }); + const newer = new HandoffLedger(f.ledger.path, { clock: f.clock }); + const newerWait = newer.beginWait(original.correlationId, 'delivered', new HandoffBudget(6, f.clock), { observationSupported: true }); + // The newer operation belongs to another observer, but does not invalidate + // the earlier resumed operation's ability to observe its own deadline. + assert.equal(resumed.operationStatus(original.correlationId, resumedWait.wait.operationId!).observation.status, 'pending'); + const observers = firstObserver === 'original' + ? [[f.ledger, original], [resumed, resumedWait], [newer, newerWait]] as const + : [[resumed, resumedWait], [f.ledger, original], [newer, newerWait]] as const; + f.advance(999); + const evidence = { clientUserMessageId: original.correlationId, injectionObserved: true }; + const [first, firstWait] = observers[0]; first.recordObservation(original.correlationId, evidence, binding); + assert.deepEqual(first.operationStatus(original.correlationId, firstWait.wait.operationId!).wait, { ...firstWait.wait, status: 'satisfied' }); + for (const [observer, wait] of observers.slice(1)) { + assert.deepEqual(observer.operationStatus(original.correlationId, wait.wait.operationId!).wait, wait.wait); + observer.recordObservation(original.correlationId, evidence, binding); + } + for (const [observer, wait] of observers) { + assert.deepEqual(observer.operationStatus(original.correlationId, wait.wait.operationId!).wait, { ...wait.wait, status: 'satisfied' }); + } + } +}); + +test('reconfiguring prepared delivery cannot retain qualification for a different submission path', posix, t => { + for (const mode of ['not_requested', 'unsupported', 'unqualified'] as const) { + const f = fixtureLedger(t); f.ledger.init(); + const binding = { ...f.binding, generation: 'a'.repeat(64), writerIdentity: 'a'.repeat(64) }; + const qualification = { version: '0.160.1', commit: 'd27764b82f7118f674371e6d6e76271d9d606edb', platform: 'darwin-arm64' }; + const original = f.ledger.prepare(binding, { observeDelivery: true, observationSupported: true, observationQualification: qualification }); + const file = join(f.ledger.path, 'ledger.json'), before = readFileSync(file, 'utf8'); + assert.throws(() => f.ledger.prepare(binding, { correlationId: original.correlationId, + observeDelivery: true, observationSupported: true, observationQualification: { ...qualification, version: 'other' } }), /handoff_binding_conflict/); + assert.equal(readFileSync(file, 'utf8'), before); + const configured = f.ledger.prepare(binding, { correlationId: original.correlationId, + ...(mode === 'not_requested' ? {} : { observeDelivery: true, observationSupported: mode === 'unqualified' }) }); + assert.equal(configured.observation.status, mode === 'unqualified' ? 'pending' : mode); + const stored = JSON.parse(readFileSync(file, 'utf8')).data.records[original.correlationId]; + assert.equal(stored.observationQualification, undefined); + assert.equal(stored.deliverySupported, mode === 'unqualified'); + assert.deepEqual(stored.binding, binding); + f.ledger.commitEffect(original.correlationId, binding); f.ledger.recordSubmission(original.correlationId, 'submitted'); + const restarted = new HandoffLedger(f.ledger.path, { clock: f.clock }); + assert.equal(restarted.observationContext(original.correlationId), undefined); + assert.equal(restarted.beginWait(original.correlationId, 'delivered', new HandoffBudget(6, f.clock), { observationSupported: true }).wait.status, 'unsupported'); + } +}); diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index 2a1228a..3432c5e 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -8,6 +8,7 @@ import {chmodSync,existsSync,mkdirSync,mkdtempSync,readFileSync,realpathSync,rmS import {join,delimiter,resolve} from 'node:path'; import {tmpdir} from 'node:os'; import {validateHandoff} from '../dist/handoff.js'; +import {payloadDigest} from '../dist/handoff-ledger.js'; const qualified={skip:process.platform!=='darwin'||process.arch!=='arm64'}; const thread='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',cli=resolve('dist/cli.js'); const schema=`CREATE TABLE thread_turns(thread_id TEXT NOT NULL,turn_id TEXT NOT NULL,status TEXT NOT NULL,PRIMARY KEY(thread_id,turn_id)); @@ -24,13 +25,14 @@ async function fixture(t:TestContext,mode='inject',version='0.160.1',warm=true){ const lsof=join(bin,'lsof');writeFileSync(lsof+'.json',JSON.stringify('p'+held.pid+'\0ccodex-fixture\0u'+process.getuid?.()+'\0'),{mode:0o600}); writeFileSync(lsof,`#!${process.execPath}\nprocess.stdout.write(JSON.parse(require('fs').readFileSync(__filename+'.json','utf8')));`,{mode:0o700}); writeFileSync(join(bin,'ps'),`#!${process.execPath}\nconsole.log('Fixture stable start');`,{mode:0o700}); - const log=join(root,'queue-log-\'"\\\n\u2028\u2029');writeFileSync(log,'');const codex=join(bin,'codex'); + const log=join(root,'queue-log-\'"\\\n\u2028\u2029'),nativeLog=join(root,'native-calls.jsonl');writeFileSync(log,'');writeFileSync(nativeLog,'');const codex=join(bin,'codex'); const initializeReady=join(root,'initialize-ready'),initializeRelease=join(root,'initialize-release'); // Runtime configuration is JSON data; no fixture path/mode/version is code. - writeFileSync(codex+'.json',JSON.stringify({mode,version,log,initializeReady,initializeRelease}),{mode:0o600}); + writeFileSync(codex+'.json',JSON.stringify({mode,version,log,nativeLog,initializeReady,initializeRelease}),{mode:0o600}); writeFileSync(codex,`#!${process.execPath} const fs=require('fs'),readline=require('readline'),path=require('path'); -const {mode,version,log,initializeReady,initializeRelease}=JSON.parse(fs.readFileSync(__filename+'.json','utf8')); +const {mode,version,log,nativeLog,initializeReady,initializeRelease}=JSON.parse(fs.readFileSync(__filename+'.json','utf8')); +fs.appendFileSync(nativeLog,JSON.stringify({command:process.argv[2]??null})+'\\n'); if(process.argv[2]==='--version'){console.log('codex-cli '+version);process.exit(0);} if(process.argv[2]==='queue'){fs.appendFileSync(log,JSON.stringify({method:'legacy'})+'\\n');console.log('Queued message legacy-1 for thread '+process.argv[4]+'.');process.exit(0);} const {DatabaseSync:Sql}=require('node:sqlite');globalThis.keptDatabases=[new Sql(path.join(process.env.CODEX_HOME,'state_5.sqlite'),{readOnly:true}),new Sql(path.join(process.env.CODEX_SQLITE_HOME,'queue_1.sqlite'))]; @@ -46,13 +48,30 @@ console.log(JSON.stringify({id:r.id,result:{queuedSubmission:{id:'queue-fixture' }});`,{mode:0o700}); const env={...process.env,HOME:root,USERPROFILE:root,CODEX_HOME:home,SESSION_PEER_CODEX_HOMES:'[]',CLAUDE_CONFIG_DIR:join(root,'.claude'),SESSION_PEER_TAILSCALE:'off',SESSION_PEER_UPDATE_NOTICE:'',CODEX_THREAD_ID:'',CODEX_SESSION_ID:'',PATH:bin+delimiter+(process.env.PATH??''),SESSION_PEER_HANDOFF_HOME:join(root,'state','handoff')}; const args=['send','--to','codex:'+thread,'--codex-home',home,'--codex-bin',codex,'--message','fixture request','--no-from','--no-reply-to']; - function start(options:string[]){const child=spawn(process.execPath,[cli,...options,'--json'],{env});let stdout='',stderr='';child.stdout.on('data',c=>stdout+=c);child.stderr.on('data',c=>stderr+=c);child.stdin.end();const timer=setTimeout(()=>child.kill('SIGKILL'),15000);const result=once(child,'close').then(([code,signal])=>{clearTimeout(timer);assert.equal(signal,null,stderr);return{code,value:JSON.parse(stdout),stdout,stderr};});return{child,result};} + function start(options:string[]){const child=spawn(process.execPath,[cli,...options,'--json'],{env});let stdout='',stderr='';child.stdout.on('data',c=>stdout+=c);child.stderr.on('data',c=>stderr+=c);child.stdin.end();const timer=setTimeout(()=>child.kill('SIGKILL'),15000);const closed=once(child,'close');const result=closed.then(([code,signal])=>{clearTimeout(timer);assert.equal(signal,null,stderr);return{code,value:JSON.parse(stdout),stdout,stderr};});void result.catch(()=>{});t.after(async()=>{if(child.exitCode===null&&child.signalCode===null){child.kill('SIGKILL');await closed;}});return{child,result};} async function invoke(options:string[]){return start(options).result;} assert.equal((await invoke(['handoff','init'])).code,0); const calls=()=>readFileSync(log,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)); + const nativeCalls=()=>readFileSync(nativeLog,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)); async function queued(){if(calls().length)return;await new Promise((ok,fail)=>{const watcher=watch(root,()=>{if(calls().length){clearTimeout(timer);watcher.close();ok();}});const timer=setTimeout(()=>{watcher.close();fail(new Error('queue fixture not called'));},10000);if(calls().length){clearTimeout(timer);watcher.close();ok();}});} async function initialized(){if(existsSync(initializeReady))return;await new Promise((ok,fail)=>{const watcher=watch(root,()=>{if(existsSync(initializeReady)){clearTimeout(timer);watcher.close();ok();}});const timer=setTimeout(()=>{watcher.close();fail(new Error('initialize fixture not reached'));},10000);if(existsSync(initializeReady)){clearTimeout(timer);watcher.close();ok();}});} - return{root,home,codex,env,calls,args,invoke,start,queued,initialized,releaseInitialize:()=>writeFileSync(initializeRelease,'release')}; + const ledgerFile=join(root,'state','handoff','ledger.json'); + const ledger=()=>JSON.parse(readFileSync(ledgerFile,'utf8')); + // Watch the directory because the ledger publishes by rename. Reading errors + // are failures, not a fabricated absence or a fixed-sleep readiness signal. + async function pendingWait(id:string,previousOperation?:string){ + const pending=()=>{const waits=ledger().data.records[id].waits;return waits.find((wait:any)=>wait.public.operationId!==previousOperation&&wait.public.status==='pending')?.public;}; + const current=pending();if(current)return current; + return new Promise((ok,fail)=>{let settled=false; + const watcher=watch(join(root,'state','handoff'),()=>check()); + const timer=setTimeout(()=>finish(new Error('pending observation operation not persisted')),10000); + const finish=(error?:unknown,value?:unknown)=>{if(settled)return;settled=true;clearTimeout(timer);watcher.close();if(error)fail(error);else ok(value);}; + const check=()=>{try{const value=pending();if(value)finish(undefined,value);}catch(error){finish(error);}}; + t.after(()=>finish(new Error('fixture ended before wait barrier')));check(); + }); + } + function inject(clientId:string){const db=new DatabaseSync(join(home,'thread_history_1.sqlite'));try{db.prepare('INSERT INTO thread_items VALUES(?,?,?,?,?,?)').run(thread,'turn-fixture','after-submit-item',1,JSON.stringify({type:'userMessage',id:'after-submit-item',clientId,content:[{text:'PRIVATE_BODY_SENTINEL'}]}),'userMessage');}finally{db.close();}} + return{root,home,codex,env,calls,nativeCalls,args,invoke,start,queued,initialized,ledger,ledgerFile,pendingWait,inject,foreignOwner:()=>writeFileSync(lsof+'.json',JSON.stringify('p'+held.pid+'\0ccodex-fixture\0u'+((process.getuid?.()??0)+1)+'\0'),{mode:0o600}),releaseInitialize:()=>writeFileSync(initializeRelease,'release')}; } test('scripted fixture paths, modes and versions remain literal data', {skip:process.platform==='win32'}, async t=>{ const payload='\');throw new Error("fixture data executed");//\n\u2028\u2029'; @@ -79,12 +98,14 @@ test('positive native acceptance survives a subsequent ledger failure without an assert.equal(r.value.queueId,'queue-fixture');assert.equal(r.value.consumptionConfirmed,false); const h=validateHandoff(r.value.handoff); assert.equal(h.submission.status,'submitted');assert.equal(h.state,'submitted'); - assert.equal(h.wait.status,'failed');assert.equal(h.wait.reason,'evidence_failed'); + assert.equal(h.wait.status,'pending');assert.equal(h.wait.reason,undefined);assert.equal(r.value.handoffWarning,'persistence_failed'); assert.equal(h.observation.injectionObserved,false);assert.equal(h.ack.status,'not_requested'); assert.equal(h.retry.allowed,false);assert.deepEqual(f.calls().map(call=>call.method),['thread/queue/add']); // The accepted result is an in-memory fact. Persistence failure did not // magically commit it: a different invocation must reconcile the retained // pre-effect fence as unknown and cannot submit this intent a second time. + const retained=JSON.parse(readFileSync(f.ledgerFile,'utf8')); + assert.equal(retained.data.records[h.correlationId].handoff.wait.status,'pending'); chmodSync(join(f.root,'state','handoff','ledger.json'),0o600); const recovered=await f.invoke(['handoff','status','--correlation-id',h.correlationId]); assert.equal(recovered.code,0);assert.equal(recovered.value.handoff.submission.status,'unknown'); @@ -93,7 +114,7 @@ test('positive native acceptance survives a subsequent ledger failure without an assert.equal(f.calls().length,1); }); test('current unqualified native version cannot enter the app-server or required delivery path',qualified,async t=>{ - const f=await fixture(t,'inject','0.162.1'); + const f=await fixture(t,'inject','0.163.0'); const required=await f.invoke([...f.args,'--wait-for','delivered']); assert.equal(required.code,1);assert.equal(required.value.submitted,false); assert.equal(required.value.handoff.wait.status,'unsupported');assert.equal(f.calls().length,0); @@ -133,3 +154,122 @@ test('concurrent wait before effect cannot hide the original pending operation f assert.deepEqual(latest.value.handoff.wait,newer.value.handoff.wait); const duplicate=await f.invoke([...f.args,'--correlation-id',id]);assert.equal(duplicate.code,1);assert.equal(duplicate.value.error,'handoff_already_attempted');assert.equal(f.calls().length,1); }); + +test('independent wait resumes exact submitted scope and observes later injection without native calls',qualified,async t=>{ + const f=await fixture(t,'none');const sent=await f.invoke([...f.args,'--observe-delivery']); + assert.equal(sent.code,0,sent.stdout);assert.equal(sent.value.status,'queued');assert.equal(sent.value.submitted,true); + const original=validateHandoff(sent.value.handoff);assert.equal(original.observation.status,'pending'); + const before=f.ledger().data.records[original.correlationId],nativeBefore=f.nativeCalls(); + assert.ok(before.observationQualification);assert.equal(f.calls().length,1); + assert.equal(f.calls()[0].clientId,original.correlationId); + const active=f.start(['handoff','wait','--correlation-id',original.correlationId,'--wait-for','delivered','--wait-timeout','10']); + const pending=await f.pendingWait(original.correlationId); + f.inject(original.correlationId);const result=await active.result; + assert.equal(result.code,0,result.stdout);assert.equal(result.value.ok,true); + const observed=validateHandoff(result.value.handoff); + assert.equal(observed.wait.operationId,pending.operationId);assert.equal(observed.wait.status,'satisfied'); + assert.equal(observed.targetGeneration,original.targetGeneration);assert.equal(observed.submission.status,'submitted'); + assert.equal(observed.observation.clientUserMessageId,original.correlationId);assert.equal(observed.observation.injectionObserved,true); + assert.equal(observed.ack.status,'not_requested');assert.equal(observed.retry.allowed,false); + const after=f.ledger().data.records[original.correlationId]; + assert.equal(after.clockId,before.clockId);assert.equal(after.preparedMono,before.preparedMono); + assert.deepEqual(after.binding,before.binding);assert.deepEqual(f.calls().map(call=>call.method),['thread/queue/add']);assert.deepEqual(f.nativeCalls(),nativeBefore); + for(const privateField of ['PRIVATE_BODY_SENTINEL','observationQualification','payloadDigest'])assert.equal(result.stdout.includes(privateField),false); +}); + +test('independent wait timeout preserves original submission and never starts another native queue',qualified,async t=>{ + const f=await fixture(t,'none');const sent=await f.invoke([...f.args,'--observe-delivery']); + assert.equal(sent.code,0,sent.stdout);const original=validateHandoff(sent.value.handoff),nativeBefore=f.nativeCalls(); + const started=performance.now();const result=await f.invoke(['handoff','wait','--correlation-id',original.correlationId,'--wait-for','delivered','--wait-timeout','6']); + assert.equal(result.code,1,result.stdout);assert.equal(result.value.ok,false); + const waited=validateHandoff(result.value.handoff); + assert.equal(waited.submission.status,'submitted');assert.equal(waited.targetGeneration,original.targetGeneration); + assert.equal(waited.wait.status,'timed_out_unknown');assert.equal(waited.observation.injectionObserved,false); + assert.equal(waited.retry.allowed,false);assert.equal(f.calls().length,1);assert.deepEqual(f.nativeCalls(),nativeBefore);assert.ok(performance.now()-started<6000); + const status=await f.invoke(['handoff','status','--correlation-id',original.correlationId]); + assert.equal(status.code,0);assert.deepEqual(status.value.handoff.wait,waited.wait);assert.equal(f.calls().length,1); +}); + +test('independent wait SIGINT records its persisted operation as stopped without retracting submission',qualified,async t=>{ + const f=await fixture(t,'none');const sent=await f.invoke([...f.args,'--observe-delivery']); + assert.equal(sent.code,0,sent.stdout);const original=validateHandoff(sent.value.handoff),nativeBefore=f.nativeCalls(); + const active=f.start(['handoff','wait','--correlation-id',original.correlationId,'--wait-for','delivered','--wait-timeout','10']); + const pending=await f.pendingWait(original.correlationId);active.child.kill('SIGINT');const result=await active.result; + assert.equal(result.code,130,result.stdout);assert.equal(result.value.ok,false); + const stopped=validateHandoff(result.value.handoff); + assert.equal(stopped.wait.operationId,pending.operationId);assert.equal(stopped.wait.status,'stopped');assert.equal(stopped.wait.reason,'stopped_by_operator'); + assert.equal(stopped.submission.status,'submitted');assert.equal(stopped.targetGeneration,original.targetGeneration); + assert.equal(stopped.retry.allowed,false);assert.equal(f.calls().length,1);assert.deepEqual(f.nativeCalls(),nativeBefore); + const status=await f.invoke(['handoff','status','--correlation-id',original.correlationId]); + assert.equal(status.code,0);assert.deepEqual(status.value.handoff.wait,stopped.wait);assert.equal(f.calls().length,1); +}); + +test('independent wait cannot adopt another owner or reconstruct missing retained qualification',qualified,async t=>{ + const wrong=await fixture(t,'none');const submitted=await wrong.invoke([...wrong.args,'--observe-delivery']); + assert.equal(submitted.code,0,submitted.stdout);const original=validateHandoff(submitted.value.handoff),wrongNative=wrong.nativeCalls();wrong.foreignOwner(); + const refused=await wrong.invoke(['handoff','wait','--correlation-id',original.correlationId,'--wait-for','delivered','--wait-timeout','10']); + assert.equal(refused.code,1,refused.stdout);const changed=validateHandoff(refused.value.handoff); + assert.equal(changed.wait.status,'unsupported');assert.equal(changed.submission.status,'submitted');assert.equal(changed.targetGeneration,original.targetGeneration); + assert.equal(changed.observation.injectionObserved,false);assert.equal(wrong.calls().length,1);assert.deepEqual(wrong.nativeCalls(),wrongNative); + const old=await fixture(t,'none');const sent=await old.invoke([...old.args,'--observe-delivery']); + assert.equal(sent.code,0,sent.stdout);const handoff=validateHandoff(sent.value.handoff),oldNative=old.nativeCalls(); + const retained=old.ledger();delete retained.data.records[handoff.correlationId].observationQualification; + retained.digest=payloadDigest(JSON.stringify(retained.data)); + writeFileSync(old.ledgerFile,JSON.stringify(retained),{mode:0o600});old.inject(handoff.correlationId); + const historical=await old.invoke(['handoff','status','--correlation-id',handoff.correlationId]); + assert.equal(historical.code,0,historical.stdout);assert.equal(historical.value.handoff.submission.status,'submitted'); + assert.equal(historical.value.handoff.observation.injectionObserved,false); + const unsupported=await old.invoke(['handoff','wait','--correlation-id',handoff.correlationId,'--wait-for','delivered','--wait-timeout','10']); + assert.equal(unsupported.code,1,unsupported.stdout);const result=validateHandoff(unsupported.value.handoff); + assert.equal(result.wait.status,'unsupported');assert.equal(result.submission.status,'submitted');assert.equal(result.observation.injectionObserved,false); + assert.equal(result.targetGeneration,handoff.targetGeneration);assert.equal(result.retry.allowed,false);assert.equal(old.calls().length,1);assert.deepEqual(old.nativeCalls(),oldNative); +}); + +async function submittedBarrier(t: TestContext, f: Awaited>, id: string): Promise { + const ready=()=>f.ledger().data.records[id].phase==='finished'; + if(ready())return; + await new Promise((resolve,reject)=>{ + let settled=false;const watcher=watch(join(f.root,'state','handoff'),()=>check()); + const finish=(error?:unknown)=>{if(settled)return;settled=true;clearTimeout(timer);watcher.close();if(error)reject(error);else resolve();}; + const timer=setTimeout(()=>finish(new Error('submitted outcome not persisted')),10000); + const check=()=>{try{if(ready())finish();}catch(error){finish(error);}}; + t.after(()=>finish(new Error('fixture ended before submission barrier')));check(); + }); +} +test('original send and independent concurrent wait each observe injection under their own deadline',qualified,async t=>{ + const f=await fixture(t,'none'); + const original=f.start([...f.args,'--wait-for','delivered','--wait-timeout','15']);await f.queued(); + const id=Object.keys(f.ledger().data.records)[0]!;await submittedBarrier(t,f,id); + const first=f.ledger().data.records[id].waits[0].public; + const second=f.start(['handoff','wait','--correlation-id',id,'--wait-for','delivered','--wait-timeout','15']); + const next=await f.pendingWait(id,first.operationId);f.inject(id); + const [a,b]=await Promise.all([original.result,second.result]); + for(const result of [a,b]){assert.equal(result.code,0,result.stdout);assert.equal(result.value.ok,true);assert.equal(result.value.handoff.submission.status,'submitted');assert.equal(result.value.handoff.wait.status,'satisfied');assert.equal(result.value.handoff.ack.status,'not_requested');} + assert.equal(a.value.handoff.wait.operationId,first.operationId);assert.equal(a.value.handoff.wait.deadlineAtUtcMs,first.deadlineAtUtcMs); + assert.equal(b.value.handoff.wait.operationId,next.operationId);assert.equal(b.value.handoff.wait.deadlineAtUtcMs,next.deadlineAtUtcMs); + assert.equal(f.calls().length,1);assert.equal(f.calls()[0].method,'thread/queue/add'); + const stored=f.ledger().data.records[id].waits;assert.equal(stored[0].public.status,'satisfied');assert.equal(stored[1].public.status,'satisfied'); +}); +test('independent wait retains known submission after persistence becomes unreadable',qualified,async t=>{ + const f=await fixture(t,'none');const sent=await f.invoke([...f.args,'--observe-delivery']);assert.equal(sent.code,0,sent.stdout); + const id=sent.value.handoff.correlationId,native=f.nativeCalls(); + const active=f.start(['handoff','wait','--correlation-id',id,'--wait-for','delivered','--wait-timeout','10']); + const pending=await f.pendingWait(id);chmodSync(f.ledgerFile,0o644);f.inject(id); + const result=await active.result; + assert.equal(result.code,1,result.stdout);assert.equal(result.value.error,'handoff_persistence_failed');assert.equal(result.value.handoffWarning,'persistence_failed'); + const h=validateHandoff(result.value.handoff);assert.equal(h.submission.status,'submitted');assert.equal(h.wait.operationId,pending.operationId); + assert.equal(h.wait.status,'pending');assert.equal(h.retry.allowed,false);assert.equal(h.observation.injectionObserved,false); + assert.equal(f.calls().length,1);assert.deepEqual(f.nativeCalls(),native); + chmodSync(f.ledgerFile,0o600);const after=f.ledger().data.records[id].waits.at(-1).public;assert.deepEqual(after,pending); +}); + +test('exact qualified 0.162.1 native profile retains its own source when resuming observation',qualified,async t=>{ + const f=await fixture(t,'none','0.162.1');const sent=await f.invoke([...f.args,'--observe-delivery']);assert.equal(sent.code,0,sent.stdout); + const id=sent.value.handoff.correlationId,record=f.ledger().data.records[id],nativeBefore=f.nativeCalls(); + assert.equal(record.observationQualification.version,'0.162.1');assert.equal(record.observationQualification.commit,'092d3acd6bec3e3a14bdc7e7a2810ab628ab759d');assert.equal(record.observationQualification.platform,'darwin-arm64'); + const active=f.start(['handoff','wait','--correlation-id',id,'--wait-for','delivered','--wait-timeout','10']); + await f.pendingWait(id);f.inject(id);const result=await active.result;assert.equal(result.code,0,result.stdout); + assert.equal(result.value.handoff.submission.status,'submitted');assert.equal(result.value.handoff.observation.injectionObserved,true); + assert.equal(result.value.handoff.wait.status,'satisfied');assert.equal(result.value.handoff.ack.status,'not_requested'); + assert.equal(f.calls().length,1);assert.deepEqual(f.nativeCalls(),nativeBefore); +}); diff --git a/test/metadata-observation.test.ts b/test/metadata-observation.test.ts index 883f2b7..43a3f6e 100644 --- a/test/metadata-observation.test.ts +++ b/test/metadata-observation.test.ts @@ -8,9 +8,11 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSyn import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { performance } from 'node:perf_hooks'; -import { metadataCapability, observeMetadata, METADATA_SOURCE, type MetadataScope } from '../dist/metadata-observation.js'; +import { metadataCapability, metadataQualification, observeMetadata, resumeMetadataObservation, METADATA_RESUME_QUALIFICATION, + METADATA_SOURCE, type MetadataScope, type MetadataQualification } from '../dist/metadata-observation.js'; const posix = { skip: process.platform === 'win32' }; +const resumeQualified = { skip: process.platform !== 'darwin' || process.arch !== 'arm64' }; const threadId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const clientId = 'client-message-fixture-1'; // Structural fixture from the official 0.160.1 migrations 0001/0002. Later @@ -44,6 +46,108 @@ const budget = () => ({ observationRemainingMs: () => 5000 }); const owner = () => true; const observe = (scope: MetadataScope, clientUserMessageId = clientId) => observeMetadata({ ...scope, clientUserMessageId }, budget(), owner); +test('native metadata qualification is a finite immutable source/platform tuple, not a SemVer range', () => { + assert.deepEqual(metadataQualification('0.160.1'), METADATA_RESUME_QUALIFICATION); + const current = metadataQualification('codex-cli 0.162.1'); + assert.deepEqual(current, { version: '0.162.1', commit: '092d3acd6bec3e3a14bdc7e7a2810ab628ab759d', platform: 'darwin-arm64' }); + assert.equal(Object.isFrozen(current), true); + for (const version of ['0.162.0', '0.162.2', '0.163.0', '0.162.1+unknown', ' 0.162.1', 'codex-cli 0.162.1\n']) { + assert.equal(metadataQualification(version), undefined); + } + const evidence = JSON.parse(readFileSync(new URL('./fixtures/metadata-observation-native-evidence.json', import.meta.url), 'utf8')); + assert.equal(evidence.nativeProfile.version, current!.version); + assert.equal(evidence.nativeProfile.commit, current!.commit); + assert.equal(evidence.nativeProfile.platform, current!.platform); + assert.equal(evidence.liveFixture.oneQueueAttempt, true); + assert.equal(evidence.liveFixture.matchingClientIdRows, 1); + assert.equal(evidence.liveFixture.nativeItemIdEqualsClientId, false); + assert.equal(evidence.liveFixture.generationUnchanged, true); + assert.equal(evidence.liveFixture.acknowledged, false); + assert.deepEqual(evidence.productionReader.exactQualification, current); + assert.equal(evidence.productionReader.capabilitySupported, true); + assert.equal(evidence.productionReader.injectionObserved, true); + assert.equal(evidence.productionReader.originalGenerationProvedBeforeAndAfter, true); + assert.equal(evidence.actualCli.secondThreadOperatorConfirmation.assurance, 'operator_confirmed'); + assert.equal(evidence.actualCli.automatedReceiptCapability, false); +}); + +test('0.162.1 structural store fixture retains exact native client ID and body-free source qualification', posix, async t => { + const f = fixture(t, true); f.item(1); f.db.exec("UPDATE thread_turns SET status='completed'"); + assert.deepEqual(await metadataCapability(f.scope, 'codex-cli 0.162.1', budget(), owner), { supported: true }); + assert.deepEqual(await observe(f.scope), { supported: true, injectionObserved: true, + clientUserMessageId: clientId, turn: { id: 'turn-fixture-1', status: 'completed' } }); + // Structural fixture coverage on another POSIX platform is not native qualification. + if (process.platform === 'darwin' && process.arch === 'arm64') { + assert.deepEqual(await resumeMetadataObservation({ ...f.scope, generation: 'a'.repeat(64), clientUserMessageId: clientId }, + metadataQualification('0.162.1')!, budget(), owner), { supported: true, injectionObserved: true, + clientUserMessageId: clientId, turn: { id: 'turn-fixture-1', status: 'completed' } }); + } else { + assert.deepEqual(await resumeMetadataObservation({ ...f.scope, generation: 'a'.repeat(64), clientUserMessageId: clientId }, + metadataQualification('0.162.1')!, budget(), owner), + { supported: false, injectionObserved: false, reason: 'metadata_platform_unsupported' }); + } +}); + +test('retained observation source qualification rejects unknown versions/commits/platforms before opening storage', async t => { + let spawned = 0, owners = 0; + mock.method(childProcess, 'spawn', () => { spawned++; throw new Error('unexpected observation worker'); }); + t.after(() => mock.restoreAll()); + const scope = { home: '/no-such-fixture', threadId, generation: 'a'.repeat(64), clientUserMessageId: clientId }; + const cases: [MetadataQualification, string][] = [ + [{ ...METADATA_RESUME_QUALIFICATION, version: '0.163.0' }, 'metadata_version_unsupported'], + [{ ...METADATA_RESUME_QUALIFICATION, commit: 'a'.repeat(40) }, 'metadata_schema_unsupported'], + [{ ...metadataQualification('0.162.1')!, commit: METADATA_RESUME_QUALIFICATION.commit }, 'metadata_schema_unsupported'], + [{ ...METADATA_RESUME_QUALIFICATION, platform: 'win32-x64' }, 'metadata_platform_unsupported'], + [{} as MetadataQualification, 'metadata_scope_invalid'], + [{ ...METADATA_RESUME_QUALIFICATION, future: true } as MetadataQualification, 'metadata_scope_invalid'], + ]; + if (process.platform !== 'darwin' || process.arch !== 'arm64') cases.push([METADATA_RESUME_QUALIFICATION, 'metadata_platform_unsupported']); + for (const [qualification, reason] of cases) { + assert.deepEqual(await resumeMetadataObservation(scope, qualification, budget(), () => { owners++; return true; }), + { supported: false, injectionObserved: false, reason }); + } + assert.equal(spawned, 0); assert.equal(owners, 0); +}); + +test('retained exact scope resumes only body-free metadata workers, with original generation and no native submission', resumeQualified, async t => { + const f = fixture(t, true); f.item(1); + const scope = { ...f.scope, generation: 'a'.repeat(64), clientUserMessageId: clientId }; + const before = new Map(readdirSync(f.home).filter(name => !name.endsWith('-shm')).map(name => [name, readFileSync(join(f.home, name))])); + const spawned: string[][] = [], actualSpawn = childProcess.spawn; + mock.method(childProcess, 'spawn', (binary: string, args: readonly string[], options: any) => { + assert.equal(binary, process.execPath); assert.equal(options.shell, false); + assert.equal(args.length, 1); assert.ok(args[0]!.endsWith('/handoff-observer-probe.js')); + spawned.push([...args]); return actualSpawn(binary, args, options); + }); + t.after(() => mock.restoreAll()); + let owners = 0; + const result = await resumeMetadataObservation(scope, METADATA_RESUME_QUALIFICATION, budget(), original => { + owners++; assert.equal(original.home, scope.home); assert.equal(original.threadId, scope.threadId); + assert.equal(original.generation, scope.generation); assert.equal(Object.isFrozen(original), true); return true; + }); + assert.deepEqual(result, { supported: true, injectionObserved: true, clientUserMessageId: clientId, + turn: { id: 'turn-fixture-1', status: 'running' } }); + assert.equal(owners, 2); assert.equal(spawned.length, 3); + assert.equal(JSON.stringify(result).includes('PRIVATE_'), false); + for (const [name, bytes] of before) assert.deepEqual(readFileSync(join(f.home, name)), bytes); +}); + +test('resume does not replace original generation, infer a different client ID or create proof for malformed scope', resumeQualified, async t => { + const f = fixture(t); f.item(1); + const scope = { ...f.scope, generation: 'a'.repeat(64), clientUserMessageId: clientId }; + assert.deepEqual(await resumeMetadataObservation(scope, METADATA_RESUME_QUALIFICATION, budget(), () => false), + { supported: false, injectionObserved: false, reason: 'metadata_owner_changed' }); + assert.deepEqual(await resumeMetadataObservation({ ...scope, clientUserMessageId: 'different-client' }, METADATA_RESUME_QUALIFICATION, budget(), owner), + { supported: true, injectionObserved: false }); + let owners = 0; + for (const invalid of [{ ...scope, generation: null }, { ...scope, home: f.home + '/../other' }, + { ...scope, home: 'relative-fixture' }, { ...scope, generation: 'unverified' }]) { + assert.deepEqual(await resumeMetadataObservation(invalid, METADATA_RESUME_QUALIFICATION, budget(), () => { owners++; return true; }), + { supported: false, injectionObserved: false, reason: 'metadata_scope_invalid' }); + } + assert.equal(owners, 0); +}); + test('qualified warm metadata is projected without body/error output and without DB/WAL writes', posix, async t => { const f = fixture(t, true); f.item(1); const files = readdirSync(f.home).sort(), before = new Map(files.filter(name => !name.endsWith('-shm')).map(name => [name, readFileSync(join(f.home, name))])); @@ -102,7 +206,7 @@ test('duplicate native client IDs fail closed instead of choosing a turn', posix test('version/platform scope and unknown generation refuse before starting any probe', posix, async t => { const f = fixture(t); let called = 0; const verify = () => { called++; return true; }; - for (const version of ['0.159.0', '0.160.2', '0.160.1+unknown', 'garbage']) { + for (const version of ['0.159.0', '0.160.2', '0.162.2', '0.163.0', '0.160.1+unknown', 'garbage']) { assert.deepEqual(await metadataCapability(f.scope, version, budget(), verify), { supported: false, reason: 'metadata_version_unsupported' }); } assert.deepEqual(await metadataCapability({ ...f.scope, generation: null }, '0.160.1', budget(), verify), diff --git a/test/ssh-handoff.test.ts b/test/ssh-handoff.test.ts new file mode 100644 index 0000000..e261272 --- /dev/null +++ b/test/ssh-handoff.test.ts @@ -0,0 +1,270 @@ +// Owned fake SSH and a recording socket only; no live agent or remote host. +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer } from 'node:net'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { responseOutcome, type ResponseRequest } from '../dist/ssh-response.js'; +import { HandoffBudget, type Handoff } from '../dist/handoff.js'; +import { VERSION_LINE } from '../dist/protocol.js'; +import type { Done } from '../dist/process.js'; + +const cli = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); +const posix = { skip: process.platform === 'win32' }; +const wireArgs = (ms: number) => ['--stdio-request', `--handoff-remaining-ms=${ms}`]; +const cid = '11111111-1111-4111-8111-111111111111', epoch = '22222222-2222-4222-8222-222222222222'; +const thread = '33333333-3333-4333-8333-333333333333'; +const prepared = { correlationId: cid, ledgerEpoch: epoch, targetGeneration: 'original-generation' }; +const native = { schemaVersion: 1, host: 'fixture', command: 'send', ok: true, status: 'queued', submitted: true, + consumptionConfirmed: false, queueId: 'queue-1', target: { id: thread }, codexHome: '/fixture/home' }; +function evidence(): Handoff { + return { schemaVersion: 1, ...prepared, state: 'submitted', submission: { status: 'submitted' }, + observation: { status: 'unsupported', injectionObserved: false }, ack: { status: 'not_requested' }, + wait: { for: 'delivered', status: 'timed_out_unknown', operationId: '44444444-4444-4444-8444-444444444444', deadlineAtUtcMs: 1000 }, + decisionOwner: 'sender_operator', retry: { allowed: false, reason: 'receiver_dedup_unavailable' }, nextActions: ['reconcile'] }; +} +const requested: ResponseRequest = { command: 'send', to: `codex:${thread}`, home: '/fixture/home', + handoff: { prepared, correlationId: cid, waitFor: 'delivered', observeDelivery: true, destinationHost: 'fixture' } }; +function result(value: unknown, code = 1, extra: Partial = {}): Done { + return { spawned: true, interrupted: false, code, stdout: '', stderr: '', stdoutBytes: Buffer.from(JSON.stringify(value)), ...extra }; +} + +test('SSH wait failures retain native facts only under original request and prepared bindings', () => { + const timedOut = { ...native, ok: false, error: 'wait_timed_out_unknown', handoff: evidence() }; + const accepted = responseOutcome(result(timedOut), requested); + assert.equal(accepted.exitCode, 1); assert.equal(accepted.value.submitted, true); assert.equal(accepted.value.queueId, native.queueId); + assert.deepEqual(accepted.value.handoff, timedOut.handoff); + assert.throws(() => responseOutcome(result(timedOut), { command: 'send', to: `codex:${thread}` })); + for (const patch of [{ target: { id: cid } }, { codexHome: '/other' }, { status: 'posted' }, { consumptionConfirmed: true }, { host: 'wrong-destination' }]) { + assert.throws(() => responseOutcome(result({ ...timedOut, ...patch }), requested)); + } + for (const patch of [{ correlationId: epoch }, { ledgerEpoch: cid }, { targetGeneration: 'successor' }, + { ack: { status: 'unsupported' } }, { wait: { ...evidence().wait, for: 'acknowledged' } }]) { + const invalid = responseOutcome(result({ ...timedOut, handoff: { ...evidence(), ...patch } }), requested); + assert.equal(invalid.value.handoff, undefined); assert.equal(invalid.value.handoffWarning, 'invalid_handoff'); + assert.equal(invalid.value.submitted, true); assert.equal(invalid.exitCode, 1); + } + for (const error of [undefined, null, {}]) { + const invalid = responseOutcome(result({ ...timedOut, error }), requested); + assert.equal(invalid.value.handoff, undefined); assert.equal(invalid.value.error, 'invalid_handoff'); + assert.equal(invalid.value.submitted, true); assert.equal(invalid.value.queueId, native.queueId); assert.equal(invalid.exitCode, 1); + } + const stopped = { ...timedOut, handoff: { ...evidence(), wait: { ...evidence().wait, status: 'stopped', reason: 'stopped_by_operator' } } }; + assert.equal(responseOutcome(result(stopped, 130), requested).exitCode, 130); + for (const code of [0, 1]) { + const invalid = responseOutcome(result(stopped, code), requested); + assert.equal(invalid.exitCode, 1); assert.equal(invalid.value.handoff, undefined); + assert.equal(invalid.value.handoffWarning, 'invalid_handoff'); + assert.equal(invalid.value.submitted, true); assert.equal(invalid.value.queueId, native.queueId); + } +}); + +test('SSH persistence failure preserves the last verified pending wait without inventing a terminal record', () => { + const pending = { ...evidence(), wait: { ...evidence().wait, status: 'pending' as const } }; + const value = { ...native, ok: false, error: 'handoff_persistence_failed', handoffWarning: 'persistence_failed', handoff: pending }; + const accepted = responseOutcome(result(value), requested); + assert.equal(accepted.exitCode, 1); assert.equal(accepted.value.ok, false); + assert.deepEqual(accepted.value.handoff, pending); assert.equal(accepted.value.handoffWarning, 'persistence_failed'); + assert.equal(accepted.value.submitted, true); assert.equal(accepted.value.queueId, native.queueId); + for (const patch of [{ handoffWarning: undefined }, { error: 'another_error' }]) { + const invalid = responseOutcome(result({ ...value, ...patch }), requested); + assert.equal(invalid.value.handoff, undefined); assert.equal(invalid.value.submitted, true); assert.equal(invalid.exitCode, 1); + } + assert.throws(() => responseOutcome(result(value), { ...requested, handoff: undefined })); +}); + +test('strict handoff lexemes, malformed metadata and transport uncertainty preserve independent native evidence', () => { + const value = { ...native, ok: false, error: 'wait_timed_out_unknown', handoff: evidence(), nativeNumber: 1.25 }; + const bytes = JSON.stringify(value).replace('"deadlineAtUtcMs":1000', '"deadlineAtUtcMs":1e3'); + const invalid = responseOutcome(result(value, 1, { stdoutBytes: Buffer.from(bytes) }), requested); + assert.equal(invalid.value.handoff, undefined); assert.equal(invalid.value.nativeNumber, 1.25); assert.equal(invalid.value.submitted, true); + for (const extra of [{ code: 255 }, { code: null, interrupted: true, stopReason: 'timeout' as const }]) { + const complete = responseOutcome(result(value, 1, extra), requested); + assert.equal(complete.value.submitted, true); assert.equal(complete.exitCode, 1); assert.deepEqual(complete.value.handoff, evidence()); + assert.throws(() => responseOutcome(result(value, 1, { ...extra, stdoutBytes: Buffer.from(JSON.stringify(value).slice(0, -1)) }), requested)); + } + const best = responseOutcome(result({ ...native, handoff: { capability: 'forged' } }, 0), + { ...requested, handoff: { prepared, observeDelivery: true } }); + assert.equal(best.value.ok, true); assert.equal(best.exitCode, 0); assert.equal(best.value.handoff, undefined); + const optOut = responseOutcome(result({ ...native, handoff: evidence(), handoffQuery: {}, handoffWarning: 'forged' }, 0), + { command: 'send', to: `codex:${thread}` }); + for (const field of ['handoff', 'handoffQuery', 'handoffWarning']) assert.equal(optOut.value[field], undefined); +}); + +test('SSH remaining duration charges setup/frame time and never renews cleanup or UTC authority', () => { + let mono = 100, utc = 10000; + const clock = { monotonic: () => mono, utc: () => utc }; + const budget = HandoffBudget.fromRemainingMs(6010, clock, 90); + assert.equal(budget.remainingMs(), 6000); assert.equal(budget.observationRemainingMs(), 1000); + assert.equal(budget.cutoff, 1100); assert.equal(budget.deadline, 6100); + utc += 999999; mono = 1100; assert.throws(() => budget.beforeEffect(), /deadline_before_effect/); + assert.throws(() => HandoffBudget.fromRemainingMs(5000, clock).beforeEffect(), /insufficient_budget/); + for (const ms of [0, -1, 60001, 5.5, NaN, Infinity]) assert.throws(() => HandoffBudget.fromRemainingMs(ms, clock)); +}); + +async function fixture(t: TestContext) { + const root = realpathSync(mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-ssh-handoff-'))); chmodSync(root, 0o700); + const destination = join(root, 'destination'), origin = join(root, 'origin'), socket = join(root, 'socket'), log = join(root, 'calls.jsonl'); + mkdirSync(origin); mkdirSync(join(destination, '.claude', 'sessions'), { recursive: true }); writeFileSync(log, ''); + const messages: string[] = []; + const server = createServer(s => { let body = ''; s.on('data', part => { body += part; }); s.on('end', () => { messages.push(body); s.end(); }); }); + server.listen(socket); await once(server, 'listening'); + t.after(async () => { await new Promise(resolve => server.close(() => resolve())); rmSync(root, { recursive: true, force: true }); }); + writeFileSync(join(destination, '.claude', 'sessions', `${process.pid}.json`), JSON.stringify({ pid: process.pid, name: 'fixture', messagingSocketPath: socket })); + writeFileSync(join(root, 'ssh'), `#!${process.execPath} +const fs=require('node:fs'),cp=require('node:child_process'),args=process.argv.slice(2); +if(args.includes('-G')){console.log('user fixture-user');process.exit(0);} +if(args.at(-1).endsWith('--version')){console.log(process.env.FIXTURE_VERSION);process.exit(0);} +const input=fs.readFileSync(0,'utf8'),request=JSON.parse(input); +fs.appendFileSync(process.env.FIXTURE_LOG,JSON.stringify(request)+'\\n'); +if(process.env.FIXTURE_PARTIAL_PREPARE&&request.handoff?.preparation){process.stdout.write('{');process.exit(0);} +const env={...process.env,HOME:process.env.FIXTURE_DEST,USERPROFILE:process.env.FIXTURE_DEST,CLAUDE_CONFIG_DIR:process.env.FIXTURE_DEST+'/.claude',SESSION_PEER_HANDOFF_HOME:process.env.FIXTURE_DEST+'/state/handoff'}; +const selector=args.at(-1).match(/ --handoff-remaining-ms=([1-9][0-9]{0,4})$/); +const child=cp.spawn(process.execPath,[process.env.FIXTURE_CLI,'--stdio-request',...(selector?['--handoff-remaining-ms='+selector[1]]:[])],{env,stdio:['pipe','inherit','inherit']}); +child.stdin.end(input);child.on('exit',(code,signal)=>{if(signal)process.kill(process.pid,signal);else process.exitCode=request.handoff?.preparation&&process.env.FIXTURE_PREPARE_EXIT255?255:code;}); +`, { mode: 0o700 }); + const common = { ...process.env, CODEX_HOME: '', CODEX_THREAD_ID: '', CODEX_SESSION_ID: '', SESSION_PEER_TAILSCALE: 'off', SESSION_PEER_UPDATE_NOTICE: '', + PATH: root + delimiter + process.env.PATH, FIXTURE_VERSION: VERSION_LINE, FIXTURE_LOG: log, FIXTURE_CLI: cli, FIXTURE_DEST: destination }; + const destEnv = { ...common, HOME: destination, USERPROFILE: destination, CLAUDE_CONFIG_DIR: join(destination, '.claude'), SESSION_PEER_HANDOFF_HOME: join(destination, 'state', 'handoff') }; + const originEnv = { ...common, HOME: origin, USERPROFILE: origin, CLAUDE_CONFIG_DIR: join(origin, '.claude'), SESSION_PEER_HANDOFF_HOME: join(origin, 'state', 'handoff') }; + async function invoke(args: string[], destinationSide = false, input?: string, extra: NodeJS.ProcessEnv = {}) { + const child = spawn(process.execPath, [cli, ...args], { env: { ...(destinationSide ? destEnv : originEnv), ...extra } }); + let stdout = '', stderr = ''; child.stdout.on('data', part => { stdout += part; }); child.stderr.on('data', part => { stderr += part; }); child.stdin.end(input); + const timer = setTimeout(() => child.kill('SIGKILL'), 10000); + const [code, signal] = await once(child, 'close'); clearTimeout(timer); assert.equal(signal, null, stderr); + return { code, value: JSON.parse(stdout), stderr }; + } + return { root, destination, origin, destEnv, messages, invoke, calls: () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)), + args: ['send', '--host', 'fixture', '--to', String(process.pid), '--message=fixture body', '--no-from', '--no-reply-to', '--json'] }; +} + +test('SSH uses only an explicitly initialized destination ledger, prepares before one effect, and leaves origin untouched', posix, async t => { + const f = await fixture(t); + const missing = await f.invoke([...f.args, '--request-ack']); + assert.equal(missing.code, 1); assert.equal(missing.value.submitted, false); assert.equal(f.messages.length, 0); + assert.equal(f.calls().length, 1); assert.equal(existsSync(join(f.destination, 'state', 'handoff', 'ledger.json')), false); + assert.equal((await f.invoke(['handoff', 'init', '--json'], true)).code, 0); + const sent = await f.invoke([...f.args, '--request-ack']); + assert.equal(sent.code, 0); assert.equal(sent.value.status, 'posted'); assert.equal(sent.value.handoff.ack.status, 'unsupported'); assert.equal(f.messages.length, 1); + assert.equal(existsSync(join(f.origin, 'state', 'handoff')), false); + const calls = f.calls().slice(1); assert.equal(calls.length, 2); assert.equal(calls[0].handoff.preparation, true); assert.equal(calls[1].handoff.preparation, undefined); + assert.ok(calls[1].handoff.remainingMs < calls[0].handoff.remainingMs); + assert.equal(calls[1].args[calls[1].args.indexOf('--correlation-id') + 1], sent.value.handoff.correlationId); + const repeated = await f.invoke([...f.args, '--correlation-id', sent.value.handoff.correlationId]); + assert.equal(repeated.code, 1); assert.equal(repeated.value.error, 'handoff_already_attempted'); assert.equal(f.messages.length, 1); + const ack = await f.invoke([...f.args, '--wait-for', 'acknowledged']); + assert.equal(ack.code, 1); assert.equal(ack.value.handoff.wait.status, 'unsupported'); assert.equal(f.messages.length, 1); +}); + +test('SSH refuses incomplete preparation, unknown supplied IDs, reused fanout IDs and exhausted budgets before effect', posix, async t => { + const f = await fixture(t); await f.invoke(['handoff', 'init', '--json'], true); + const partial = await f.invoke([...f.args, '--request-ack'], false, undefined, { FIXTURE_PARTIAL_PREPARE: '1' }); + assert.equal(partial.value.error, 'remote_preparation_unverified'); assert.equal(f.calls().length, 1); + const unknown = await f.invoke([...f.args, '--correlation-id', cid]); + assert.equal(unknown.value.error, 'handoff_id_unknown'); assert.equal(f.calls().length, 2); + const multi = await f.invoke([...f.args, '--host', 'other', '--correlation-id', cid]); + assert.equal(multi.code, 2); assert.ok(multi.value.every((v: { error: string }) => v.error === 'handoff_correlation_fanout_forbidden')); assert.equal(f.calls().length, 2); + const short = await f.invoke([...f.args, '--request-ack', '--wait-timeout', '5']); + assert.equal(short.value.error, 'insufficient_budget'); assert.equal(f.calls().length, 2); + const noContext = await f.invoke(['--stdio-request'], true, JSON.stringify({ schemaVersion: 1, args: ['send', '--to', String(process.pid), '--message=x', '--request-ack', '--json'] })); + assert.equal(noContext.value.error, 'remote_handoff_context_required'); assert.equal(f.messages.length, 0); +}); + +test('complete successful preparation followed by SSH255 refuses actual send without an effect request', posix, async t => { + const f = await fixture(t); await f.invoke(['handoff', 'init', '--json'], true); + const rejected = await f.invoke([...f.args, '--request-ack'], false, undefined, { FIXTURE_PREPARE_EXIT255: '1' }); + assert.equal(rejected.code, 1); assert.equal(rejected.value.ok, false); assert.equal(rejected.value.error, 'remote_preparation_unverified'); + assert.equal(rejected.value.submitted, false); assert.equal(rejected.value.status, 'refused'); + assert.equal(f.calls().length, 1); assert.equal(f.calls()[0].handoff.preparation, true); assert.equal(f.messages.length, 0); +}); + +test('SSH fanout owns distinct destination intents and refuses excessive destinations before any request', posix, async t => { + const f = await fixture(t); await f.invoke(['handoff', 'init', '--json'], true); + const sent = await f.invoke([...f.args, '--host', 'second', '--request-ack']); + assert.equal(sent.code, 0); assert.equal(sent.value.length, 2); assert.equal(f.messages.length, 2); + assert.notEqual(sent.value[0].handoff.correlationId, sent.value[1].handoff.correlationId); + const calls = f.calls(); assert.equal(calls.length, 4); + assert.deepEqual(calls.map(call => !!call.handoff.preparation), [true, false, true, false]); + assert.ok(calls[3].handoff.remainingMs < calls[2].handoff.remainingMs); + const excessive = await f.invoke([...f.args, ...Array.from({ length: 32 }, (_, index) => ['--host', `extra-${index}`]).flat(), '--request-ack']); + assert.equal(excessive.code, 2); assert.equal(f.calls().length, 4); assert.equal(f.messages.length, 2); +}); + +test('private SSH frames cannot widen the requested duration or opt an ordinary request in', posix, async t => { + const f = await fixture(t); await f.invoke(['handoff', 'init', '--json'], true); + const args = ['send', '--to', String(process.pid), '--message=x', '--request-ack', '--wait-timeout', '6', '--json']; + for (const handoff of [{ schemaVersion: 1, remainingMs: 7000 }, { schemaVersion: 1, remainingMs: 6000, preparation: false }, + { schemaVersion: 1, remainingMs: 6000, capability: 'forged' }]) { + const invalid = await f.invoke(wireArgs(6000), true, JSON.stringify({ schemaVersion: 1, args, handoff })); + assert.equal(invalid.value.error, 'invalid_remote_request'); + } + const ordinary = args.filter(arg => !['--request-ack', '--wait-timeout', '6'].includes(arg)); + const invalid = await f.invoke(wireArgs(6000), true, JSON.stringify({ schemaVersion: 1, args: ordinary, handoff: { schemaVersion: 1, remainingMs: 6000 } })); + assert.equal(invalid.value.error, 'remote_handoff_context_required'); assert.equal(f.messages.length, 0); + const unprepared = await f.invoke(wireArgs(6000), true, JSON.stringify({ schemaVersion: 1, args, handoff: { schemaVersion: 1, remainingMs: 6000 } })); + assert.equal(unprepared.value.error, 'remote_preparation_required'); assert.equal(f.messages.length, 0); + const missingSelector = await f.invoke(['--stdio-request'], true, JSON.stringify({ schemaVersion: 1, args, + handoff: { schemaVersion: 1, preparation: true, remainingMs: 6000 } })); + assert.equal(missingSelector.value.error, 'invalid_remote_request'); + for (const selector of ['0', '06000', '60001', '+6000', '6e3', '6000.0']) { + const invalid = await f.invoke(['--stdio-request', `--handoff-remaining-ms=${selector}`], true, '{}'); + assert.equal(invalid.value.error, 'invalid_remote_request'); + } + const extraArg = await f.invoke([...wireArgs(6000), '--json'], true, '{}'); + assert.equal(extraArg.value.error, 'invalid_remote_request'); assert.equal(f.messages.length, 0); +}); + +test('private wire input has a deadline before EOF, with bootstrap charged and no intent or effect', posix, async t => { + const f = await fixture(t), delayed = join(f.root, 'delayed-bootstrap.mjs'); + writeFileSync(delayed, 'await new Promise(resolve=>setTimeout(resolve,700));'); + for (const mode of ['empty', 'partial', 'delayed'] as const) { + const started = performance.now(); + const child = spawn(process.execPath, [...(mode === 'delayed' ? ['--import', delayed] : []), cli, ...wireArgs(5500)], { env: f.destEnv }); + let stdout = ''; child.stdout.on('data', part => { stdout += part; }); child.stderr.resume(); + // Leave stdin open: no EOF or source-pipe closure can rescue this handler. + if (mode !== 'empty') child.stdin.write('{"schemaVersion":1,"handoff":'); + const timer = setTimeout(() => child.kill('SIGKILL'), 2500); + t.after(() => { clearTimeout(timer); if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); }); + const [code, signal] = await once(child, 'close'); clearTimeout(timer); + assert.equal(signal, null); assert.equal(code, 1); + const response = JSON.parse(stdout); + if (mode === 'delayed') assert.equal(response.error, 'deadline_before_effect'); + else assert.ok(['deadline_before_effect', 'invalid_remote_request'].includes(response.error)); + assert.ok(performance.now() - started < 2000); + assert.equal(f.messages.length, 0); assert.equal(existsSync(join(f.destination, 'state', 'handoff')), false); + } +}); + +test('destination hard watchdog terminates a hung handler and reaps its owned native process group', posix, async t => { + const f = await fixture(t), pidFile = join(f.root, 'owned-worker.pid'); + const worker = `require('node:fs').writeFileSync(process.argv[1],String(process.pid));setInterval(()=>{},1000);`; + const processModule = new URL('../dist/process.js', import.meta.url).href; + // Replace only the operation with a hung owned-worker stand-in. The actual + // wire parser, monotonic budget, watchdog and process signal cleanup run. + const replacement = `import { run } from ${JSON.stringify(processModule)}; +export const privateMessage=()=>'';export const handoffCommand=()=>{}; +export async function prepareHandoff(){await run(process.execPath,['-e',${JSON.stringify(worker)},${JSON.stringify(pidFile)}],{timeout:60000});return {value:{},exitCode:1};} +export const handoffSend=prepareHandoff;`; + const hook = join(f.root, 'watchdog-hook.mjs'), loader = join(f.root, 'watchdog-loader.mjs'); + writeFileSync(hook, `export async function load(url,context,next){if(url.endsWith('/handoff-operations.js'))return {format:'module',shortCircuit:true,source:${JSON.stringify(replacement)}};return next(url,context);}`); + writeFileSync(loader, `import {register} from 'node:module';register(${JSON.stringify(new URL('file://' + hook).href)},import.meta.url);`); + const child = spawn(process.execPath, ['--import', loader, cli, ...wireArgs(6500)], { env: f.destEnv }); + child.stdout.resume(); child.stderr.resume(); + const timeout = setTimeout(() => child.kill('SIGKILL'), 10000); + t.after(() => { clearTimeout(timeout); if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); }); + child.stdin.end(JSON.stringify({ schemaVersion: 1, args: ['send', '--to', String(process.pid), '--message=x', '--request-ack', '--json'], + handoff: { schemaVersion: 1, preparation: true, remainingMs: 6500 } })); + const [code, signal] = await once(child, 'close'); clearTimeout(timeout); + assert.equal(code, null); assert.equal(signal, 'SIGTERM'); assert.equal(existsSync(pidFile), true); + const ownedPid = Number(readFileSync(pidFile, 'utf8')); + let alive = true; + for (let attempt = 0; attempt < 20 && alive; attempt++) { + try { process.kill(ownedPid, 0); await new Promise(resolve => setTimeout(resolve, 25)); } catch { alive = false; } + } + if (alive) { try { process.kill(ownedPid, 'SIGKILL'); } catch {} } + assert.equal(alive, false); assert.equal(f.messages.length, 0); +}); From 4656e2200507c61f10f54c3106212b54940aa9ec Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 11 Oct 2026 12:32:35 +0900 Subject: [PATCH 12/18] test(ssh): keep watchdog fixture code separate from runtime data --- test/fixtures/watchdog-bootstrap.mjs | 5 +++++ test/fixtures/watchdog-loader.mjs | 8 ++++++++ test/fixtures/watchdog-operation.mjs | 11 +++++++++++ test/fixtures/watchdog-worker.cjs | 2 ++ test/ssh-handoff.test.ts | 18 ++++++++---------- 5 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 test/fixtures/watchdog-bootstrap.mjs create mode 100644 test/fixtures/watchdog-loader.mjs create mode 100644 test/fixtures/watchdog-operation.mjs create mode 100644 test/fixtures/watchdog-worker.cjs diff --git a/test/fixtures/watchdog-bootstrap.mjs b/test/fixtures/watchdog-bootstrap.mjs new file mode 100644 index 0000000..b712255 --- /dev/null +++ b/test/fixtures/watchdog-bootstrap.mjs @@ -0,0 +1,5 @@ +import { register } from 'node:module'; + +register('./watchdog-loader.mjs', import.meta.url, { + data: JSON.parse(process.env.FIXTURE_WATCHDOG_LOADER), +}); diff --git a/test/fixtures/watchdog-loader.mjs b/test/fixtures/watchdog-loader.mjs new file mode 100644 index 0000000..dfa9384 --- /dev/null +++ b/test/fixtures/watchdog-loader.mjs @@ -0,0 +1,8 @@ +let targetUrl, replacementUrl; +export function initialize(data) { + ({ targetUrl, replacementUrl } = data); +} +export async function resolve(specifier, context, next) { + const resolved = await next(specifier, context); + return resolved.url === targetUrl ? { url: replacementUrl, shortCircuit: true } : resolved; +} diff --git a/test/fixtures/watchdog-operation.mjs b/test/fixtures/watchdog-operation.mjs new file mode 100644 index 0000000..85e683b --- /dev/null +++ b/test/fixtures/watchdog-operation.mjs @@ -0,0 +1,11 @@ +import { fileURLToPath } from 'node:url'; +import { run } from '../../dist/process.js'; + +export const privateMessage = () => ''; +export const handoffCommand = () => {}; +export async function prepareHandoff() { + const worker = fileURLToPath(new URL('./watchdog-worker.cjs', import.meta.url)); + await run(process.execPath, [worker, process.env.FIXTURE_WATCHDOG_PID_FILE], { timeout: 60000 }); + return { value: {}, exitCode: 1 }; +} +export const handoffSend = prepareHandoff; diff --git a/test/fixtures/watchdog-worker.cjs b/test/fixtures/watchdog-worker.cjs new file mode 100644 index 0000000..0f168b3 --- /dev/null +++ b/test/fixtures/watchdog-worker.cjs @@ -0,0 +1,2 @@ +require('node:fs').writeFileSync(process.argv[2], String(process.pid)); +setInterval(() => {}, 1000); diff --git a/test/ssh-handoff.test.ts b/test/ssh-handoff.test.ts index e261272..a1be05f 100644 --- a/test/ssh-handoff.test.ts +++ b/test/ssh-handoff.test.ts @@ -241,18 +241,16 @@ test('private wire input has a deadline before EOF, with bootstrap charged and n test('destination hard watchdog terminates a hung handler and reaps its owned native process group', posix, async t => { const f = await fixture(t), pidFile = join(f.root, 'owned-worker.pid'); - const worker = `require('node:fs').writeFileSync(process.argv[1],String(process.pid));setInterval(()=>{},1000);`; - const processModule = new URL('../dist/process.js', import.meta.url).href; // Replace only the operation with a hung owned-worker stand-in. The actual // wire parser, monotonic budget, watchdog and process signal cleanup run. - const replacement = `import { run } from ${JSON.stringify(processModule)}; -export const privateMessage=()=>'';export const handoffCommand=()=>{}; -export async function prepareHandoff(){await run(process.execPath,['-e',${JSON.stringify(worker)},${JSON.stringify(pidFile)}],{timeout:60000});return {value:{},exitCode:1};} -export const handoffSend=prepareHandoff;`; - const hook = join(f.root, 'watchdog-hook.mjs'), loader = join(f.root, 'watchdog-loader.mjs'); - writeFileSync(hook, `export async function load(url,context,next){if(url.endsWith('/handoff-operations.js'))return {format:'module',shortCircuit:true,source:${JSON.stringify(replacement)}};return next(url,context);}`); - writeFileSync(loader, `import {register} from 'node:module';register(${JSON.stringify(new URL('file://' + hook).href)},import.meta.url);`); - const child = spawn(process.execPath, ['--import', loader, cli, ...wireArgs(6500)], { env: f.destEnv }); + // Paths are structured loader data/process arguments, never generated code. + const loader = fileURLToPath(new URL('./fixtures/watchdog-bootstrap.mjs', import.meta.url)); + const child = spawn(process.execPath, ['--import', loader, cli, ...wireArgs(6500)], { + env: { ...f.destEnv, FIXTURE_WATCHDOG_PID_FILE: pidFile, FIXTURE_WATCHDOG_LOADER: JSON.stringify({ + targetUrl: new URL('../dist/handoff-operations.js', import.meta.url).href, + replacementUrl: new URL('./fixtures/watchdog-operation.mjs', import.meta.url).href, + }) }, + }); child.stdout.resume(); child.stderr.resume(); const timeout = setTimeout(() => child.kill('SIGKILL'), 10000); t.after(() => { clearTimeout(timeout); if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); }); From 827c2477dc3174d7c7c7335ce0d948d0cad680bc Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 11 Oct 2026 12:59:10 +0900 Subject: [PATCH 13/18] test(windows): use Node child launcher and record real SSH checks --- VALIDATION.md | 81 +++++++++++++++++++++++++++++ test/fixtures/windows-native.cs | 15 +++++- test/fixtures/windows-ssh-child.mjs | 10 ++++ test/windows-contract.test.ts | 1 + 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/windows-ssh-child.mjs diff --git a/VALIDATION.md b/VALIDATION.md index b4c3786..8f54557 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -142,6 +142,51 @@ request-count assertions are preserved. This was test isolation, not a runtime failure or a CI rerun treated as an unchanged-head success. Final PR-head CI results are recorded in the PR after the correction. +## 0.4.0 real Linux SSH submission checks — 2026-10-11 KST + +The unpublished development checkpoint `e83fcffa2dbee33de0694e30b6420d83a128fc5e` +was copied explicitly to fresh, private marked test prefixes on two Linux +arm64 hosts. Both ends used that candidate, rather than the public npm package +whose version label is also 0.3.3. Candidate/dependency archive SHA-256 was +`aadfd3630b67d905a8f474e305f972c7bf670a99365c62dd53fa83d1e04764da`; +each destination verified the hash before extraction. No global installation, +profile, service, SSH trust, native configuration or authentication change was +made. SSH used strict host-key checking, batch authentication and no control +socket. Each private destination ledger was explicitly initialized. + +On the first host, Node 24.16.0 / native Codex 0.162.1, the user-authorized +existing session passed saved-thread and unique stable live-writer dry-run +checks. One best-effort correlated send exited 0 with `queued`, +`submitted=true`, an optional native `queueId` and handoff `submitted`. +Observation and automatic ACK remained `unsupported`; a later status query +preserved those facts. A requested voluntary reply was not yet received at +this checkpoint; no transcript or queue polling was used to infer receipt. + +On the second host, Node 24.18.0 / native Codex 0.159.2, the existing saved +session was inactive and dry-run correctly refused with `inactive_writer`. +It was neither resumed nor queued through inactive opt-in. A separately owned +read-only test TUI in an already trusted working directory produced READY, +passed unique live-writer dry-run, and received exactly one candidate SSH +submission. The original terminal displayed its requested identification +response. An explicit operator confirmation bound to the original correlation, +ledger epoch and generation was recorded before any separate wait: confirmation +and status exited 0 with `acknowledged`, `assurance=operator_confirmed`, +`late=false`; a subsequent acknowledged wait exited 0 with `satisfied` and +performed no submission. This is operator attestation, not automated receipt +authentication or native client-ID observation. The test TUI closed and +`inspectWriter` then reported inactive/absent. Its native test record remains; +no existing native record was deleted. + +On both hosts, required `--wait-for delivered` refused before effect with +`evidence_unsupported`, `submitted=false` and retry disallowed. Best-effort +results retained `consumptionConfirmed=false`; neither queue receipt nor +terminal response changed that field. These are real SSH/native ordinary-queue +checks, not Linux observation qualification: Linux native app-server/client-ID +projection and automatic original-sender SSH ACK bootstrap remain unsupported. +Both marked remote prefixes and their test ledgers were removed with ownership +checks, and their absence was verified. Local evidence retains only the test +results; public descriptions omit host/account paths and session identifiers. + ## 0.4.0 integrated SSH review checkpoint — 2026-10-11 KST Independent read-only review used Claude Code 2.1.296 with tools disabled and @@ -297,6 +342,42 @@ included here. Windows native observation, SSH handoff, automated ACK, restart/crash continuity and other native versions/platforms remain outside these successful macOS checks. +### Windows follow-up: alternate test launcher + +Further controls on the same Windows 10 x64 / Node 24.16.0 host separated +launcher behavior without changing policies, permissions, services or production +guards. C#/.NET Framework launching PowerShell 5.1 `-EncodedCommand` reproduced +AccessDenied for both the owned `.cmd` wrapper and a direct Node version command. +The same PowerShell 5.1 encoded command launched by Node succeeded, as did +PowerShell 5.1 `-File`, direct `cmd.exe /d /s /c`, and the already installed +PowerShell 7 alternatives. This identifies a tested launcher-dependent difference; +it does not establish which security product or host setting caused the failure. + +The test-only C# fake SSH now delegates to a fixed Node helper, preserving +PowerShell 5.1, `-NoProfile -NonInteractive -EncodedCommand`, the encoded request, +stdin, stdout/stderr and exit behavior. No execution-policy option or production +fallback was added. The helper's 55-second child limit remains inside the fake +SSH parent's 60-second limit; harness timeout returns 254, not native success. + +An isolated copy of checkpoint `e83fcffa2dbee33de0694e30b6420d83a128fc5e` +with only those three test-file changes passed build/types, the Windows native +contract **9/9**, and `npm run test:windows`: **295 total / 165 passed / zero +failed / 130 skipped**. Unsupported native-platform guards separately passed +3/3. This replaces the failed fixture execution path with a validated alternate +test launcher; the original failure record above remains historical evidence. +It neither qualifies Windows native observation/ACK nor proves a real remote +agent send. The final C# fixture additionally requires the exact production +PowerShell prefix and one base64 token. Three real-host controls passed: exact +version command exits 0, a changed option is refused with exit 2, and trailing +whitespace is refused with exit 2. The C# file SHA-256 was +`b7845190939d4d915c56d1d072f23f5eeac3447a9994791d0b06ea2f0f147c38`. +The final guarded contract repeat and ownership-checked cleanup are reported +in the PR checkpoint. The integrated macOS arm64 / Node 24.16.0 run after these +test-file changes passed build/types and all tests: **337 total / 332 passed / +zero failed / five Windows-only skipped**, using the same exact Python 1.1.0 +reference export. Repository/diff checks passed and audit reported zero +vulnerabilities. No runtime source or package metadata changed in this follow-up. + ## Python 1.1.0 ordinary comparison — 2026-10-11 KST Unpublished 0.4.0 development checkpoint: an isolated `git archive` export of diff --git a/test/fixtures/windows-native.cs b/test/fixtures/windows-native.cs index 0263b89..ac1e0ff 100644 --- a/test/fixtures/windows-native.cs +++ b/test/fixtures/windows-native.cs @@ -64,8 +64,19 @@ public static int Main(string[] args) { if (preflight && modeSsh == "mismatch") { Console.WriteLine("session-peer 1.0.2"); return 0; } if (preflight && modeSsh == "auth") { Console.Error.WriteLine("Permission denied (publickey). SECRET-SENTINEL"); return 255; } if (!preflight && modeSsh == "loss") return 255; - if (!command.StartsWith("powershell.exe ")) throw new Exception("expected_windows_remote_command"); - var start = new ProcessStartInfo("powershell.exe", command.Substring("powershell.exe ".Length)); + const string remotePrefix = "powershell.exe -NoProfile -NonInteractive -EncodedCommand "; + if (!command.StartsWith(remotePrefix, StringComparison.Ordinal)) + throw new Exception("expected_windows_remote_command"); + // Test-only bridge: preserve the production PowerShell command, but + // use Node's native child launcher rather than .NET Framework's launcher. + string fixtureNode = Environment.GetEnvironmentVariable("FIXTURE_NODE"); + string fixtureChild = Environment.GetEnvironmentVariable("FIXTURE_SSH_CHILD"); + if (String.IsNullOrEmpty(fixtureNode) || String.IsNullOrEmpty(fixtureChild)) + throw new Exception("fixture_node_bridge_missing"); + string encodedCommand = command.Substring(remotePrefix.Length); + if (!System.Text.RegularExpressions.Regex.IsMatch(encodedCommand, "^[A-Za-z0-9+/]+={0,2}$")) + throw new Exception("invalid_fixture_encoded_command"); + var start = new ProcessStartInfo(fixtureNode, "\"" + fixtureChild + "\" " + encodedCommand); // A distinct simulated destination environment for discovery tests. These // are fixture controls, not SSH SendEnv/AcceptEnv or application options. string remoteProfile = Environment.GetEnvironmentVariable("FIXTURE_REMOTE_PROFILE"); diff --git a/test/fixtures/windows-ssh-child.mjs b/test/fixtures/windows-ssh-child.mjs new file mode 100644 index 0000000..020480a --- /dev/null +++ b/test/fixtures/windows-ssh-child.mjs @@ -0,0 +1,10 @@ +// Test-only native SSH destination launcher. Base64 is request data, not JavaScript. +// Execute the exact production PowerShell command without a shell intermediary. +import { spawnSync } from 'node:child_process'; +const encoded = process.argv[2]; +if (process.argv.length !== 3 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded ?? '')) { + process.stderr.write('invalid_fixture_encoded_command\n'); process.exit(2); +} +const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], + { stdio: 'inherit', windowsHide: true, timeout: 55_000 }); +process.exit(result.status ?? 254); diff --git a/test/windows-contract.test.ts b/test/windows-contract.test.ts index e6e68a3..b9fcf15 100644 --- a/test/windows-contract.test.ts +++ b/test/windows-contract.test.ts @@ -45,6 +45,7 @@ async function fixture(t: TestContext) { CODEX_THREAD_ID: '', CODEX_SESSION_ID: '', FIXTURE_QUEUE_LOG: join(root, 'queue.jsonl'), FIXTURE_QUEUE_MODE: '', FIXTURE_SSH_LOG: join(root, 'ssh.jsonl'), FIXTURE_SSH_MODE: '', FIXTURE_REMOTE_PROFILE: '', FIXTURE_REMOTE_HOMES: '', + FIXTURE_NODE: process.execPath, FIXTURE_SSH_CHILD: resolve('test/fixtures/windows-ssh-child.mjs'), PATH: root + ';' + (process.env.PATH ?? '') }); const home = join(root, 'selected'); function database(path: string) { From f8197553f79ae5086e65ef6bdd328ffa3629fc8a Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 11 Oct 2026 13:06:40 +0900 Subject: [PATCH 14/18] test(handoff): reduce owner probe fixture startup overhead --- VALIDATION.md | 22 ++++++++++++++++++++++ test/handoff-delivery.test.ts | 11 +++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/VALIDATION.md b/VALIDATION.md index 8f54557..491a77e 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -142,6 +142,28 @@ request-count assertions are preserved. This was test isolation, not a runtime failure or a CI rerun treated as an unchanged-head success. Final PR-head CI results are recorded in the PR after the correction. +## 0.4.0 owner-probe fixture follow-up — 2026-10-11 KST + +CI run `38110140277` at test/docs checkpoint +`60895b46d8758c7427b90bc154414bd4d01487e7` failed the macOS Node 22 +positive-acceptance/ledger-failure regression: it returned +`app_server_owner_changed` before the intended persistence failure. +The other platform jobs passed; the release gate failed, so this run is not +all green. CodeQL run `38110137845` passed with no open branch alerts. +The holder remains alive until test cleanup; no holder expiry was found. +The bounded owner probe had four extra Node starts for its fake lsof/ps tools, +in addition to the production 250ms sample interval. This is a fixture startup +burden consistent with the failure, not proof of its precise timing cause. + +The fixture now uses static POSIX scripts for lsof/ps and keeps owner evidence +as separate raw NUL-delimited data. PID, UID, start identity, changed-owner +checks, real lock holder and cleanup are preserved. Production 800ms probe +budget, 250ms recheck, refusal behavior and regression assertions are unchanged. +No runtime, native qualification or platform-support change is part of this fix. +The focused macOS arm64 / Node 24.16.0 delivery suite passed 16/16, including +the intended persistence failure and changed-owner refusal. Follow-up full +validation and exact-head CI are recorded in PR #109. + ## 0.4.0 real Linux SSH submission checks — 2026-10-11 KST The unpublished development checkpoint `e83fcffa2dbee33de0694e30b6420d83a128fc5e` diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index 3432c5e..ad316cf 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -22,9 +22,12 @@ async function fixture(t:TestContext,mode='inject',version='0.160.1',warm=true){ if(warm){const db=new DatabaseSync(join(home,'thread_history_1.sqlite'));db.exec(schema);db.prepare('INSERT INTO thread_turns VALUES(?,?,?)').run(thread,'turn-fixture','completed');db.prepare('INSERT INTO thread_history_projection_state VALUES(?,?,?)').run(thread,100,100);db.close();} const lock=join(home,'thread-writer-locks',thread+'.lock'),locks=createRequire(import.meta.url).resolve('fs-ext-extra-prebuilt'); const held=spawn(process.execPath,['-e',"const f=require('fs').openSync(process.argv[1],'w',0o600);require(process.argv[2]).flockSync(f,'exnb');console.log('ready');setInterval(()=>{},1000)",lock,locks]);held.stderr.resume();await once(held.stdout,'data');t.after(async()=>{if(held.exitCode===null&&held.signalCode===null){held.kill('SIGKILL');await once(held,'close');}}); - const lsof=join(bin,'lsof');writeFileSync(lsof+'.json',JSON.stringify('p'+held.pid+'\0ccodex-fixture\0u'+process.getuid?.()+'\0'),{mode:0o600}); - writeFileSync(lsof,`#!${process.execPath}\nprocess.stdout.write(JSON.parse(require('fs').readFileSync(__filename+'.json','utf8')));`,{mode:0o700}); - writeFileSync(join(bin,'ps'),`#!${process.execPath}\nconsole.log('Fixture stable start');`,{mode:0o700}); + // The real tools are native executables. Do not spend the bounded owner + // probe's budget on four extra Node startups under parallel CI load. + // Owner data stays in a separate file, never interpolated into shell code. + const lsof=join(bin,'lsof');writeFileSync(lsof+'.data','p'+held.pid+'\0ccodex-fixture\0u'+process.getuid?.()+'\0',{mode:0o600}); + writeFileSync(lsof,'#!/bin/sh\nexec /bin/cat "$0.data"\n',{mode:0o700}); + writeFileSync(join(bin,'ps'),'#!/bin/sh\nprintf "Fixture stable start\\n"\n',{mode:0o700}); const log=join(root,'queue-log-\'"\\\n\u2028\u2029'),nativeLog=join(root,'native-calls.jsonl');writeFileSync(log,'');writeFileSync(nativeLog,'');const codex=join(bin,'codex'); const initializeReady=join(root,'initialize-ready'),initializeRelease=join(root,'initialize-release'); // Runtime configuration is JSON data; no fixture path/mode/version is code. @@ -71,7 +74,7 @@ console.log(JSON.stringify({id:r.id,result:{queuedSubmission:{id:'queue-fixture' }); } function inject(clientId:string){const db=new DatabaseSync(join(home,'thread_history_1.sqlite'));try{db.prepare('INSERT INTO thread_items VALUES(?,?,?,?,?,?)').run(thread,'turn-fixture','after-submit-item',1,JSON.stringify({type:'userMessage',id:'after-submit-item',clientId,content:[{text:'PRIVATE_BODY_SENTINEL'}]}),'userMessage');}finally{db.close();}} - return{root,home,codex,env,calls,nativeCalls,args,invoke,start,queued,initialized,ledger,ledgerFile,pendingWait,inject,foreignOwner:()=>writeFileSync(lsof+'.json',JSON.stringify('p'+held.pid+'\0ccodex-fixture\0u'+((process.getuid?.()??0)+1)+'\0'),{mode:0o600}),releaseInitialize:()=>writeFileSync(initializeRelease,'release')}; + return{root,home,codex,env,calls,nativeCalls,args,invoke,start,queued,initialized,ledger,ledgerFile,pendingWait,inject,foreignOwner:()=>writeFileSync(lsof+'.data','p'+held.pid+'\0ccodex-fixture\0u'+((process.getuid?.()??0)+1)+'\0',{mode:0o600}),releaseInitialize:()=>writeFileSync(initializeRelease,'release')}; } test('scripted fixture paths, modes and versions remain literal data', {skip:process.platform==='win32'}, async t=>{ const payload='\');throw new Error("fixture data executed");//\n\u2028\u2029'; From d896d7a249b06cde69e9ab8f1f6d5e2a604e2bfe Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 11 Oct 2026 17:34:31 +0900 Subject: [PATCH 15/18] test(handoff): verify scoped 0.4.0 acceptance --- PARITY.md | 81 ++++++++----- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- VALIDATION.md | 47 ++++++++ docs/api.md | 22 +++- docs/guide.ja.md | 4 +- docs/guide.ko.md | 4 +- docs/guide.md | 4 +- docs/guide.zh-CN.md | 4 +- test/fixtures/handoff-python-validator.py | 61 ++++++++++ test/fixtures/ssh-codex-lock-holder.cjs | 6 + test/fixtures/ssh-codex-native.cjs | 44 +++++++ test/handoff-cli.test.ts | 41 +++++++ test/handoff-core.test.ts | 25 ++++ test/handoff-cross-runtime.test.ts | 139 ++++++++++++++++++++++ test/ssh-handoff.test.ts | 113 ++++++++++++++++-- 18 files changed, 554 insertions(+), 49 deletions(-) create mode 100644 test/fixtures/handoff-python-validator.py create mode 100644 test/fixtures/ssh-codex-lock-holder.cjs create mode 100644 test/fixtures/ssh-codex-native.cjs create mode 100644 test/handoff-cross-runtime.test.ts diff --git a/PARITY.md b/PARITY.md index d31d316..e549164 100644 --- a/PARITY.md +++ b/PARITY.md @@ -356,7 +356,9 @@ Windows support, WSL/POSIX SSH and TS native Windows SSH are separate paths. ## Handoff v1 design candidate — #69 -**Status: jointly approved, frozen DESIGN v0.2; runtime unimplemented and unshipped.** All four issues +**Design-freeze checkpoint: jointly approved, frozen DESIGN v0.2; runtime was +unimplemented and unshipped at that checkpoint.** The current source implementation +and its limited, unreleased scope are described below. All four issues [#69](https://github.com/abruption/session-peer-ts/issues/69), [#70](https://github.com/abruption/session-peer-ts/issues/70), [#71](https://github.com/abruption/session-peer-ts/issues/71) and @@ -386,7 +388,7 @@ runtime publication and the bounded ordinary comparison. | Axis | Candidate meaning / TS boundary | | --- | --- | | Submission | Preserve native status, target, queueId, submitted and optional-field absence; an observer failure must not pass through a catch that erases known submission evidence. | -| Observation | Opt-in, body-free metadata for an exact native message ID/home/original generation. Queue deletion or turn completion is not receipt ACK. | +| Observation | Opt-in metadata-only output for an exact native message ID/home/original generation. SQLite may evaluate body-bearing cells internally; this is not a blanket no-body-access promise. Queue deletion or turn completion is not receipt ACK. | | Receipt ACK | Receipt-only capability or explicitly recorded operator attestation; never generic reply parsing, a new model prompt, wake or permission to act. | | Wait | Separate immutable wait operation/results; a late receipt can add current evidence without changing the original timeout result. | | Retry | Native local/SSH replay remains forbidden. Sender fences do not implement receiver dedup or a universal exactly-once guarantee. | @@ -394,8 +396,9 @@ runtime publication and the bounded ordinary comparison. The candidate adds a nested `handoff` object only in opted-in mode. Its proposed init/prepare/status/wait/confirm/ack commands, epoch IDs, private collector, -30-second per-destination budget and 5-second cleanup reserve are **not current -CLI capabilities**. Budgets 1–5 seconds are syntactically valid in the proposal +30-second per-destination budget and 5-second cleanup reserve were **not CLI +capabilities at the design-freeze checkpoint**. The source subset is described +in the unreleased implementation sections below. Budgets 1–5 seconds are syntactically valid in the proposal but refuse before effect. Source-streamed SSH without a compatible receipt handler remains ACK-unsupported under this candidate. No transcript/body-returning observer or fallback after a possible submission is authorized. @@ -450,8 +453,9 @@ authorize merge, implementation, live effects, operational changes or publicatio Python's exact-candidate documentation tests and CodeQL passed; its optional Control dependency audit and required release gate failed. That CI status is separate from this static design review. No bypass, dependency repair or claim of -a fully passing upstream PR is made here. Runtime producer/collector/bootstrap, -platform cleanup, metadata-only observation and live acceptance remain future gates. +a fully passing upstream PR is made by this historical review. At that checkpoint, +runtime producer/collector/bootstrap, platform cleanup, metadata-only observation +and live acceptance were future gates; current implementation evidence appears below. `handoff-contract.test.ts` checks exact pins, synthetic status and selected golden safety invariants. It does not implement or test a collector, native receipt, @@ -1035,9 +1039,10 @@ historical Python 1.0.3 design baseline and the historical Python 1.0.2 ordinary oracle. The current candidate's ordinary source pin is recorded separately in `python-reference.pin.json`. The Python release's experimental local Claude receipt path does not establish -automated ACK support in the TS implementation. Cross-runtime execution, -receipt bootstrap, original generation and platform acceptance remain #69–72 -implementation gates. This design-identity record alone does not repin a runtime +automated ACK support in the TS implementation. The pure-validator comparisons +below do not establish native execution parity. +Receipt bootstrap and additional native platforms are deferred to #130–#132; +original binding/generation guards remain mandatory in the included scope. This design-identity record alone does not repin a runtime reference or establish runtime conformance. @@ -1048,10 +1053,17 @@ strict protocol helpers now implement prepare, at-most-once sender fencing, status, bounded wait records and explicit receipt/operator-confirmation validation. [CLI details](docs/api.md#unreleased-opt-in-handoff-cli--040-work) separate current source support from the published 0.3.5 runtime. Automated ACK -bootstrap, Windows private ACL support and original-sender SSH coordination are -still implementation gates. Fixture passes are not live receipt/injection -qualification. Default output and the historical Python runtime parity pin stay -unchanged; no future Python runtime is claimed shipped. +bootstrap, Windows private ACL support and original-sender SSH collector +coordination are deferred extensions (#131/#132), not prerequisites of the +user-approved limited 0.4.0 scope. Fixture passes are not live receipt/injection +qualification. Default ordinary output stays unchanged. The ordinary conformance oracle is +published Python 1.1.0; experimental receipt implementation parity is not claimed. +`handoff-cross-runtime.test.ts` also invokes the pinned published Python pure +validators/projection helpers and the TS validators on 45 data cases: all seven +shared states, schema/privacy/ACK/retry negatives, unsupported observation and +agent-specific native field absence. Standalone and canonical module hashes are +pinned. No native CLI, ledger/collector, model or network is invoked by that +comparison; equal validator decisions are not end-to-end handoff parity. The journal encrypts its private message-binding digest with a separate mode-0600 `ledger.key`; missing/changed/untrusted keys and plaintext journals @@ -1111,8 +1123,9 @@ once. Strict request-aware native facts survive complete failed-transport responses; unsolicited/malformed handoff cannot create ACK. Source deadlines are not renewed; destination remaining-duration watchdogs reap owned groups. Transit delay cannot be synchronized across independent monotonic clocks. -Actual destination-handler/recording-socket fixtures use fake SSH; real remote -network/native-agent qualification is not implied. See [SSH details](docs/api.md#unreleased-destination-side-ssh-handoff). +Destination-handler/recording-socket fixtures use fake SSH. Separate real +KR/US Linux checks established ordinary native queue acceptance and a US manual +operator receipt; they did not qualify native client-ID observation on Linux. See [SSH details](docs/api.md#unreleased-destination-side-ssh-handoff). Open capabilities include original-sender SSH collector coordination, Windows ACL/native observation and automated receipt producer/collector bootstrap. Other @@ -1141,19 +1154,29 @@ range, Linux/Windows qualification, live delivery or ACK follows from these queue-only fixtures. Initial harness failures are recorded separately and were never retried with an already-attempted intent. -## Unreleased local observation integration — #72 +## 0.4.0 candidate release scope -[Source-only runtime details](docs/api.md#unreleased-local-delivery-observation--72) -cover bounded one-shot/explicit-wait observation, native CID queueing, original -writer generation checks, per-operation terminal waits and positive native fact -preservation. Generated schema/native queue mechanics are distinct from -synthetic metadata/wait integration and live consumption evidence. The combined -CLI path is restricted to Codex 0.160.1/macOS arm64; POSIX SQL fixtures alone do -not qualify other native platforms. No transcript is read, no ACK is inferred -and no post-attempt fallback/resend occurs. +On 2026-10-11 the user approved keeping #69/#70/#71/#72 together in 0.4.0, +with the following explicit qualification boundaries. This is source-candidate +scope, not a publication claim or blanket Python runtime parity. -Open release gates include real local/platform qualification, SSH originating -ledger coordination, Windows ACL support, receipt producer/collector bootstrap, -restart observation and bounded cancellation of all initial setup operations. -#69/#70/#71/#72 remain together in 0.4.0 and open. Ordinary runtime parity and -published version markers are unchanged. +| Capability | Included candidate scope | Deferred extension | +| --- | --- | --- | +| Native client-ID injection observation | Exact Codex 0.160.1/0.162.1 on macOS arm64; warm original metadata store and unchanged writer generation | Linux [#130](https://github.com/abruption/session-peer-ts/issues/130), Windows [#131](https://github.com/abruption/session-peer-ts/issues/131); no inherited profile support | +| Explicit receipt ACK | Operator attestation on supported POSIX ledgers, with original binding/order validated; `operator_confirmed` is not model authentication | Voluntary receipt producer, authenticated collector/bootstrap and original-sender SSH collector [#132](https://github.com/abruption/session-peer-ts/issues/132) | +| Windows handoff storage | Required capability reports unsupported; ordinary published Windows send/SSH are separate | Private DACL/reparse storage and owned cleanup qualification [#131](https://github.com/abruption/session-peer-ts/issues/131) | +| SSH handoff | Explicit same-version destination preparation/ledger, strict binding and one send; unsupported goals refuse before effect | Real remote native client-ID observation remains unqualified; actual Linux queues/manual ACK do not establish it | + +An automatic receipt collector, if later implemented, still records only a +receiver's explicit receipt. Visible replies, arbitrary message text, queue +deletions and turn completion never authorize ACK promotion. The frozen design +bytes and historical approval metadata remain unchanged. SQLite projections may +evaluate body-bearing cells internally; bodies/errors do not cross into JS or +ordinary output. Required unsupported goals refuse before effect; after any +possible submission no fallback/resend is permitted. + +The four development issues stay open until their scoped acceptance and human +PR review/merge are complete. Platform/collector follow-ups are not completion +conditions for this limited release. Companion skill candidate guidance and +version/profile rollout is tracked in [companion #31](https://github.com/abruption/session-peer-skill/issues/31); an unreviewed new runtime +version is not automatically compatible with the published skill metadata. diff --git a/README.ja.md b/README.ja.md index a3e3183..20c54ed 100644 --- a/README.ja.md +++ b/README.ja.md @@ -112,7 +112,7 @@ session-peer --version - [開発・貢献](CONTRIBUTING.md). - [リリース手順](RELEASING.md). -- [0.4.0 handoff 設計(共同合意・未実装)](PARITY.md#handoff-v1-design-candidate--69). +- [0.4.0 handoff 設計と未公開実装の範囲](PARITY.md#handoff-v1-design-candidate--69). - [開発中の private handoff CLI と制限](docs/api.md#unreleased-opt-in-handoff-cli--040-work). diff --git a/README.ko.md b/README.ko.md index 6247269..fee1879 100644 --- a/README.ko.md +++ b/README.ko.md @@ -126,7 +126,7 @@ session-peer --version - [개발·기여 안내](CONTRIBUTING.md). - [릴리스 절차](RELEASING.md). -- [0.4.0 handoff 설계(공동 확정·미구현)](PARITY.md#handoff-v1-design-candidate--69). +- [0.4.0 handoff 설계와 미발행 구현 범위](PARITY.md#handoff-v1-design-candidate--69). - [개발 중인 private handoff CLI와 지원 한계](docs/api.md#unreleased-opt-in-handoff-cli--040-work). diff --git a/README.md b/README.md index 1cb8c7e..86a2080 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ does not install a skill. Review its pinned-source instructions in the guide. - [Development and contributions](CONTRIBUTING.md). - [Release process](RELEASING.md). -- [0.4.0 handoff design (jointly agreed, unimplemented)](PARITY.md#handoff-v1-design-candidate--69). +- [0.4.0 handoff design and unreleased implementation scope](PARITY.md#handoff-v1-design-candidate--69). - [Unreleased private handoff CLI and limitations](docs/api.md#unreleased-opt-in-handoff-cli--040-work). diff --git a/README.zh-CN.md b/README.zh-CN.md index 6effd7b..e16f293 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -108,7 +108,7 @@ session-peer --version - [开发与贡献](CONTRIBUTING.md). - [发布流程](RELEASING.md). -- [0.4.0 handoff 设计(联合确认、未实现)](PARITY.md#handoff-v1-design-candidate--69). +- [0.4.0 handoff 设计与未发布实现范围](PARITY.md#handoff-v1-design-candidate--69). - [开发中的 private handoff CLI 与限制](docs/api.md#unreleased-opt-in-handoff-cli--040-work). diff --git a/VALIDATION.md b/VALIDATION.md index 491a77e..e8e20cc 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -142,6 +142,53 @@ request-count assertions are preserved. This was test isolation, not a runtime failure or a CI rerun treated as an unchanged-head success. Final PR-head CI results are recorded in the PR after the correction. +## 0.4.0 scoped acceptance follow-up — 2026-10-11 KST + +The user approved qualified macOS arm64 Codex observation and explicit operator +confirmation as the limited 0.4.0 scope. #69/#70/#71/#72 remain together. Linux +client-ID observation #130, Windows observation/private handoff storage #131 +and voluntary receipt producer/authenticated collector bootstrap #132 are +Future extensions, not mandatory platforms/collectors for this release. Required +unsupported goals still refuse before effect; ordinary queues/manual receipts +do not qualify native observation or automated receipt authentication. + +The source follow-up updates four-language README/guide scope, API and PARITY +without changing runtime or the frozen contract/pin bytes. Dated 0.3.3 preparation +evidence is retained; installation examples and explicitly unreleased handoff +sections are distinguished. SQLite may evaluate body-bearing cells internally; +body/error values are not returned to JavaScript or ordinary output. + +Pure-validator cross-runtime checks invoke exact published Python 1.1.0 +`8ca77cf31254c8287a2593fe484bb30a7dba2727` standalone and canonical handoff +module hashes, never its main/ledger/native CLI/collector. Five tests/45 data +cases passed: 19 accepted examples spanning all seven states, 12 schema/privacy/ +ACK/retry negatives, four Python unsupported-observation state projections, +four Claude optional-absence/native-profile cases and six Codex native-profile +cases. Python UUID reason names remain distinct from TS. The first launch lacked +the reference environment (ENOENT), and the next run exposed an incorrect +expected Python UUID reason; both were corrected before the final pass. This is +actual pure-function comparison, not native delivery/receipt/runtime parity. + +Focused ACK core/CLI checks passed 55/55 after adding wrong correlation/epoch/ +generation and malformed/private-input negatives, byte-identical ledger +preservation and positive/duplicate operator confirmation with no native queue. +The companion guidance candidate is tracked in session-peer-skill #31. Its named +session was inactive at dry-run, so no message was queued and no wake/resume was +used. Existing skill metadata/immutable pin are not upgraded by this work. + +The SSH handoff suite passed 14/14 on macOS arm64. Three destination-side Codex +fixtures cover exact-ID injection, no injection before deadline and queue-response +loss, with original preparation binding and at most one queue RPC. These use fake +SSH and a scripted native peer, not a new remote/native qualification. The request +budget is 15 seconds; the harness has a separate 20-second cleanup limit. + +Integrated `npm test` (including build and type checks) passed 347 tests: +342 pass, zero fail, five Windows-only skips on macOS arm64 / Node 24.16.0. +Packaging and exact-head CI are recorded in PR #109. Tests are separate from the +earlier KR/US real ordinary queues and local native qualification. Issue checklists +and publication approval are separate gates; no merge/release/tag operation is +included here. + ## 0.4.0 owner-probe fixture follow-up — 2026-10-11 KST CI run `38110140277` at test/docs checkpoint diff --git a/docs/api.md b/docs/api.md index 88b843d..3d59f36 100644 --- a/docs/api.md +++ b/docs/api.md @@ -197,7 +197,9 @@ turn/start or full item/turn notification APIs. SQLite itself projects only `clientId`, thread/turn IDs and an allowlisted status. Recent row keys are bounded to 256; cells over 256 KiB are excluded before JSON extraction. Temporary SQLite storage is memory-only and materializes keys/bounded scalars. Body/error columns -are never returned to JavaScript. Only an already-warm original store is used; +are never returned to JavaScript. SQLite internally evaluates body-bearing +`item_json` cells to project metadata; this is not a guarantee that message-body +bytes are never accessed inside the SQLite engine. Only an already-warm original store is used; there is no migration, warm-up/backfill, new model turn or wake. Existing WAL/SHM must already be trusted; SQLite read-only readers can participate in transient SHM read locks. DB/WAL bytes and sidecar creation remain unchanged in fixtures. @@ -239,8 +241,11 @@ operator attestation, not automatic token-possession receipt collection. Dedicat confirmed correlated socket delivery and best-effort ACK remaining unsupported; a required unsupported ACK refused before posting. -Linux/Windows native queue/observation, Windows private ledger and automated -receipt collector/bootstrap remain unsupported. Other native versions are not +The qualified native app-server queue/observation route is unsupported on +Linux/Windows. Ordinary Codex queue submission and explicit operator confirmation +on supported POSIX ledgers are separate capabilities, demonstrated by the KR/US +checks in VALIDATION. Windows private ledger and automated receipt +collector/bootstrap remain unsupported. Other native versions are not inferred from a minor range. The original-sender SSH collector is not provided. No package/version publication or completed milestone is implied by these source-only checks. Exact evidence and limits are in [VALIDATION](../VALIDATION.md). @@ -248,3 +253,14 @@ source-only checks. Exact evidence and limits are in [VALIDATION](../VALIDATION. + +## 0.4.0 candidate scope + +The release candidate includes the qualified macOS arm64 observer and explicit +`operator_confirmed` receipts on supported POSIX ledgers. Other platforms do not +inherit that qualification. Linux observation [#130](https://github.com/abruption/session-peer-ts/issues/130), +Windows observation/private storage [#131](https://github.com/abruption/session-peer-ts/issues/131) and +voluntary receipt producer/authenticated collector bootstrap [#132](https://github.com/abruption/session-peer-ts/issues/132) +are future extensions. An automatic collector would still require an explicit +receiver receipt; it must never infer ACK from messages or turn completion. +Unsupported required goals refuse before effect; no post-attempt fallback or resend. diff --git a/docs/guide.ja.md b/docs/guide.ja.md index d3edd37..a2b4253 100644 --- a/docs/guide.ja.md +++ b/docs/guide.ja.md @@ -25,7 +25,7 @@ [移行の詳細](../PARITY.md#ordinary-cli-migration-notes--117)と [Windows の検証記録](../VALIDATION.md#windows-ssh-destination-f5--2026-10-07-kst)を参照してください。上の日付付き移行比較は当時の公開版を対象にしています。未公開の 0.4.0 ソース候補は開発時の比較基準を Python 1.1.0 に更新しますが、公開 npm 0.3.3 の従来の 1.0.2 表示は変わりません。Python 1.1.0 には実験的なローカル Claude の受領確認専用 ACK 経路があります。これは TS 候補の自動 ACK、元の送信者側の SSH 受領確認収集、Windows handoff 対応を意味しません。どちらの基準もエンドツーエンドの消費を保証せず、Side Session は実験段階です。Relay/MCP は比較対象外です。 -[0.4.0 handoff 設計(共同合意・未実装)](../PARITY.md#handoff-v1-design-candidate--69) +[0.4.0 handoff 設計と未公開実装の範囲](../PARITY.md#handoff-v1-design-candidate--69) ## 目次 @@ -350,3 +350,5 @@ npm `--ignore-scripts` は利用でき、postinstall は Skills CLI を呼び出 未公開のソース候補では、macOS arm64 の厳密な Codex 0.160.1/0.162.1 と既存のメタデータ保存先に限って配送を観測します。検証根拠が保存された提出は、別の `handoff wait` から読み取り専用で観測を再開でき、native 提出は繰り返しません。各待機は自分の期限だけで判定し、他の待機と過去の終了結果を保持します。専用の新規 Codex 0.162.1・Claude Code 2.1.296 セッションによる実機根拠と合成テストを区別します。queued/posted、注入、ターン終了、通常の返信は受領確認 ACK ではありません。自動 ACK と Windows handoff は未対応です。 [Observation](api.md#unreleased-local-delivery-observation--72) + +Linux・Windows の native 注入観測は未対応です。対応する POSIX ledger では、運用者が明示的に確認した受領を `operator_confirmed` として記録できますが、自動 ACK 収集ではありません。0.4.0 候補の範囲は、検証済みの macOS 観測と明示的な運用者確認です。Linux・Windows 観測と receipt bootstrap は [#130](https://github.com/abruption/session-peer-ts/issues/130)、[#131](https://github.com/abruption/session-peer-ts/issues/131)、[#132](https://github.com/abruption/session-peer-ts/issues/132) の後続作業です。 diff --git a/docs/guide.ko.md b/docs/guide.ko.md index dc4223a..2a80200 100644 --- a/docs/guide.ko.md +++ b/docs/guide.ko.md @@ -25,7 +25,7 @@ [마이그레이션 상세](../PARITY.md#ordinary-cli-migration-notes--117)와 [Windows 검증 기록](../VALIDATION.md#windows-ssh-destination-f5--2026-10-07-kst)을 참고하세요. 위 날짜별 마이그레이션 비교는 당시 공개 버전을 기준으로 합니다. 미발행 0.4.0 소스 후보는 개발 비교 기준을 Python 1.1.0으로 갱신하며, 공개 npm 0.3.3의 기존 1.0.2 표시는 바뀌지 않습니다. Python 1.1.0에는 실험적인 로컬 Claude 수신 확인 전용 ACK 경로가 있습니다. 이것이 TS 후보의 자동 ACK·원래 발신자의 SSH 수신 확인 수집기·Windows handoff 지원을 뜻하지는 않습니다. 어느 기준도 종단 간 소비를 보장하지 않으며 Side Session은 실험 범위입니다. Relay/MCP는 이 비교에서 제외합니다. -[0.4.0 handoff 설계(공동 확정·미구현)](../PARITY.md#handoff-v1-design-candidate--69) +[0.4.0 handoff 설계와 미발행 구현 범위](../PARITY.md#handoff-v1-design-candidate--69) ## 목차 @@ -603,3 +603,5 @@ npm `--ignore-scripts`로 설치해도 되며, 설치 후 Skills CLI를 호출 미발행 소스 후보의 전송 관찰은 macOS arm64의 정확한 Codex 0.160.1/0.162.1과 이미 생성된 메타데이터 저장소에 한정됩니다. 검증 근거가 보존된 전송은 별도 `handoff wait`로 읽기 전용 관찰을 다시 시작할 수 있으며, native 전송을 반복하지 않습니다. 각 대기는 자신의 마감 시각으로만 판단하고 다른 대기와 기존 종료 결과를 보존합니다. 새로 만든 Codex 0.162.1·Claude Code 2.1.296 테스트 세션의 실기 근거와 합성 테스트는 구분합니다. queued/posted, 실제 주입, 턴 종료, 일반 응답은 수령 확인 ACK와 다릅니다. 자동 ACK와 Windows handoff는 미지원입니다. [Observation](api.md#unreleased-local-delivery-observation--72) + +Linux·Windows의 native 주입 관측은 미지원입니다. 지원되는 POSIX ledger에서는 운영자가 명시적으로 확인한 수령 사실을 `operator_confirmed`로 기록할 수 있지만, 자동 ACK 수집은 아닙니다. 0.4.0 후보 범위는 검증된 macOS 관측과 명시적 운영자 확인입니다. Linux·Windows 관측과 receipt bootstrap은 각각 [#130](https://github.com/abruption/session-peer-ts/issues/130), [#131](https://github.com/abruption/session-peer-ts/issues/131), [#132](https://github.com/abruption/session-peer-ts/issues/132)에서 후속 작업으로 관리합니다. diff --git a/docs/guide.md b/docs/guide.md index 01c7d27..e14305f 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -59,7 +59,7 @@ candidate. Neither baseline provides an end-to-end consumption guarantee; Side Session remains experimental. Relay/MCP are outside this comparison. -[0.4.0 handoff design (jointly agreed, unimplemented)](../PARITY.md#handoff-v1-design-candidate--69) +[0.4.0 handoff design and unreleased implementation scope](../PARITY.md#handoff-v1-design-candidate--69) ## Contents @@ -630,3 +630,5 @@ Unreleased 0.4.0 source adds an opt-in private POSIX handoff ledger. Published 0 The source candidate supports exact Codex 0.160.1/0.162.1 on macOS arm64 with an already-warm metadata store. Retained qualified intents can start an independent read-only `handoff wait`; no native submission is repeated. Each operation owns its cutoff and preserves other pending or terminal waits. Dedicated new 0.162.1 Codex and Claude Code 2.1.296 sessions supplied local evidence; synthetic tests remain separate. Queued/posted, injection, turn completion and plain reply are distinct from receipt ACK. Automated ACK and Windows handoff remain unsupported. [Observation](api.md#unreleased-local-delivery-observation--72) + +Native delivery observation is unsupported on Linux and Windows. The supported POSIX ledger can record an explicitly verified receipt as `operator_confirmed`; this is not automatic ACK collection. The 0.4.0 candidate scope is qualified macOS observation and explicit operator confirmation. Linux, Windows and receipt-bootstrap extensions are tracked separately in [#130](https://github.com/abruption/session-peer-ts/issues/130), [#131](https://github.com/abruption/session-peer-ts/issues/131) and [#132](https://github.com/abruption/session-peer-ts/issues/132). diff --git a/docs/guide.zh-CN.md b/docs/guide.zh-CN.md index b4b3ab2..04a2414 100644 --- a/docs/guide.zh-CN.md +++ b/docs/guide.zh-CN.md @@ -25,7 +25,7 @@ 请参阅[迁移详情](../PARITY.md#ordinary-cli-migration-notes--117)和 [Windows 验证记录](../VALIDATION.md#windows-ssh-destination-f5--2026-10-07-kst)。上述带日期的迁移比较针对当时的公开版本。未发布的 0.4.0 源码候选将开发比较基准更新为 Python 1.1.0;公开 npm 0.3.3 原有的 1.0.2 标识保持不变。Python 1.1.0 包含实验性的本地 Claude 收件确认专用 ACK 路径。这不表示 TS 候选支持自动 ACK、原发送方的 SSH 收件确认收集器或 Windows handoff。两种基准均不保证端到端消费,Side Session 仍属实验功能。Relay/MCP 不在比较范围内。 -[0.4.0 handoff 设计(联合确认、未实现)](../PARITY.md#handoff-v1-design-candidate--69) +[0.4.0 handoff 设计与未发布实现范围](../PARITY.md#handoff-v1-design-candidate--69) ## 目录 @@ -334,3 +334,5 @@ postinstall 不会调用 Skills CLI,安装此技能不会覆盖 Python 技能 未发布的源码候选仅支持 macOS arm64 上的精确 Codex 0.160.1/0.162.1 和已有的元数据存储。保留验证依据的提交可通过独立的 `handoff wait` 继续只读观察,不会再次进行 native 提交。每项等待只按自己的截止时间判断,并保留其他等待及过去的终态。新建专用 Codex 0.162.1、Claude Code 2.1.296 会话的实机依据与合成测试分开记录。queued/posted、注入、轮次结束及普通回复均不等于收件确认 ACK。自动 ACK 和 Windows handoff 仍不支持。 [Observation](api.md#unreleased-local-delivery-observation--72) + +Linux、Windows 的 native 注入观察尚不支持。支持的 POSIX ledger 可将操作人员明确确认的收件事实记录为 `operator_confirmed`,但这不是自动 ACK 收集。0.4.0 候选范围是已验证的 macOS 观察和操作人员的明确确认。Linux、Windows 观察及 receipt bootstrap 分别作为 [#130](https://github.com/abruption/session-peer-ts/issues/130)、[#131](https://github.com/abruption/session-peer-ts/issues/131)、[#132](https://github.com/abruption/session-peer-ts/issues/132) 的后续工作。 diff --git a/test/fixtures/handoff-python-validator.py b/test/fixtures/handoff-python-validator.py new file mode 100644 index 0000000..c274f5d --- /dev/null +++ b/test/fixtures/handoff-python-validator.py @@ -0,0 +1,61 @@ +"""Bounded, no-effect adapter to the pinned published Python validators. + +Only imports the reviewed standalone source and calls pure validation/projection +helpers. It never invokes main, a ledger, a native CLI, a collector or a network. +Inputs are JSON data on stdin; neither inputs nor generated code are executed. +""" +import hashlib +import importlib.util +import json +from pathlib import Path +import sys + +EXPECTED = { + "session_peer.py": "d36d83f3d6f59eb20c958aac530ca6d5d988a99f6ceabf2704c7c51db1fe524f", + "session_peer_core/handoff.py": "138115b9cc8ee73c77ad1099625f2b6cc2e0a6bd343e864a6b5219328735a808", +} + + +def main(): + root = Path(sys.argv[1]) + for relative, digest in EXPECTED.items(): + if hashlib.sha256((root / relative).read_bytes()).hexdigest() != digest: + raise ValueError("pinned_python_source_mismatch") + raw = sys.stdin.buffer.read(1048577) + if len(raw) > 1048576: + raise ValueError("fixture_input_too_large") + cases = json.loads(raw.decode("utf-8", errors="strict")) + if type(cases) is not list or len(cases) > 256: + raise ValueError("fixture_case_limit") + spec = importlib.util.spec_from_file_location("pinned_handoff_reference", root / "session_peer.py") + if spec is None or spec.loader is None: + raise ValueError("pinned_python_import_unavailable") + peer = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = peer + spec.loader.exec_module(peer) + if peer.__version__ != "1.1.0": + raise ValueError("pinned_python_version_mismatch") + results = [] + for case in cases: + if type(case) is not dict or set(case) not in ({"kind", "value"}, {"kind", "value", "record"}): + raise ValueError("fixture_case_shape") + try: + kind = case["kind"] + if kind == "public": + value = peer.handoff_validate_public(case["value"]) + elif kind == "native": + peer.handoff_validate_native(case["value"], case["record"]) + value = case["value"] + elif kind == "projection": + value = peer.handoff_public(case["value"], case["record"]) + else: + raise ValueError("fixture_case_kind") + results.append({"valid": True, "value": value}) + except peer.CcPeerError as error: + results.append({"valid": False, "reason": error.details.get("reason")}) + json.dump({"version": peer.__version__, "sourceHashes": EXPECTED, "results": results}, sys.stdout, ensure_ascii=True, allow_nan=False) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/test/fixtures/ssh-codex-lock-holder.cjs b/test/fixtures/ssh-codex-lock-holder.cjs new file mode 100644 index 0000000..11b630d --- /dev/null +++ b/test/fixtures/ssh-codex-lock-holder.cjs @@ -0,0 +1,6 @@ +'use strict'; +// All fixture paths arrive as arguments, never executable source text. +const fd = require('node:fs').openSync(process.argv[2], 'w', 0o600); +require(process.argv[3]).flockSync(fd, 'exnb'); +console.log('ready'); +setInterval(() => {}, 1000); diff --git a/test/fixtures/ssh-codex-native.cjs b/test/fixtures/ssh-codex-native.cjs new file mode 100644 index 0000000..1c7d893 --- /dev/null +++ b/test/fixtures/ssh-codex-native.cjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +'use strict'; +// Runtime fixture configuration is JSON data next to this copied executable. +const fs = require('node:fs'), path = require('node:path'); +const config = JSON.parse(fs.readFileSync(__filename + '.json', 'utf8')); +fs.appendFileSync(config.nativeLog, JSON.stringify({ command: process.argv[2] ?? null }) + '\n'); +if (process.argv[2] === '--version') { + console.log('codex-cli ' + config.version); + process.exit(0); +} +if (process.argv[2] === 'queue') { + fs.appendFileSync(config.queueLog, JSON.stringify({ method: 'legacy' }) + '\n'); + console.log('Queued message legacy-1 for thread ' + process.argv[4] + '.'); + process.exit(0); +} +const { DatabaseSync } = require('node:sqlite'); +globalThis.keptDatabases = [ + new DatabaseSync(path.join(process.env.CODEX_HOME, 'state_5.sqlite'), { readOnly: true }), + new DatabaseSync(path.join(process.env.CODEX_SQLITE_HOME, 'queue_1.sqlite')), +]; +require('node:readline').createInterface({ input: process.stdin }).on('line', line => { + const request = JSON.parse(line); + if (request.method === 'initialize') { + console.log(JSON.stringify({ id: request.id, result: { + codexHome: process.env.CODEX_HOME, platformFamily: 'unix', platformOs: 'macos', userAgent: 'fixture', + } })); + } else if (request.method === 'thread/queue/add') { + fs.appendFileSync(config.queueLog, JSON.stringify({ method: request.method, + clientId: request.params.clientUserMessageId, threadId: request.params.threadId, + codexHome: process.env.CODEX_HOME }) + '\n'); + if (config.mode === 'loss') { process.exit(0); return; } + if (config.mode === 'inject') { + const db = new DatabaseSync(path.join(process.env.CODEX_HOME, 'thread_history_1.sqlite')); + db.prepare('INSERT INTO thread_items VALUES(?,?,?,?,?,?)').run(request.params.threadId, + 'ssh-turn-fixture', 'native-item-not-client-id', 1, JSON.stringify({ type: 'userMessage', + id: 'native-item-not-client-id', clientId: request.params.clientUserMessageId, + content: [{ text: 'PRIVATE_SSH_CODEX_BODY_SENTINEL' }] }), 'userMessage'); + db.close(); + } + console.log(JSON.stringify({ id: request.id, result: { queuedSubmission: { + id: 'ssh-queue-fixture', clientUserMessageId: request.params.clientUserMessageId, input: request.params.input, + } } })); + } +}); diff --git a/test/handoff-cli.test.ts b/test/handoff-cli.test.ts index ab5df0d..ad8106f 100644 --- a/test/handoff-cli.test.ts +++ b/test/handoff-cli.test.ts @@ -4,9 +4,11 @@ import { spawn } from 'node:child_process'; import { once } from 'node:events'; import { createServer } from 'node:net'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync, symlinkSync, existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { HandoffLedger, payloadDigest } from '../dist/handoff-ledger.js'; const cli = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); async function fixture(t: TestContext) { const path = realpathSync(mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-hc-'))); @@ -50,6 +52,45 @@ test('syntax/private inputs reject without minting authority or submitting', { s assert.equal(f.messages.length,0); }); +test('operator confirmation accepts only a matching private frame and never queues or infers injection', { skip: process.platform === 'win32' }, async t => { + const f = await fixture(t), path = join(f.path, 'state', 'handoff'); + // Seed known submission evidence in an isolated ledger; no native send occurs. + const initialized = await f.invoke(['handoff', 'init']); assert.equal(initialized.code, 0); + const ledger = new HandoffLedger(path); + const binding = { agent: 'codex' as const, destination: 'fixture', target: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + home: f.path, writerIdentity: 'fixture-writer', generation: 'fixture-generation', payloadDigest: payloadDigest('fixture body') }; + const prepared = ledger.prepare(binding); ledger.commitEffect(prepared.correlationId, binding); + const submitted = ledger.recordSubmission(prepared.correlationId, 'submitted'); + const proof = { schemaVersion: 1, ledgerEpoch: submitted.ledgerEpoch, correlationId: submitted.correlationId, + targetGeneration: submitted.targetGeneration, confirmed: true }; + const raw = JSON.stringify(proof), file = join(path, 'ledger.json'), before = readFileSync(file); + const command = ['handoff', 'confirm', '--receipt', '-']; + for (const [input, error] of [ + [JSON.stringify({ ...proof, correlationId: randomUUID() }), 'handoff_id_unknown'], + [JSON.stringify({ ...proof, ledgerEpoch: randomUUID() }), 'confirmation_binding_invalid'], + [JSON.stringify({ ...proof, targetGeneration: 'successor' }), 'confirmation_binding_invalid'], + [JSON.stringify({ ...proof, confirmed: false }), 'invalid_confirmation'], + [JSON.stringify({ ...proof, body: 'a model reply is not an ACK' }), 'invalid_handoff'], + [raw.replace('"confirmed":true', '"confirmed":true,"confirmed":true'), 'invalid_handoff_json'], + ['\ufeff' + raw, 'invalid_handoff_json'], + [raw + ' '.repeat(4097 - Buffer.byteLength(raw)), 'input_too_large'], + ] as const) { + const result = await f.invoke(command, input); + assert.notEqual(result.code, 0); assert.equal(result.value.ok, false); assert.equal(result.value.error, error); + assert.equal(result.value.handoff, undefined); assert.equal(result.value.submitted, undefined); + assert.deepEqual(readFileSync(file), before); assert.deepEqual(ledger.status(submitted.correlationId), { handoff: submitted }); + assert.equal(f.messages.length, 0); + } + const argv = await f.invoke(['handoff', 'confirm', '--receipt', raw]); + assert.equal(argv.code, 2); assert.equal(argv.value.error, 'private_receipt_stdin_required'); assert.deepEqual(readFileSync(file), before); + const accepted = await f.invoke(command, raw); + assert.equal(accepted.code, 0); assert.equal(accepted.value.handoff.ack.assurance, 'operator_confirmed'); + assert.equal(accepted.value.handoff.observation.injectionObserved, false); assert.equal(accepted.value.handoff.submission.status, 'submitted'); + const confirmed = readFileSync(file), duplicate = await f.invoke(command, raw); + assert.equal(duplicate.code, 0); assert.deepEqual(duplicate.value.handoff, accepted.value.handoff); + assert.deepEqual(readFileSync(file), confirmed); assert.equal(f.messages.length, 0); +}); + test('init refuses a symlinked parent before creating a directory through it', { skip: process.platform === 'win32' }, async t => { const f = await fixture(t); const external = join(f.path, 'external'); mkdirSync(external); symlinkSync(external, join(f.path,'link')); const child = spawn(process.execPath, [cli,'handoff','init','--json'], {env:{...process.env,SESSION_PEER_HANDOFF_HOME:join(f.path,'link','new','handoff')}}); let stdout='';child.stdout.on('data',c=>stdout+=c);child.stderr.resume();child.stdin.end();const [code]=await once(child,'close');assert.equal(code,1);assert.equal(JSON.parse(stdout).error,'handoff_storage_untrusted');assert.equal(existsSync(join(external,'new')),false); diff --git a/test/handoff-core.test.ts b/test/handoff-core.test.ts index 5a68c86..3d7ac56 100644 --- a/test/handoff-core.test.ts +++ b/test/handoff-core.test.ts @@ -291,6 +291,31 @@ test('operator confirmation is metadata-only, correlated and never manufactures const restarted = new HandoffLedger(f.ledger.path, { clock: { monotonic: () => 0, utc: () => 1 } }); assert.deepEqual(handoff(restarted, h.correlationId).ack, acknowledged.ack); }); +test('invalid operator confirmation cannot acknowledge another intent or change retained evidence', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); submit(f, h); + const otherBinding = { ...f.binding, generation: 'different-original-generation' }; + const other = f.ledger.prepare(otherBinding); + f.ledger.commitEffect(other.correlationId, otherBinding); f.ledger.recordSubmission(other.correlationId, 'submitted'); + const raw = confirmation(h), proof = JSON.parse(raw); + const file = join(f.ledger.path, 'ledger.json'), before = readFileSync(file); + const original = handoff(f.ledger, h.correlationId), unrelated = handoff(f.ledger, other.correlationId); + for (const [invalid, error] of [ + [confirmation({ ...h, correlationId: randomUUID() }), /handoff_id_unknown/], + [confirmation({ ...h, correlationId: other.correlationId }), /confirmation_binding_invalid/], + [confirmation({ ...h, ledgerEpoch: randomUUID() }), /confirmation_binding_invalid/], + [confirmation({ ...h, targetGeneration: 'successor' }), /confirmation_binding_invalid/], + [JSON.stringify({ ...proof, confirmed: false }), /invalid_confirmation/], + [JSON.stringify({ ...proof, body: 'ordinary reply is not confirmation' }), /invalid_handoff/], + [raw.replace('"schemaVersion":1', '"schemaVersion":1.0'), /invalid_handoff_json/], + [raw.replace('"confirmed":true', '"confirmed":true,"confirmed":true'), /invalid_handoff_json/], + ] as const) { + assert.throws(() => f.ledger.confirm(invalid), error); + assert.deepEqual(readFileSync(file), before); + assert.deepEqual(handoff(f.ledger, h.correlationId), original); + assert.deepEqual(handoff(f.ledger, other.correlationId), unrelated); + assert.equal(handoff(f.ledger, h.correlationId).ack.status, 'not_requested'); + } +}); test('private receipt authority is hash-only, bound to original tuple and first receipt', posix, t => { const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); const capability = f.ledger.mintCapability(h.correlationId, bootstrap); assert.equal(capability.length, 43); diff --git a/test/handoff-cross-runtime.test.ts b/test/handoff-cross-runtime.test.ts new file mode 100644 index 0000000..10fb260 --- /dev/null +++ b/test/handoff-cross-runtime.test.ts @@ -0,0 +1,139 @@ +// Actual pure validators, not native interoperability, receipt auth or live ACK. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { validateHandoff } from '../dist/handoff.js'; +import { validateResponse } from '../dist/ssh-response.js'; + +type Case = { kind: 'public' | 'native' | 'projection'; value: unknown; record?: Record }; +type Result = { valid: boolean; value?: Record; reason?: string }; +const root = process.env.SESSION_PEER_PYTHON_ROOT ?? fileURLToPath(new URL('../../../', import.meta.url)); +const pin = JSON.parse(readFileSync(new URL('./fixtures/python-reference.pin.json', import.meta.url), 'utf8')); +const hashes = { + 'session_peer.py': 'd36d83f3d6f59eb20c958aac530ca6d5d988a99f6ceabf2704c7c51db1fe524f', + 'session_peer_core/handoff.py': '138115b9cc8ee73c77ad1099625f2b6cc2e0a6bd343e864a6b5219328735a808', +}; +const fixture = JSON.parse(readFileSync(new URL('./fixtures/handoff-v1.json', import.meta.url), 'utf8')); +const python = process.env.PYTHON ?? 'python3'; +function reference(cases: Case[]): Result[] { + assert.equal(pin.version, '1.1.0'); + assert.equal(pin.commit, '8ca77cf31254c8287a2593fe484bb30a7dba2727'); + assert.equal(pin.files['session_peer.py'], hashes['session_peer.py']); + for (const [path, digest] of Object.entries(hashes)) { + assert.equal(createHash('sha256').update(readFileSync(join(root, path))).digest('hex'), digest, path); + } + const child = spawnSync(python, [fileURLToPath(new URL('./fixtures/handoff-python-validator.py', import.meta.url)), root], { + input: JSON.stringify(cases), encoding: 'utf8', timeout: 10000, maxBuffer: 1048576, + env: { PATH: process.env.PATH, PYTHONDONTWRITEBYTECODE: '1', HOME: '/fixture/unavailable-home', + USERPROFILE: '/fixture/unavailable-home', SESSION_PEER_NO_UPDATE_NOTICE: '1' }, + }); + assert.ifError(child.error); assert.equal(child.signal, null); assert.equal(child.status, 0, child.stderr); + const response = JSON.parse(child.stdout); + assert.equal(response.version, pin.version); assert.deepEqual(response.sourceHashes, hashes); + assert.equal(response.results.length, cases.length); + return response.results; +} + +test('published Python and TS pure validators accept every frozen state without modifying native snapshots', () => { + const cases: Case[] = fixture.accepted.map((sample: any) => ({ kind: 'public', value: sample.result.handoff })); + const before = JSON.stringify(fixture.accepted), results = reference(cases); + const states = new Set(); + for (const [index, sample] of fixture.accepted.entries()) { + states.add(sample.result.handoff.state); + assert.equal(results[index]!.valid, true, sample.name); + assert.deepEqual(results[index]!.value, sample.result.handoff, sample.name); + assert.deepEqual(validateHandoff(sample.result.handoff), sample.result.handoff, sample.name); + assert.equal(sample.result.handoff.retry.allowed, false); + } + assert.deepEqual([...states].sort(), [...fixture.enums.state].sort()); + assert.equal(JSON.stringify(fixture.accepted), before); +}); + +test('both published validators reject schema, privacy, forged ACK and retry authority violations', () => { + const names = ['boolean_schema_version', 'fraction_schema_version', 'wrong_schema_version', 'noncanonical_uuid', + 'invalid_epoch', 'unknown_state', 'resend_action', 'native_retry_allowed', 'future_dedup_claim', + 'ack_without_receipt', 'secret_extra_field', 'unknown_nested_field']; + const samples = names.map(name => { + const sample = fixture.rejected.find((row: any) => row.name === name); assert.ok(sample, name); + const base = fixture.accepted.find((row: any) => row.name === sample.base); assert.ok(base, sample.base); + const value = structuredClone(base.result.handoff); + for (const [path, replacement] of Object.entries(sample.set)) { + const keys = path.split('.'); let owner = value; + for (const key of keys.slice(0, -1)) owner = owner[key]; + owner[keys.at(-1)!] = replacement; + } + return { kind: 'public', value } as Case; + }); + const results = reference(samples); + for (const [index, sample] of samples.entries()) { + // Published Python's UUID helper keeps its own reason code; do not force + // cross-runtime error spelling parity on a shared rejection decision. + const reason = ['noncanonical_uuid', 'invalid_epoch'].includes(names[index]!) ? 'invalid_correlation_id' : 'invalid_handoff'; + assert.deepEqual(results[index], { valid: false, reason }, names[index]); + assert.throws(() => validateHandoff(sample.value), /invalid_handoff/, names[index]); + } +}); + +test('published Python state projection validates in TS while unsupported observation remains explicit', () => { + const epoch = '11111111-1111-4111-8111-111111111111'; + const id = '22222222-2222-4222-8222-222222222222'; + const statuses = ['not_attempted', 'refused', 'submitted', 'unknown']; + const cases: Case[] = statuses.map(submission => ({ kind: 'projection', value: epoch, record: { + id, generation: 'fixture-original-generation', submission, waits: [], ack: null, capability: null, observe: true, + } })); + for (const [index, result] of reference(cases).entries()) { + assert.equal(result.valid, true); const value = validateHandoff(result.value); + assert.equal(value.submission.status, statuses[index]); + assert.deepEqual(value.observation, { status: 'unsupported', injectionObserved: false }); + assert.equal(value.retry.allowed, false); + } +}); + +test('Python Claude optional absence and TS posted facts remain distinct actual native profiles', () => { + const native = { ok: true, target: { pid: 4242, name: null }, chars: 0, dryRun: false }; + const record = { binding: { agent: 'claude', destination: ['local'], target: '4242' } }; + const tsNative = { ...native, status: 'posted', submitted: true, consumptionConfirmed: false }; + const cases: Case[] = [native, tsNative, { ...native, target: { pid: 4243, name: null } }, + { ...native, target: { pid: 4242, name: 7 } }].map(value => ({ kind: 'native', value, record })); + const before = JSON.stringify(native), results = reference(cases); + assert.equal(results[0]!.valid, true); assert.deepEqual(results[0]!.value, native); + for (const result of results.slice(1)) assert.deepEqual(result, { valid: false, reason: 'invalid_native_profile' }); + const envelope = { schemaVersion: 1, host: 'fixture', command: 'send' }; + assert.throws(() => validateResponse({ ...envelope, ...native }, { command: 'send', to: '4242' })); + assert.doesNotThrow(() => validateResponse({ ...envelope, ...tsNative }, { command: 'send', to: '4242' })); + assert.throws(() => validateResponse({ ...envelope, ...tsNative, target: { pid: 4243, name: null } }, { command: 'send', to: '4242' })); + assert.throws(() => validateResponse({ ...envelope, ...tsNative, target: { pid: 4242, name: 7 } }, { command: 'send', to: '4242' })); + for (const key of ['status', 'submitted', 'consumptionConfirmed', 'handoff']) assert.equal(Object.hasOwn(native, key), false); + assert.equal(JSON.stringify(native), before); +}); + +test('both actual Codex native validators preserve optional queue ID and enforce original home/target', () => { + // Native UUIDs are not restricted to the correlation ID's UUIDv4 rule. + const id = 'aaaaaaaa-aaaa-1aaa-8aaa-aaaaaaaaaaaa', home = '/fixture/codex-home'; + const resolution = { schemaVersion: 1, status: 'explicit', selected: home, reason: 'explicit_live_writer', + candidates: [{ codexHome: home, savedThread: true, writerLock: 'held', activity: 'live_writer', reason: 'fixture' }] }; + const native = { ok: true, target: { agent: 'codex', id: id.toUpperCase() }, chars: 0, dryRun: false, + status: 'queued', submitted: true, consumptionConfirmed: false, codexHome: home, codexHomeResolution: resolution }; + const record = { binding: { agent: 'codex', destination: ['local'], target: id, home } }; + const values = [native, { ...native, queueId: 'fixture-native-queue' }, { ...native, status: 'posted' }, + { ...native, consumptionConfirmed: true }, { ...native, codexHome: '/fixture/wrong-home' }, + { ...native, target: { agent: 'claude', id } }]; + const results = reference(values.map(value => ({ kind: 'native', value, record }))); + const envelope = { schemaVersion: 1, host: 'fixture', command: 'send' }; + const request = { command: 'send', to: `codex:${id}`, home }; + for (const [index, value] of values.entries()) { + if (index < 2) { + assert.equal(results[index]!.valid, true); assert.deepEqual(results[index]!.value, value); + assert.doesNotThrow(() => validateResponse({ ...envelope, ...value }, request)); + } else { + assert.deepEqual(results[index], { valid: false, reason: 'invalid_native_profile' }); + assert.throws(() => validateResponse({ ...envelope, ...value }, request)); + } + } + assert.equal(Object.hasOwn(results[0]!.value!, 'queueId'), false); + assert.equal(results[1]!.value!.queueId, 'fixture-native-queue'); +}); diff --git a/test/ssh-handoff.test.ts b/test/ssh-handoff.test.ts index a1be05f..72ba56a 100644 --- a/test/ssh-handoff.test.ts +++ b/test/ssh-handoff.test.ts @@ -1,20 +1,23 @@ -// Owned fake SSH and a recording socket only; no live agent or remote host. +// Owned fake SSH, recording socket and scripted native fixture; no live agent or remote host. import { test, type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { once } from 'node:events'; +import { createRequire } from 'node:module'; import { createServer } from 'node:net'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { delimiter, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { responseOutcome, type ResponseRequest } from '../dist/ssh-response.js'; -import { HandoffBudget, type Handoff } from '../dist/handoff.js'; +import { HandoffBudget, validateHandoff, type Handoff } from '../dist/handoff.js'; import { VERSION_LINE } from '../dist/protocol.js'; import type { Done } from '../dist/process.js'; const cli = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); const posix = { skip: process.platform === 'win32' }; +const qualified = { skip: process.platform !== 'darwin' || process.arch !== 'arm64' }; const wireArgs = (ms: number) => ['--stdio-request', `--handoff-remaining-ms=${ms}`]; const cid = '11111111-1111-4111-8111-111111111111', epoch = '22222222-2222-4222-8222-222222222222'; const thread = '33333333-3333-4333-8333-333333333333'; @@ -109,7 +112,8 @@ test('SSH remaining duration charges setup/frame time and never renews cleanup o async function fixture(t: TestContext) { const root = realpathSync(mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-ssh-handoff-'))); chmodSync(root, 0o700); const destination = join(root, 'destination'), origin = join(root, 'origin'), socket = join(root, 'socket'), log = join(root, 'calls.jsonl'); - mkdirSync(origin); mkdirSync(join(destination, '.claude', 'sessions'), { recursive: true }); writeFileSync(log, ''); + const preparationLog = join(root, 'preparations.jsonl'); + mkdirSync(origin); mkdirSync(join(destination, '.claude', 'sessions'), { recursive: true }); writeFileSync(log, ''); writeFileSync(preparationLog, ''); const messages: string[] = []; const server = createServer(s => { let body = ''; s.on('data', part => { body += part; }); s.on('end', () => { messages.push(body); s.end(); }); }); server.listen(socket); await once(server, 'listening'); @@ -124,24 +128,115 @@ fs.appendFileSync(process.env.FIXTURE_LOG,JSON.stringify(request)+'\\n'); if(process.env.FIXTURE_PARTIAL_PREPARE&&request.handoff?.preparation){process.stdout.write('{');process.exit(0);} const env={...process.env,HOME:process.env.FIXTURE_DEST,USERPROFILE:process.env.FIXTURE_DEST,CLAUDE_CONFIG_DIR:process.env.FIXTURE_DEST+'/.claude',SESSION_PEER_HANDOFF_HOME:process.env.FIXTURE_DEST+'/state/handoff'}; const selector=args.at(-1).match(/ --handoff-remaining-ms=([1-9][0-9]{0,4})$/); -const child=cp.spawn(process.execPath,[process.env.FIXTURE_CLI,'--stdio-request',...(selector?['--handoff-remaining-ms='+selector[1]]:[])],{env,stdio:['pipe','inherit','inherit']}); -child.stdin.end(input);child.on('exit',(code,signal)=>{if(signal)process.kill(process.pid,signal);else process.exitCode=request.handoff?.preparation&&process.env.FIXTURE_PREPARE_EXIT255?255:code;}); +const child=cp.spawn(process.execPath,[process.env.FIXTURE_CLI,'--stdio-request',...(selector?['--handoff-remaining-ms='+selector[1]]:[])],{env,stdio:['pipe','pipe','inherit']}); +let output='';child.stdout.on('data',part=>{output+=part;process.stdout.write(part);}); +child.stdin.end(input);child.on('close',(code,signal)=>{if(request.handoff?.preparation)fs.appendFileSync(process.env.FIXTURE_PREPARATIONS,output);if(signal)process.kill(process.pid,signal);else process.exitCode=request.handoff?.preparation&&process.env.FIXTURE_PREPARE_EXIT255?255:code;}); `, { mode: 0o700 }); const common = { ...process.env, CODEX_HOME: '', CODEX_THREAD_ID: '', CODEX_SESSION_ID: '', SESSION_PEER_TAILSCALE: 'off', SESSION_PEER_UPDATE_NOTICE: '', - PATH: root + delimiter + process.env.PATH, FIXTURE_VERSION: VERSION_LINE, FIXTURE_LOG: log, FIXTURE_CLI: cli, FIXTURE_DEST: destination }; + PATH: root + delimiter + process.env.PATH, FIXTURE_VERSION: VERSION_LINE, FIXTURE_LOG: log, FIXTURE_PREPARATIONS: preparationLog, FIXTURE_CLI: cli, FIXTURE_DEST: destination }; const destEnv = { ...common, HOME: destination, USERPROFILE: destination, CLAUDE_CONFIG_DIR: join(destination, '.claude'), SESSION_PEER_HANDOFF_HOME: join(destination, 'state', 'handoff') }; const originEnv = { ...common, HOME: origin, USERPROFILE: origin, CLAUDE_CONFIG_DIR: join(origin, '.claude'), SESSION_PEER_HANDOFF_HOME: join(origin, 'state', 'handoff') }; async function invoke(args: string[], destinationSide = false, input?: string, extra: NodeJS.ProcessEnv = {}) { const child = spawn(process.execPath, [cli, ...args], { env: { ...(destinationSide ? destEnv : originEnv), ...extra } }); let stdout = '', stderr = ''; child.stdout.on('data', part => { stdout += part; }); child.stderr.on('data', part => { stderr += part; }); child.stdin.end(input); - const timer = setTimeout(() => child.kill('SIGKILL'), 10000); + // Harness margin permits cleanup/reporting; the request budget is unchanged. + const timer = setTimeout(() => child.kill('SIGKILL'), 20000); const [code, signal] = await once(child, 'close'); clearTimeout(timer); assert.equal(signal, null, stderr); - return { code, value: JSON.parse(stdout), stderr }; + return { code, value: JSON.parse(stdout), stdout, stderr }; } return { root, destination, origin, destEnv, messages, invoke, calls: () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)), + preparations: () => readFileSync(preparationLog, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)), args: ['send', '--host', 'fixture', '--to', String(process.pid), '--message=fixture body', '--no-from', '--no-reply-to', '--json'] }; } +async function codexFixture(t: TestContext, mode: 'inject' | 'none' | 'loss') { + const f = await fixture(t), home = join(f.destination, 'codex-home'), bin = join(f.root, 'codex-bin'); + mkdirSync(home, { mode: 0o700 }); mkdirSync(bin, { mode: 0o700 }); + mkdirSync(join(home, 'thread-writer-locks'), { mode: 0o700 }); + const state = new DatabaseSync(join(home, 'state_5.sqlite')); + state.exec('CREATE TABLE threads(id TEXT)'); state.prepare('INSERT INTO threads VALUES(?)').run(thread); state.close(); + const warm = new DatabaseSync(join(home, 'thread_history_1.sqlite')); + warm.exec(`CREATE TABLE thread_turns(thread_id TEXT NOT NULL,turn_id TEXT NOT NULL,status TEXT NOT NULL,PRIMARY KEY(thread_id,turn_id)); +CREATE TABLE thread_items(thread_id TEXT NOT NULL,turn_id TEXT NOT NULL,item_id TEXT NOT NULL,rollout_ordinal INTEGER NOT NULL,item_json TEXT NOT NULL,item_type TEXT NOT NULL,PRIMARY KEY(thread_id,turn_id,item_id)); +CREATE INDEX idx_thread_items_user_messages ON thread_items(thread_id,rollout_ordinal) WHERE item_type='userMessage'; +CREATE TABLE thread_history_projection_state(thread_id TEXT PRIMARY KEY,next_rollout_byte_offset INTEGER NOT NULL,next_rollout_ordinal INTEGER NOT NULL);`); + warm.prepare('INSERT INTO thread_turns VALUES(?,?,?)').run(thread, 'ssh-turn-fixture', 'completed'); + warm.prepare('INSERT INTO thread_history_projection_state VALUES(?,?,?)').run(thread, 100, 100); warm.close(); + const holder = fileURLToPath(new URL('./fixtures/ssh-codex-lock-holder.cjs', import.meta.url)); + const held = spawn(process.execPath, [holder, join(home, 'thread-writer-locks', thread + '.lock'), createRequire(import.meta.url).resolve('fs-ext-extra-prebuilt')]); + held.stderr.resume(); + t.after(async () => { if (held.exitCode === null && held.signalCode === null) { held.kill('SIGKILL'); await once(held, 'close'); } }); + const readyTimer = setTimeout(() => held.kill('SIGKILL'), 3000); + try { await Promise.race([once(held.stdout, 'data'), once(held, 'close').then(() => { throw new Error('fixture lock holder exited before readiness'); })]); } + finally { clearTimeout(readyTimer); } + const lsof = join(bin, 'lsof'); + writeFileSync(lsof + '.data', 'p' + held.pid + '\0ccodex-fixture\0u' + process.getuid?.() + '\0', { mode: 0o600 }); + writeFileSync(lsof, '#!/bin/sh\nexec /bin/cat "$0.data"\n', { mode: 0o700 }); + writeFileSync(join(bin, 'ps'), '#!/bin/sh\nprintf "Fixture stable start\\n"\n', { mode: 0o700 }); + const queueLog = join(f.root, 'codex-queue.jsonl'), nativeLog = join(f.root, 'codex-native.jsonl'), codex = join(bin, 'codex'); + writeFileSync(queueLog, ''); writeFileSync(nativeLog, ''); + copyFileSync(fileURLToPath(new URL('./fixtures/ssh-codex-native.cjs', import.meta.url)), codex); chmodSync(codex, 0o700); + writeFileSync(codex + '.json', JSON.stringify({ mode, version: '0.160.1', queueLog, nativeLog }), { mode: 0o600 }); + const env = { CODEX_HOME: home, SESSION_PEER_CODEX_HOMES: '[]', PATH: bin + delimiter + f.destEnv.PATH }; + assert.equal((await f.invoke(['handoff', 'init', '--json'], true, undefined, env)).code, 0); + const readLog = (path: string) => readFileSync(path, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); + return { ...f, home, env, queueCalls: () => readLog(queueLog), nativeCalls: () => readLog(nativeLog), + ledger: () => JSON.parse(readFileSync(join(f.destination, 'state', 'handoff', 'ledger.json'), 'utf8')), + args: ['send', '--host', 'fixture', '--to', `codex:${thread}`, '--codex-home', home, '--codex-bin', codex, + '--message=fixture request', '--no-from', '--no-reply-to', '--wait-for', 'delivered', '--wait-timeout', '15', '--json'] }; +} + +for (const mode of ['inject', 'none', 'loss'] as const) { + test(`qualified SSH Codex ${mode} keeps destination preparation binding and at most one native queue`, qualified, async t => { + const f = await codexFixture(t, mode), started = performance.now(); + const sent = await f.invoke(f.args, false, undefined, f.env); + assert.equal(sent.code, mode === 'inject' ? 0 : 1, sent.stdout); + const calls = f.calls(); assert.equal(calls.length, 2); + assert.equal(calls[0].handoff.preparation, true); assert.equal(calls[1].handoff.preparation, undefined); + assert.ok(calls[1].handoff.remainingMs < calls[0].handoff.remainingMs); + const original = f.preparations()[0], initial = validateHandoff(original.handoff); + assert.equal(original.ok, true); assert.equal(original.status, 'validated'); assert.equal(original.submitted, false); + assert.equal(original.target.id, thread); assert.equal(original.codexHome, f.home); + assert.ok(initial.targetGeneration); + assert.equal(calls[1].args[calls[1].args.indexOf('--correlation-id') + 1], initial.correlationId); + assert.equal(calls[1].args[calls[1].args.indexOf('--codex-home') + 1], original.codexHome); + assert.equal(calls[1].args[calls[1].args.indexOf('--to') + 1], `codex:${original.target.id}`); + assert.deepEqual(f.queueCalls(), [{ method: 'thread/queue/add', clientId: initial.correlationId, threadId: thread, codexHome: f.home }]); + assert.equal(f.nativeCalls().some(call => call.command === 'queue'), false); + assert.equal(f.messages.length, 0); assert.equal(existsSync(join(f.origin, 'state', 'handoff')), false); + const retained = f.ledger().data.records[initial.correlationId]; + assert.equal(retained.binding.agent, 'codex'); assert.equal(retained.binding.target, thread); + assert.equal(retained.binding.home, original.codexHome); assert.equal(retained.binding.generation, initial.targetGeneration); + assert.equal(retained.handoff.ledgerEpoch, initial.ledgerEpoch); + if (mode === 'loss') { + assert.equal(sent.value.status, 'unknown'); assert.equal(sent.value.submitted, null); + assert.equal(retained.handoff.submission.status, 'unknown'); + } else { + assert.equal(sent.value.status, 'queued'); assert.equal(sent.value.submitted, true); + assert.equal(sent.value.consumptionConfirmed, false); assert.equal(sent.value.queueId, 'ssh-queue-fixture'); + assert.equal(sent.value.target.id, original.target.id); assert.equal(sent.value.codexHome, original.codexHome); + const h = validateHandoff(sent.value.handoff); + for (const key of ['correlationId', 'ledgerEpoch', 'targetGeneration'] as const) assert.equal(h[key], initial[key]); + assert.equal(h.submission.status, 'submitted'); assert.equal(h.retry.allowed, false); assert.equal(h.ack.status, 'not_requested'); + assert.equal(h.wait.status, mode === 'inject' ? 'satisfied' : 'timed_out_unknown'); + assert.equal(h.observation.injectionObserved, mode === 'inject'); + if (mode === 'inject') { + assert.equal(h.state, 'delivered'); assert.equal(h.observation.clientUserMessageId, initial.correlationId); + assert.equal(h.observation.turn?.status, 'completed'); + } else { assert.equal(sent.value.ok, false); assert.equal(sent.value.error, 'timed_out_unknown'); } + const beforeStatus = f.nativeCalls(); + const status = await f.invoke(['handoff', 'status', '--correlation-id', initial.correlationId, '--json'], true, undefined, f.env); + assert.equal(status.code, 0); assert.deepEqual(status.value.handoff.wait, h.wait); assert.deepEqual(f.nativeCalls(), beforeStatus); + } + assert.ok(performance.now() - started < 20000); + for (const secret of ['PRIVATE_SSH_CODEX_BODY_SENTINEL', 'payloadDigest', 'observationQualification']) assert.equal(sent.stdout.includes(secret), false); + const duplicate = await f.invoke([...f.args, '--correlation-id', initial.correlationId], false, undefined, f.env); + assert.equal(duplicate.code, 1); assert.equal(duplicate.value.error, 'handoff_already_attempted'); + assert.equal(f.calls().length, 3); assert.equal(f.calls()[2].handoff.preparation, true); + assert.equal(f.queueCalls().length, 1); assert.equal(f.nativeCalls().some(call => call.command === 'queue'), false); + }); +} + test('SSH uses only an explicitly initialized destination ledger, prepares before one effect, and leaves origin untouched', posix, async t => { const f = await fixture(t); const missing = await f.invoke([...f.args, '--request-ack']); From 8fbdca512be83dec16293d40b81cfee0ce10927c Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sat, 17 Oct 2026 23:54:40 +0900 Subject: [PATCH 16/18] fix(observation): preserve independent owner wait deadlines --- src/handoff-ledger.ts | 7 ++++--- test/handoff-observation-waits.test.ts | 21 +++++++++++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/handoff-ledger.ts b/src/handoff-ledger.ts index 56be0b7..4533df8 100644 --- a/src/handoff-ledger.ts +++ b/src/handoff-ledger.ts @@ -382,9 +382,10 @@ export class HandoffLedger { // cutoff and terminal result. A newer ACK wait cannot hide older waits. const observedAt = this.clock.monotonic(); for (const wait of item.waits) { - if (wait.public.status !== 'pending' || wait.public.for !== 'delivered') continue; - if (wait.clockId !== this.clockId) { wait.public.status = 'failed'; wait.public.reason = 'history_unavailable'; } - else wait.public.status = observedAt < wait.cutoff ? 'satisfied' : 'timed_out_unknown'; + // A resumed or concurrent observer owns its own monotonic cutoff. Keep + // foreign operations unclassified so their live owner can resolve them. + if (wait.public.status !== 'pending' || wait.public.for !== 'delivered' || wait.clockId !== this.clockId) continue; + wait.public.status = observedAt < wait.cutoff ? 'satisfied' : 'timed_out_unknown'; } if (item.waits.length) h.wait = structuredClone(item.waits.at(-1)!.public); h.nextActions = ['reconcile']; this.write(data); return this.snapshot(item); diff --git a/test/handoff-observation-waits.test.ts b/test/handoff-observation-waits.test.ts index c96ab50..ec04e9a 100644 --- a/test/handoff-observation-waits.test.ts +++ b/test/handoff-observation-waits.test.ts @@ -50,7 +50,7 @@ test('injection preserves terminal delivery waits and never resets their deadlin assert.equal(observed[2].operationId, pending.wait.operationId); assert.equal(observed[2].status, 'satisfied'); f.advance(1000); f.observe(); assert.deepEqual(f.waits(), observed); }); -test('restarted observer retains injection but fails every unorderable delivery wait conservatively', posix, t => { +test('independent observer retains injection without classifying another owner delivery deadlines', posix, t => { const f = fixture(t); f.ledger.beginWait(f.h.correlationId, 'delivered', new HandoffBudget(6, f.clock)); f.ledger.beginWait(f.h.correlationId, 'delivered', new HandoffBudget(30, f.clock)); @@ -58,8 +58,10 @@ test('restarted observer retains injection but fails every unorderable delivery const result = f.observe(new HandoffLedger(f.ledger.path, { clock: f.clock })); assert.equal(result.state, 'delivered'); assert.equal(result.observation.injectionObserved, true); assert.notEqual(result.ack.status, 'acknowledged'); assert.equal(result.wait.operationId, ack.wait.operationId); + // Resumed/concurrent observation is introduced by #72. Another observer + // cannot prove these owners stopped or compare their monotonic cutoffs. assert.deepEqual(f.waits().slice(0, 2).map((w: any) => [w.status, w.reason]), [ - ['failed', 'history_unavailable'], ['failed', 'history_unavailable'], + ['pending', undefined], ['pending', undefined], ]); assert.equal(f.waits()[2].status, 'pending'); }); @@ -90,3 +92,18 @@ test('explicit wait selection rejects unknown IDs and cannot timeout a foreign m assert.throws(() => f.ledger.recordWait(f.h.correlationId, 'stopped', 'stopped_by_operator', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'), /handoff_wait_unknown/); assert.equal(f.waits()[0].status, 'pending'); }); +test('same-owner delivery cutoffs resolve while a newer unsupported foreign ACK wait stays terminal', posix, t => { + const f = fixture(t); + const first = f.ledger.beginWait(f.h.correlationId, 'delivered', new HandoffBudget(6, f.clock)); + f.advance(500); + const second = f.ledger.beginWait(f.h.correlationId, 'delivered', new HandoffBudget(6, f.clock)); + const foreign = new HandoffLedger(f.ledger.path, { clock: f.clock }); + const newest = foreign.beginWait(f.h.correlationId, 'acknowledged', new HandoffBudget(30, f.clock)); + // This unqualified foreign ACK operation is terminal unsupported; it must not + // hide the earlier delivery operations or become ACK through injection. + assert.equal(newest.wait.status, 'unsupported'); + f.advance(500); f.observe(); + assert.deepEqual(f.waits().map((w: any) => [w.operationId, w.status]), [ + [first.wait.operationId, 'timed_out_unknown'], [second.wait.operationId, 'satisfied'], [newest.wait.operationId, 'unsupported'], + ]); +}); From 91e8df3a2d2ea3fea85167e694b83daf5778cc72 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 00:12:32 +0900 Subject: [PATCH 17/18] test(observation): validate encrypted bindings without comparing nonces --- test/handoff-cli.test.ts | 2 +- test/handoff-core.test.ts | 7 ++++++- test/handoff-delivery.test.ts | 7 +++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/test/handoff-cli.test.ts b/test/handoff-cli.test.ts index ad8106f..fc4bc7a 100644 --- a/test/handoff-cli.test.ts +++ b/test/handoff-cli.test.ts @@ -20,7 +20,7 @@ async function fixture(t: TestContext) { writeFileSync(join(path, '.claude/sessions', `${process.pid}.json`), JSON.stringify({ pid: process.pid, name: null, messagingSocketPath: socket })); const file = join(path, 'message'); writeFileSync(file, 'fixture body', { mode: 0o600 }); const env: NodeJS.ProcessEnv = { ...process.env, HOME: path, USERPROFILE: path, CLAUDE_CONFIG_DIR: join(path, '.claude'), CODEX_HOME: '', CODEX_THREAD_ID: '', CODEX_SESSION_ID: '', SESSION_PEER_TAILSCALE: 'off', SESSION_PEER_UPDATE_NOTICE: '', SESSION_PEER_HANDOFF_HOME: join(path, 'state', 'handoff') }; - async function invoke(args: string[], input = '', closeInput = true, timeout = 10000) { const child = spawn(process.execPath, [cli, ...args, '--json'], { env }); let stdout = ''; child.stdout.on('data', c => stdout += c); child.stderr.resume(); if (closeInput) child.stdin.end(input); else child.stdin.write(input); const timer = setTimeout(() => child.kill('SIGKILL'), timeout); const [code, signal] = await once(child, 'close'); clearTimeout(timer); assert.equal(signal, null); return { code, value: JSON.parse(stdout) }; } + async function invoke(args: string[], input = '', closeInput = true, timeout = 10000) { const child = spawn(process.execPath, [cli, ...args, '--json'], { env }); let stdout = ''; child.stdout.on('data', c => stdout += c); child.stderr.resume(); let stdinError: NodeJS.ErrnoException | undefined; child.stdin.on('error', error => { stdinError = error; }); if (closeInput) { if (input) child.stdin.end(input); else child.stdin.end(); } else child.stdin.write(input); const timer = setTimeout(() => child.kill('SIGKILL'), timeout); const [code, signal] = await once(child, 'close'); clearTimeout(timer); assert.equal(signal, null); if (stdinError) { assert.equal(stdinError.code, 'EPIPE'); assert.ok(code !== 0 || input === '', 'successful stdin consumption must not lose input'); } return { code, value: JSON.parse(stdout) }; } return { path, env, messages, file, invoke, send: ['send', '--to', String(process.pid), '--message-file', file, '--no-from', '--no-reply-to'] }; } test('opt-out untouched; explicit init/prepare/status/dry-run/single effect fence', { skip: process.platform === 'win32' }, async t => { diff --git a/test/handoff-core.test.ts b/test/handoff-core.test.ts index 3d7ac56..90561d2 100644 --- a/test/handoff-core.test.ts +++ b/test/handoff-core.test.ts @@ -645,7 +645,12 @@ test('reconfiguring prepared delivery cannot retain qualification for a differen const stored = JSON.parse(readFileSync(file, 'utf8')).data.records[original.correlationId]; assert.equal(stored.observationQualification, undefined); assert.equal(stored.deliverySupported, mode === 'unqualified'); - assert.deepEqual(stored.binding, binding); + const { payloadDigest: sealed, ...publicBinding } = stored.binding; + const { payloadDigest: privateDigest, ...expectedBinding } = binding; + assert.deepEqual(publicBinding, expectedBinding); + assert.match(sealed, /^sealed-v1:/); assert.notEqual(sealed, privateDigest); + // Successful effect fencing below proves the decrypted private digest still + // matches; randomized at-rest encryption must not be compared as plaintext. f.ledger.commitEffect(original.correlationId, binding); f.ledger.recordSubmission(original.correlationId, 'submitted'); const restarted = new HandoffLedger(f.ledger.path, { clock: f.clock }); assert.equal(restarted.observationContext(original.correlationId), undefined); diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index ad316cf..b5844f5 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -8,7 +8,7 @@ import {chmodSync,existsSync,mkdirSync,mkdtempSync,readFileSync,realpathSync,rmS import {join,delimiter,resolve} from 'node:path'; import {tmpdir} from 'node:os'; import {validateHandoff} from '../dist/handoff.js'; -import {payloadDigest} from '../dist/handoff-ledger.js'; +import {HandoffLedger,payloadDigest} from '../dist/handoff-ledger.js'; const qualified={skip:process.platform!=='darwin'||process.arch!=='arm64'}; const thread='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',cli=resolve('dist/cli.js'); const schema=`CREATE TABLE thread_turns(thread_id TEXT NOT NULL,turn_id TEXT NOT NULL,status TEXT NOT NULL,PRIMARY KEY(thread_id,turn_id)); @@ -163,6 +163,7 @@ test('independent wait resumes exact submitted scope and observes later injectio assert.equal(sent.code,0,sent.stdout);assert.equal(sent.value.status,'queued');assert.equal(sent.value.submitted,true); const original=validateHandoff(sent.value.handoff);assert.equal(original.observation.status,'pending'); const before=f.ledger().data.records[original.correlationId],nativeBefore=f.nativeCalls(); + const contextBefore=new HandoffLedger(f.env.SESSION_PEER_HANDOFF_HOME!).observationContext(original.correlationId);assert.ok(contextBefore); assert.ok(before.observationQualification);assert.equal(f.calls().length,1); assert.equal(f.calls()[0].clientId,original.correlationId); const active=f.start(['handoff','wait','--correlation-id',original.correlationId,'--wait-for','delivered','--wait-timeout','10']); @@ -176,7 +177,9 @@ test('independent wait resumes exact submitted scope and observes later injectio assert.equal(observed.ack.status,'not_requested');assert.equal(observed.retry.allowed,false); const after=f.ledger().data.records[original.correlationId]; assert.equal(after.clockId,before.clockId);assert.equal(after.preparedMono,before.preparedMono); - assert.deepEqual(after.binding,before.binding);assert.deepEqual(f.calls().map(call=>call.method),['thread/queue/add']);assert.deepEqual(f.nativeCalls(),nativeBefore); + assert.deepEqual(new HandoffLedger(f.env.SESSION_PEER_HANDOFF_HOME!).observationContext(original.correlationId),contextBefore); + assert.match(after.binding.payloadDigest,/^sealed-v1:/);assert.notEqual(after.binding.payloadDigest,contextBefore.binding.payloadDigest); + assert.deepEqual(f.calls().map(call=>call.method),['thread/queue/add']);assert.deepEqual(f.nativeCalls(),nativeBefore); for(const privateField of ['PRIVATE_BODY_SENTINEL','observationQualification','payloadDigest'])assert.equal(result.stdout.includes(privateField),false); }); From 9e123cf9e3bfb07a0526215c9e797a0fb538be46 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 00:29:01 +0900 Subject: [PATCH 18/18] test(observation): qualify actual native fixture build responses --- test/fixtures/ssh-codex-native.cjs | 2 +- test/handoff-delivery.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/fixtures/ssh-codex-native.cjs b/test/fixtures/ssh-codex-native.cjs index 1c7d893..655ad84 100644 --- a/test/fixtures/ssh-codex-native.cjs +++ b/test/fixtures/ssh-codex-native.cjs @@ -22,7 +22,7 @@ require('node:readline').createInterface({ input: process.stdin }).on('line', li const request = JSON.parse(line); if (request.method === 'initialize') { console.log(JSON.stringify({ id: request.id, result: { - codexHome: process.env.CODEX_HOME, platformFamily: 'unix', platformOs: 'macos', userAgent: 'fixture', + codexHome: process.env.CODEX_HOME, platformFamily: 'unix', platformOs: 'macos', userAgent: 'session-peer-native-queue/' + config.version + ' (fixture)', } })); } else if (request.method === 'thread/queue/add') { fs.appendFileSync(config.queueLog, JSON.stringify({ method: request.method, diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index b5844f5..32f4987 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -41,7 +41,7 @@ if(process.argv[2]==='queue'){fs.appendFileSync(log,JSON.stringify({method:'lega const {DatabaseSync:Sql}=require('node:sqlite');globalThis.keptDatabases=[new Sql(path.join(process.env.CODEX_HOME,'state_5.sqlite'),{readOnly:true}),new Sql(path.join(process.env.CODEX_SQLITE_HOME,'queue_1.sqlite'))]; readline.createInterface({input:process.stdin}).on('line',async line=>{const r=JSON.parse(line);if(r.method==='initialize'){ if(mode==='concurrent-stop'){fs.writeFileSync(initializeReady,'ready');await new Promise(resolve=>{const timer=setInterval(()=>{if(fs.existsSync(initializeRelease)){clearInterval(timer);resolve();}},10);});} -console.log(JSON.stringify({id:r.id,result:{codexHome:process.env.CODEX_HOME,platformFamily:'unix',platformOs:'macos',userAgent:'fixture'}}));}else if(r.method==='thread/queue/add'){ +console.log(JSON.stringify({id:r.id,result:{codexHome:process.env.CODEX_HOME,platformFamily:'unix',platformOs:'macos',userAgent:'session-peer-native-queue/'+version+' (fixture)'}}));}else if(r.method==='thread/queue/add'){ fs.appendFileSync(log,JSON.stringify({method:r.method,clientId:r.params.clientUserMessageId})+'\\n'); if(mode==='concurrent-stop')return; if(mode==='loss'){process.exit(0);return;}