diff --git a/PARITY.md b/PARITY.md index 2b8e2be..67b73b2 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1039,3 +1039,23 @@ 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 reference or establish runtime conformance. + + +## Unreleased handoff implementation — #70 + +The frozen #69 design pin is unchanged. Source-only private POSIX ledger and +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. + +The journal encrypts its private message-binding digest with a separate +mode-0600 `ledger.key`; missing/changed/untrusted keys and plaintext journals +are refused without automatic key recovery or history migration. This is an +at-rest journal boundary, not protection against a reader of both private +files. Duplicate committed operator confirmation returns the original receipt +without rewriting its wait classification or journal. diff --git a/README.ja.md b/README.ja.md index e2a7c0c..a3e3183 100644 --- a/README.ja.md +++ b/README.ja.md @@ -114,6 +114,8 @@ session-peer --version - [0.4.0 handoff 設計(共同合意・未実装)](PARITY.md#handoff-v1-design-candidate--69). +- [開発中の private handoff CLI と制限](docs/api.md#unreleased-opt-in-handoff-cli--040-work). + ## License [MIT ライセンス](LICENSE)で公開しています。 diff --git a/README.ko.md b/README.ko.md index 6e8ad66..6247269 100644 --- a/README.ko.md +++ b/README.ko.md @@ -128,6 +128,8 @@ session-peer --version - [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 f57d25a..1cb8c7e 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,8 @@ does not install a skill. Review its pinned-source instructions in the guide. - [0.4.0 handoff design (jointly agreed, unimplemented)](PARITY.md#handoff-v1-design-candidate--69). +- [Unreleased private handoff CLI and limitations](docs/api.md#unreleased-opt-in-handoff-cli--040-work). + ## License Licensed under the [MIT License](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index d289cb4..6effd7b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -110,6 +110,8 @@ session-peer --version - [0.4.0 handoff 设计(联合确认、未实现)](PARITY.md#handoff-v1-design-candidate--69). +- [开发中的 private handoff CLI 与限制](docs/api.md#unreleased-opt-in-handoff-cli--040-work). + ## License 采用 [MIT 许可证](LICENSE)。 diff --git a/docs/api.md b/docs/api.md index c5d094c..1a40149 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,3 +33,52 @@ not package subpath exports. Use the [CLI guide](guide.md#use) for sending and The ordinary SSH response correction is documented separately as the [0.3.3 ordinary SSH response correction](design/ordinary-ssh-responses.md); its internal parser is not a package-root export. + +## Unreleased opt-in handoff CLI — 0.4.0 work + +This source adds an explicit private POSIX sender ledger. The published 0.3.5 +package does not contain these commands. Default sends retain their native +status, optional fields and exit behavior. The frozen design is tracked in #69; +this implementation does not change its exact vendored document or fixture. + +Initialize once with `session-peer handoff init --json`. The optional absolute +`SESSION_PEER_HANDOFF_HOME` selects its directory; commands never auto-initialize +missing or corrupt history. `handoff prepare --to TARGET --message-file FILE +--json` reserves a fresh correlation ID without sending. Use a private regular +UTF-8 file (POSIX mode 0600), then pass the retained ID with +`send --correlation-id UUID --message-file FILE --json`. A changed destination, +original generation or message binding is rejected. An unknown supplied ID is +not accepted. Ordinary opted-in sends can automatically prepare a fresh ID. + +Only opted-in results add `handoff`. The inert correlation metadata in a native +message is not authentication. Durable intent is fenced before native effect; +crash recovery, `handoff status`, `handoff wait`, `ack` and `handoff confirm` +never submit again. Retention/quota limits preserve epoch-bound fences rather +than evicting old authority. Clone/rollback protection assumes intact, retained, +non-rolled-back history. Native local/SSH retry permission remains false. + +Keep `ledger.key` with the private ledger: the journal stores an authenticated +encrypted message-binding digest, using that separate mode-0600 key. The raw +digest is not stored in the journal. Missing, changed or untrusted keys and old +plaintext journals fail closed; commands do not regenerate a key or migrate +history automatically. This does not protect against a same-user reader who +can obtain both files. Lost history never authorizes resubmission. + +`--request-ack`, `--observe-delivery` and `--wait-for delivered|acknowledged` +select separate evidence goals. A required unsupported goal refuses before +submission; best-effort mode may submit while reporting the channel unsupported. +`--wait-timeout` is an ASCII integer 1..60 (default 30); its total operation +budget includes a 5-second cleanup reserve. Budgets 1..5 are syntactically valid +but cannot authorize an effect. A successful submission is preserved when +subsequent ledger/evidence operations fail. Best-effort sends keep their native +success exit and may add `handoffWarning: persistence_failed`; required waits +fail without erasing independently established native submission facts. No body, transcript, raw native +stderr, payload digest or receipt secret appears in handoff JSON. + +Automated receipt producer/bootstrap, Windows ledger and original-sender SSH +handoff coordination remain unsupported. `ack --receipt -` and `handoff confirm +--receipt -` consume at most 4096 raw UTF-8 bytes from private stdin; no receipt +secret is accepted in argv/URI/environment. Receipt ACK means receipt only, not +ownership, completed work or permission to act. Operator confirmation is +explicitly labeled and must match retained original context. No arbitrary +message/transcript scan manufactures ACK. Ordinary generated Reply-To stays v1. diff --git a/docs/guide.ja.md b/docs/guide.ja.md index 1471f07..c89accb 100644 --- a/docs/guide.ja.md +++ b/docs/guide.ja.md @@ -338,3 +338,10 @@ npm `--ignore-scripts` は利用でき、postinstall は Skills CLI を呼び出 任意の `sp` 短縮名は 0.3.0 以降に含まれ(0.2.1 以前にはありません)、自動では有効になりません。 [明示的な有効化・衝突・解除(英語)](shorthand.md)を参照してください。 + + +## Unreleased handoff + +開発中の 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) diff --git a/docs/guide.ko.md b/docs/guide.ko.md index e9faeea..2da46aa 100644 --- a/docs/guide.ko.md +++ b/docs/guide.ko.md @@ -592,3 +592,9 @@ npm `--ignore-scripts`로 설치해도 되며, 설치 후 Skills CLI를 호출 선택적 `sp` 단축 이름은 0.3.0 이상에 포함되며(0.2.1 이하에는 없음) 자동으로 켜지지 않습니다. [명시적 활성화와 이름 충돌 안내(영문)](shorthand.md)를 참고하세요. + +## Unreleased handoff + +개발 중인 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) diff --git a/docs/guide.md b/docs/guide.md index 44c4eef..36a41f6 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -618,3 +618,10 @@ install a runtime. See [compatibility and validation](../PARITY.md#source-ts-ski Optional `sp` shorthand ships with 0.3.0 and later (not 0.2.1 or earlier) and is never enabled automatically. See [explicit activation and collisions](shorthand.md). + + +## 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. + +[CLI](api.md#unreleased-opt-in-handoff-cli--040-work) diff --git a/docs/guide.zh-CN.md b/docs/guide.zh-CN.md index 47ade0f..86c1814 100644 --- a/docs/guide.zh-CN.md +++ b/docs/guide.zh-CN.md @@ -322,3 +322,10 @@ postinstall 不会调用 Skills CLI,安装此技能不会覆盖 Python 技能 可选的 `sp` 简写包含在 0.3.0 及更高版本中(0.2.1 及更早版本没有),不会自动启用。 请参阅[显式启用、冲突与停用(英文)](shorthand.md)。 + + +## Unreleased handoff + +开发中的 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) diff --git a/scripts/release.mjs b/scripts/release.mjs index a03e175..db44366 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', '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', '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/cli.ts b/src/cli.ts index ad8b272..a8c97f5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { handoffCommand, handoffSend, privateMessage } from './handoff-operations.js'; +import { canonicalId, HandoffBudget, parseWaitTimeout } from './handoff.js'; import { doctor } from './diagnostics.js'; import { help } from './help.js'; import { renderOutput, type OutputFormat } from './output.js'; @@ -16,8 +18,8 @@ import { configuredHost, detectedHost, isSelf, probeReturnRoute, unsafeReturnHos type Options = { command: 'list' | 'send' | 'doctor' | 'update'; values: Map; flags: Set; hosts: string[]; ssh: SshOptions; jump?: SshJump; address?: { uri: string; transport: 'local' | 'ssh'; implicit: boolean } }; -const FLAGS = ['--json', '--all', '--dry-run', '--no-from', '--no-reply-to', '--no-update-notice', '--allow-inactive-codex-home', '--check-return-route', '--check']; -const VALUES = ['--agent', '--codex-home', '--codex-bin', '--output-format', '--to', '--message', '-m', '--host', '--remote-bin', '--remote-platform', '--ssh-control-path', '--reply-address', '--ssh-opt', '--ssh-jump', '--reply-to', '--return-route-host', '--channel']; +const FLAGS = ['--json', '--all', '--dry-run', '--no-from', '--no-reply-to', '--no-update-notice', '--allow-inactive-codex-home', '--check-return-route', '--check', '--request-ack', '--observe-delivery']; +const VALUES = ['--agent', '--codex-home', '--codex-bin', '--output-format', '--to', '--message', '-m', '--host', '--remote-bin', '--remote-platform', '--ssh-control-path', '--reply-address', '--ssh-opt', '--ssh-jump', '--reply-to', '--return-route-host', '--channel', '--correlation-id', '--wait-for', '--wait-timeout', '--message-file']; // Repeated in order; every other value option is single. const REPEATED = ['--host', '--ssh-opt']; // Requested destinations, so a command-wide refusal can be attributed to each. @@ -68,6 +70,20 @@ export function parse(args: string[]): Options { if (values.has('--output-format') && !['json', 'text'].includes(values.get('--output-format')!)) throw new Refusal('unsupported_output_format'); if (flags.has('--json') && values.get('--output-format') === 'text') throw new Refusal('conflicting_output_options'); if (!flags.has('--json') && !values.has('--output-format')) throw new Refusal('json_output_required'); + const handoff = flags.has('--request-ack') || flags.has('--observe-delivery') || values.has('--correlation-id') || values.has('--wait-for') || values.has('--wait-timeout'); + if (handoff) { + if (command !== 'send') throw new Refusal('inapplicable_option'); + if (values.has('--correlation-id') && !canonicalId(values.get('--correlation-id'))) throw new Refusal('invalid_correlation_id'); + if (values.has('--wait-for') && !['delivered', 'acknowledged'].includes(values.get('--wait-for')!)) throw new Refusal('invalid_wait_for'); + if (values.has('--wait-timeout')) { + parseWaitTimeout(values.get('--wait-timeout')!); + 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') { if (values.has('--channel') && !CHANNELS.includes(values.get('--channel')!)) throw new Refusal('unsupported_update_channel'); // Local client only: remote hosts and other installations update through their own managers. @@ -102,6 +118,9 @@ 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 (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'); @@ -143,17 +162,19 @@ export function parse(args: string[]): Options { } return { command, values, flags, hosts, ssh, ...(jump ? { jump } : {}), ...(address ? { address } : {}) }; } -async function input(): Promise { - const chunks: Buffer[] = []; - let bytes = 0; - for await (const chunk of process.stdin) { - const part = Buffer.from(chunk); bytes += part.length; - if (bytes > 4_100_000) throw new Refusal('input_too_large'); - chunks.push(part); - } - try { return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)); } - catch { throw new Refusal('invalid_utf8'); } +async function input(limit = 4_100_000, preserveBOM = false, timeoutMs?: number): Promise { + const chunks: Buffer[] = []; let bytes = 0; + const timer = timeoutMs === undefined ? undefined : setTimeout(() => process.stdin.destroy(new Refusal('deadline_before_effect', 1)), Math.max(1,timeoutMs)); + try { + for await (const chunk of process.stdin) { + const part = Buffer.from(chunk); bytes += part.length; + if (bytes > limit) throw new Refusal('input_too_large'); chunks.push(part); + } + try { return new TextDecoder('utf-8', {fatal:true,ignoreBOM:preserveBOM}).decode(Buffer.concat(chunks)); } + catch { throw new Refusal('invalid_utf8'); } + } finally { if(timer) clearTimeout(timer); } } + const quote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"; // Classify only the no-message version preflight. Never expose SSH stderr, // which can contain user paths or agent output. @@ -214,6 +235,8 @@ async function remote(options: Options, ssh: string, target: string, resolved: { 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 } { @@ -270,7 +293,13 @@ try { if (args.length === 1 && args[0] === REFRESH_ARG) await refreshCache(); else if (args.length === 1 && args[0] === '--version') console.log(VERSION_LINE); else if ((args.length === 1 && ['--help', '-h'].includes(args[0]!)) || - (args.length === 2 && ['list', 'send', 'doctor', 'update'].includes(args[0]!) && ['--help', '-h'].includes(args[1]!))) console.log(help(args.length === 2 ? args[0] : undefined)); + (args.length === 2 && ['list', 'send', 'doctor', 'update', 'handoff', 'ack'].includes(args[0]!) && ['--help', '-h'].includes(args[1]!))) console.log(help(args.length === 2 ? args[0] : undefined)); + else if (args[0] === 'handoff' || args[0] === 'ack') { + command = args[0]; + const operation = await handoffCommand(args, limit => input(limit, true)); + console.log(JSON.stringify({ schemaVersion: 1, host: hostname(), command, ok: operation.exitCode === 0, ...operation.value })); + process.exitCode = operation.exitCode; + } else { wire = args.length === 1 && args[0] === '--stdio-request'; if (wire) { @@ -282,6 +311,8 @@ try { } 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; 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'); @@ -291,11 +322,21 @@ try { // Tailscale status is queried at most once, and only when a route needs it. let status: Tailnet | undefined, queried = false; const tailnetStatus = async () => { if (!queried) { queried = true; status = await tailnet(); } return status; }; + let rawMessage: string | undefined; let message: string | undefined, routing: Record = {}; if (command === 'send') { - message = options.values.get('--message'); - if (message === undefined || message === '-') { if (wire) throw new Refusal('remote_message_required'); message = await input(); } - checkMessage(message); + if (options.values.has('--message-file')) { + // File contents are literal, including a single dash. Only the local + // command-line message source uses '-' as the stdin selector. + message = privateMessage(options.values.get('--message-file')!); + } else { + message = options.values.get('--message'); + if (message === undefined || (!wire && message === '-')) { + if (wire) throw new Refusal('remote_message_required'); + message = await input(4_100_000, false, handoffBudget?.observationRemainingMs()); + } + } + checkMessage(message); rawMessage = message; const noFrom = options.flags.has('--no-from'), noReply = options.flags.has('--no-reply-to'); if (options.address) { const resolution: Record = { uri: options.address.uri, transport: options.address.transport }; @@ -337,7 +378,13 @@ 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)); - else if (command === 'send') result = await send({ 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') }); + 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 })); + 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')); else if (command === 'doctor') { result = await doctor(options.values.get('--agent') as 'claude' | 'codex' | undefined, options.values.get('--codex-home'), options.values.get('--codex-bin')); @@ -366,7 +413,13 @@ try { } } catch (error) { // A command-wide refusal before dispatch applies to every requested host. + if (command === 'handoff' || command === 'ack') { + const failureCode = error instanceof Refusal ? error.code : 'handoff_operation_failed'; + console.log(JSON.stringify({ schemaVersion: 1, host: hostname(), command, ok: false, error: failureCode })); + process.exitCode = error instanceof Refusal ? error.exitCode : 1; + } else { const { value, exitCode } = failure(error, command, hostname()); console.log(renderOutput(!wire && requested.length > 1 ? requested.map(item => ({ ...value, host: item })) : value, format)); process.exitCode = exitCode; + } } diff --git a/src/handoff-ledger.ts b/src/handoff-ledger.ts new file mode 100644 index 0000000..6c6b8c0 --- /dev/null +++ b/src/handoff-ledger.ts @@ -0,0 +1,469 @@ +// Private POSIX ledger. No native submission or transcript access occurs here. +// Fencing assumes intact retained history: unmarked clones/rollbacks cannot be +// detected by a local-only ledger. Windows ACL/bootstrap support is not claimed. +import { randomBytes, randomUUID, createHash, timingSafeEqual, createCipheriv, createDecipheriv } from 'node:crypto'; +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 { 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'; + +export type HandoffBinding = Readonly<{ agent: 'claude' | 'codex'; destination: string; target: string; + 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 }; +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 }; +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' } }; +export type HandoffStatus = { handoff: Handoff } | { reason: 'handoff_history_unavailable'; handoffQuery: HandoffQueryError }; +const reserveBytes = 65536; +export const payloadDigest = (payload: string | Uint8Array): string => createHash('sha256').update(payload).digest('hex'); +function checkBinding(value: HandoffBinding): HandoffBinding { + const row = closed(value, ['agent', 'destination', 'target', 'generation', 'payloadDigest'], ['home', 'writerIdentity']); + if (!['claude', 'codex'].includes(row.agent as string) || !safeId(row.destination, 512) || !safeId(row.target, 256) || + (row.generation !== null && !safeId(row.generation, 256)) || typeof row.payloadDigest !== 'string' || !/^[a-f0-9]{64}$/.test(row.payloadDigest) || + (row.home !== undefined && (!safeId(row.home, 4096) || !isAbsolute(row.home))) || + (row.writerIdentity !== undefined && !safeId(row.writerIdentity, 256))) fail('invalid_handoff_binding', 2); + return structuredClone(value); +} +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 privateStat(path: string, directory: boolean) { + const uid = process.getuid?.(); if (uid === undefined || process.platform === 'win32') fail('handoff_storage_unsupported'); + const stat = lstatSync(path); + if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || stat.uid !== uid || + (stat.mode & 0o777) !== (directory ? 0o700 : 0o600) || (!directory && stat.nlink !== 1)) fail('handoff_storage_untrusted'); + return stat; +} +export class HandoffLedger { + readonly path: string; + private readonly clock: Clock; + private readonly clockId = randomUUID(); + constructor(path: string, options: { clock?: Clock } = {}) { + if (!isAbsolute(path)) fail('invalid_handoff_ledger', 2); + this.path = resolve(path); this.clock = options.clock ?? systemClock; + } + private filename(): string { return join(this.path, 'ledger.json'); } + // The separate private key is created only by fresh explicit init. A missing, + // changed or untrusted key cannot authorize migration or new submission. + private key(): Buffer { + const path = join(this.path, 'ledger.key'), before = privateStat(path, false); + if (before.size !== 32) fail('handoff_ledger_corrupt'); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const opened = fstatSync(fd), bytes = Buffer.alloc(33); + if (opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== 32 || opened.nlink !== 1 || opened.uid !== before.uid || (opened.mode & 0o777) !== 0o600) fail('handoff_storage_untrusted'); + let length = 0; + while (length < bytes.length) { const count = readSync(fd, bytes, length, bytes.length - length, length); if (!count) break; length += count; } + const after = fstatSync(fd), current = privateStat(path, false); + if (length !== 32 || after.size !== 32 || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || + after.nlink !== 1 || after.uid !== opened.uid || after.mode !== opened.mode || current.dev !== opened.dev || current.ino !== opened.ino || + current.size !== after.size || current.mtimeMs !== after.mtimeMs || current.ctimeMs !== after.ctimeMs) fail('handoff_storage_untrusted'); + return Buffer.from(bytes.subarray(0, 32)); + } finally { closeSync(fd); } + } + private protectDigest(digest: string, key: Buffer, epoch: string, id: string): string { + const nonce = randomBytes(12), cipher = createCipheriv('aes-256-gcm', key, nonce); + cipher.setAAD(Buffer.from(JSON.stringify(['handoff-binding-v1', epoch, id]))); + const ciphertext = Buffer.concat([cipher.update(Buffer.from(digest, 'hex')), cipher.final()]); + return `sealed-v1:${nonce.toString('hex')}:${ciphertext.toString('hex')}:${cipher.getAuthTag().toString('hex')}`; + } + private revealDigest(value: unknown, key: Buffer, epoch: string, id: string): string { + if (typeof value !== 'string' || !/^sealed-v1:[a-f0-9]{24}:[a-f0-9]{64}:[a-f0-9]{32}$/.test(value)) fail('handoff_ledger_corrupt'); + const [, nonce, ciphertext, tag] = (value as string).split(':'); + const cipher = createDecipheriv('aes-256-gcm', key, Buffer.from(nonce!, 'hex')); + cipher.setAAD(Buffer.from(JSON.stringify(['handoff-binding-v1', epoch, id]))); cipher.setAuthTag(Buffer.from(tag!, 'hex')); + return Buffer.concat([cipher.update(Buffer.from(ciphertext!, 'hex')), cipher.final()]).toString('hex'); + } + private lock(operation: () => T, create = false): T { + try { privateStat(this.path, true); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') fail('handoff_history_unavailable'); throw error; } + const path = join(this.path, 'ledger.lock'); + if (create) { + try { const created = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); fsyncSync(created); closeSync(created); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; } + } + const before = privateStat(path, false); + const fd = openSync(path, constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const opened = fstatSync(fd); if (opened.dev !== before.dev || opened.ino !== before.ino) fail('handoff_storage_untrusted'); + const locks = createRequire(import.meta.url)('fs-ext-extra-prebuilt') as { flockSync: (fd: number, mode: string) => void }; + try { locks.flockSync(fd, 'exnb'); } catch { fail('handoff_ledger_busy'); } + try { return operation(); } finally { locks.flockSync(fd, 'un'); } + } finally { closeSync(fd); } + } + private read(): Ledger { + privateStat(this.path, true); + const path = this.filename(), stat = privateStat(path, false); + if (stat.size > HANDOFF_LIMITS.journal) fail('handoff_ledger_corrupt'); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + let bytes: Buffer; + try { + const opened = fstatSync(fd); if (opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size > HANDOFF_LIMITS.journal) fail('handoff_storage_untrusted'); + const buffer = Buffer.alloc(Math.min(opened.size + 1, HANDOFF_LIMITS.journal + 1)); + let length = 0; + while (length < buffer.length) { const count = readSync(fd, buffer, length, buffer.length - length, length); if (!count) break; length += count; } + if (length > opened.size || length > HANDOFF_LIMITS.journal) fail('handoff_ledger_corrupt'); + bytes = buffer.subarray(0, length); + } finally { closeSync(fd); } + try { + const wrapper = closed(parseStrictJson(bytes, { scope: 'outer', maxBytes: HANDOFF_LIMITS.journal }), ['digest', 'data']); + if (typeof wrapper.digest !== 'string' || wrapper.digest !== payloadDigest(JSON.stringify(wrapper.data))) fail(); + const data = closed(wrapper.data, ['version', 'epoch', 'keyId', 'quarantined', 'records']); + if (data.version !== 1 || !canonicalId(data.epoch) || typeof data.quarantined !== 'boolean' || !data.records || typeof data.records !== 'object' || Array.isArray(data.records)) fail(); + const key = this.key(); + if (data.keyId !== payloadDigest(key)) fail(); + 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']); + if (!canonicalId(id)) fail(); + item.binding = { ...item.binding, payloadDigest: this.revealDigest(item.binding?.payloadDigest, key, data.epoch as string, id) }; + checkBinding(item.binding); + 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)) || + (item.capabilityClockId !== undefined && !canonicalId(item.capabilityClockId)) || + (item.receiptId !== undefined && !canonicalId(item.receiptId)) || + (item.deliverySupported !== undefined && typeof item.deliverySupported !== 'boolean') || + (item.clientUserMessageId !== undefined && !safeId(item.clientUserMessageId)) || + (item.deliverySupported && (!safeId(item.clientUserMessageId) || item.binding.generation === null))) fail(); + const h = validateHandoff(item.handoff); + if (h.correlationId !== id || h.ledgerEpoch !== data.epoch || h.targetGeneration !== item.binding.generation) fail(); + for (const wait of item.waits) { + closed(wait, ['public', 'clockId', 'cutoff']); + if (!canonicalId(wait.clockId) || typeof wait.cutoff !== 'number' || !Number.isFinite(wait.cutoff)) fail(); + // Current actions belong to the latest operation, not archived waits. + validateHandoff({ ...h, wait: wait.public, nextActions: ['reconcile'] }); + } + } + return data as Ledger; + } catch { fail('handoff_ledger_corrupt'); } + } + private write(data: Ledger): void { + for (const item of Object.values(data.records)) validateHandoff(item.handoff); + const key = this.key(); + if (data.keyId !== payloadDigest(key)) fail('handoff_ledger_corrupt'); + const stored = structuredClone(data); + for (const [id, item] of Object.entries(stored.records)) { + checkBinding(item.binding); + item.binding = { ...item.binding, payloadDigest: this.protectDigest(item.binding.payloadDigest, key, data.epoch, id) }; + } + // Both quota accounting and the corruption checksum use only ciphertext. + // A checksum over transient plaintext would itself permit payload guessing. + const text = JSON.stringify({ digest: payloadDigest(JSON.stringify(stored)), data: stored }); + const reserves = Object.values(stored.records).reduce((sum, item) => sum + Math.max(0, item.reserve - Buffer.byteLength(JSON.stringify(item))), 0); + if (Buffer.byteLength(text) + reserves > HANDOFF_LIMITS.journal) fail('handoff_ledger_full'); + const temporary = join(this.path, `.ledger-${randomUUID()}.tmp`); + const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + try { writeFileSync(fd, text, 'utf8'); fsyncSync(fd); } + catch (error) { try { unlinkSync(temporary); } catch {} throw error; } + finally { closeSync(fd); } + try { + renameSync(temporary, this.filename()); + const directory = openSync(this.path, constants.O_RDONLY); try { fsyncSync(directory); } finally { closeSync(directory); } + } catch (error) { try { unlinkSync(temporary); } catch {} throw error; } + } + init(): { ledgerEpoch: string; storage: 'private_posix'; cloneRollbackProtection: 'intact_retained_history_only' } { + if (process.platform === 'win32' || process.getuid?.() === undefined) fail('handoff_storage_unsupported'); + try { mkdirSync(this.path, { mode: 0o700 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; } + return this.lock(() => { + try { lstatSync(this.filename()); fail('handoff_ledger_exists'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + const key = randomBytes(32), keyPath = join(this.path, 'ledger.key'); + const keyFd = openSync(keyPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + try { writeFileSync(keyFd, key); fsyncSync(keyFd); } finally { closeSync(keyFd); } + const directory = openSync(this.path, constants.O_RDONLY); try { fsyncSync(directory); } finally { closeSync(directory); } + const epoch = randomUUID(); this.write({ version: 1, epoch, keyId: payloadDigest(key), quarantined: false, records: {} }); + return { ledgerEpoch: epoch, storage: 'private_posix', cloneRollbackProtection: 'intact_retained_history_only' }; + }, true); + } + private usable(data: Ledger, id: string): RecordIntent { + if (!canonicalId(id)) fail('invalid_correlation_id', 2); + const item = data.records[id]; if (!item) fail('handoff_id_unknown'); + if (data.quarantined) fail('handoff_ledger_quarantined'); + if (item.expired || this.clock.utc() - item.preparedUtc >= HANDOFF_LIMITS.detailMs) fail('handoff_history_unavailable'); + return item; + } + private snapshot(item: RecordIntent, quarantine = false): 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 }; + h.ack = { status: 'unsupported' }; h.wait = { for: 'none', status: 'not_requested' }; h.nextActions = ['reconcile']; + } else if (item.phase === 'attempted') { h.state = 'unknown'; h.submission = { status: 'unknown' }; h.nextActions = ['reconcile']; } + 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') { + h.observation = { status: 'unsupported', injectionObserved: false }; h.nextActions = ['reconcile']; + } + return validateHandoff(h); + } + status(id: string): HandoffStatus { + if (!canonicalId(id)) fail('invalid_correlation_id', 2); + let context: HandoffQueryError['context'] = 'ledger_corrupt'; + try { + const data = this.read(), item = data.records[id]; + if (item) return { handoff: this.snapshot(item, data.quarantined) }; + context = 'id_unknown'; + } 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' } } }; + } + 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); + if (options.correlationId && !canonicalId(options.correlationId)) fail('invalid_correlation_id', 2); + return this.lock(() => { + const data = this.read(); if (data.quarantined) fail('handoff_ledger_quarantined'); + let id = options.correlationId; + if (id) { + const item = this.usable(data, id); + if (!sameBinding(item.binding, binding)) fail('handoff_binding_conflict'); + if (item.phase !== 'prepared') fail('handoff_already_attempted'); + if (!options.dryRun) { this.configure(item, options); this.write(data); } + return this.snapshot(item); + } + if (options.dryRun) fail('prepared_correlation_required', 2); + if (Object.keys(data.records).length >= HANDOFF_LIMITS.intents) fail('handoff_ledger_full'); + id = randomUUID(); const goal = options.waitFor ?? 'none'; + if (!['none', 'delivered', 'acknowledged'].includes(goal)) fail('invalid_wait_for', 2); + // Runtime channels remain unsupported until a compatible private bootstrap + // and producer are proven by the integration, not merely by generation. + const h: Handoff = { schemaVersion: 1, correlationId: id, ledgerEpoch: data.epoch, state: 'validated', submission: { status: 'not_attempted' }, + observation: { status: options.observeDelivery ? 'unsupported' : 'not_requested', injectionObserved: false }, + ack: { status: options.requestAck ? 'unsupported' : 'not_requested' }, wait: { for: 'none', status: 'not_requested' }, + targetGeneration: binding.generation, decisionOwner: 'sender_operator', retry: { allowed: false, reason: 'receiver_dedup_unavailable' }, nextActions: [] }; + const item: RecordIntent = { binding, preparedUtc: Math.floor(this.clock.utc()), clockId: this.clockId, preparedMono: this.clock.monotonic(), phase: 'prepared', handoff: h, waits: [], reserve: reserveBytes }; + data.records[id] = item; + this.configure(item, options); + this.write(data); return this.snapshot(item); + }); + } + private configure(item: RecordIntent, options: PrepareOptions): void { + const h = item.handoff, goal = options.waitFor ?? 'none'; + if (!['none', 'delivered', 'acknowledged'].includes(goal)) fail('invalid_wait_for', 2); + if (options.clientUserMessageId !== undefined && !safeId(options.clientUserMessageId)) fail('invalid_client_user_message_id', 2); + const clientId = options.clientUserMessageId ?? h.correlationId; + if (item.clientUserMessageId !== undefined && item.clientUserMessageId !== clientId) fail('handoff_binding_conflict'); + if (options.requestAck) h.ack.status = 'unsupported'; + const delivery = (options.observationSupported === true || options.channels?.delivery === true) && item.binding.generation !== null; + if (options.observeDelivery || goal === 'delivered') { + item.deliverySupported = delivery; item.clientUserMessageId = clientId; + h.observation.status = delivery ? 'pending' : 'unsupported'; + } + if (goal !== 'none') { + const budget = options.budget ?? new HandoffBudget(30, this.clock); this.addWait(item, goal, budget); + if (goal === 'delivered' && delivery) h.wait.status = 'pending'; + else { h.wait.status = 'unsupported'; h.wait.reason = 'evidence_unsupported'; } + try { budget.beforeEffect(); } catch (error) { + h.wait.status = 'failed'; h.wait.reason = (error as { code: WaitReason }).code; + } + if (goal === 'acknowledged') h.ack.status = 'unsupported'; + if (h.wait.status !== 'pending') { h.state = 'refused'; h.submission.status = 'refused'; item.phase = 'refused'; } + item.waits.at(-1)!.public = structuredClone(h.wait); + } + } + // Durable intent is synced before returning authorization to the caller. + // A crash after this point, even before native effect, can never authorize retry. + commitEffect(id: string, binding: HandoffBinding, budget?: HandoffBudget): Handoff { + binding = checkBinding(binding); + return this.lock(() => { + const data = this.read(), item = this.usable(data, id); + if (!sameBinding(item.binding, binding)) fail('handoff_binding_conflict'); + if (item.phase !== 'prepared') fail('handoff_already_attempted'); + try { budget?.beforeEffect(); } catch (error) { + this.refuse(item, (error as { code: WaitReason }).code); this.write(data); throw error; + } + item.phase = 'attempted'; item.clockId = this.clockId; + item.handoff.state = 'unknown'; item.handoff.submission.status = 'unknown'; item.handoff.nextActions = ['reconcile']; + this.write(data); return this.snapshot(item); + }); + } + private refuse(item: RecordIntent, reason?: WaitReason): void { + item.phase = 'refused'; item.handoff.state = 'refused'; item.handoff.submission.status = 'refused'; item.handoff.nextActions = []; + if (item.handoff.observation.status === 'pending') item.handoff.observation = { status: 'failed', injectionObserved: false }; + if (item.handoff.ack.status === 'pending') { item.handoff.ack = { status: 'unsupported' }; item.revoked = true; } + const wait = item.waits.at(-1); + if (wait?.public.status === 'pending') { + item.handoff.wait.status = 'failed'; item.handoff.wait.reason = reason ?? 'evidence_failed'; wait.public = structuredClone(item.handoff.wait); + } + } + refusePrepared(id: string, binding: HandoffBinding, reason?: WaitReason): Handoff { + binding = checkBinding(binding); + return this.lock(() => { + const data = this.read(), item = this.usable(data, id); + if (!sameBinding(item.binding, binding)) fail('handoff_binding_conflict'); + if (item.phase !== 'prepared') fail('handoff_already_attempted'); + this.refuse(item, reason); this.write(data); return this.snapshot(item); + }); + } + recordSubmission(id: string, status: 'submitted' | 'unknown' | 'refused'): Handoff { + return this.lock(() => { + const data = this.read(), item = this.usable(data, id); + if (item.phase !== 'attempted' || item.clockId !== this.clockId) fail('handoff_outcome_not_authorized'); + item.phase = 'finished'; item.handoff.submission.status = status; + item.handoff.state = status; item.handoff.nextActions = ['reconcile']; + if (status === 'refused') this.refuse(item, 'evidence_failed'); + this.write(data); return this.snapshot(item); + }); + } + recordObservation(id: string, observation: HandoffObservation, originalBinding: HandoffBinding): Handoff { + originalBinding = checkBinding(originalBinding); + closed(observation, ['clientUserMessageId', 'injectionObserved'], ['turn']); + if (!safeId(observation.clientUserMessageId) || typeof observation.injectionObserved !== 'boolean' || + (!observation.injectionObserved && observation.turn !== undefined)) fail('invalid_handoff_observation'); + return this.lock(() => { + const data = this.read(), item = this.usable(data, id), h = item.handoff; + if (!sameBinding(item.binding, originalBinding)) fail('handoff_binding_conflict'); + if (!item.deliverySupported || item.binding.generation === null || item.clientUserMessageId !== observation.clientUserMessageId || + item.phase !== 'finished' || h.submission.status !== 'submitted') fail('handoff_observation_unverified'); + if (!observation.injectionObserved) return this.snapshot(item); + if (observation.turn !== undefined) { + closed(observation.turn, ['id', 'status']); + if (!safeId(observation.turn.id) || !['running', 'completed', 'failed', 'interrupted', 'unknown'].includes(observation.turn.status)) fail('invalid_handoff_observation'); + const prior = h.observation.turn; + if (prior && (prior.id !== observation.turn.id || (['completed', 'failed', 'interrupted'].includes(prior.status) && prior.status !== observation.turn.status))) fail('handoff_turn_conflict'); + } + h.observation = { status: 'observed', injectionObserved: true, clientUserMessageId: observation.clientUserMessageId, + ...(observation.turn ? { turn: structuredClone(observation.turn) } : h.observation.turn ? { turn: h.observation.turn } : {}) }; + if (h.ack.status !== 'acknowledged') h.state = 'delivered'; + // All delivery operations share this evidence instant, but keep their own + // 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'; + } + if (item.waits.length) h.wait = structuredClone(item.waits.at(-1)!.public); + h.nextActions = ['reconcile']; this.write(data); return 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 }; + 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 { + 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); + const h = item.handoff; + 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') { + h.nextActions = ['keep_waiting', 'reconcile', 'stop_waiting']; + } + else if (goal === 'acknowledged' && item.capabilityHash && !item.revoked && item.capabilityClockId === this.clockId && + this.clock.monotonic() - item.preparedMono < HANDOFF_LIMITS.capabilityMs && item.binding.generation !== null && h.submission.status === 'submitted') { + h.ack.status = 'pending'; h.nextActions = ['keep_waiting', 'reconcile', 'stop_waiting']; + } else { h.wait.status = 'unsupported'; h.wait.reason = 'evidence_unsupported'; } + item.waits.at(-1)!.public = structuredClone(h.wait); + 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); + 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); + }); + } + private commitAck(item: RecordIntent, assurance: 'operator_confirmed' | 'token_possession'): void { + const h = item.handoff, origin = item.waits[0]; + // Classify every pending ACK operation against one observed instant. A + // newer wait must not hide an older operation's deadline or clock loss. + const now = this.clock.monotonic(); + const pending = item.waits.filter(wait => wait.public.status === 'pending' && wait.public.for === 'acknowledged'); + if (pending.some(wait => wait.clockId !== this.clockId)) fail('receipt_order_unprovable'); + let late = false; + if (origin) { + if (origin.public.status === 'timed_out_unknown') late = true; + else if (origin.clockId === this.clockId) late = now >= origin.cutoff; + else fail('receipt_order_unprovable'); + } + h.state = 'acknowledged'; h.ack = { status: 'acknowledged', assurance, receivedAtUtcMs: Math.floor(this.clock.utc()), late }; + for (const wait of pending) wait.public.status = now < wait.cutoff ? 'satisfied' : 'timed_out_unknown'; + if (item.waits.length) h.wait = structuredClone(item.waits.at(-1)!.public); + h.nextActions = ['reconcile']; + } + confirm(raw: string | Uint8Array): Handoff { + const proof = validateConfirmation(raw); + return this.lock(() => { + const data = this.read(), item = this.usable(data, proof.correlationId); + if (data.epoch !== proof.ledgerEpoch || item.binding.generation !== proof.targetGeneration || item.handoff.submission.status !== 'submitted') fail('confirmation_binding_invalid'); + if (item.handoff.ack.status === 'acknowledged') return this.snapshot(item); + this.commitAck(item, 'operator_confirmed'); + this.write(data); return this.snapshot(item); + }); + } + // Caller must already validate a compatible private receipt-only producer. + // This returns authority for authorized private effect input only; never argv, + // environment, public output or persistence. The raw value is not recoverable. + mintCapability(id: string, bootstrap: { privateConsumerValidated: true; compatibleProducerValidated: true }): string { + if (bootstrap.privateConsumerValidated !== true || bootstrap.compatibleProducerValidated !== true) fail('receipt_bootstrap_unsupported'); + return this.lock(() => { + const data = this.read(), item = this.usable(data, id); + if (item.phase !== 'prepared' || item.binding.generation === null || item.capabilityHash || item.clockId !== this.clockId || + this.clock.monotonic() - item.preparedMono >= HANDOFF_LIMITS.capabilityMs) fail('receipt_authority_unavailable'); + const capability = randomBytes(32).toString('base64url'); item.capabilityHash = payloadDigest(capability); + item.capabilityClockId = this.clockId; + item.handoff.ack.status = 'pending'; item.clockId = this.clockId; this.write(data); return capability; + }); + } + acceptReceipt(raw: string | Uint8Array): Handoff { + const proof = validateReceipt(raw); + return this.lock(() => { + const data = this.read(), item = this.usable(data, proof.correlationId); + const expected = Buffer.from(item.capabilityHash ?? '0'.repeat(64), 'hex'), actual = Buffer.from(payloadDigest(proof.capability), 'hex'); + const authenticated = timingSafeEqual(expected, actual); + if (!authenticated || !item.capabilityHash || item.revoked || data.epoch !== proof.ledgerEpoch || + item.binding.generation !== proof.targetGeneration || item.handoff.submission.status !== 'submitted') fail('receipt_authentication_failed'); + if (item.receiptId) { if (item.receiptId !== proof.receiptId) fail('receipt_already_committed'); return this.snapshot(item); } + if (item.handoff.ack.status === 'acknowledged') fail('receipt_already_committed'); + if (item.capabilityClockId !== this.clockId || this.clock.monotonic() - item.preparedMono >= HANDOFF_LIMITS.capabilityMs) fail('receipt_authority_expired'); + this.commitAck(item, 'token_possession'); item.receiptId = proof.receiptId; + this.write(data); return this.snapshot(item); + }); + } + revoke(id: string): void { this.lock(() => { const data = this.read(), item = this.usable(data, id); item.revoked = true; this.write(data); }); } + quarantine(): void { this.lock(() => { const data = this.read(); data.quarantined = true; this.write(data); }); } + prune(): void { + this.lock(() => { + const data = this.read(); + for (const item of Object.values(data.records)) if (this.clock.utc() - item.preparedUtc >= HANDOFF_LIMITS.detailMs) { + item.expired = true; item.phase = 'finished'; item.handoff = this.snapshot(item); item.waits = []; item.reserve = 0; + delete item.capabilityHash; delete item.capabilityClockId; delete item.receiptId; + } + this.write(data); + }); + } +} diff --git a/src/handoff-operations.ts b/src/handoff-operations.ts new file mode 100644 index 0000000..aa49b4e --- /dev/null +++ b/src/handoff-operations.ts @@ -0,0 +1,160 @@ +// Opt-in integration only. Default CLI imports this without opening storage or +// starting an observer/collector. Production receipt bootstrap is unqualified. +import { homedir, hostname } from 'node:os'; +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 { Refusal } from './discovery.js'; +import { 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 }; +export type OperationResult = { value: Record; exitCode: number }; +export function ledgerPath(): string { + const path = process.env.SESSION_PEER_HANDOFF_HOME ?? join(homedir(), '.local', 'state', 'session-peer-ts', 'handoff'); + if (!isAbsolute(path)) throw new Refusal('invalid_handoff_ledger', 2); + return resolve(path); +} +export function privateMessage(path: string): string { + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || before.size > 4_100_000 || + (process.platform !== 'win32' && (before.uid !== process.getuid?.() || (before.mode & 0o777) !== 0o600))) throw new Refusal('private_message_file_untrusted', 2); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const actual = fstatSync(fd); + if (!actual.isFile() || actual.ino !== before.ino || actual.dev !== before.dev || actual.size !== before.size) throw new Refusal('private_message_file_untrusted', 2); + const buffer = Buffer.alloc(4_100_001); let length = 0; + while (length < buffer.length) { const count = readSync(fd, buffer, length, buffer.length - length, null); if (!count) break; length += count; } + const bytes = buffer.subarray(0, length); if (bytes.length > 4_100_000) throw new Refusal('input_too_large'); + const after = fstatSync(fd); + if (after.size !== actual.size || after.mtimeMs !== actual.mtimeMs || length !== actual.size) throw new Refusal('private_message_file_changed', 1); + try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { throw new Refusal('invalid_utf8'); } + } finally { closeSync(fd); } +} +function binding(context: SendContext, message: string, destination = hostname()): HandoffBinding { + return { ...context, destination, payloadDigest: payloadDigest(message) }; +} +export function correlationEnvelope(message: string, id: string): string { + if (!canonicalId(id)) throw new Refusal('invalid_correlation_id', 2); + // A dedicated inert metadata line, not an executable Reply-To extension. + return message + '\n\n---\nHandoff: ' + JSON.stringify({ schemaVersion: 1, correlationId: id }); +} +class BeforeEffectRefused extends Error {} +export async function prepareHandoff(options: SendOptions, path = ledgerPath()): 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)); + } } }); + return { value: { ...value, handoff }, exitCode: 0 }; +} +export async function handoffSend(options: SendOptions, handoffOptions: HandoffOptions): Promise { + const ledger = new HandoffLedger(handoffOptions.ledgerPath ?? ledgerPath()); + const budget = handoffOptions.budget ?? new HandoffBudget(handoffOptions.seconds ?? 30); + let handoff: Handoff | undefined, snapshot: Record | undefined; + let original: HandoffBinding | undefined, fenced = false, knownNative: Record | undefined; + const finish = (value: Record, exitCode: number): OperationResult => ({ value: { ...value, ...(handoff ? { handoff } : {}) }, exitCode }); + try { + const value = await send({ ...options, hooks: { + 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'); + 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 }); + 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; }, + timeoutMs() { budget.beforeEffect(); return Math.max(1, Math.floor(budget.observationRemainingMs())); }, + } }); + knownNative = value; + if (!options.dryRun) handoff = ledger.recordSubmission(handoff!.correlationId, 'submitted'); + 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'] }; + 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); + } + 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'); } + catch { const state = ledger.status(handoff.correlationId); if ('handoff' in state) handoff = state.handoff; } + } + 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); + } +} + +// 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 { + const ack = args[0] === 'ack', operation = ack ? 'ack' : args[1]; + if (!operation || !['init', 'prepare', 'status', 'wait', 'confirm', 'ack'].includes(operation)) throw new Refusal('unsupported_handoff_command'); + const values = new Map(); const flags = new Set(); + const names = ['--correlation-id', '--wait-for', '--wait-timeout', '--receipt', '--to', '--message-file', '--codex-home', '--codex-bin', '--output-format']; + for (let i = ack ? 1 : 2; i < args.length; i++) { + const token = args[i]!, equal = token.indexOf('='), name = equal < 0 ? token : token.slice(0, equal); + if (['--json', '--no-update-notice', '--allow-inactive-codex-home'].includes(name)) { + if (equal >= 0 || flags.has(name)) throw new Refusal('invalid_option'); flags.add(name); continue; + } + if (!names.includes(name) || values.has(name)) throw new Refusal('unsupported_option'); + const value = equal < 0 ? args[++i] : token.slice(equal + 1); + if (value === undefined || (equal < 0 && value.startsWith('--'))) throw new Refusal('invalid_option'); values.set(name, value); + } + if (!flags.has('--json') && values.get('--output-format') !== 'json') throw new Refusal('json_output_required'); + if (values.has('--output-format') && values.get('--output-format') !== 'json') throw new Refusal('unsupported_output_format'); + const permitted: Record = { init: [], prepare: ['--to', '--message-file', '--codex-home', '--codex-bin'], + status: ['--correlation-id'], wait: ['--correlation-id', '--wait-for', '--wait-timeout'], confirm: ['--receipt'], ack: ['--receipt'] }; + if ([...values.keys()].some(k => k !== '--output-format' && !permitted[operation]!.includes(k)) || + (flags.has('--allow-inactive-codex-home') && operation !== 'prepare')) throw new Refusal('inapplicable_option'); + const path = ledgerPath(), ledger = new HandoffLedger(path); + if (operation === 'init') { + if (process.platform === 'win32') throw new Refusal('handoff_platform_unsupported', 1); + const parent = dirname(path); + // Initialization is explicit; never follow a symlinked state prefix. + let current = parse(parent).root; + for (const component of parent.slice(current.length).split('/').filter(Boolean)) { + current = join(current, component); + try { const stat = lstatSync(current); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Refusal('handoff_storage_untrusted', 1); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; mkdirSync(current, { mode: 0o700 }); } + } + if (realpathSync(parent) !== parent) throw new Refusal('handoff_storage_untrusted', 1); + return { value: ledger.init(), exitCode: 0 }; + } + if (operation === 'prepare') { + if (!values.get('--to') || !values.get('--message-file')) throw new Refusal('invalid_send_options'); + const value = await prepareHandoff({ to: values.get('--to')!, message: privateMessage(values.get('--message-file')!), + home: values.get('--codex-home'), codexBin: values.get('--codex-bin'), allowInactive: flags.has('--allow-inactive-codex-home') }, path); + return { value: { handoff: value.value.handoff }, exitCode: 0 }; + } + if (operation === 'confirm' || operation === 'ack') { + if (values.get('--receipt') !== '-') throw new Refusal('private_receipt_stdin_required', 2); + const raw = await readInput(4096); + 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 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 }; +} diff --git a/src/handoff.ts b/src/handoff.ts new file mode 100644 index 0000000..9876db5 --- /dev/null +++ b/src/handoff.ts @@ -0,0 +1,176 @@ +// Pure opt-in protocol helpers. Importing this module performs no filesystem IO. +import { Refusal } from './discovery.js'; + +export const HANDOFF_LIMITS = Object.freeze({ privateFrame: 4096, publicFrame: 8192, outerFrame: 1048576, + fanout: 32, aggregateFrame: 34603008, intents: 10000, journal: 33554432, waits: 64, + detailMs: 30 * 86400000, capabilityMs: 86400000, cleanupMs: 5000 }); +export type WaitFor = 'none' | 'delivered' | 'acknowledged'; +export type WaitStatus = 'not_requested' | 'pending' | 'satisfied' | 'timed_out_unknown' | 'stopped' | 'unsupported' | 'failed'; +export type WaitReason = 'insufficient_budget' | 'deadline_before_effect' | 'evidence_unsupported' | 'evidence_failed' | 'history_unavailable' | 'stopped_by_operator' | 'invalid_handoff'; +export type Handoff = { + schemaVersion: 1; correlationId: string; ledgerEpoch: string; + state: 'validated' | 'refused' | 'submitted' | 'delivered' | 'acknowledged' | 'unknown' | 'timed_out_unknown'; + submission: { status: 'not_attempted' | 'submitted' | 'refused' | 'unknown' }; + observation: { status: 'not_requested' | 'pending' | 'observed' | 'unsupported' | 'failed'; injectionObserved: boolean; + clientUserMessageId?: string; turn?: { id: string; status: 'running' | 'completed' | 'failed' | 'interrupted' | 'unknown' } }; + ack: { status: 'not_requested' | 'pending' | 'acknowledged' | 'unsupported'; assurance?: 'token_possession' | 'operator_confirmed'; receivedAtUtcMs?: number; late?: boolean }; + wait: { for: WaitFor; status: WaitStatus; operationId?: string; deadlineAtUtcMs?: number; reason?: WaitReason }; + targetGeneration: string | null; decisionOwner: 'sender_operator'; + retry: { allowed: false; reason: 'receiver_dedup_unavailable' }; + nextActions: ('keep_waiting' | 'reconcile' | 'stop_waiting')[]; +}; +export type PublicHandoff = Handoff; +export type Clock = { monotonic: () => number; utc: () => number }; +export const systemClock: Clock = { monotonic: () => performance.now(), utc: () => Date.now() }; +export function fail(code = 'invalid_handoff', exitCode = 1): never { throw new Refusal(code, exitCode); } +export function canonicalId(value: unknown): value is string { + return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value); +} +export function safeInteger(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; } +export function safeId(value: unknown, bytes = 128): value is string { + return typeof value === 'string' && value.length > 0 && Buffer.byteLength(value, 'utf8') <= bytes && + !/[\x00-\x1f\x7f-\x9f]/.test(value) && !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?= this.cutoff) fail('deadline_before_effect'); + } +} + +// Recursive token parser keeps integer lexemes before JavaScript can round them. +// Outer mode restricts new-schema subtrees only; native numeric siblings retain +// their existing grammar. Duplicate keys/nonfinite numbers always fail closed. +export function parseStrictJson(raw: string | Uint8Array, options: { scope?: 'private' | 'handoff' | 'query' | 'outer'; maxBytes?: number } = {}): unknown { + if (typeof raw === 'string' && /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? maximum) fail('handoff_frame_too_large'); + let text: string; + try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { fail('invalid_handoff_json'); } + let position = 0; + const space = () => { while (/[\x20\t\r\n]/.test(text[position] ?? 'x')) position++; }; + const string = (): string => { + const start = position++; + while (position < text.length) { + const c = text[position++]; + if (c === '"') { try { return JSON.parse(text.slice(start, position)) as string; } catch { fail('invalid_handoff_json'); } } + if (c === '\\') position++; + } + fail('invalid_handoff_json'); + }; + const value = (strict: boolean, depth: number): unknown => { + if (depth > 64) fail('invalid_handoff_json'); + space(); const c = text[position]; + if (c === '"') return string(); + if (c === '{') { + position++; space(); const result: Record = Object.create(null), keys = new Set(); + if (text[position] === '}') { position++; return result; } + while (true) { + space(); if (text[position] !== '"') fail('invalid_handoff_json'); const key = string(); + if (keys.has(key)) fail('invalid_handoff_json'); keys.add(key); + space(); if (text[position++] !== ':') fail('invalid_handoff_json'); + result[key] = value(strict || (scope === 'outer' && depth === 0 && ['handoff', 'handoffQuery'].includes(key)), depth + 1); + space(); const separator = text[position++]; if (separator === '}') break; if (separator !== ',') fail('invalid_handoff_json'); + } + return result; + } + if (c === '[') { + position++; space(); const result: unknown[] = []; if (text[position] === ']') { position++; return result; } + while (true) { result.push(value(strict, depth + 1)); space(); const separator = text[position++]; if (separator === ']') break; if (separator !== ',') fail('invalid_handoff_json'); } + return result; + } + for (const [literal, item] of [['true', true], ['false', false], ['null', null]] as const) { + if (text.startsWith(literal, position)) { position += literal.length; return item; } + } + const token = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(text.slice(position))?.[0]; + if (!token) fail('invalid_handoff_json'); position += token.length; const number = Number(token); + if (!Number.isFinite(number) || (strict && (!/^(?:0|[1-9][0-9]*)$/.test(token) || !safeInteger(number)))) fail('invalid_handoff_json'); + return number; + }; + const result = value(scope !== 'outer', 0); space(); if (position !== text.length) fail('invalid_handoff_json'); return result; +} +export function closed(value: unknown, required: string[], optional: string[] = []): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) fail(); + const row = value as Record; + if (required.some(key => !Object.hasOwn(row, key)) || Object.keys(row).some(key => !required.includes(key) && !optional.includes(key))) fail(); + return row; +} +const member = (value: unknown, values: string[]) => typeof value === 'string' && values.includes(value); +export function validateHandoff(value: unknown): Handoff { + const row = closed(value, ['schemaVersion', 'correlationId', 'ledgerEpoch', 'state', 'submission', 'observation', 'ack', 'wait', 'targetGeneration', 'decisionOwner', 'retry', 'nextActions']); + const sub = closed(row.submission, ['status']), obs = closed(row.observation, ['status', 'injectionObserved'], ['clientUserMessageId', 'turn']); + const ack = closed(row.ack, ['status'], ['assurance', 'receivedAtUtcMs', 'late']); + const wait = closed(row.wait, ['for', 'status'], ['operationId', 'deadlineAtUtcMs', 'reason']); + const retry = closed(row.retry, ['allowed', 'reason']); + if (row.schemaVersion !== 1 || !canonicalId(row.correlationId) || !canonicalId(row.ledgerEpoch) || + !member(row.state, ['validated', 'refused', 'submitted', 'delivered', 'acknowledged', 'unknown', 'timed_out_unknown']) || + !member(sub.status, ['not_attempted', 'submitted', 'refused', 'unknown']) || + !member(obs.status, ['not_requested', 'pending', 'observed', 'unsupported', 'failed']) || typeof obs.injectionObserved !== 'boolean' || + !member(ack.status, ['not_requested', 'pending', 'acknowledged', 'unsupported']) || + !member(wait.for, ['none', 'delivered', 'acknowledged']) || !member(wait.status, ['not_requested', 'pending', 'satisfied', 'timed_out_unknown', 'stopped', 'unsupported', 'failed']) || + (row.targetGeneration !== null && !safeId(row.targetGeneration, 256)) || row.decisionOwner !== 'sender_operator' || + retry.allowed !== false || retry.reason !== 'receiver_dedup_unavailable' || !Array.isArray(row.nextActions) || + row.nextActions.some(item => !member(item, ['keep_waiting', 'reconcile', 'stop_waiting'])) || new Set(row.nextActions).size !== row.nextActions.length) fail(); + if (Object.hasOwn(obs, 'clientUserMessageId') && !safeId(obs.clientUserMessageId)) fail(); + if (obs.injectionObserved !== (obs.status === 'observed') || + (obs.injectionObserved && (!safeId(obs.clientUserMessageId) || row.targetGeneration === null || sub.status !== 'submitted')) || + (!obs.injectionObserved && Object.hasOwn(obs, 'clientUserMessageId'))) fail(); + if (Object.hasOwn(obs, 'turn')) { + const turn = closed(obs.turn, ['id', 'status']); + if (!obs.injectionObserved || !safeId(turn.id) || !member(turn.status, ['running', 'completed', 'failed', 'interrupted', 'unknown'])) fail(); + } + if (ack.status === 'acknowledged') { + if (!member(ack.assurance, ['token_possession', 'operator_confirmed']) || !safeInteger(ack.receivedAtUtcMs) || typeof ack.late !== 'boolean' || row.state !== 'acknowledged' || sub.status !== 'submitted' || row.targetGeneration === null) fail(); + } else if (['assurance', 'receivedAtUtcMs', 'late'].some(key => Object.hasOwn(ack, key))) fail(); + if (row.targetGeneration === null && (ack.status === 'pending' || ack.status === 'acknowledged' || wait.status === 'pending' || wait.status === 'satisfied')) fail(); + const states: Record = { validated: 'not_attempted', refused: 'refused', submitted: 'submitted', delivered: 'submitted', acknowledged: 'submitted', unknown: 'unknown' }; + if ((row.state !== 'timed_out_unknown' && sub.status !== states[row.state as string]) || + (row.state === 'timed_out_unknown' && !['submitted', 'unknown'].includes(sub.status as string)) || + (row.state === 'delivered' && !obs.injectionObserved) || (row.state === 'acknowledged' && ack.status !== 'acknowledged')) fail(); + if (wait.for === 'none') { + if (wait.status !== 'not_requested' || Object.keys(wait).length !== 2) fail(); + } else if (wait.status === 'not_requested' || !canonicalId(wait.operationId) || !safeInteger(wait.deadlineAtUtcMs)) fail(); + if (Object.hasOwn(wait, 'reason') && !member(wait.reason, ['insufficient_budget', 'deadline_before_effect', 'evidence_unsupported', 'evidence_failed', 'history_unavailable', 'stopped_by_operator', 'invalid_handoff'])) fail(); + if (wait.status === 'stopped' && wait.reason !== 'stopped_by_operator') fail(); + if (wait.status === 'satisfied' && (wait.for === 'delivered' ? !obs.injectionObserved : ack.status !== 'acknowledged')) fail(); + if (row.nextActions.includes('keep_waiting') && (row.targetGeneration === null || wait.status === 'unsupported' || + (wait.for === 'delivered' ? obs.status !== 'pending' : wait.for !== 'acknowledged' || ack.status !== 'pending') || + !['submitted', 'unknown'].includes(sub.status as string))) fail(); + if (row.nextActions.includes('stop_waiting') && wait.status !== 'pending') fail(); + if (Buffer.byteLength(JSON.stringify(row), 'utf8') > HANDOFF_LIMITS.publicFrame) fail('handoff_frame_too_large'); + return structuredClone(row) as Handoff; +} +export type Receipt = { schemaVersion: 1; kind: 'receipt'; ledgerEpoch: string; correlationId: string; targetGeneration: string; receiptId: string; capability: string }; +export type Confirmation = { schemaVersion: 1; ledgerEpoch: string; correlationId: string; targetGeneration: string; confirmed: true }; +export function validateHandoffQuery(value: unknown): { schemaVersion: 1; correlationId: string; status: 'unknown'; context: 'ledger_missing' | 'ledger_corrupt' | 'id_unknown'; retry: { allowed: false; reason: 'history_unavailable' } } { + const row = closed(value, ['schemaVersion', 'correlationId', 'status', 'context', 'retry']); + const retry = closed(row.retry, ['allowed', 'reason']); + if (row.schemaVersion !== 1 || !canonicalId(row.correlationId) || row.status !== 'unknown' || + !member(row.context, ['ledger_missing', 'ledger_corrupt', 'id_unknown']) || retry.allowed !== false || retry.reason !== 'history_unavailable') fail(); + return structuredClone(row) as ReturnType; +} +export function validateReceipt(raw: string | Uint8Array): Receipt { + const row = closed(parseStrictJson(raw), ['schemaVersion', 'kind', 'ledgerEpoch', 'correlationId', 'targetGeneration', 'receiptId', 'capability']); + if (row.schemaVersion !== 1 || row.kind !== 'receipt' || !canonicalId(row.ledgerEpoch) || !canonicalId(row.correlationId) || !canonicalId(row.receiptId) || !safeId(row.targetGeneration, 256) || + typeof row.capability !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(row.capability) || Buffer.from(row.capability, 'base64url').length !== 32 || Buffer.from(row.capability, 'base64url').toString('base64url') !== row.capability) fail('invalid_receipt'); + return row as Receipt; +} +export function validateConfirmation(raw: string | Uint8Array): Confirmation { + const row = closed(parseStrictJson(raw), ['schemaVersion', 'ledgerEpoch', 'correlationId', 'targetGeneration', 'confirmed']); + if (row.schemaVersion !== 1 || !canonicalId(row.ledgerEpoch) || !canonicalId(row.correlationId) || !safeId(row.targetGeneration, 256) || row.confirmed !== true) fail('invalid_confirmation'); + return row as Confirmation; +} diff --git a/src/help.ts b/src/help.ts index 1bfdc15..a2d222f 100644 --- a/src/help.ts +++ b/src/help.ts @@ -17,6 +17,16 @@ Transport: known online peers use their MagicDNS name, known offline peers are refused. --no-update-notice Suppress the opt-in cached update notice -h, --help Show command help`; + if (command === 'handoff' || command === 'ack') return `Usage: session-peer handoff init|prepare|status|wait|confirm --json + init Initialize private POSIX sender ledger explicitly + prepare --to TARGET --message-file FILE Reserve a fresh ID without submitting (FILE mode 0600) + status --correlation-id UUID Inspect retained evidence; never submit again + wait --correlation-id UUID --wait-for delivered|acknowledged --wait-timeout SECONDS + confirm --receipt - Read private operator-confirmation JSON from stdin + ack --receipt - Record a qualified private receipt; never prompt an agent +SESSION_PEER_HANDOFF_HOME selects an absolute private ledger directory. +Production automated receipt bootstrap and Windows ledger are unsupported. +Missing history never authorizes retry. Secrets must never be passed through argv.`; if (command === 'list') return `Usage: session-peer list [options] List reachable Claude and saved Codex sessions. Listing is not send authorization. --agent claude|codex Filter one agent (default: both) @@ -47,6 +57,12 @@ Notices on other commands are opt-in: SESSION_PEER_UPDATE_NOTICE=1.`; TARGET: Claude name/PID, claude:NAME/PID, codex:UUID, or session-peer reply URI. Omit the body or use - for UTF-8 stdin. Only one body source is permitted. Unicode names use exact Unicode 14.0.0 default casefold; collisions require PID. + --correlation-id UUID Use an already-prepared private handoff ID (opt-in) + --request-ack Add handoff metadata; automated ACK currently unsupported + --observe-delivery Request optional body-free observation (qualified routes only) + --wait-for delivered|acknowledged Refuse before submission if required evidence is unsupported + --wait-timeout SECONDS Total budget 1..60 (default 30, includes 5 s cleanup) + --message-file FILE Private UTF-8 input file (POSIX mode 0600) --dry-run Validate without submission --codex-home HOME Explicit Codex home; otherwise select unique live writer --codex-bin PATH Native Codex executable @@ -66,6 +82,7 @@ Usage: session-peer [options] send Submit one guarded message doctor Inspect read-only metadata and capabilities update --check Check npm for a newer version (no self-update) + handoff / ack Opt-in private evidence operations (see handoff --help) --version Print exact runtime version --help, -h Show help; --help for details ${common} diff --git a/src/output.ts b/src/output.ts index afea0b0..089075d 100644 --- a/src/output.ts +++ b/src/output.ts @@ -47,7 +47,12 @@ export function renderOutput(value: Record | Record; + lines.push(`Handoff: ${safe(h.correlationId)} (${safe(h.state)})`); + for (const key of ['observation', 'ack', 'wait']) lines.push(`${key}: ${safe(JSON.stringify(h[key]))}`); } if (value.sshTransport && typeof value.sshTransport === 'object') { const transport = value.sshTransport as Record; diff --git a/src/send.ts b/src/send.ts index f251ab3..ffede82 100644 --- a/src/send.ts +++ b/src/send.ts @@ -1,11 +1,23 @@ +import { createHash } from 'node:crypto'; import { casefold } from './casefold.js'; import { createConnection } from 'node:net'; +import { closeSync, constants, fstatSync, lstatSync, openSync, type BigIntStats } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { claude, Refusal, sqliteHome } from './discovery.js'; import { executable, run, UnknownOutcome } from './process.js'; import { HomeRefusal, resolveWriter, revalidateWriter, uuid, type HomeResolution } from './writer.js'; import { postWindowsPipe } from './windows.js'; -export type SendOptions = { to: string; home?: string; codexBin?: string; message: string; dryRun?: boolean; allowInactive?: boolean }; +export type SendContext = { agent: 'claude' | 'codex'; target: string; home?: string; generation: string | null; writerIdentity?: string }; +export type SendHooks = { + // Only explicitly opted-in callers resolve this additional context. Default + // send output, inspection count and native execution remain unchanged. + resolved: (context: SendContext, snapshot: Record) => Promise | string | void; + beforeEffect?: () => Promise | void; + timeoutMs?: () => number; +}; +export type SendOptions = { to: string; home?: string; codexBin?: string; message: string; dryRun?: boolean; allowInactive?: boolean; hooks?: SendHooks }; export class CodexUnknownOutcome extends UnknownOutcome { constructor(readonly codexHomeResolution: HomeResolution) { super(); } } @@ -18,11 +30,12 @@ export function checkMessage(text: string, codex = false): void { if (!text.trim() || text.includes('\0') || [...text].length > 1_000_000 || (codex && Buffer.byteLength(text, 'utf8') > 32768)) throw new Refusal('invalid_message', 2); } -export async function postSocket(path: string, text: string): Promise { +export async function postSocket(path: string, text: string, timeout?: number): Promise { return new Promise((resolve, reject) => { let attempted = false, finished = false; + const startedAt = performance.now(); const socket = createConnection(path); - const timer = setTimeout(() => finish(new Error('timeout')), 10000); + const timer = setTimeout(() => finish(new Error('timeout')), timeout ?? 10000); const finish = (error?: Error) => { if (finished) return; finished = true; clearTimeout(timer); socket.destroy(); @@ -35,13 +48,39 @@ export async function postSocket(path: string, text: string): Promise { socket.end(JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n', () => { // Write completion is submission only, never consumption. Match Python's drain window. clearTimeout(timer); - const drain = setTimeout(() => finish(), 2000); + const drain = setTimeout(() => finish(), timeout === undefined ? 2000 : Math.min(2000, Math.max(0, timeout - (performance.now() - startedAt)))); socket.once('close', () => { clearTimeout(drain); finish(); }); socket.once('data', () => { clearTimeout(drain); finish(); }); }); }); }); } +// A private continuity fingerprint, not proof of a native incarnation. Do not +// read or persist registry contents (which can include authentication material). +function claudeBindingIdentity(row: Record): string { + const stable = (s: BigIntStats) => [s.dev,s.ino,s.uid,s.size,s.mtimeNs,s.ctimeNs,s.birthtimeNs].map(String); + const same = (a: BigIntStats,b: BigIntStats) => JSON.stringify(stable(a))===JSON.stringify(stable(b)); + const directory=process.env.CLAUDE_CONFIG_DIR || process.env.ANTHROPIC_CONFIG_DIR || join(homedir(),'.claude'); + const path=join(directory,'sessions',String(row.pid)+'.json'); + try { + const before=lstatSync(path,{bigint:true}); + if(!before.isFile() || before.size>1024n*1024n)throw new Error(); + const fd=openSync(path,constants.O_RDONLY | (process.platform==='win32'?0:constants.O_NOFOLLOW | constants.O_NONBLOCK)); + let metadata:string[]; + try { + const opened=fstatSync(fd,{bigint:true}); + if(!opened.isFile() || !same(before,opened) || !same(opened,lstatSync(path,{bigint:true})))throw new Error(); + metadata=stable(opened); + } finally {closeSync(fd);} + let socket:string[]|null=null; + if(process.platform!=='win32'){ + const file=lstatSync(String(row.socket),{bigint:true}); + if(!file.isSocket())throw new Error(); + socket=stable(file); + } + return createHash('sha256').update(JSON.stringify({pid:row.pid,name:row.name,socketPath:row.socket,metadata,socket})).digest('hex'); + } catch {throw new Refusal('target_changed',1);} +} export async function send(options: SendOptions): Promise> { checkMessage(options.message, options.to.startsWith('codex:')); const base = { ok: true, submitted: !options.dryRun, consumptionConfirmed: false, @@ -56,14 +95,34 @@ export async function send(options: SendOptions): Promise B -> A. + const proof = JSON.parse(selection.evidence) as { fingerprints: { home: string; database: string; writer: string }[] }; + const selected = proof.fingerprints.find(item => item.home === home); + const identity = selected?.writer; + const live = selection.resolution.candidates.find(item => 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, + // 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); + if (body !== undefined) { checkMessage(body, true); options = { ...options, message: body }; result.chars = [...body].length; } + } if (options.dryRun) return result; await revalidateWriter(selection, options.home, id, options.allowInactive); + if (options.hooks) { + const currentStorage = sqliteHome(home); + if (currentStorage !== 'default') throw new HomeRefusal(currentStorage === 'configured' ? 'unsupported_codex_sqlite_home' : 'codex_config_unreadable', selection.resolution); + } // `--message=` keeps a body starting with `-` from being parsed as an option. // An inherited CODEX_SQLITE_HOME would queue outside the validated home. // Windows environment names are case-insensitive. const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toUpperCase() !== 'CODEX_SQLITE_HOME')); + await options.hooks?.beforeEffect?.(); const done = await run(binary, ['queue', '--thread', id, `--message=${options.message}`], - { env: { ...inherited, CODEX_HOME: home }, timeout: 30000 }); + { env: { ...inherited, CODEX_HOME: home }, timeout: options.hooks?.timeoutMs?.() ?? 30000 }); if (!done.spawned) throw new HomeRefusal('native_spawn_failed', selection.resolution); if (done.interrupted || done.code !== 0) throw new CodexUnknownOutcome(selection.resolution); const queuedId = queueId(done.stdout, id); @@ -81,11 +140,20 @@ export async function send(options: SendOptions): Promise { let data = ''; s.on('data', c => data += c); s.on('end', () => { messages.push(data); s.end(); }); }); + server.listen(socket); await once(server, 'listening'); + t.after(async () => { await new Promise(ok => server.close(() => ok())); rmSync(path, { recursive: true }); }); + 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) }; } + 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 => { + const f = await fixture(t); + const plain = await f.invoke([...f.send, '--dry-run']); assert.equal(plain.code, 0); assert.equal(plain.value.handoff, undefined); assert.equal(plain.value.target.name, null); + const missing = await f.invoke(['handoff','status','--correlation-id','11111111-1111-4111-8111-111111111111']); assert.equal(missing.code, 1); assert.equal(missing.value.handoffQuery.context,'ledger_missing'); assert.equal(missing.value.submitted,undefined); + assert.equal((await f.invoke(['handoff','init'])).code,0); + const prepare = await f.invoke(['handoff','prepare','--to',String(process.pid),'--message-file',f.file]); assert.equal(prepare.code,0); const id = prepare.value.handoff.correlationId; + const dry = await f.invoke([...f.send,'--correlation-id',id,'--dry-run']); assert.equal(dry.code,0); assert.equal(dry.value.handoff.submission.status,'not_attempted'); assert.equal(f.messages.length,0); + const sent = await f.invoke([...f.send,'--correlation-id',id]); assert.equal(sent.code,0); assert.equal(sent.value.status,'posted'); assert.equal(sent.value.submitted,true); assert.equal(sent.value.handoff.submission.status,'submitted'); assert.equal(sent.value.handoff.ack.status,'not_requested'); assert.equal(f.messages.length,1); assert.match(f.messages[0]!,new RegExp(id)); + const duplicate = await f.invoke([...f.send,'--correlation-id',id]); assert.equal(duplicate.code,1); assert.equal(duplicate.value.error,'handoff_already_attempted'); assert.equal(f.messages.length,1); + const status = await f.invoke(['handoff','status','--correlation-id',id]); assert.equal(status.code,0); assert.equal(status.value.handoff.submission.status,'submitted'); assert.equal(f.messages.length,1); +}); +test('unsupported required ACK refuses before effect; best-effort sends once without claiming ACK', { skip: process.platform === 'win32' }, async t => { + const f = await fixture(t); await f.invoke(['handoff','init']); + const refused = await f.invoke([...f.send,'--wait-for','acknowledged']); assert.equal(refused.code,1); assert.equal(refused.value.submitted,false); assert.equal(refused.value.handoff.wait.status,'unsupported'); assert.equal(f.messages.length,0); + const best = await f.invoke([...f.send,'--request-ack']); assert.equal(best.code,0); assert.equal(best.value.submitted,true); assert.equal(best.value.handoff.ack.status,'unsupported'); assert.equal(f.messages.length,1); + const query = await f.invoke(['handoff','wait','--correlation-id',best.value.handoff.correlationId,'--wait-for','acknowledged']); assert.equal(query.code,1); assert.equal(query.value.handoff.wait.status,'unsupported'); assert.equal(query.value.submitted,undefined); assert.equal(f.messages.length,1); +}); +test('syntax/private inputs reject without minting authority or submitting', { skip: process.platform === 'win32' }, async t => { + 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 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'); + 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); +}); + +test('positive native acceptance survives a subsequent durable-record I/O failure', { skip: process.platform === 'win32' }, async t => { + const f = await fixture(t); await f.invoke(['handoff','init']); + const fs = await import('node:fs'), { syncBuiltinESMExports } = await import('node:module'); + const { handoffSend } = await import('../dist/handoff-operations.js'); + const { HandoffLedger } = await import('../dist/handoff-ledger.js'); + const oldDir = process.env.CLAUDE_CONFIG_DIR; process.env.CLAUDE_CONFIG_DIR = join(f.path,'.claude'); + const rename = fs.default.renameSync; let writes = 0; + const mocked = t.mock.method(fs.default, 'renameSync', (...args: Parameters) => { + if (++writes === 3) throw Object.assign(new Error('fixture'), {code:'EIO'}); + return rename(...args); + }); syncBuiltinESMExports(); + try { + const r = await handoffSend({to:String(process.pid),message:'fixture body'}, {requestAck:true,ledgerPath:join(f.path,'state','handoff')}); + assert.equal(r.exitCode,0); assert.equal(r.value.ok,true); assert.equal(r.value.status,'posted'); assert.equal(r.value.submitted,true); + assert.equal(r.value.handoffWarning,'persistence_failed'); const h = r.value.handoff as {correlationId:string;submission:{status:string}}; assert.equal(h.submission.status,'submitted'); assert.equal(f.messages.length,1); + const retained = new HandoffLedger(join(f.path,'state','handoff')).status(h.correlationId); assert.ok('handoff' in retained); assert.equal(retained.handoff.submission.status,'unknown'); + await assert.rejects(handoffSend({to:String(process.pid),message:'fixture body'}, {correlationId:h.correlationId,ledgerPath:join(f.path,'state','handoff')}), /handoff_already_attempted/); assert.equal(f.messages.length,1); + } finally { mocked.mock.restore(); syncBuiltinESMExports(); if(oldDir===undefined) delete process.env.CLAUDE_CONFIG_DIR;else process.env.CLAUDE_CONFIG_DIR=oldDir; } +}); + +test('opt-in reserves bounded outer-result space before any native effect', {skip:process.platform==='win32'}, async t=>{ + const f=await fixture(t);await f.invoke(['handoff','init']); + writeFileSync(join(f.path,'.claude/sessions',`${process.pid}.json`),JSON.stringify({pid:process.pid,name:'x'.repeat(1040000),messagingSocketPath:join(f.path,'sock')})); + const r=await f.invoke([...f.send,'--request-ack']);assert.equal(r.code,1);assert.equal(r.value.error,'handoff_result_too_large');assert.equal(r.value.submitted,false);assert.equal(f.messages.length,0); +}); + + +test('message-file dash is literal with stdin open, while --message dash keeps stdin semantics', {skip:process.platform==='win32'}, async t=>{ + const f=await fixture(t);writeFileSync(f.file,'-'); + const file=await f.invoke(f.send,'different stdin body',false); + assert.equal(file.code,0);assert.equal(file.value.status,'posted');assert.equal(file.value.chars,1); + assert.equal(f.messages.length,1);assert.equal(JSON.parse(f.messages[0]!).message.content,'-'); + await f.invoke(['handoff','init']); + const opted=await f.invoke([...f.send,'--request-ack'],'different stdin body',false); + assert.equal(opted.code,0);assert.equal(opted.value.submitted,true);assert.equal(f.messages.length,2); + assert.equal(JSON.parse(f.messages[1]!).message.content,'-\n\n---\nHandoff: '+JSON.stringify({schemaVersion:1,correlationId:opted.value.handoff.correlationId})); + const stdin=await f.invoke(['send','--to',String(process.pid),'--message','-','--no-from','--no-reply-to'],'chosen stdin body'); + assert.equal(stdin.code,0);assert.equal(JSON.parse(f.messages[2]!).message.content,'chosen stdin body'); + // SSH transports carry the already selected literal body in their JSON frame; + // a wire receiver must not interpret that body as a second stdin selector. + const receiver=spawn(process.execPath,[cli,'--stdio-request'],{env:f.env});let stdout='';receiver.stdout.on('data',c=>stdout+=c);receiver.stderr.resume(); + const timer=setTimeout(()=>receiver.kill('SIGKILL'),10000); + receiver.stdin.end(JSON.stringify({schemaVersion:1,args:['send','--to',String(process.pid),'--message=-','--no-from','--no-reply-to','--json']})); + const [code,signal]=await once(receiver,'close');clearTimeout(timer);assert.equal(signal,null);assert.equal(code,0,stdout); + assert.equal(JSON.parse(f.messages[3]!).message.content,'-'); + +}); + + +test('prepared inactive Codex binding retains lock and saved-home evidence without claiming generation', {skip:process.platform==='win32'}, async t=>{ + const {DatabaseSync}=await import('node:sqlite'); + const {inspectWriter,resolveWriter}=await import('../dist/writer.js'); + const id='11111111-1111-4111-8111-111111111111'; + for (const change of ['unchanged','lock','database'] as const) await t.test(change, async t=>{ + const f=await fixture(t),home=join(f.path,'codex-home'),locks=join(home,'thread-writer-locks');mkdirSync(locks,{recursive:true}); + const dbPath=join(home,'state_5.sqlite'); + const database=()=>{const db=new DatabaseSync(dbPath);try{db.exec('CREATE TABLE threads(id TEXT)');db.prepare('INSERT INTO threads VALUES (?)').run(id);}finally{db.close();}};database(); + const log=join(f.path,'queue-calls');writeFileSync(log,'');f.env.FIXTURE_QUEUE_LOG=log;f.env.SESSION_PEER_CODEX_HOMES='[]'; + const binary=join(f.path,'fake-codex');writeFileSync(binary,`#!${process.execPath}\nrequire('node:fs').appendFileSync(process.env.FIXTURE_QUEUE_LOG,'call\\n');console.log('Queued message inactive-queue for thread '+process.argv[4]+'.');`,{mode:0o700}); + const target=['--to','codex:'+id,'--codex-home',home,'--codex-bin',binary,'--allow-inactive-codex-home','--message-file',f.file]; + const before=await inspectWriter(home,id);assert.equal(before.activity,'inactive');assert.equal(before.writerLock,'absent'); + assert.equal((await f.invoke(['handoff','init'])).code,0); + const prepared=await f.invoke(['handoff','prepare',...target]);assert.equal(prepared.code,0);assert.equal((await resolveWriter(home,id,true)).resolution.candidates.find(c=>c.codexHome===home)?.savedThread,true); + const correlation=prepared.value.handoff.correlationId;assert.equal(prepared.value.handoff.targetGeneration,null);assert.equal(readFileSync(log,'utf8'),''); + if(change==='lock')writeFileSync(join(locks,id+'.lock'),''); + if(change==='database'){renameSync(dbPath,dbPath+'.previous');database();} + const current=await inspectWriter(home,id);assert.equal(current.activity,'inactive');assert.equal(current.writerLock,change==='lock'?'free':'absent'); + const sent=await f.invoke(['send',...target,'--correlation-id',correlation,'--request-ack','--no-from','--no-reply-to']); + if(change==='unchanged'){ + assert.equal(sent.code,0);assert.equal(sent.value.status,'queued');assert.equal(sent.value.submitted,true);assert.equal(sent.value.queueId,'inactive-queue');assert.equal(sent.value.handoff.targetGeneration,null);assert.equal(sent.value.handoff.retry.allowed,false);assert.equal(sent.value.handoff.ack.status,'unsupported');assert.equal(readFileSync(log,'utf8'),'call\n'); + }else{ + assert.equal(sent.code,1);assert.equal(sent.value.error,'handoff_binding_conflict');assert.equal(sent.value.submitted,false);assert.equal(readFileSync(log,'utf8'),''); + } + }); +}); + + +test('prepared Claude binding refuses same-PID registry or inbox replacement', {skip:process.platform==='win32'}, async t=>{ + for(const change of ['unchanged','record','socket','socket-same-path'] as const)await t.test(change,async t=>{ + const f=await fixture(t);await f.invoke(['handoff','init']); + const prepared=await f.invoke(['handoff','prepare','--to',String(process.pid),'--message-file',f.file]);assert.equal(prepared.code,0); + const correlation=prepared.value.handoff.correlationId;assert.equal(prepared.value.handoff.targetGeneration,null); + const replacementMessages:string[]=[]; + if(change==='record'){ + const record=join(f.path,'.claude/sessions',`${process.pid}.json`);renameSync(record,record+'.old'); + writeFileSync(record,JSON.stringify({pid:process.pid,name:null,messagingSocketPath:join(f.path,'sock')})); + } + if(change==='socket'||change==='socket-same-path'){ + const socket=join(f.path,change==='socket'?'replacement-sock':'sock');if(change==='socket-same-path')unlinkSync(socket);const server=createServer(s=>{let data='';s.on('data',c=>data+=c);s.on('end',()=>{replacementMessages.push(data);s.end();});}); + server.listen(socket);await once(server,'listening');t.after(()=>new Promise(ok=>server.close(()=>ok()))); + if(change==='socket')writeFileSync(join(f.path,'.claude/sessions',`${process.pid}.json`),JSON.stringify({pid:process.pid,name:null,messagingSocketPath:socket})); + } + const sent=await f.invoke([...f.send,'--correlation-id',correlation,'--request-ack']); + if(change==='unchanged'){ + assert.equal(sent.code,0);assert.equal(sent.value.status,'posted');assert.equal(sent.value.submitted,true);assert.equal(sent.value.handoff.targetGeneration,null);assert.equal(sent.value.handoff.ack.status,'unsupported');assert.equal(sent.value.handoff.retry.allowed,false);assert.equal(f.messages.length,1); + }else{ + assert.equal(sent.code,1);assert.equal(sent.value.error,'handoff_binding_conflict');assert.equal(sent.value.submitted,false);assert.equal(f.messages.length,0); + } + assert.equal(replacementMessages.length,0); + }); +}); diff --git a/test/handoff-core.test.ts b/test/handoff-core.test.ts new file mode 100644 index 0000000..8452d28 --- /dev/null +++ b/test/handoff-core.test.ts @@ -0,0 +1,483 @@ +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCipheriv, randomBytes, randomUUID } 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, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HandoffBudget, parseStrictJson, parseWaitTimeout, validateHandoff, validateHandoffQuery, validateReceipt, validateConfirmation, HANDOFF_LIMITS, type Clock, type Handoff } from '../dist/handoff.js'; +import { HandoffLedger, payloadDigest, type HandoffBinding } from '../dist/handoff-ledger.js'; + +const fixture = JSON.parse(readFileSync(new URL('./fixtures/handoff-v1.json', import.meta.url), 'utf8')); +const posix = { skip: process.platform === 'win32' }; +function fixtureLedger(t: TestContext) { + const root = realpathSync(mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-ho-'))); + chmodSync(root, 0o700); t.after(() => rmSync(root, { recursive: true, force: true })); + let mono = 10, utc = 100000; + const clock: Clock = { monotonic: () => mono, utc: () => utc }; + const ledger = new HandoffLedger(join(root, 'ledger'), { clock }); + const binding: HandoffBinding = { agent: 'codex', destination: 'fixture', target: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', home: '/fixture/home', + writerIdentity: 'fixture-writer', generation: 'fixture-generation', payloadDigest: payloadDigest('private fixture body') }; + return { root, clock, ledger, binding, advance: (ms: number) => { mono += ms; utc += ms; }, jumpUtc: (ms: number) => { utc += ms; } }; +} +function handoff(ledger: HandoffLedger, id: string): Handoff { + const value = ledger.status(id); assert.ok('handoff' in value); return value.handoff; +} +function receipt(h: Handoff, capability: string, receiptId = randomUUID()) { + return JSON.stringify({ schemaVersion: 1, kind: 'receipt', ledgerEpoch: h.ledgerEpoch, correlationId: h.correlationId, + targetGeneration: h.targetGeneration, receiptId, capability }); +} +function confirmation(h: Handoff) { + return JSON.stringify({ schemaVersion: 1, ledgerEpoch: h.ledgerEpoch, correlationId: h.correlationId, + targetGeneration: h.targetGeneration, confirmed: true }); +} +function submit(f: ReturnType, h: Handoff) { + f.ledger.commitEffect(h.correlationId, f.binding); return f.ledger.recordSubmission(h.correlationId, 'submitted'); +} +// Build large, valid quota fixtures without O(n²) durable API writes. Each +// synthetic record still needs the real ID/epoch-authenticated encrypted field; +// copying one ciphertext to another ID must remain invalid (privacy regression). +function sealedQuotaDigest(path: string, epoch: string, id: string, digest: string): string { + const key = readFileSync(join(path, 'ledger.key')), nonce = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, nonce); + cipher.setAAD(Buffer.from(JSON.stringify(['handoff-binding-v1', epoch, id]))); + const encrypted = Buffer.concat([cipher.update(Buffer.from(digest, 'hex')), cipher.final()]); + return `sealed-v1:${nonce.toString('hex')}:${encrypted.toString('hex')}:${cipher.getAuthTag().toString('hex')}`; +} +const bootstrap = { privateConsumerValidated: true, compatibleProducerValidated: true } as const; + +test('all pinned positive public schema fixtures validate without rewriting native fields', () => { + for (const sample of fixture.accepted) { + const original = JSON.stringify(sample.result); + assert.deepEqual(validateHandoff(sample.result.handoff), sample.result.handoff, sample.name); + assert.equal(JSON.stringify(sample.result), original); + } +}); +test('all pinned negative handoff schema fixtures fail closed', () => { + for (const sample of fixture.rejected) { + const base = fixture.accepted.find((item: any) => item.name === sample.base); + const h = structuredClone(base.result.handoff); + for (const [path, value] of Object.entries(sample.set)) { + const keys = path.split('.'); let target = h; + for (const key of keys.slice(0, -1)) target = target[key]; + target[keys.at(-1)!] = value; + } + assert.throws(() => validateHandoff(h), /invalid_handoff/, sample.name); + } +}); +test('keep_waiting requires the selected goal evidence channel to remain pending', () => { + const ackPending = structuredClone(fixture.accepted.find((item: any) => item.name === 'queued_explicit_ack_timeout').result.handoff); + // An expired/stopped wait may suggest a new wait while its evidence channel is + // still pending; the frozen positive fixtures retain that distinction. + assert.deepEqual(validateHandoff(ackPending), ackPending); + for (const status of ['not_requested', 'unsupported']) { + const invalid = structuredClone(ackPending); + invalid.state = 'submitted'; invalid.wait.status = 'pending'; + invalid.ack = { status }; invalid.nextActions = ['keep_waiting']; + assert.throws(() => validateHandoff(invalid), /invalid_handoff/, `ACK ${status}`); + } + const alreadyAcknowledged = structuredClone(fixture.accepted.find((item: any) => item.name === 'receipt_satisfies_wait_without_injection_claim').result.handoff); + alreadyAcknowledged.wait.status = 'pending'; alreadyAcknowledged.nextActions = ['keep_waiting']; + assert.throws(() => validateHandoff(alreadyAcknowledged), /invalid_handoff/, 'ACK already committed'); + const noGoal = structuredClone(ackPending); + noGoal.state = 'submitted'; noGoal.wait = { for: 'none', status: 'not_requested' }; noGoal.nextActions = ['keep_waiting']; + assert.throws(() => validateHandoff(noGoal), /invalid_handoff/, 'no selected evidence goal'); +}); +test('keep_waiting permits pending delivery evidence but rejects an observed channel', () => { + const pending = structuredClone(fixture.accepted.find((item: any) => item.name === 'queued_explicit_ack_timeout').result.handoff); + pending.state = 'submitted'; pending.wait.for = 'delivered'; pending.wait.status = 'pending'; + pending.observation = { status: 'pending', injectionObserved: false }; pending.ack = { status: 'not_requested' }; + pending.nextActions = ['keep_waiting']; + assert.deepEqual(validateHandoff(pending), pending); + for (const status of ['not_requested', 'failed', 'unsupported']) { + const invalid = structuredClone(pending); invalid.observation.status = status; + assert.throws(() => validateHandoff(invalid), /invalid_handoff/, `delivery ${status}`); + } + const observed = structuredClone(fixture.accepted.find((item: any) => item.name === 'delivered_wait_satisfied').result.handoff); + observed.wait.status = 'pending'; observed.nextActions = ['keep_waiting']; + assert.throws(() => validateHandoff(observed), /invalid_handoff/, 'delivery already observed'); +}); +test('raw strict JSON rejects duplicate keys, lossy integer tokens, invalid UTF-8 and trailing data', () => { + for (const sample of fixture.wireRejected) assert.throws(() => parseStrictJson(sample.raw ?? Buffer.from(sample.bytesHex, 'hex')), /invalid_handoff_json/, sample.name); + assert.throws(() => parseStrictJson('{"schemaVersion":-0}')); + assert.throws(() => parseStrictJson('{"n":9007199254740993}')); + assert.equal((parseStrictJson('{"native":1e0,"handoff":{"schemaVersion":1}}', { scope: 'outer' }) as any).native, 1); + assert.throws(() => parseStrictJson('{"native":1.0,"handoff":{"schemaVersion":1e0}}', { scope: 'outer' })); + assert.throws(() => parseStrictJson('{"handoffQuery":{"schemaVersion":1.0}}', { scope: 'outer' })); + assert.throws(() => parseStrictJson('['.repeat(65) + '0' + ']'.repeat(65))); + assert.throws(() => parseStrictJson('"\ud800"')); + assert.throws(() => parseStrictJson(Buffer.from('\ufeff{}', 'utf8'))); +}); +test('closed missing-history query schemas have no fabricated epoch or native facts', () => { + for (const sample of fixture.queryErrors) { + assert.deepEqual(validateHandoffQuery(sample.result.handoffQuery), sample.result.handoffQuery); + assert.throws(() => validateHandoffQuery({ ...sample.result.handoffQuery, ledgerEpoch: randomUUID() })); + } +}); +test('private frame bound counts every byte and no extra trailing LF allowance', () => { + const frame = '{}' + ' '.repeat(HANDOFF_LIMITS.privateFrame - 2); + assert.deepEqual(Object.keys(parseStrictJson(frame) as object), []); + assert.throws(() => parseStrictJson(frame + '\n'), /handoff_frame_too_large/); +}); +test('strict timeout grammar and monotonic budget reserve obey all cutoff boundaries', () => { + for (let n = 1; n <= 60; n++) assert.equal(parseWaitTimeout(String(n)), n); + for (const value of ['', '0', '01', '61', '+1', '-1', '1.0', '1e0', ' 1', '1 ', '١', '1']) assert.throws(() => parseWaitTimeout(value), /invalid_wait_timeout/); + let mono = 10, utc = 10000; const clock = { monotonic: () => mono, utc: () => utc }; + const budget = new HandoffBudget(6, clock); assert.equal(budget.deadlineAtUtcMs, 11000); + utc += 1000000000; assert.equal(budget.observationRemainingMs(), 1000); + mono = 1009; budget.beforeEffect(); mono = 1010; assert.throws(() => budget.beforeEffect(), /deadline_before_effect/); + for (let n = 1; n <= 5; n++) assert.throws(() => new HandoffBudget(n, clock).beforeEffect(), /insufficient_budget/); +}); + +test('ledger use never auto-initializes and missing/unknown queries invent no epoch', posix, t => { + const f = fixtureLedger(t), id = randomUUID(); + const missing = f.ledger.status(id); assert.ok('handoffQuery' in missing); assert.equal(missing.handoffQuery.context, 'ledger_missing'); + assert.equal('ledgerEpoch' in missing, false); assert.equal('handoff' in missing, false); + assert.throws(() => f.ledger.prepare(f.binding), /handoff_history_unavailable/); assert.equal(existsSync(f.ledger.path), false); + f.ledger.init(); const unknown = f.ledger.status(id); assert.ok('handoffQuery' in unknown); assert.equal(unknown.handoffQuery.context, 'id_unknown'); + assert.throws(() => f.ledger.prepare(f.binding, { correlationId: id }), /handoff_id_unknown/); + assert.throws(() => f.ledger.init(), /handoff_ledger_exists/); +}); +test('prepared exact bindings and correlation-only dry runs reserve authority without effect', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); + const before = readFileSync(join(f.ledger.path, 'ledger.json'), 'utf8'); + assert.equal(f.ledger.prepare(f.binding, { correlationId: h.correlationId, dryRun: true }).state, 'validated'); + assert.equal(readFileSync(join(f.ledger.path, 'ledger.json'), 'utf8'), before); + for (const binding of [{ ...f.binding, generation: 'successor' }, { ...f.binding, payloadDigest: payloadDigest('other') }, { ...f.binding, home: '/different' }]) { + assert.throws(() => f.ledger.commitEffect(h.correlationId, binding), /handoff_binding_conflict/); + } + assert.equal(handoff(f.ledger, h.correlationId).state, 'validated'); submit(f, h); + assert.throws(() => f.ledger.commitEffect(h.correlationId, f.binding), /handoff_already_attempted/); +}); +test('durable effect fence recovers unknown after a crash and never authorizes another attempt', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); + f.ledger.commitEffect(h.correlationId, f.binding); + const restarted = new HandoffLedger(f.ledger.path, { clock: f.clock }); + assert.equal(handoff(restarted, h.correlationId).state, 'unknown'); + assert.throws(() => restarted.commitEffect(h.correlationId, f.binding), /handoff_already_attempted/); + assert.throws(() => restarted.recordSubmission(h.correlationId, 'submitted'), /handoff_outcome_not_authorized/); +}); +test('process crash after synced intent still fences the native effect and releases the OS lock', posix, async t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); + const moduleUrl = new URL('../dist/handoff-ledger.js', import.meta.url).href; + const script = `import { HandoffLedger } from ${JSON.stringify(moduleUrl)}; +let input=''; for await (const chunk of process.stdin) input+=chunk; +const request=JSON.parse(input); new HandoffLedger(request.path,{clock:{monotonic:()=>10,utc:()=>100000}}).commitEffect(request.id,request.binding); +process.kill(process.pid,'SIGKILL');`; + const child = spawn(process.execPath, ['--input-type=module', '-e', script], { stdio: ['pipe', 'pipe', 'pipe'] }); + let stderr = ''; child.stdout.resume(); child.stderr.on('data', chunk => { stderr += chunk; }); child.stdin.end(JSON.stringify({ path: f.ledger.path, id: h.correlationId, binding: f.binding })); + const [, signal] = await once(child, 'close'); assert.equal(signal, 'SIGKILL', stderr); + assert.equal(handoff(f.ledger, h.correlationId).state, 'unknown'); + assert.throws(() => f.ledger.commitEffect(h.correlationId, f.binding), /handoff_already_attempted/); +}); +test('cross-process private advisory lock serializes mutations without overwriting history', posix, async t => { + const f = fixtureLedger(t); f.ledger.init(); + const native = createRequire(import.meta.url).resolve('fs-ext-extra-prebuilt'); + const script = `const fs=require('node:fs'), locks=require(${JSON.stringify(native)}); +const fd=fs.openSync(process.argv[1],'r+'); locks.flockSync(fd,'exnb');console.log('ready');setInterval(()=>{},1000);`; + const child = spawn(process.execPath, ['-e', script, join(f.ledger.path, 'ledger.lock')], { stdio: ['ignore', 'pipe', 'pipe'] }); + child.stderr.resume(); + t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } }); + await once(child.stdout, 'data'); + assert.throws(() => f.ledger.prepare(f.binding), /handoff_ledger_busy/); + child.kill('SIGTERM'); await once(child, 'close'); + assert.equal(f.ledger.prepare(f.binding).state, 'validated'); +}); +test('required channels and null generation refuse before durable effect, including supplied prepared IDs', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); + for (const waitFor of ['delivered', 'acknowledged'] as const) { + const binding = { ...f.binding, generation: null }, h = f.ledger.prepare(binding); + const refused = f.ledger.prepare(binding, { correlationId: h.correlationId, waitFor }); + assert.equal(refused.state, 'refused'); assert.equal(refused.wait.status, 'unsupported'); + assert.throws(() => f.ledger.commitEffect(h.correlationId, binding), /handoff_already_attempted/); + assert.throws(() => f.ledger.mintCapability(h.correlationId, bootstrap), /receipt_authority_unavailable/); + } + const best = f.ledger.prepare({ ...f.binding, generation: null }, { requestAck: true, observeDelivery: true }); + assert.equal(best.ack.status, 'unsupported'); assert.equal(best.observation.status, 'unsupported'); +}); +test('qualified internal delivery supports required waits while receipt flags cannot qualify ACK', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); + const pending = f.ledger.prepare(f.binding, { waitFor: 'delivered', observationSupported: true, requestAck: true, budget: new HandoffBudget(6, f.clock) }); + assert.equal(pending.wait.status, 'pending'); assert.equal(pending.observation.status, 'pending'); assert.equal(pending.ack.status, 'unsupported'); + submit(f, pending); + const observed = f.ledger.recordObservation(pending.correlationId, { clientUserMessageId: pending.correlationId, injectionObserved: true, turn: { id: 'turn', status: 'completed' } }, f.binding); + assert.equal(observed.state, 'delivered'); assert.equal(observed.wait.status, 'satisfied'); assert.equal(observed.ack.status, 'unsupported'); + const refused = f.ledger.prepare(f.binding, { waitFor: 'acknowledged', channels: { delivery: true, receipt: true } }); + assert.equal(refused.wait.status, 'unsupported'); assert.equal(refused.state, 'refused'); +}); +test('observation requires qualified original generation, exact binding/message ID and known submission', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); + const h = f.ledger.prepare(f.binding, { observeDelivery: true, channels: { delivery: true }, clientUserMessageId: 'original-user-message' }); + const evidence = { clientUserMessageId: 'original-user-message', injectionObserved: true }; + assert.throws(() => f.ledger.recordObservation(h.correlationId, evidence, f.binding), /handoff_observation_unverified/); + submit(f, h); + assert.throws(() => f.ledger.recordObservation(h.correlationId, { ...evidence, clientUserMessageId: 'different-user-message' }, f.binding), /handoff_observation_unverified/); + for (const binding of [{ ...f.binding, generation: 'successor' }, { ...f.binding, destination: 'different' }, { ...f.binding, payloadDigest: payloadDigest('different') }]) { + assert.throws(() => f.ledger.recordObservation(h.correlationId, evidence, binding), /handoff_binding_conflict/); + } + const observed = f.ledger.recordObservation(h.correlationId, evidence, f.binding); + assert.equal(observed.state, 'delivered'); assert.equal(observed.submission.status, 'submitted'); assert.equal(observed.observation.clientUserMessageId, evidence.clientUserMessageId); + assert.equal(f.ledger.recordObservation(h.correlationId, { ...evidence, injectionObserved: false }, f.binding).state, 'delivered'); + assert.throws(() => f.ledger.commitEffect(h.correlationId, f.binding), /handoff_already_attempted/); + const unqualified = f.ledger.prepare(f.binding); submit(f, unqualified); + assert.throws(() => f.ledger.recordObservation(unqualified.correlationId, { clientUserMessageId: unqualified.correlationId, injectionObserved: true }, f.binding), /handoff_observation_unverified/); + const nullBinding = { ...f.binding, generation: null }, nullH = f.ledger.prepare(nullBinding, { observeDelivery: true, observationSupported: true }); + f.ledger.commitEffect(nullH.correlationId, nullBinding); f.ledger.recordSubmission(nullH.correlationId, 'submitted'); + assert.throws(() => f.ledger.recordObservation(nullH.correlationId, { clientUserMessageId: nullH.correlationId, injectionObserved: true }, nullBinding), /handoff_observation_unverified/); +}); +test('delivery at cutoff preserves timed-out wait; repeated observation cannot change original turn', 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); f.advance(1000); + const evidence = { clientUserMessageId: h.correlationId, injectionObserved: true, turn: { id: 'turn', status: 'completed' as const } }; + const observed = f.ledger.recordObservation(h.correlationId, evidence, f.binding); + assert.equal(observed.wait.status, 'timed_out_unknown'); assert.equal(observed.state, 'delivered'); + assert.throws(() => f.ledger.recordObservation(h.correlationId, { ...evidence, turn: { id: 'successor-turn', status: 'completed' } }, f.binding), /handoff_turn_conflict/); + assert.throws(() => f.ledger.recordObservation(h.correlationId, { ...evidence, turn: { id: 'turn', status: 'running' } }, f.binding), /handoff_turn_conflict/); + const later = f.ledger.beginWait(h.correlationId, 'delivered', new HandoffBudget(6, f.clock)); assert.equal(later.wait.status, 'satisfied'); +}); +test('exact injection evidence survives observer restart without inventing old wait ordering or ACK', posix, t => { + 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.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 => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding, { waitFor: 'delivered', observationSupported: true }); + assert.throws(() => f.ledger.refusePrepared(randomUUID(), f.binding), /handoff_id_unknown/); + assert.throws(() => f.ledger.refusePrepared(h.correlationId, { ...f.binding, generation: 'successor' }), /handoff_binding_conflict/); + assert.equal(handoff(f.ledger, h.correlationId).state, 'validated'); + const refused = f.ledger.refusePrepared(h.correlationId, f.binding, 'evidence_failed'); + assert.equal(refused.state, 'refused'); assert.equal(refused.submission.status, 'refused'); assert.equal(refused.wait.status, 'failed'); + const restarted = new HandoffLedger(f.ledger.path, { clock: f.clock }); + assert.throws(() => restarted.commitEffect(h.correlationId, f.binding), /handoff_already_attempted/); + assert.throws(() => restarted.prepare(f.binding, { correlationId: h.correlationId }), /handoff_already_attempted/); +}); +test('insufficient budget consumes a real send reservation but dry runs do not', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); + const h = f.ledger.prepare(f.binding, { waitFor: 'acknowledged', budget: new HandoffBudget(5, f.clock) }); + assert.equal(h.state, 'refused'); assert.equal(h.wait.reason, 'insufficient_budget'); + assert.throws(() => f.ledger.commitEffect(h.correlationId, f.binding), /handoff_already_attempted/); + const other = f.ledger.prepare(f.binding); + assert.throws(() => f.ledger.commitEffect(other.correlationId, f.binding, new HandoffBudget(5, f.clock)), /insufficient_budget/); + assert.equal(handoff(f.ledger, other.correlationId).state, 'refused'); +}); +test('owned 0700/0600 storage rejects permissive paths and symlink records', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); + assert.equal(statSync(f.ledger.path).mode & 0o777, 0o700); + assert.equal(statSync(join(f.ledger.path, 'ledger.json')).mode & 0o777, 0o600); + chmodSync(f.ledger.path, 0o755); assert.throws(() => f.ledger.prepare(f.binding), /handoff_storage_untrusted/); chmodSync(f.ledger.path, 0o700); + const target = join(f.root, 'external'); writeFileSync(target, 'private', { mode: 0o600 }); + rmSync(join(f.ledger.path, 'ledger.json')); symlinkSync(target, join(f.ledger.path, 'ledger.json')); + assert.throws(() => f.ledger.prepare(f.binding), /handoff_storage_untrusted/); + assert.equal(readFileSync(target, 'utf8'), 'private'); +}); +test('corrupt ledger status remains a query error and does not silently recreate history', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); + writeFileSync(join(f.ledger.path, 'ledger.json'), '{}'); + const value = f.ledger.status(h.correlationId); assert.ok('handoffQuery' in value); assert.equal(value.handoffQuery.context, 'ledger_corrupt'); + assert.throws(() => f.ledger.prepare(f.binding), /handoff_ledger_corrupt/); + assert.equal(readFileSync(join(f.ledger.path, 'ledger.json'), 'utf8'), '{}'); +}); +test('operator confirmation is metadata-only, correlated and never manufactures injection', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); submit(f, h); + assert.throws(() => f.ledger.confirm(confirmation({ ...h, targetGeneration: 'successor' })), /confirmation_binding_invalid/); + assert.throws(() => validateConfirmation(JSON.stringify({ ...JSON.parse(confirmation(h)), body: 'transcript' })), /invalid_handoff/); + const acknowledged = f.ledger.confirm(confirmation(h)); + assert.equal(acknowledged.ack.assurance, 'operator_confirmed'); assert.equal(acknowledged.ack.late, false); + assert.equal(acknowledged.observation.injectionObserved, false); + const restarted = new HandoffLedger(f.ledger.path, { clock: { monotonic: () => 0, utc: () => 1 } }); + assert.deepEqual(handoff(restarted, h.correlationId).ack, acknowledged.ack); +}); +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); + assert.equal(readFileSync(join(f.ledger.path, 'ledger.json'), 'utf8').includes(capability), false); + assert.equal(JSON.stringify(handoff(f.ledger, h.correlationId)).includes(capability), false); + submit(f, h); + const raw = receipt(h, capability), wrong = receipt(h, Buffer.alloc(32).toString('base64url')); + assert.throws(() => f.ledger.acceptReceipt(wrong), /receipt_authentication_failed/); + assert.throws(() => f.ledger.acceptReceipt(receipt({ ...h, targetGeneration: 'successor' }, capability)), /receipt_authentication_failed/); + const accepted = f.ledger.acceptReceipt(raw); + assert.equal(accepted.ack.assurance, 'token_possession'); assert.equal(accepted.observation.injectionObserved, false); + assert.deepEqual(f.ledger.acceptReceipt(raw), accepted); + assert.throws(() => f.ledger.acceptReceipt(receipt(h, capability)), /receipt_already_committed/); + f.advance(HANDOFF_LIMITS.capabilityMs + 1); + const restarted = new HandoffLedger(f.ledger.path, { clock: f.clock }); + assert.deepEqual(restarted.acceptReceipt(raw).ack, accepted.ack); + assert.throws(() => restarted.acceptReceipt(wrong), /receipt_authentication_failed/); + restarted.revoke(h.correlationId); assert.throws(() => restarted.acceptReceipt(raw), /receipt_authentication_failed/); +}); +test('restarted unused authority expires conservatively and never regenerates a secret', 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); + const restarted = new HandoffLedger(f.ledger.path, { clock: f.clock }); + assert.throws(() => restarted.acceptReceipt(receipt(h, capability)), /receipt_authority_expired/); + assert.throws(() => restarted.mintCapability(h.correlationId, bootstrap), /receipt_authority_unavailable/); + assert.equal(handoff(restarted, h.correlationId).ack.status, 'unsupported'); +}); +test('a new native effect owner cannot revive receipt authority minted before process restart', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); + const capability = f.ledger.mintCapability(h.correlationId, bootstrap); + const restarted = new HandoffLedger(f.ledger.path, { clock: { monotonic: () => 0, utc: f.clock.utc } }); + restarted.commitEffect(h.correlationId, f.binding); restarted.recordSubmission(h.correlationId, 'submitted'); + assert.throws(() => restarted.acceptReceipt(receipt(h, capability)), /receipt_authority_expired/); + assert.equal(handoff(restarted, h.correlationId).ack.status, 'unsupported'); +}); +test('expired unspent receipt authority never offers keep_waiting or accepts a new 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); submit(f, h); + f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); f.advance(HANDOFF_LIMITS.capabilityMs); + assert.equal(handoff(f.ledger, h.correlationId).nextActions.includes('keep_waiting'), false); + assert.throws(() => f.ledger.acceptReceipt(receipt(h, capability)), /receipt_authority_expired/); + assert.equal(f.ledger.beginWait(h.correlationId, 'acknowledged').wait.status, 'unsupported'); + assert.equal(handoff(f.ledger, h.correlationId).wait.status, 'unsupported'); +}); +test('receipt at cutoff is late, preserves original terminal wait, and satisfies a later wait', 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); + const pending = f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); + assert.equal(pending.wait.status, 'pending'); f.advance(1000); + const accepted = f.ledger.acceptReceipt(receipt(h, capability)); + assert.equal(accepted.ack.late, true); assert.equal(accepted.wait.status, 'timed_out_unknown'); + const later = f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); + assert.equal(later.wait.status, 'satisfied'); assert.notEqual(later.wait.operationId, pending.wait.operationId); assert.equal(later.ack.late, true); +}); +test('strictly-before-cutoff receipt satisfies wait and terminal results cannot be rewritten', 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); + f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); f.advance(999); + assert.equal(f.ledger.acceptReceipt(receipt(h, capability)).wait.status, 'satisfied'); + assert.throws(() => f.ledger.recordWait(h.correlationId, 'stopped', 'stopped_by_operator'), /handoff_wait_terminal/); +}); +test('one receipt or confirmation resolves concurrent ACK waits by their own immutable deadlines', posix, t => { + for (const route of ['receipt', 'confirmation'] as const) { + 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); + const first = f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); + f.advance(500); + const second = f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); + f.advance(500); // At the first cutoff, strictly before the second cutoff. + const raw = receipt(h, capability); + const accepted = route === 'receipt' ? f.ledger.acceptReceipt(raw) : f.ledger.confirm(confirmation(h)); + assert.equal(accepted.ack.late, true, route); + assert.equal(accepted.wait.operationId, second.wait.operationId, route); + assert.equal(accepted.wait.status, 'satisfied', route); + const stored = JSON.parse(readFileSync(join(f.ledger.path, 'ledger.json'), 'utf8')).data.records[h.correlationId]; + assert.deepEqual(stored.waits.map((wait: any) => [wait.public.operationId, wait.public.status]), [ + [first.wait.operationId, 'timed_out_unknown'], [second.wait.operationId, 'satisfied'], + ], route); + const before = readFileSync(join(f.ledger.path, 'ledger.json'), 'utf8'); + f.advance(1000); + const duplicate = route === 'receipt' ? f.ledger.acceptReceipt(raw) : f.ledger.confirm(confirmation(h)); + assert.deepEqual(duplicate.ack, accepted.ack, route); + assert.equal(readFileSync(join(f.ledger.path, 'ledger.json'), 'utf8'), before, route); + } +}); +test('receipt preserves an earlier stopped ACK wait while satisfying a newer wait', 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); + const first = f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); + f.ledger.recordWait(h.correlationId, 'stopped', 'stopped_by_operator'); + const second = f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); + const accepted = f.ledger.acceptReceipt(receipt(h, capability)); + assert.equal(accepted.wait.operationId, second.wait.operationId); assert.equal(accepted.wait.status, 'satisfied'); + const stored = JSON.parse(readFileSync(join(f.ledger.path, 'ledger.json'), 'utf8')).data.records[h.correlationId]; + assert.deepEqual(stored.waits[0].public, { ...first.wait, status: 'stopped', reason: 'stopped_by_operator' }); +}); +test('newer unsupported wait cannot hide a restarted pending ACK operation from receipt ordering checks', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); + f.ledger.mintCapability(h.correlationId, bootstrap); submit(f, h); + f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); + f.advance(1000); f.ledger.recordWait(h.correlationId, 'timed_out_unknown'); + f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); + const restarted = new HandoffLedger(f.ledger.path, { clock: f.clock }); + assert.equal(restarted.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)).wait.status, 'unsupported'); + const before = readFileSync(join(f.ledger.path, 'ledger.json'), 'utf8'); + assert.throws(() => restarted.confirm(confirmation(h)), /receipt_order_unprovable/); + assert.equal(readFileSync(join(f.ledger.path, 'ledger.json'), 'utf8'), before); + assert.notEqual(handoff(restarted, h.correlationId).ack.status, 'acknowledged'); +}); +test('manual confirmation with unprovable restarted pending wait ordering refuses without ACK', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); + f.ledger.mintCapability(h.correlationId, bootstrap); submit(f, h); + f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); + const restarted = new HandoffLedger(f.ledger.path, { clock: f.clock }); + assert.throws(() => restarted.confirm(confirmation(h)), /receipt_order_unprovable/); + assert.notEqual(handoff(restarted, h.correlationId).ack.status, 'acknowledged'); +}); +test('known durable timeout proves a new late manual event after restart without rewriting that wait', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); + f.ledger.mintCapability(h.correlationId, bootstrap); submit(f, h); + const pending = f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); f.advance(1000); + f.ledger.recordWait(h.correlationId, 'timed_out_unknown'); + const restarted = new HandoffLedger(f.ledger.path, { clock: f.clock }); + const accepted = restarted.confirm(confirmation(h)); assert.equal(accepted.ack.late, true); assert.equal(accepted.wait.status, 'timed_out_unknown'); + assert.equal(accepted.wait.operationId, pending.wait.operationId); +}); +test('wait quota exhaustion and insufficient new-wait budget preserve native submission facts', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); submit(f, h); + for (let i = 0; i < 64; i++) { + const wait = f.ledger.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(5, f.clock)); + assert.equal(wait.wait.reason, 'insufficient_budget'); assert.equal(wait.submission.status, 'submitted'); + } + assert.throws(() => f.ledger.beginWait(h.correlationId, 'acknowledged'), /handoff_wait_quota/); + assert.equal(handoff(f.ledger, h.correlationId).state, 'submitted'); +}); +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); + const raw = receipt(h, capability); f.ledger.acceptReceipt(raw); f.advance(HANDOFF_LIMITS.detailMs); f.ledger.prune(); + const tombstone = handoff(f.ledger, h.correlationId); assert.equal(tombstone.state, 'unknown'); assert.equal(tombstone.ledgerEpoch, h.ledgerEpoch); + assert.equal(tombstone.targetGeneration, h.targetGeneration); assert.equal(tombstone.ack.status, 'unsupported'); + assert.throws(() => f.ledger.commitEffect(h.correlationId, f.binding), /handoff_history_unavailable/); + assert.throws(() => f.ledger.acceptReceipt(raw), /handoff_history_unavailable/); +}); +test('known restoration quarantines original intents and authentic receipts', 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); f.ledger.quarantine(); + assert.equal(handoff(f.ledger, h.correlationId).state, 'unknown'); + assert.throws(() => f.ledger.prepare(f.binding), /handoff_ledger_quarantined/); + assert.throws(() => f.ledger.acceptReceipt(receipt(h, capability)), /handoff_ledger_quarantined/); +}); +test('byte admission reserves terminal/wait/receipt room and never evicts an existing fence', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); + const file = join(f.ledger.path, 'ledger.json'), wrapper = JSON.parse(readFileSync(file, 'utf8')); + const template = wrapper.data.records[h.correlationId]; + for (let i = 1; i < 511; i++) { + const id = randomUUID(), item = structuredClone(template); item.handoff.correlationId = id; + item.binding.payloadDigest = sealedQuotaDigest(f.ledger.path, wrapper.data.epoch, id, f.binding.payloadDigest); + wrapper.data.records[id] = item; + } + writeFileSync(file, JSON.stringify({ digest: payloadDigest(JSON.stringify(wrapper.data)), data: wrapper.data })); + const before = readFileSync(file, 'utf8'); + assert.throws(() => f.ledger.prepare(f.binding), /handoff_ledger_full/); + assert.equal(readFileSync(file, 'utf8'), before); + assert.equal(handoff(f.ledger, h.correlationId).state, 'validated'); + // Reserved terminal space still permits the existing intent's one transition. + submit(f, h); assert.equal(handoff(f.ledger, h.correlationId).state, 'submitted'); +}); +test('retained tombstones count toward the 10,000 intent limit with no LRU replacement', posix, t => { + const f = fixtureLedger(t); f.ledger.init(); const h = f.ledger.prepare(f.binding); + const file = join(f.ledger.path, 'ledger.json'), wrapper = JSON.parse(readFileSync(file, 'utf8')); + const template = wrapper.data.records[h.correlationId]; template.reserve = 0; template.expired = true; + for (let i = 1; i < HANDOFF_LIMITS.intents; i++) { + const id = randomUUID(), item = structuredClone(template); item.handoff.correlationId = id; + item.binding.payloadDigest = sealedQuotaDigest(f.ledger.path, wrapper.data.epoch, id, f.binding.payloadDigest); + wrapper.data.records[id] = item; + } + writeFileSync(file, JSON.stringify({ digest: payloadDigest(JSON.stringify(wrapper.data)), data: wrapper.data })); + const before = readFileSync(file, 'utf8'); + assert.throws(() => f.ledger.prepare(f.binding), /handoff_ledger_full/); + assert.equal(readFileSync(file, 'utf8'), before); + assert.equal(handoff(f.ledger, h.correlationId).state, 'unknown'); +}); +test('private receipt rejects incorrect capability encoding, extra fields and lexical versions', () => { + const sample = { schemaVersion: 1, kind: 'receipt', ledgerEpoch: randomUUID(), correlationId: randomUUID(), targetGeneration: 'g', receiptId: randomUUID(), capability: Buffer.alloc(32).toString('base64url') }; + assert.equal(validateReceipt(JSON.stringify(sample)).capability, sample.capability); + for (const capability of ['x'.repeat(42), 'x'.repeat(44), sample.capability + '=', 'A'.repeat(42) + 'B']) assert.throws(() => validateReceipt(JSON.stringify({ ...sample, capability }))); + assert.throws(() => validateReceipt(JSON.stringify({ ...sample, receivedAtUtcMs: 1 }))); + assert.throws(() => validateReceipt(JSON.stringify(sample).replace('"schemaVersion":1', '"schemaVersion":1.0'))); +}); diff --git a/test/handoff-ledger-privacy.test.ts b/test/handoff-ledger-privacy.test.ts new file mode 100644 index 0000000..71cf406 --- /dev/null +++ b/test/handoff-ledger-privacy.test.ts @@ -0,0 +1,102 @@ +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { chmodSync, existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HandoffLedger, payloadDigest, type HandoffBinding } from '../dist/handoff-ledger.js'; +import { HandoffBudget, type Clock } from '../dist/handoff.js'; + +const posix = { skip: process.platform === 'win32' }; +function fixture(t: TestContext) { + const root = realpathSync(mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-ledger-privacy-'))); + chmodSync(root, 0o700); t.after(() => rmSync(root, { recursive: true, force: true })); + const clock: Clock = { monotonic: () => 100, utc: () => 100000 }; + const path = join(root, 'ledger'), ledger = new HandoffLedger(path, { clock }); + const body = 'private low entropy fixture message'; + const binding: HandoffBinding = { agent: 'codex', destination: 'fixture', target: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + home: '/fixture/home', generation: 'fixture-generation', writerIdentity: 'fixture-writer', payloadDigest: payloadDigest(body) }; + ledger.init(); return { root, path, ledger, clock, body, binding, journal: join(path, 'ledger.json'), key: join(path, 'ledger.key') }; +} + +test('journal never persists raw payload, payload digest, receipt capability or encryption key', posix, t => { + const f = fixture(t), h = f.ledger.prepare(f.binding); + const capability = f.ledger.mintCapability(h.correlationId, { privateConsumerValidated: true, compatibleProducerValidated: true }); + const text = readFileSync(f.journal, 'utf8'); + assert.equal(text.includes(f.body), false); + assert.equal(text.includes(f.binding.payloadDigest), false); + assert.equal(text.includes(capability), false); + const key = readFileSync(f.key); + assert.equal(key.length, 32); assert.equal(statSync(f.key).mode & 0o777, 0o600); + assert.equal(text.includes(key.toString('hex')), false); assert.equal(text.includes(key.toString('base64')), false); + assert.deepEqual(readdirSync(f.path).sort(), ['ledger.json', 'ledger.key', 'ledger.lock']); + assert.equal(JSON.stringify(h).includes(f.binding.payloadDigest), false); +}); + +test('encrypted binding survives restart, refuses conflicting body, and keeps receipt and wait evidence', posix, t => { + const f = fixture(t), h = f.ledger.prepare(f.binding); + const restarted = new HandoffLedger(f.path, { clock: f.clock }); + assert.equal(restarted.prepare(f.binding, { correlationId: h.correlationId }).correlationId, h.correlationId); + const before = readFileSync(f.journal); + assert.throws(() => restarted.commitEffect(h.correlationId, { ...f.binding, payloadDigest: payloadDigest('conflict') }), /handoff_binding_conflict/); + assert.deepEqual(readFileSync(f.journal), before); + restarted.commitEffect(h.correlationId, f.binding); restarted.recordSubmission(h.correlationId, 'submitted'); + const confirmed = restarted.confirm(JSON.stringify({ schemaVersion: 1, ledgerEpoch: h.ledgerEpoch, correlationId: h.correlationId, + targetGeneration: h.targetGeneration, confirmed: true })); + assert.equal(confirmed.ack.assurance, 'operator_confirmed'); + const wait = restarted.beginWait(h.correlationId, 'acknowledged', new HandoffBudget(30, f.clock)); + assert.equal(wait.wait.status, 'satisfied'); + const status = new HandoffLedger(f.path, { clock: f.clock }).status(h.correlationId); + assert.ok('handoff' in status); assert.equal(status.handoff.ack.status, 'acknowledged'); + assert.equal(status.handoff.wait.operationId, wait.wait.operationId); + assert.equal(readFileSync(f.journal, 'utf8').includes(f.binding.payloadDigest), false); +}); + +for (const damage of ['missing', 'short', 'rotated', 'permissions', 'symlink'] as const) { + test(`a ${damage} key fails closed without journal changes or key regeneration`, posix, t => { + const f = fixture(t), h = f.ledger.prepare(f.binding), before = readFileSync(f.journal); + if (damage === 'missing') unlinkSync(f.key); + if (damage === 'short') writeFileSync(f.key, 'bad', { mode: 0o600 }); + if (damage === 'rotated') writeFileSync(f.key, randomBytes(32)); + if (damage === 'permissions') chmodSync(f.key, 0o644); + if (damage === 'symlink') { const other = join(f.root, 'other'); writeFileSync(other, randomBytes(32), { mode: 0o600 }); unlinkSync(f.key); symlinkSync(other, f.key); } + const keyBefore = damage === 'missing' ? undefined : readFileSync(f.key); + const restarted = new HandoffLedger(f.path, { clock: f.clock }); + assert.throws(() => restarted.commitEffect(h.correlationId, f.binding)); + assert.ok('handoffQuery' in restarted.status(h.correlationId)); + assert.throws(() => restarted.init(), /handoff_ledger_exists/); + assert.deepEqual(readFileSync(f.journal), before); + if (damage === 'missing') assert.equal(existsSync(f.key), false); + else assert.deepEqual(readFileSync(f.key), keyBefore); + }); +} + +test('rotated key is rejected even for an empty ledger; orphan key is never reused by init', posix, t => { + const f = fixture(t), before = readFileSync(f.journal); writeFileSync(f.key, randomBytes(32)); + assert.throws(() => new HandoffLedger(f.path).prepare(f.binding)); + assert.deepEqual(readFileSync(f.journal), before); + unlinkSync(f.journal); const keyBefore = readFileSync(f.key); + assert.throws(() => new HandoffLedger(f.path).init()); + assert.equal(existsSync(f.journal), false); assert.deepEqual(readFileSync(f.key), keyBefore); +}); + +test('ciphertext is authenticated against the original epoch and intent ID', posix, t => { + const f = fixture(t), a = f.ledger.prepare(f.binding), b = f.ledger.prepare({ ...f.binding, target: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }); + const original = readFileSync(f.journal, 'utf8'), wrapper = JSON.parse(original); + wrapper.data.records[b.correlationId].binding.payloadDigest = wrapper.data.records[a.correlationId].binding.payloadDigest; + wrapper.digest = payloadDigest(JSON.stringify(wrapper.data)); writeFileSync(f.journal, JSON.stringify(wrapper)); + assert.throws(() => new HandoffLedger(f.path).commitEffect(b.correlationId, f.binding), /handoff_ledger_corrupt/); + const changedEpoch = JSON.parse(original); changedEpoch.data.epoch = randomUUID(); + for (const item of Object.values(changedEpoch.data.records) as { handoff: { ledgerEpoch: string } }[]) item.handoff.ledgerEpoch = changedEpoch.data.epoch; + changedEpoch.digest = payloadDigest(JSON.stringify(changedEpoch.data)); writeFileSync(f.journal, JSON.stringify(changedEpoch)); + assert.throws(() => new HandoffLedger(f.path).prepare(f.binding, { correlationId: a.correlationId }), /handoff_ledger_corrupt/); +}); + +test('plaintext historical journal is refused without silently migrating or replacing the key', posix, t => { + const f = fixture(t), h = f.ledger.prepare(f.binding), wrapper = JSON.parse(readFileSync(f.journal, 'utf8')); + wrapper.data.records[h.correlationId].binding.payloadDigest = f.binding.payloadDigest; + wrapper.digest = payloadDigest(JSON.stringify(wrapper.data)); writeFileSync(f.journal, JSON.stringify(wrapper)); + const journalBefore = readFileSync(f.journal), keyBefore = readFileSync(f.key); + assert.throws(() => new HandoffLedger(f.path).prepare(f.binding, { correlationId: h.correlationId }), /handoff_ledger_corrupt/); + assert.deepEqual(readFileSync(f.journal), journalBefore); assert.deepEqual(readFileSync(f.key), keyBefore); +}); diff --git a/test/handoff-observation-waits.test.ts b/test/handoff-observation-waits.test.ts new file mode 100644 index 0000000..c96ab50 --- /dev/null +++ b/test/handoff-observation-waits.test.ts @@ -0,0 +1,92 @@ +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HandoffBudget, type Clock } from '../dist/handoff.js'; +import { HandoffLedger, payloadDigest, type HandoffBinding } from '../dist/handoff-ledger.js'; +const posix = { skip: process.platform === 'win32' }; +function fixture(t: TestContext) { + const root = realpathSync(mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-ho-observe-'))); + chmodSync(root, 0o700); t.after(() => rmSync(root, { recursive: true, force: true })); + let mono = 10; + const clock: Clock = { monotonic: () => mono, utc: () => 100000 + mono }; + const ledger = new HandoffLedger(join(root, 'ledger'), { clock }); ledger.init(); + const binding: HandoffBinding = { agent: 'codex', destination: 'fixture', target: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + home: '/fixture/home', generation: 'fixture-generation', payloadDigest: payloadDigest('fixture body') }; + const h = ledger.prepare(binding, { observeDelivery: true, observationSupported: true }); + ledger.mintCapability(h.correlationId, { privateConsumerValidated: true, compatibleProducerValidated: true }); + ledger.commitEffect(h.correlationId, binding); ledger.recordSubmission(h.correlationId, 'submitted'); + const waits = () => JSON.parse(readFileSync(join(ledger.path, 'ledger.json'), 'utf8')).data.records[h.correlationId].waits.map((w: any) => w.public); + const observe = (reader = ledger) => reader.recordObservation(h.correlationId, { clientUserMessageId: h.correlationId, injectionObserved: true }, binding); + return { ledger, clock, h, waits, observe, advance: (ms: number) => { mono += ms; } }; +} +test('one injection resolves every pending delivery wait using its own cutoff even when ACK is newest', 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 ack = f.ledger.beginWait(f.h.correlationId, 'acknowledged', new HandoffBudget(6, f.clock)); + f.advance(500); // First cutoff is inclusive; second cutoff is still in the future. + const result = f.observe(); + assert.equal(result.state, 'delivered'); assert.equal(result.ack.status, 'pending'); + assert.equal(result.wait.operationId, ack.wait.operationId); assert.equal(result.wait.status, 'pending'); + assert.deepEqual(f.waits().map((w: any) => [w.operationId, w.status]), [ + [first.wait.operationId, 'timed_out_unknown'], [second.wait.operationId, 'satisfied'], [ack.wait.operationId, 'pending'], + ]); +}); +test('injection preserves terminal delivery waits and never resets their deadlines', posix, t => { + const f = fixture(t); + const stopped = f.ledger.beginWait(f.h.correlationId, 'delivered', new HandoffBudget(6, f.clock)); + f.ledger.recordWait(f.h.correlationId, 'stopped', 'stopped_by_operator'); + const completed = f.ledger.beginWait(f.h.correlationId, 'delivered', new HandoffBudget(6, f.clock)); + f.advance(1000); f.ledger.recordWait(f.h.correlationId, 'timed_out_unknown'); + const before = f.waits(); + const pending = f.ledger.beginWait(f.h.correlationId, 'delivered', new HandoffBudget(6, f.clock)); + f.observe(); const observed = f.waits(); + assert.deepEqual(observed.slice(0, 2), before); + assert.equal(observed[0].operationId, stopped.wait.operationId); + assert.equal(observed[1].operationId, completed.wait.operationId); + 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 => { + 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)); + const ack = f.ledger.beginWait(f.h.correlationId, 'acknowledged', new HandoffBudget(30, f.clock)); + 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); + assert.deepEqual(f.waits().slice(0, 2).map((w: any) => [w.status, w.reason]), [ + ['failed', 'history_unavailable'], ['failed', 'history_unavailable'], + ]); + assert.equal(f.waits()[2].status, 'pending'); +}); +test('explicit operation timeout resolves the older cutoff without changing a newer pending wait', 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)); + f.advance(500); + const settled = f.ledger.recordWait(f.h.correlationId, 'timed_out_unknown', undefined, first.wait.operationId); + assert.equal(settled.wait.operationId, first.wait.operationId); assert.equal(settled.wait.status, 'timed_out_unknown'); + assert.deepEqual(f.waits().map((w: any) => [w.operationId, w.status]), [ + [first.wait.operationId, 'timed_out_unknown'], [second.wait.operationId, 'pending'], + ]); + const status = f.ledger.status(f.h.correlationId); assert.ok('handoff' in status); + assert.deepEqual(status.handoff.wait, second.wait); + const before = f.waits(); + assert.throws(() => f.ledger.recordWait(f.h.correlationId, 'stopped', 'stopped_by_operator', first.wait.operationId), /handoff_wait_terminal/); + assert.deepEqual(f.waits(), before); +}); +test('explicit wait selection rejects unknown IDs and cannot timeout a foreign monotonic clock', posix, t => { + const f = fixture(t); + const first = f.ledger.beginWait(f.h.correlationId, 'delivered', new HandoffBudget(6, f.clock)); + f.advance(1000); + const foreign = new HandoffLedger(f.ledger.path, { clock: f.clock }); + assert.throws(() => foreign.recordWait(f.h.correlationId, 'timed_out_unknown', undefined, first.wait.operationId), /receipt_order_unprovable/); + assert.throws(() => f.ledger.recordWait(f.h.correlationId, 'stopped', 'stopped_by_operator', 'not-a-uuid'), /invalid_wait_operation_id/); + 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'); +}); diff --git a/test/package-smoke.ts b/test/package-smoke.ts index d8080e4..6c3eafb 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', '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', '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; diff --git a/test/skill-guidance.test.ts b/test/skill-guidance.test.ts index edbcc27..3096a5d 100644 --- a/test/skill-guidance.test.ts +++ b/test/skill-guidance.test.ts @@ -60,7 +60,7 @@ test('TS skill baseline list and guarded Codex dry-run examples never submit', t const env = { ...process.env, HOME: root, USERPROFILE: root, CODEX_HOME: '', SESSION_PEER_CODEX_HOMES: '[]', CLAUDE_CONFIG_DIR: join(root, '.claude'), ANTHROPIC_CONFIG_DIR: '' }; function invoke(args: string[]) { - const result = spawnSync(process.execPath, [cli, ...args], { env, encoding: 'utf8', input: 'fixture message', timeout: 15000 }); + const result = spawnSync(process.execPath, [cli, ...args], { env, encoding: 'utf8', input: args.includes('--message') && args[args.indexOf('--message') + 1] === '-' ? 'fixture message' : undefined, timeout: 15000 }); assert.ifError(result.error); assert.equal(result.signal, null); return result; } diff --git a/test/transport.test.ts b/test/transport.test.ts index 0783d83..a9c6ad6 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -8,6 +8,7 @@ import { createServer } from 'node:net'; import { once } from 'node:events'; import { fileURLToPath } from 'node:url'; import { probeLock, inspectWriter } from '../dist/writer.js'; +import { send } from '../dist/send.js'; import { reply, envelope } from '../dist/protocol.js'; const cli = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); @@ -352,3 +353,24 @@ test('Windows --remote-bin doubles every PowerShell single-quote variant', async const decoded = Buffer.from(readFileSync(calls, 'utf8').trim().split(' ').at(-1)!, 'base64').toString('utf16le'); assert.equal(decoded, "& 'C:\\a''\u2018\u2018\u2019\u2019\u201a\u201a\u201b\u201b;calc;#' --version; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"); }); + +test('opt-in handoff context stays bound to selected generation and rechecks storage before fencing', async t => { + const {path} = setup(t), home = join(path,'home'); database(home); await holder(t,path,home); + const owner = await inspectWriter(home,id); const crypto = await import('node:crypto'); const expected = crypto.createHash('sha256').update(owner.identity).digest('hex'); + let effects = 0; + await assert.rejects(send({ to:`codex:${id}`,home,codexBin:process.execPath,message:'fixture',hooks:{ + resolved(context) {assert.equal(context.generation,expected);writeFileSync(join(home,'config.toml'),'sqlite_home = "/fixture/relocated"');}, + beforeEffect() {effects++;} + }}), /unsupported_codex_sqlite_home/); + assert.equal(effects,0); +}); + +test('ordinary SSH results cannot opt themselves into handoff or claim unsolicited ACK', async t=>{ + const {path,env}=setup(t); const fake=join(path,'ssh'),log=join(path,'ssh-count-\'"\\\n\u2028\u2029');writeFileSync(log,''); + const h={schemaVersion:1,correlationId:id,ledgerEpoch:id,state:'acknowledged',submission:{status:'submitted'},observation:{status:'not_requested',injectionObserved:false},ack:{status:'acknowledged',assurance:'token_possession',receivedAtUtcMs:1,late:false},wait:{for:'none',status:'not_requested'},targetGeneration:'fixture',decisionOwner:'sender_operator',retry:{allowed:false,reason:'receiver_dedup_unavailable'},nextActions:['reconcile']}; + // Fixture values are data, never part of the executable JavaScript source. + const config={log,handoff:{...h,targetGeneration:'\');throw new Error("fixture data executed");//\u2028\u2029'}}; + writeFileSync(fake+'.json',JSON.stringify(config),{mode:0o600}); + writeFileSync(fake,`#!${process.execPath}\n${sshConfigUser}if(process.argv.at(-1).includes('--version')){console.log('session-peer 0.3.6 (typescript)');process.exit(0);}const fs=require('node:fs');const config=JSON.parse(fs.readFileSync(__filename+'.json','utf8'));let input='';process.stdin.on('data',p=>input+=p);process.stdin.on('end',()=>{fs.appendFileSync(config.log,'1');console.log(JSON.stringify({schemaVersion:1,command:'send',host:'fixture',ok:true,status:'posted',submitted:true,consumptionConfirmed:false,dryRun:false,target:{agent:'claude',pid:123,name:null},remoteMarker:'kept',handoff:config.handoff,handoffQuery:{status:'unknown'},handoffWarning:'fixture-only'}));});`,{mode:0o700}); + const r=await invoke(['send','--host','fixture','--to','123','--message','fixture','--no-from','--no-reply-to'],{...env,PATH:path+delimiter+(env.PATH??'')});assert.equal(r.code,0);assert.equal(r.status,'posted');assert.equal(r.submitted,true);assert.equal(r.target.name,null);assert.equal(r.remoteMarker,'kept');assert.equal('handoff' in r,false);assert.equal('handoffQuery' in r,false);assert.equal('handoffWarning' in r,false);assert.equal(readFileSync(log,'utf8'),'1'); +});