From 08831edf31e9b9816d2fa524f1daa3c838559299 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 09:07:55 +0900 Subject: [PATCH 01/15] feat: add private durable handoff ledger and strict protocol helpers --- src/handoff-ledger.ts | 328 ++++++++++++++++++++++++++++++++++++++ src/handoff.ts | 174 ++++++++++++++++++++ test/handoff-core.test.ts | 325 +++++++++++++++++++++++++++++++++++++ 3 files changed, 827 insertions(+) create mode 100644 src/handoff-ledger.ts create mode 100644 src/handoff.ts create mode 100644 test/handoff-core.test.ts diff --git a/src/handoff-ledger.ts b/src/handoff-ledger.ts new file mode 100644 index 0000000..debbee1 --- /dev/null +++ b/src/handoff-ledger.ts @@ -0,0 +1,328 @@ +// 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 } 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 } }; +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; receiptId?: string; revoked?: boolean; expired?: boolean }; +type Ledger = { version: 1; epoch: 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'); } + 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', '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 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', 'receiptId', 'revoked', 'expired']); + 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.receiptId !== undefined && !canonicalId(item.receiptId))) 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(); + validateHandoff({ ...h, wait: wait.public }); + } + } + 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 text = JSON.stringify({ digest: payloadDigest(JSON.stringify(data)), data }); + const reserves = Object.values(data.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 epoch = randomUUID(); this.write({ version: 1, epoch, 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.clockId !== this.clockId || item.revoked || this.clock.monotonic() - item.preparedMono >= HANDOFF_LIMITS.capabilityMs) && h.ack.status === 'pending') { + h.ack = { status: 'unsupported' }; 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.requestAck) h.ack.status = 'unsupported'; + if (options.observeDelivery) h.observation.status = 'unsupported'; + if (goal !== 'none') { + const budget = options.budget ?? new HandoffBudget(30, this.clock); this.addWait(item, goal, budget); + 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'; else h.observation.status = 'unsupported'; + 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) { + item.phase = 'refused'; item.handoff.state = 'refused'; item.handoff.submission.status = 'refused'; 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); + }); + } + 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']; + 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 === 'acknowledged' && item.capabilityHash && !item.revoked && item.clockId === 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); + }); + } + recordWait(id: string, status: Exclude, reason?: WaitReason): Handoff { + return this.lock(() => { + const data = this.read(), item = this.usable(data, id), wait = item.waits.at(-1); + if (!wait || 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'); + item.handoff.wait.status = status; if (reason) item.handoff.wait.reason = reason; + if (status === 'timed_out_unknown') item.handoff.state = 'timed_out_unknown'; + item.handoff.nextActions = ['reconcile']; + wait.public = structuredClone(item.handoff.wait); validateHandoff(item.handoff); + this.write(data); return this.snapshot(item); + }); + } + private commitAck(item: RecordIntent, assurance: 'operator_confirmed' | 'token_possession'): void { + const h = item.handoff, origin = item.waits[0]; + let late = false; + if (origin) { + if (origin.public.status === 'timed_out_unknown') late = true; + else if (origin.clockId === this.clockId) late = this.clock.monotonic() >= origin.cutoff; + else fail('receipt_order_unprovable'); + } + h.state = 'acknowledged'; h.ack = { status: 'acknowledged', assurance, receivedAtUtcMs: Math.floor(this.clock.utc()), late }; + const latest = item.waits.at(-1); + if (latest?.public.status === 'pending' && latest.public.for === 'acknowledged') { + if (latest.clockId !== this.clockId) fail('receipt_order_unprovable'); + latest.public.status = this.clock.monotonic() < latest.cutoff ? 'satisfied' : 'timed_out_unknown'; h.wait = structuredClone(latest.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') 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.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.clockId !== 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.receiptId; + } + this.write(data); + }); + } +} diff --git a/src/handoff.ts b/src/handoff.ts new file mode 100644 index 0000000..ae223c0 --- /dev/null +++ b/src/handoff.ts @@ -0,0 +1,174 @@ +// 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' || ack.status === 'unsupported' || !['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/test/handoff-core.test.ts b/test/handoff-core.test.ts new file mode 100644 index 0000000..637b6f4 --- /dev/null +++ b/test/handoff-core.test.ts @@ -0,0 +1,325 @@ +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { 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'); +} +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('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('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('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('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; + 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; + 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'))); +}); From f46ea04c8e6cf382dcda2a39d91041772db3233d Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 09:27:34 +0900 Subject: [PATCH 02/15] feat: record qualified delivery evidence and pre-effect refusals --- src/handoff-ledger.ts | 96 +++++++++++++++++++++++++++++++++------ src/handoff.ts | 4 +- test/handoff-core.test.ts | 66 +++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 14 deletions(-) diff --git a/src/handoff-ledger.ts b/src/handoff-ledger.ts index debbee1..3580bf9 100644 --- a/src/handoff-ledger.ts +++ b/src/handoff-ledger.ts @@ -11,11 +11,14 @@ import { canonicalId, closed, fail, HANDOFF_LIMITS, HandoffBudget, parseStrictJs 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 } }; + 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; receiptId?: string; revoked?: boolean; expired?: boolean }; + reserve: number; capabilityHash?: string; capabilityClockId?: string; receiptId?: string; revoked?: boolean; expired?: boolean; + deliverySupported?: boolean; clientUserMessageId?: string }; type Ledger = { version: 1; epoch: 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' } }; @@ -87,12 +90,16 @@ export class HandoffLedger { const records = data.records as Record; if (Object.keys(records).length > HANDOFF_LIMITS.intents) fail(); for (const [id, item] of Object.entries(records)) { - closed(item, ['binding', 'preparedUtc', 'clockId', 'preparedMono', 'phase', 'handoff', 'waits', 'reserve'], ['capabilityHash', 'receiptId', 'revoked', 'expired']); + closed(item, ['binding', 'preparedUtc', 'clockId', 'preparedMono', 'phase', 'handoff', 'waits', 'reserve'], ['capabilityHash', 'capabilityClockId', 'receiptId', 'revoked', 'expired', 'deliverySupported', 'clientUserMessageId']); 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.receiptId !== undefined && !canonicalId(item.receiptId))) fail(); + (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) { @@ -141,9 +148,12 @@ export class HandoffLedger { 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.clockId !== this.clockId || item.revoked || this.clock.monotonic() - item.preparedMono >= HANDOFF_LIMITS.capabilityMs) && h.ack.status === 'pending') { + 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 { @@ -189,16 +199,24 @@ export class HandoffLedger { 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'; - if (options.observeDelivery) h.observation.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); - h.wait.status = 'unsupported'; h.wait.reason = 'evidence_unsupported'; + 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'; else h.observation.status = 'unsupported'; - h.state = 'refused'; h.submission.status = 'refused'; item.phase = 'refused'; + 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); } } @@ -211,22 +229,70 @@ export class HandoffLedger { if (!sameBinding(item.binding, binding)) fail('handoff_binding_conflict'); if (item.phase !== 'prepared') fail('handoff_already_attempted'); try { budget?.beforeEffect(); } catch (error) { - item.phase = 'refused'; item.handoff.state = 'refused'; item.handoff.submission.status = 'refused'; this.write(data); throw 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'; + const latest = item.waits.at(-1); + if (latest?.public.status === 'pending' && latest.public.for === 'delivered') { + if (latest.clockId !== this.clockId) { latest.public.status = 'failed'; latest.public.reason = 'history_unavailable'; } + else latest.public.status = this.clock.monotonic() < latest.cutoff ? 'satisfied' : 'timed_out_unknown'; + h.wait = structuredClone(latest.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 }; @@ -240,7 +306,10 @@ export class HandoffLedger { h.nextActions = ['reconcile']; if ((goal === 'acknowledged' && h.ack.status === 'acknowledged') || (goal === 'delivered' && h.observation.injectionObserved)) h.wait.status = 'satisfied'; else if (budget.seconds <= 5) { h.wait.status = 'failed'; h.wait.reason = 'insufficient_budget'; } - else if (goal === 'acknowledged' && item.capabilityHash && !item.revoked && item.clockId === this.clockId && + 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'; } @@ -295,6 +364,7 @@ export class HandoffLedger { 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; }); } @@ -308,7 +378,7 @@ export class HandoffLedger { 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.clockId !== this.clockId || this.clock.monotonic() - item.preparedMono >= HANDOFF_LIMITS.capabilityMs) fail('receipt_authority_expired'); + 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); }); @@ -320,7 +390,7 @@ export class HandoffLedger { 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.receiptId; + delete item.capabilityHash; delete item.capabilityClockId; delete item.receiptId; } this.write(data); }); diff --git a/src/handoff.ts b/src/handoff.ts index ae223c0..c692d95 100644 --- a/src/handoff.ts +++ b/src/handoff.ts @@ -147,7 +147,9 @@ export function validateHandoff(value: unknown): Handoff { 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' || ack.status === 'unsupported' || !['submitted', 'unknown'].includes(sub.status as string))) fail(); + if (row.nextActions.includes('keep_waiting') && (row.targetGeneration === null || wait.status === 'unsupported' || + (wait.for === 'delivered' ? !['pending', 'observed'].includes(obs.status as string) : ack.status === 'unsupported') || + !['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; diff --git a/test/handoff-core.test.ts b/test/handoff-core.test.ts index 637b6f4..5b046ec 100644 --- a/test/handoff-core.test.ts +++ b/test/handoff-core.test.ts @@ -155,6 +155,64 @@ test('required channels and null generation refuse before durable effect, includ 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) }); @@ -218,6 +276,14 @@ test('restarted unused authority expires conservatively and never regenerates a 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); From 5145e91ce9a204b1122687763608f4f81487362b Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 09:32:47 +0900 Subject: [PATCH 03/15] feat(handoff): add opt-in private evidence CLI and guarded send integration --- PARITY.md | 13 ++++ README.ja.md | 2 + README.ko.md | 2 + README.md | 2 + README.zh-CN.md | 2 + docs/api.md | 42 ++++++++++ docs/guide.ja.md | 7 ++ docs/guide.ko.md | 6 ++ docs/guide.md | 7 ++ docs/guide.zh-CN.md | 7 ++ scripts/release.mjs | 2 +- src/cli.ts | 58 +++++++++++--- src/handoff-operations.ts | 159 ++++++++++++++++++++++++++++++++++++++ src/help.ts | 17 ++++ src/output.ts | 7 +- src/send.ts | 46 +++++++++-- test/handoff-cli.test.ts | 74 ++++++++++++++++++ test/package-smoke.ts | 2 +- test/transport.test.ts | 12 +++ 19 files changed, 449 insertions(+), 18 deletions(-) create mode 100644 src/handoff-operations.ts create mode 100644 test/handoff-cli.test.ts diff --git a/PARITY.md b/PARITY.md index 2b8e2be..8f72eef 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1039,3 +1039,16 @@ 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.1 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. 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..2df7d60 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,3 +33,45 @@ 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.1 +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. + +`--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..910065a 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,15 +162,15 @@ export function parse(args: string[]): Options { } return { command, values, flags, hosts, ssh, ...(jump ? { jump } : {}), ...(address ? { address } : {}) }; } -async function input(): Promise { +async function input(limit = 4_100_000, preserveBOM = false): 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'); + if (bytes > limit) throw new Refusal('input_too_large'); chunks.push(part); } - try { return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)); } + try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: preserveBOM }).decode(Buffer.concat(chunks)); } catch { throw new Refusal('invalid_utf8'); } } const quote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"; @@ -270,7 +289,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 +307,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 +318,12 @@ 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'); + message = options.values.has('--message-file') ? privateMessage(options.values.get('--message-file')!) : options.values.get('--message'); if (message === undefined || message === '-') { if (wire) throw new Refusal('remote_message_required'); message = await input(); } - checkMessage(message); + 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 +365,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 })); + 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 +400,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-operations.ts b/src/handoff-operations.ts new file mode 100644 index 0000000..ee7037a --- /dev/null +++ b/src/handoff-operations.ts @@ -0,0 +1,159 @@ +// 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, 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 }; +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); + // 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 ? '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/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..46eb069 100644 --- a/src/send.ts +++ b/src/send.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { casefold } from './casefold.js'; import { createConnection } from 'node:net'; import { claude, Refusal, sqliteHome } from './discovery.js'; @@ -5,7 +6,15 @@ 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 +27,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,7 +45,7 @@ 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(); }); }); @@ -56,14 +66,31 @@ export async function send(options: SendOptions): Promise B -> A. + const proof = JSON.parse(selection.evidence) as { fingerprints: { home: string; writer: string }[] }; + const identity = proof.fingerprints.find(item => item.home === home)?.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, + ...(generation ? { writerIdentity: generation } : {}) }, 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); @@ -82,10 +109,17 @@ 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 = { ...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 = '') { const child = spawn(process.execPath, [cli, ...args, '--json'], { env }); let stdout = ''; child.stdout.on('data', c => stdout += c); child.stderr.resume(); child.stdin.end(input); const timer = setTimeout(() => child.kill('SIGKILL'), 10000); const [code, signal] = await once(child, 'close'); clearTimeout(timer); assert.equal(signal, null); return { code, value: JSON.parse(stdout) }; } + return { path, 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); + 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; } +}); 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/transport.test.ts b/test/transport.test.ts index 0783d83..12a1a9b 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,14 @@ 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); +}); From ddc5ba0bd09fc7c84439be9fe4fbbf138cc8ae1a Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 09:38:20 +0900 Subject: [PATCH 04/15] fix(handoff): preserve strict input and bounded pre-effect refusal --- src/cli.ts | 24 +++++++++++++----------- src/handoff-operations.ts | 2 +- test/handoff-cli.test.ts | 2 ++ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 910065a..4f18281 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -162,17 +162,19 @@ export function parse(args: string[]): Options { } return { command, values, flags, hosts, ssh, ...(jump ? { jump } : {}), ...(address ? { address } : {}) }; } -async function input(limit = 4_100_000, preserveBOM = false): Promise { - const chunks: Buffer[] = []; - let bytes = 0; - 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'); } +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. @@ -322,7 +324,7 @@ try { let message: string | undefined, routing: Record = {}; if (command === 'send') { message = options.values.has('--message-file') ? privateMessage(options.values.get('--message-file')!) : options.values.get('--message'); - if (message === undefined || message === '-') { if (wire) throw new Refusal('remote_message_required'); message = await input(); } + if (message === undefined || 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) { diff --git a/src/handoff-operations.ts b/src/handoff-operations.ts index ee7037a..9ef2bd5 100644 --- a/src/handoff-operations.ts +++ b/src/handoff-operations.ts @@ -93,7 +93,7 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO 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 ? 'evidence_unsupported' : error instanceof Refusal ? error.code : uncertain ? 'outcome_unknown' : 'handoff_operation_failed'; + 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); } diff --git a/test/handoff-cli.test.ts b/test/handoff-cli.test.ts index 2c027bd..cc02af9 100644 --- a/test/handoff-cli.test.ts +++ b/test/handoff-cli.test.ts @@ -45,6 +45,8 @@ test('syntax/private inputs reject without minting authority or submitting', { s 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); }); From 1526b6ae4c629e6e23fe6e59cc1aee22b93b0e86 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 09:54:28 +0900 Subject: [PATCH 05/15] fix(handoff): bound opted-in result size before submission --- src/cli.ts | 2 +- src/handoff-operations.ts | 5 +++-- test/handoff-cli.test.ts | 6 ++++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 4f18281..f609979 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -371,7 +371,7 @@ try { 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 })); + 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')); diff --git a/src/handoff-operations.ts b/src/handoff-operations.ts index 9ef2bd5..aa49b4e 100644 --- a/src/handoff-operations.ts +++ b/src/handoff-operations.ts @@ -4,13 +4,13 @@ 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, HandoffBudget, parseWaitTimeout, type Handoff, type WaitFor } from './handoff.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 }; + 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'); @@ -59,6 +59,7 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO 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, diff --git a/test/handoff-cli.test.ts b/test/handoff-cli.test.ts index cc02af9..60d9667 100644 --- a/test/handoff-cli.test.ts +++ b/test/handoff-cli.test.ts @@ -74,3 +74,9 @@ test('positive native acceptance survives a subsequent durable-record I/O failur 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); +}); From 3537f483c2d03e4eefb65d79e47699690dbe2eb7 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 10:16:14 +0900 Subject: [PATCH 06/15] fix(handoff): discard unsolicited peer evidence on ordinary SSH results --- src/cli.ts | 2 ++ test/transport.test.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index f609979..73bdec2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -235,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 } { diff --git a/test/transport.test.ts b/test/transport.test.ts index 12a1a9b..8ef8771 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -364,3 +364,10 @@ test('opt-in handoff context stays bound to selected generation and rechecks sto }}), /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');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']}; + writeFileSync(fake,`#!${process.execPath}\n${sshConfigUser}if(process.argv.at(-1).includes('--version')){console.log('session-peer 0.3.1 (typescript)');process.exit(0);}let input='';process.stdin.on('data',p=>input+=p);process.stdin.on('end',()=>{require('node:fs').appendFileSync(${JSON.stringify(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:${JSON.stringify(h)},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'); +}); From 2eeb31f093af61d483297de47b215aaa0daae9cb Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 11:15:28 +0900 Subject: [PATCH 07/15] fix(cli): preserve literal dash from message files and wire bodies --- src/cli.ts | 13 +++++++++++-- test/handoff-cli.test.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 73bdec2..a8c97f5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -325,8 +325,17 @@ try { let rawMessage: string | undefined; let message: string | undefined, routing: Record = {}; if (command === 'send') { - message = options.values.has('--message-file') ? privateMessage(options.values.get('--message-file')!) : options.values.get('--message'); - if (message === undefined || message === '-') { if (wire) throw new Refusal('remote_message_required'); message = await input(4_100_000, false, handoffBudget?.observationRemainingMs()); } + 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) { diff --git a/test/handoff-cli.test.ts b/test/handoff-cli.test.ts index 60d9667..3344534 100644 --- a/test/handoff-cli.test.ts +++ b/test/handoff-cli.test.ts @@ -18,8 +18,8 @@ async function fixture(t: TestContext) { writeFileSync(join(path, '.claude/sessions', `${process.pid}.json`), JSON.stringify({ pid: process.pid, name: null, messagingSocketPath: socket })); const file = join(path, 'message'); writeFileSync(file, 'fixture body', { mode: 0o600 }); const env = { ...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 = '') { const child = spawn(process.execPath, [cli, ...args, '--json'], { env }); let stdout = ''; child.stdout.on('data', c => stdout += c); child.stderr.resume(); child.stdin.end(input); const timer = setTimeout(() => child.kill('SIGKILL'), 10000); const [code, signal] = await once(child, 'close'); clearTimeout(timer); assert.equal(signal, null); return { code, value: JSON.parse(stdout) }; } - return { path, messages, file, invoke, send: ['send', '--to', String(process.pid), '--message-file', file, '--no-from', '--no-reply-to'] }; + 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); @@ -80,3 +80,25 @@ test('opt-in reserves bounded outer-result space before any native effect', {ski 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,'-'); + +}); From 8161522cf1626f893f4af89dfc86469eb191b325 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 11:19:52 +0900 Subject: [PATCH 08/15] ci(handoff): validate the main-target review candidate From c981553421de28c439524bf86d01173ba96a86a8 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 7 Oct 2026 02:34:25 +0000 Subject: [PATCH 09/15] test(security): keep SSH fixture values out of executable source --- test/transport.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/transport.test.ts b/test/transport.test.ts index 8ef8771..2d11029 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -366,8 +366,11 @@ test('opt-in handoff context stays bound to selected generation and rechecks sto }); 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');writeFileSync(log,''); + 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']}; - writeFileSync(fake,`#!${process.execPath}\n${sshConfigUser}if(process.argv.at(-1).includes('--version')){console.log('session-peer 0.3.1 (typescript)');process.exit(0);}let input='';process.stdin.on('data',p=>input+=p);process.stdin.on('end',()=>{require('node:fs').appendFileSync(${JSON.stringify(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:${JSON.stringify(h)},handoffQuery:{status:'unknown'},handoffWarning:'fixture-only'}));});`,{mode:0o700}); + // 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.1 (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'); }); From 3b7e2ebd56c8270df81b79ab2787dde118866ed3 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 11 Oct 2026 09:26:26 +0900 Subject: [PATCH 10/15] fix(handoff): classify concurrent receipt waits independently --- src/handoff-ledger.ts | 17 ++++++++------ test/handoff-core.test.ts | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/handoff-ledger.ts b/src/handoff-ledger.ts index 3580bf9..af8da1c 100644 --- a/src/handoff-ledger.ts +++ b/src/handoff-ledger.ts @@ -105,7 +105,8 @@ export class HandoffLedger { for (const wait of item.waits) { closed(wait, ['public', 'clockId', 'cutoff']); if (!canonicalId(wait.clockId) || typeof wait.cutoff !== 'number' || !Number.isFinite(wait.cutoff)) fail(); - validateHandoff({ ...h, wait: wait.public }); + // Current actions belong to the latest operation, not archived waits. + validateHandoff({ ...h, wait: wait.public, nextActions: ['reconcile'] }); } } return data as Ledger; @@ -331,18 +332,20 @@ export class HandoffLedger { } 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 = this.clock.monotonic() >= origin.cutoff; + 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 }; - const latest = item.waits.at(-1); - if (latest?.public.status === 'pending' && latest.public.for === 'acknowledged') { - if (latest.clockId !== this.clockId) fail('receipt_order_unprovable'); - latest.public.status = this.clock.monotonic() < latest.cutoff ? 'satisfied' : 'timed_out_unknown'; h.wait = structuredClone(latest.public); - } + 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 { diff --git a/test/handoff-core.test.ts b/test/handoff-core.test.ts index 5b046ec..2de979b 100644 --- a/test/handoff-core.test.ts +++ b/test/handoff-core.test.ts @@ -310,6 +310,54 @@ test('strictly-before-cutoff receipt satisfies wait and terminal results cannot 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); From 24273895a1c382fe0ae7e50f97b920ca03f66bc7 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 11 Oct 2026 09:29:33 +0900 Subject: [PATCH 11/15] test(handoff): align SSH fixture and isolate stdin example --- PARITY.md | 2 +- docs/api.md | 2 +- test/skill-guidance.test.ts | 2 +- test/transport.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/PARITY.md b/PARITY.md index 8f72eef..ffef8c8 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1047,7 +1047,7 @@ 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.1 runtime. Automated ACK +separate current source support from the published 0.3.3 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 diff --git a/docs/api.md b/docs/api.md index 2df7d60..81fb7e0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -36,7 +36,7 @@ The ordinary SSH response correction is documented separately as the [0.3.3 ordi ## Unreleased opt-in handoff CLI — 0.4.0 work -This source adds an explicit private POSIX sender ledger. The published 0.3.1 +This source adds an explicit private POSIX sender ledger. The published 0.3.3 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. 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 2d11029..bcbfb89 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -371,6 +371,6 @@ test('ordinary SSH results cannot opt themselves into handoff or claim unsolicit // 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.1 (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}); + writeFileSync(fake,`#!${process.execPath}\n${sshConfigUser}if(process.argv.at(-1).includes('--version')){console.log('session-peer 0.3.3 (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'); }); From d1babad20dacf2352c94d22ebbf2775650b78946 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 14 Oct 2026 09:08:58 +0900 Subject: [PATCH 12/15] test(handoff): preserve published 0.3.5 baseline after rebase --- PARITY.md | 2 +- docs/api.md | 2 +- test/transport.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/PARITY.md b/PARITY.md index ffef8c8..3716708 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1047,7 +1047,7 @@ 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.3 runtime. Automated ACK +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 diff --git a/docs/api.md b/docs/api.md index 81fb7e0..a28bbce 100644 --- a/docs/api.md +++ b/docs/api.md @@ -36,7 +36,7 @@ The ordinary SSH response correction is documented separately as the [0.3.3 ordi ## Unreleased opt-in handoff CLI — 0.4.0 work -This source adds an explicit private POSIX sender ledger. The published 0.3.3 +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. diff --git a/test/transport.test.ts b/test/transport.test.ts index bcbfb89..a681aeb 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -371,6 +371,6 @@ test('ordinary SSH results cannot opt themselves into handoff or claim unsolicit // 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.3 (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}); + writeFileSync(fake,`#!${process.execPath}\n${sshConfigUser}if(process.argv.at(-1).includes('--version')){console.log('session-peer 0.3.5 (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'); }); From 5a0323d9c31572fe75f8ea2c0b32784f1adf2fe9 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sat, 17 Oct 2026 23:28:28 +0900 Subject: [PATCH 13/15] test(handoff): keep SSH fixture on published 0.3.6 baseline --- test/transport.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/transport.test.ts b/test/transport.test.ts index a681aeb..a9c6ad6 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -371,6 +371,6 @@ test('ordinary SSH results cannot opt themselves into handoff or claim unsolicit // 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.5 (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}); + 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'); }); From 746ce8b31b1b08540d9021b73aa2e0f9e9e060d1 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sat, 17 Oct 2026 23:45:21 +0900 Subject: [PATCH 14/15] fix(handoff): preserve wait history and inactive preparation bindings --- src/handoff-ledger.ts | 13 ++++-- src/handoff.ts | 2 +- src/send.ts | 9 ++-- test/handoff-cli.test.ts | 32 ++++++++++++- test/handoff-core.test.ts | 32 +++++++++++++ test/handoff-observation-waits.test.ts | 65 ++++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 test/handoff-observation-waits.test.ts diff --git a/src/handoff-ledger.ts b/src/handoff-ledger.ts index af8da1c..c840fbd 100644 --- a/src/handoff-ledger.ts +++ b/src/handoff-ledger.ts @@ -285,12 +285,15 @@ export class HandoffLedger { 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'; - const latest = item.waits.at(-1); - if (latest?.public.status === 'pending' && latest.public.for === 'delivered') { - if (latest.clockId !== this.clockId) { latest.public.status = 'failed'; latest.public.reason = 'history_unavailable'; } - else latest.public.status = this.clock.monotonic() < latest.cutoff ? 'satisfied' : 'timed_out_unknown'; - h.wait = structuredClone(latest.public); + // 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); }); } diff --git a/src/handoff.ts b/src/handoff.ts index c692d95..9876db5 100644 --- a/src/handoff.ts +++ b/src/handoff.ts @@ -148,7 +148,7 @@ export function validateHandoff(value: unknown): Handoff { 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' ? !['pending', 'observed'].includes(obs.status as string) : ack.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'); diff --git a/src/send.ts b/src/send.ts index 46eb069..1288c15 100644 --- a/src/send.ts +++ b/src/send.ts @@ -69,13 +69,16 @@ export async function send(options: SendOptions): Promise B -> A. - const proof = JSON.parse(selection.evidence) as { fingerprints: { home: string; writer: string }[] }; - const identity = proof.fingerprints.find(item => item.home === home)?.writer; + 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, - ...(generation ? { writerIdentity: generation } : {}) }, result); + // 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; diff --git a/test/handoff-cli.test.ts b/test/handoff-cli.test.ts index 3344534..57b9047 100644 --- a/test/handoff-cli.test.ts +++ b/test/handoff-cli.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { once } from 'node:events'; import { createServer } from 'node:net'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync, symlinkSync, existsSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync, symlinkSync, existsSync, readFileSync, renameSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -17,7 +17,7 @@ async function fixture(t: TestContext) { 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 = { ...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') }; + 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'] }; } @@ -102,3 +102,31 @@ test('message-file dash is literal with stdin open, while --message dash keeps s 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'),''); + } + }); +}); diff --git a/test/handoff-core.test.ts b/test/handoff-core.test.ts index 2de979b..1e21736 100644 --- a/test/handoff-core.test.ts +++ b/test/handoff-core.test.ts @@ -57,6 +57,38 @@ test('all pinned negative handoff schema fixtures fail closed', () => { 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}')); diff --git a/test/handoff-observation-waits.test.ts b/test/handoff-observation-waits.test.ts new file mode 100644 index 0000000..5331daf --- /dev/null +++ b/test/handoff-observation-waits.test.ts @@ -0,0 +1,65 @@ +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'); +}); From 5b878fc8cc8a5120af5870a56156f8ae769bc6b8 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 00:07:45 +0900 Subject: [PATCH 15/15] fix(handoff): bind prepared recipients and protect journal evidence --- PARITY.md | 7 ++ docs/api.md | 7 ++ src/handoff-ledger.ts | 93 ++++++++++++++++++---- src/send.ts | 35 ++++++++- test/handoff-cli.test.ts | 28 ++++++- test/handoff-core.test.ts | 14 +++- test/handoff-ledger-privacy.test.ts | 102 +++++++++++++++++++++++++ test/handoff-observation-waits.test.ts | 27 +++++++ 8 files changed, 295 insertions(+), 18 deletions(-) create mode 100644 test/handoff-ledger-privacy.test.ts diff --git a/PARITY.md b/PARITY.md index 3716708..67b73b2 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1052,3 +1052,10 @@ 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/docs/api.md b/docs/api.md index a28bbce..1a40149 100644 --- a/docs/api.md +++ b/docs/api.md @@ -57,6 +57,13 @@ 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. diff --git a/src/handoff-ledger.ts b/src/handoff-ledger.ts index c840fbd..6c6b8c0 100644 --- a/src/handoff-ledger.ts +++ b/src/handoff-ledger.ts @@ -1,7 +1,7 @@ // 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 } from 'node:crypto'; +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'; @@ -19,7 +19,7 @@ type RecordIntent = { binding: HandoffBinding; preparedUtc: number; clockId: str 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; quarantined: boolean; records: Record }; +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 }; @@ -52,6 +52,37 @@ export class HandoffLedger { 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'); @@ -85,12 +116,16 @@ export class HandoffLedger { 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', 'quarantined', 'records']); + 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 || @@ -114,8 +149,17 @@ export class HandoffLedger { } private write(data: Ledger): void { for (const item of Object.values(data.records)) validateHandoff(item.handoff); - const text = JSON.stringify({ digest: payloadDigest(JSON.stringify(data)), data }); - const reserves = Object.values(data.records).reduce((sum, item) => sum + Math.max(0, item.reserve - Buffer.byteLength(JSON.stringify(item))), 0); + 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); @@ -132,7 +176,11 @@ export class HandoffLedger { 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 epoch = randomUUID(); this.write({ version: 1, epoch, quarantined: false, records: {} }); + 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); } @@ -321,16 +369,32 @@ export class HandoffLedger { this.write(data); return this.snapshot(item); }); } - recordWait(id: string, status: Exclude, reason?: WaitReason): Handoff { + 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 = item.waits.at(-1); - if (!wait || wait.public.status !== 'pending') fail('handoff_wait_terminal'); + 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'); - item.handoff.wait.status = status; if (reason) item.handoff.wait.reason = reason; - if (status === 'timed_out_unknown') item.handoff.state = 'timed_out_unknown'; + 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']; - wait.public = structuredClone(item.handoff.wait); validateHandoff(item.handoff); - this.write(data); return this.snapshot(item); + 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 { @@ -356,7 +420,8 @@ export class HandoffLedger { 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') this.commitAck(item, 'operator_confirmed'); + if (item.handoff.ack.status === 'acknowledged') return this.snapshot(item); + this.commitAck(item, 'operator_confirmed'); this.write(data); return this.snapshot(item); }); } diff --git a/src/send.ts b/src/send.ts index 1288c15..ffede82 100644 --- a/src/send.ts +++ b/src/send.ts @@ -1,6 +1,9 @@ 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'; @@ -52,6 +55,32 @@ export async function postSocket(path: string, text: string, timeout?: number): }); }); } +// 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, @@ -111,16 +140,18 @@ export async function send(options: SendOptions): Promise{ + 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 index 1e21736..8452d28 100644 --- a/test/handoff-core.test.ts +++ b/test/handoff-core.test.ts @@ -1,6 +1,6 @@ import { test, type TestContext } from 'node:test'; import assert from 'node:assert/strict'; -import { randomUUID } from 'node:crypto'; +import { createCipheriv, randomBytes, randomUUID } from 'node:crypto'; import { spawn } from 'node:child_process'; import { once } from 'node:events'; import { createRequire } from 'node:module'; @@ -36,6 +36,16 @@ function confirmation(h: Handoff) { 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', () => { @@ -438,6 +448,7 @@ test('byte admission reserves terminal/wait/receipt room and never evicts an exi 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 })); @@ -454,6 +465,7 @@ test('retained tombstones count toward the 10,000 intent limit with no LRU repla 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 })); 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 index 5331daf..c96ab50 100644 --- a/test/handoff-observation-waits.test.ts +++ b/test/handoff-observation-waits.test.ts @@ -63,3 +63,30 @@ test('restarted observer retains injection but fails every unorderable delivery ]); 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'); +});