diff --git a/PARITY.md b/PARITY.md index 67b73b2..881cb15 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1059,3 +1059,46 @@ 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. + +## Unreleased bounded app-server foundation — #71 + +The opt-in internal client is qualified only for queue mechanics on Codex +0.160.1 and 0.162.1 / macOS arm64, using separate isolated network-denied synthetic homes. One +native queue/add, original client message ID persistence and no loaded/resumed +thread were observed. This is not live delivery, general owner-route or receipt +ACK qualification. Legacy Codex queue remains the default CLI path. + +The client bounds frames, RPCs, traffic, owner verification, monotonic deadlines +and owned process-group cleanup. An authorized caller supplies a durable +before-effect fence; at most one queue RPC follows it. After a possible write, +disconnect, timeout, schema error or remote refusal never enables fallback or +resend. initialize opts out all 83 notifications in the exact qualified native +schema; queue/add's own input echo is validated but only IDs are returned. +queue/list, history, full item/turn notifications, resume/start and model turns +are excluded. The separate synthetic metadata protocol is explicitly fixture +only. Other platforms/versions and native observation remain unsupported. + +Generated schema hashes and the fixed official source commit are recorded in +`test/fixtures/app-server-schema-evidence.json`; tests use hermetic streams and +owned Node children. These facts do not re-pin the shipped Python runtime parity +reference or implement automatic ACK. + + +Storage binding is checked on the owned queue transport before its effect: +env/CLI pins plus exact opened state/queue paths. Managed-policy redirection or +missing direct-native PID proof refuses with zero queue RPC. An additional +network-denied initialization-only fixture verified config/CLI/env precedence +with zero queued rows and no model/thread effect. Bootstrap policy files are +not promised unchanged by the native tool; no managed requirement is bypassed. + +### Exact 0.162.1 queue profile qualification + +The internal queue client also pins Codex 0.162.1/source +`092d3acd6bec3e3a14bdc7e7a2810ab628ab759d` on macOS arm64. The source/schema +fixture records the exact notification opt-out set (84 methods, compared with +83 in 0.160.1). An unloaded, isolated thread received one queue request with its +exact supplied client ID and native queue receipt; a separate owned-descriptor +fixture refused the durable effect guard and left zero queue rows. No version +range, Linux/Windows qualification, live delivery or ACK follows from these +queue-only fixtures. Initial harness failures are recorded separately and were +never retried with an already-attempted intent. diff --git a/docs/api.md b/docs/api.md index 1a40149..059ab45 100644 --- a/docs/api.md +++ b/docs/api.md @@ -82,3 +82,47 @@ 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. + + +## Unreleased bounded app-server mechanics — 0.4.0 work + +`dist/app-server.js` is an internal opt-in module, not a new public package +export or a change to ordinary CLI submission. `nativeQueueOnce` requires an +absolute executable, exact home/thread/original generation, bounded owner +verifier and durable `beforeEffect` callback. Exact Codex 0.160.1 and 0.162.1 +queue mechanics were tested on macOS arm64 in network-denied synthetic homes; +other platform or version tuples fail before an effect. There is no minor-range +or latest-version inference. The caller owns proof of original +writer/home and any resources used by its verifier. + +Only initialize, initialized and one thread/queue/add are used. Queue receipt +returns IDs only. Body-returning observation APIs, transcript reading, resume, +wake and new model turns are not implemented. The native process is an owned +queue transport, not an inferred existing session owner. Possible write marks +outcome uncertain even when the subsequent response is malformed. No automatic +fallback follows a possible effect. Fixture-only metadata ports are not native +observation qualification. See [source evidence](../PARITY.md#unreleased-bounded-app-server-foundation--71). + +The 0.162.1 qualification uses source `092d3acd6bec3e3a14bdc7e7a2810ab628ab759d` +and generated schema hashes, with all 84 native notifications opted out before +the effect (0.160.1 retains its exact 83-method list). A saved synthetic thread +remained unloaded before and after one queue request; its own echoed input and +queue/thread/client IDs were checked. A separate native API fixture verified +owned SQLite descriptor binding and durable-guard refusal with zero queue rows. +Neither fixture proves a live writer's generation, actual delivery, receipt ACK +or a body-free native observer. Linux and Windows native mechanics remain +unqualified. The initial qualification harness had echo-shape and temporary-home +mapping failures; each subsequent probe used a distinct home/thread/client ID, +and no attempted submission was replayed. Details are retained in the +`test/fixtures/app-server-schema-evidence.json` source fixture. + + +The queue-only SDK pins both environment and CLI SQLite home, then verifies +opened state/queue DB paths on its OWN native app-server PID before authorizing +the effect. Config precedes the environment and exact managed requirements can +supersede the CLI in the qualified versions, so initialize's home echo alone is insufficient. +Missing/mismatched descriptor proof refuses without queue/fallback. Direct +native binaries are qualified; launcher wrappers without this PID/storage proof +remain unsupported. Native bootstrap may create state/cache files according to +managed policy before binding refusal; zero queue is the guarantee, not zero +bootstrap filesystem activity. Observers still open only read-only warm stores. diff --git a/package.json b/package.json index 4269ed1..4706223 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "build": "tsc -p tsconfig.json", "test": "npm run build && npm run test:types && node --import ./test/fixtures/isolate-env.mjs --experimental-strip-types --test test/*.test.ts", "test:types": "tsc -p tsconfig.test.json", - "test:windows": "npm run build && npm run test:types && node --import ./test/fixtures/isolate-env.mjs --import ./test/fixtures/windows-inspection-diagnostic.mjs --experimental-strip-types --test --test-concurrency=1 test/writer-uid.test.ts test/claude-records.test.ts test/reply-agent-binding.test.ts test/windows.test.ts test/windows-contract.test.ts test/windows-inspection.test.ts test/input-boundaries.test.ts test/discovery.test.ts test/codex-homes.test.ts test/diagnostics.test.ts test/cli-usability.test.ts test/skill-guidance.test.ts test/skill-metadata.test.ts test/ssh-hosts.test.ts test/ssh-response.test.ts test/replies.test.ts test/updates.test.ts", + "test:windows": "npm run build && npm run test:types && node --import ./test/fixtures/isolate-env.mjs --import ./test/fixtures/windows-inspection-diagnostic.mjs --experimental-strip-types --test --test-concurrency=1 test/app-server.test.ts test/writer-uid.test.ts test/claude-records.test.ts test/reply-agent-binding.test.ts test/windows.test.ts test/windows-contract.test.ts test/windows-inspection.test.ts test/input-boundaries.test.ts test/discovery.test.ts test/codex-homes.test.ts test/diagnostics.test.ts test/cli-usability.test.ts test/skill-guidance.test.ts test/skill-metadata.test.ts test/ssh-hosts.test.ts test/ssh-response.test.ts test/replies.test.ts test/updates.test.ts", "test:package": "npm run build && node --experimental-strip-types test/package-smoke.ts" }, "devDependencies": { diff --git a/scripts/release.mjs b/scripts/release.mjs index db44366..a93d71d 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -19,7 +19,7 @@ export const packageFiles = ['UNICODE-LICENSE.txt', 'dist/casefold.js', 'dist/ca 'dist/cli.js', 'dist/discovery.js', 'dist/diagnostics.js', 'dist/skill-metadata.js', 'dist/process.js', 'dist/protocol.js', 'dist/replies.js', 'dist/send.js', 'dist/ssh.js', 'dist/ssh-response.js', 'dist/windows.js', 'dist/writer.js', 'dist/index.js', 'dist/index.d.ts', 'dist/cli.d.ts', 'dist/discovery.d.ts', 'dist/diagnostics.d.ts', 'dist/skill-metadata.d.ts', 'dist/process.d.ts', 'dist/protocol.d.ts', - 'dist/replies.d.ts', 'dist/send.d.ts', 'dist/ssh.d.ts', 'dist/ssh-response.d.ts', 'dist/windows.d.ts', 'dist/writer.d.ts', 'dist/updates.js', 'dist/updates.d.ts', 'dist/handoff.js', 'dist/handoff.d.ts', 'dist/handoff-ledger.js', 'dist/handoff-ledger.d.ts', 'dist/handoff-operations.js', 'dist/handoff-operations.d.ts', 'package.json'].sort(); + 'dist/replies.d.ts', 'dist/send.d.ts', 'dist/ssh.d.ts', 'dist/ssh-response.d.ts', 'dist/windows.d.ts', 'dist/writer.d.ts', 'dist/updates.js', 'dist/updates.d.ts', 'dist/handoff.js', 'dist/handoff.d.ts', 'dist/handoff-ledger.js', 'dist/handoff-ledger.d.ts', 'dist/handoff-operations.js', 'dist/handoff-operations.d.ts', 'dist/app-server.js', 'dist/app-server.d.ts', '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/app-server.ts b/src/app-server.ts new file mode 100644 index 0000000..ece8d05 --- /dev/null +++ b/src/app-server.ts @@ -0,0 +1,684 @@ +// Bounded queue-only mechanics and metadata-only client foundation. +// No live owner/delivery observation route is qualified. +// Official Codex schemas generated separately with isolated HOME/CODEX_HOME: +// https://learn.chatgpt.com/docs/app-server (schema is version-specific). +// queue/list returns QueuedSubmission.input; item/started returns full content; +// turn/completed returns Turn.items. These are not metadata-only observers. +// queue/add echoes only its OWN submitted input and can be validated as an +// effect receipt separately; it does not read other requests or thread history. +// A fresh app-server process is never evidence that it owns an existing writer. +import { spawn } from 'node:child_process'; +import { lstatSync, realpathSync, statSync } from 'node:fs'; +import { basename, isAbsolute, join } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import type { Readable, Writable } from 'node:stream'; +import { TextDecoder } from 'node:util'; +import { run } from './process.js'; + +export const APP_SERVER_EVIDENCE = Object.freeze({ + version: '0.160.1', inspectedPlatform: 'darwin-arm64', + officialSourceCommit: 'd27764b82f7118f674371e6d6e76271d9d606edb', + scope: 'generated_schema_only', + queueAddParamsSha256: '60f25b7d3e3357c215bef9d2fe7047200f545e030dca9e00527ac0620cd0c88f', + queueAddResponseSha256: 'c11b9772788427b19c9f9decf0765e2b41cbdead1aa9c3d39f42fdd70b339314', + itemStartedSha256: '7574788d2a352747f50d44be3ef649011cb8de8069da4c655faff14d0aee32c4', + turnCompletedSha256: '016870158603b0f84bd9f8f65f927161c9fd5128e5ec632087616462dc44e085', +}); +// Each entry qualifies queue-only mechanics on this exact native version and +// platform. Neither generated schemas nor isolated unloaded-thread fixtures +// qualify a live writer endpoint, metadata-only observer or delivery/ACK. +export const APP_SERVER_QUEUE_EVIDENCE = Object.freeze([ + APP_SERVER_EVIDENCE, + Object.freeze({ + version: '0.162.1', inspectedPlatform: 'darwin-arm64', + officialSourceCommit: '092d3acd6bec3e3a14bdc7e7a2810ab628ab759d', + scope: 'generated_schema_only', + queueAddParamsSha256: '60f25b7d3e3357c215bef9d2fe7047200f545e030dca9e00527ac0620cd0c88f', + queueAddResponseSha256: 'c11b9772788427b19c9f9decf0765e2b41cbdead1aa9c3d39f42fdd70b339314', + itemStartedSha256: '692c5876f44a4c12a9b9211912860ceddb65d34497ab4fe9aa46cbebe1a63166', + turnCompletedSha256: '69413578ec3ecd02691424bdeb9d640c7244b4ca4c5c3ef26f69a2efe7c37db8', + }), +]); +function queueEvidence(version: string) { return APP_SERVER_QUEUE_EVIDENCE.find(profile => profile.version === version); } +export const APP_SERVER_LIMITS = Object.freeze({ + frameBytes: 65536, receivedBytes: 1024 * 1024, sentBytes: 256 * 1024, + frames: 512, notifications: 256, requests: 16, observations: 64, messageBytes: 32768, + lifetimeMs: 60000, cleanupMs: 250, +}); +export type AppServerScope = Readonly<{ + codexHome: string; threadId: string; generation: string; ownerIdentity: string; +}>; +export type AppServerContext = Readonly<{ version: string; platform: string; scope: AppServerScope }>; +export type AppServerCapability = { + supported: false; schema: 'version_qualified' | 'version_unqualified'; route: 'route_unsupported'; + reason: 'metadata_only_owner_route_unvalidated' | 'version_unqualified'; attempted: false; queueSupported: boolean; +}; +export function appServerCapability(context: AppServerContext): AppServerCapability { + const profile = queueEvidence(context.version); + const qualified = profile !== undefined && profile.inspectedPlatform === context.platform; + return { supported: false, schema: qualified ? 'version_qualified' : 'version_unqualified', route: 'route_unsupported', + reason: qualified ? 'metadata_only_owner_route_unvalidated' : 'version_unqualified', attempted: false, queueSupported: qualified }; +} +// Hard production gate: do not spawn/connect a native endpoint even if a caller +// supplies an owner tuple. Queue-only mechanics use the separate nativeQueueOnce +// entry point; no metadata-only owner observation transport is claimed. +export async function connectAppServer(context: AppServerContext, + _open: () => Promise, _deadlineMs: number): Promise { + return appServerCapability(context); +} + +export type AppServerFaultCode = 'invalid_scope' | 'invalid_input' | 'unsupported_transport' | 'deadline' | + 'owner_changed' | 'frame_invalid' | 'frame_limit' | 'traffic_limit' | 'notification_limit' | 'request_limit' | + 'connection_closed' | 'write_failed' | 'remote_refused' | 'already_attempted' | 'unsupported_version' | 'effect_guard_failed' | + 'storage_binding_unverified'; +export class AppServerFault extends Error { + readonly fallbackEligible: boolean; + constructor(readonly code: AppServerFaultCode, readonly attempted: boolean) { + super(code); this.fallbackEligible = !attempted && ['unsupported_transport', 'unsupported_version'].includes(code); + } +} +// This contract is NOT an official Codex notification or queue response shape. +// It is used only by hermetic fixtures until an actual metadata-only owner +// endpoint is independently qualified. The producer must omit bodies BEFORE +// transmission; the consumer does not implement a body-returning API/filter. +export type MetadataOnlyTransport = { + qualification: 'hermetic_fixture_only'; + input: Readable; output: Writable; + close: () => Promise; +}; +type NativeQueueTransport = Omit & { qualification: 'native_queue_only_stdio'; pid?: number }; +type ClientTransport = MetadataOnlyTransport | NativeQueueTransport; +export type NativeQueuePort = Pick; +export type NativeQueueOptions = { + binary: string; scope: AppServerScope; message: string; clientUserMessageId: string; deadlineMs: number; + verifyOwner: (scope: AppServerScope) => Promise; beforeEffect: () => Promise; env?: NodeJS.ProcessEnv; +}; +export function nativeQueueCapability(context: AppServerContext): { + queueSupported: boolean; observationSupported: false; + qualification: 'mechanics_from_isolated_fixture' | 'unsupported'; reason: 'queue_only' | 'version_unqualified'; +} { + const qualified = appServerCapability(context).queueSupported; + return { queueSupported: qualified, observationSupported: false, + qualification: qualified ? 'mechanics_from_isolated_fixture' : 'unsupported', reason: qualified ? 'queue_only' : 'version_unqualified' }; +} +// All methods from each exact experimental ServerNotification schema (83 for +// 0.160.1, 84 for 0.162.1) are suppressed before queue/add. A notification +// reaching us is a qualification failure, not an invitation to inspect/filter +// its body or perform another RPC. +export const NATIVE_QUEUE_NOTIFICATION_OPTOUTS = Object.freeze([ + 'account/gatewayOAuth/changed', 'account/login/completed', 'account/rateLimits/updated', 'account/updated', 'app/list/updated', + 'autoApprovalReview/strictReviewRequired', 'command/exec/outputDelta', 'configWarning', 'deprecationNotice', 'error', + 'externalAgentConfig/import/completed', 'externalAgentConfig/import/progress', 'fs/changed', 'fuzzyFileSearch/sessionCompleted', + 'fuzzyFileSearch/sessionUpdated', 'guardianWarning', 'hook/completed', 'hook/started', 'item/agentMessage/delta', + 'item/autoApprovalReview/completed', 'item/autoApprovalReview/started', 'item/commandExecution/outputDelta', + 'item/commandExecution/terminalInteraction', 'item/completed', 'item/fileChange/outputDelta', 'item/fileChange/patchUpdated', + 'item/mcpToolCall/progress', 'item/plan/delta', 'item/reasoning/summaryPartAdded', 'item/reasoning/summaryTextDelta', + 'item/reasoning/textDelta', 'item/started', 'mcpServer/event/stream/notification', 'mcpServer/oauthLogin/completed', + 'mcpServer/startupStatus/updated', 'model/rerouted', 'model/safetyBuffering/updated', 'model/verification', + 'modelProvider/authRecoveryCompleted', 'modelProvider/authRecoveryStarted', 'process/exited', 'process/outputDelta', + 'project/changed', 'remoteControl/status/changed', 'serverRequest/resolved', 'skills/changed', 'thread/archived', + 'thread/attachment/updated', 'thread/closed', 'thread/compacted', 'thread/deleted', 'thread/environment/connected', + 'thread/environment/disconnected', 'thread/goal/cleared', 'thread/goal/updated', 'thread/name/updated', 'thread/project/updated', + 'thread/queue/changed', 'thread/realtime/closed', 'thread/realtime/error', 'thread/realtime/item/completed', + 'thread/realtime/item/started', 'thread/realtime/item/transcript/delta', 'thread/realtime/itemAdded', + 'thread/realtime/outputAudio/delta', 'thread/realtime/sdp', 'thread/realtime/started', 'thread/realtime/transcript/delta', + 'thread/realtime/transcript/done', 'thread/reverted', 'thread/settings/updated', 'thread/started', 'thread/status/changed', + 'thread/tokenUsage/updated', 'thread/unarchived', 'turn/completed', 'turn/diff/updated', 'turn/moderationMetadata', + 'turn/plan/updated', 'turn/started', 'warning', 'windows/worldWritableWarning', 'windowsSandbox/setupCompleted', +]); +export const NATIVE_QUEUE_01621_NOTIFICATION_OPTOUTS = Object.freeze([ + ...NATIVE_QUEUE_NOTIFICATION_OPTOUTS, 'thread/prediction/updated', +]); +function nativeNotifications(version: string): readonly string[] { + if (version === '0.160.1') return NATIVE_QUEUE_NOTIFICATION_OPTOUTS; + if (version === '0.162.1') return NATIVE_QUEUE_01621_NOTIFICATION_OPTOUTS; + throw new AppServerFault('unsupported_version', false); +} +export type MetadataEvent = + { kind: 'injected'; scope: AppServerScope; clientUserMessageId: string; itemId: string; turnId: string } | + { kind: 'turn'; scope: AppServerScope; clientUserMessageId: string; turnId: string; + status: 'running' | 'completed' | 'failed' | 'interrupted' | 'unknown' }; +export type MetadataObservation = { + injectionObserved: boolean; clientUserMessageId?: string; + turn?: { id: string; status: 'running' | 'completed' | 'failed' | 'interrupted' | 'unknown' }; +}; +export interface AppServerPort { + queueOnce(input: { clientUserMessageId: string; message: string }): Promise<{ queueId: string; clientUserMessageId: string }>; + observe(clientUserMessageId: string): Promise; + close(): Promise; +} +type ObjectValue = Record; +function disposeTransport(transport: ClientTransport): Promise { + return new Promise(resolve => { + const timer = setTimeout(resolve, APP_SERVER_LIMITS.cleanupMs); + Promise.resolve().then(() => transport.close()).catch(() => {}).finally(() => { clearTimeout(timer); resolve(); }); + }); +} +function object(value: unknown, keys: string[]): ObjectValue { + if (!value || typeof value !== 'object' || Array.isArray(value) || + Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key))) throw new Error('invalid'); + return value as ObjectValue; +} +function safeId(value: unknown, maximum = 128): value is string { + return typeof value === 'string' && Buffer.byteLength(value) >= 1 && Buffer.byteLength(value) <= maximum && + !/[\x00-\x1f\x7f-\x9f]/u.test(value) && [...value].every(char => { + const cp = char.codePointAt(0)!; return cp < 0xd800 || cp > 0xdfff; + }); +} +function validateQueueInput(input: { clientUserMessageId: string; message: string }, attempted: boolean): void { + if (!safeId(input.clientUserMessageId) || typeof input.message !== 'string' || !input.message || + Buffer.byteLength(input.message) > APP_SERVER_LIMITS.messageBytes || input.message.includes('\0') || + [...input.message].some(char => { const cp = char.codePointAt(0)!; return cp >= 0xd800 && cp <= 0xdfff; })) { + throw new AppServerFault('invalid_input', attempted); + } +} +function scopeValue(value: unknown): AppServerScope { + const v = object(value, ['codexHome', 'threadId', 'generation', 'ownerIdentity']); + if (!safeId(v.codexHome, 4096) || !isAbsolute(v.codexHome) || !safeId(v.threadId) || + !safeId(v.generation, 256) || !safeId(v.ownerIdentity, 512)) throw new Error('invalid'); + return Object.freeze({ codexHome: v.codexHome, threadId: v.threadId, generation: v.generation, ownerIdentity: v.ownerIdentity }); +} +function matches(left: AppServerScope, right: AppServerScope): boolean { + return left.codexHome === right.codexHome && left.threadId === right.threadId && + left.generation === right.generation && left.ownerIdentity === right.ownerIdentity; +} +export function parseMetadataEvent(value: unknown, original: AppServerScope): MetadataEvent { + try { + const kind = (value as ObjectValue | null)?.kind; + const v = object(value, kind === 'injected' ? ['kind', 'scope', 'clientUserMessageId', 'itemId', 'turnId'] : + ['kind', 'scope', 'clientUserMessageId', 'turnId', 'status']); + const scope = scopeValue(v.scope); + if (!matches(scope, original)) throw new AppServerFault('owner_changed', false); + if (!safeId(v.clientUserMessageId) || !safeId(v.turnId)) throw new Error('invalid'); + if (kind === 'injected' && safeId(v.itemId)) return { kind, scope, clientUserMessageId: v.clientUserMessageId, itemId: v.itemId, turnId: v.turnId }; + if (kind === 'turn' && typeof v.status === 'string' && ['running', 'completed', 'failed', 'interrupted', 'unknown'].includes(v.status)) { + return { kind, scope, clientUserMessageId: v.clientUserMessageId, turnId: v.turnId, status: v.status as 'running' }; + } + throw new Error('invalid'); + } catch (error) { + if (error instanceof AppServerFault) throw error; + throw new AppServerFault('frame_invalid', false); + } +} +// Token-aware scanner rejects duplicate keys, non-integer numbers, deep input +// and trailing frames before JSON.parse loses that information. Native message +// text is outbound only; this parser accepts metadata fixture frames only. +function parseFrame(bytes: Buffer): unknown { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + let offset = 0; + const whitespace = () => { while (/[ \t\r\n]/.test(text[offset] ?? '\0')) offset++; }; + function string(): string { + const start = offset++; + while (offset < text.length) { + const char = text[offset++]; + if (char === '\\') offset++; + else if (char === '"') return JSON.parse(text.slice(start, offset)) as string; + } + throw new Error('invalid'); + } + function value(depth: number): void { + if (depth > 8) throw new Error('invalid'); + whitespace(); const char = text[offset]; + if (char === '"') { string(); return; } + if (char === '{') { + offset++; whitespace(); const keys = new Set(); + if (text[offset] === '}') { offset++; return; } + while (true) { + whitespace(); if (text[offset] !== '"') throw new Error('invalid'); + const key = string(); if (keys.has(key)) throw new Error('invalid'); keys.add(key); + whitespace(); if (text[offset++] !== ':') throw new Error('invalid'); + value(depth + 1); whitespace(); const end = text[offset++]; + if (end === '}') return; + if (end !== ',') throw new Error('invalid'); + } + } + if (char === '[') { + offset++; whitespace(); if (text[offset] === ']') { offset++; return; } + while (true) { + value(depth + 1); whitespace(); const end = text[offset++]; + if (end === ']') return; + if (end !== ',') throw new Error('invalid'); + } + } + for (const token of ['true', 'false', 'null']) if (text.startsWith(token, offset)) { offset += token.length; return; } + const token = /^-?(?:0|[1-9][0-9]*)/.exec(text.slice(offset))?.[0]; + if (!token || !Number.isSafeInteger(Number(token))) throw new Error('invalid'); + offset += token.length; + } + value(0); whitespace(); if (offset !== text.length) throw new Error('invalid'); + return JSON.parse(text); +} + +export class BoundedAppServerClient implements AppServerPort { + private readonly pending = new Map void; reject: (error: AppServerFault) => void }>(); + private readonly observers = new Set<{ resolve: (value: MetadataObservation) => void; reject: (error: AppServerFault) => void }>(); + private readonly operations = new Set<{ reject: (error: AppServerFault) => void }>(); + private buffer = Buffer.alloc(0); private received = 0; private sent = 0; private frames = 0; private notifications = 0; private requests = 0; + private reserved = false; private attempted = false; private clientId?: string; + private observations = 0; + private observed: MetadataObservation = { injectionObserved: false }; + private injectedItemId?: string; + private fault?: AppServerFault; private closing?: Promise; private readonly timer: ReturnType; + private constructor(readonly scope: AppServerScope, private readonly transport: ClientTransport, + private readonly deadlineMs: number, private readonly verifyOwner: (scope: AppServerScope) => Promise, + private readonly nativeQueue = false, private readonly beforeEffect: () => Promise = async () => {}) { + this.timer = setTimeout(() => this.fail('deadline'), Math.max(1, deadlineMs - performance.now())); + transport.input.on('data', this.receive); + transport.input.once('end', this.ended); transport.input.once('error', this.errored); + transport.output.on('error', this.writeErrored); + } + static async connectMetadataFixture(scope: AppServerScope, transport: MetadataOnlyTransport, deadlineMs: number, + verifyOwner: (scope: AppServerScope) => Promise): Promise { + return this.connectWire(scope, transport, deadlineMs, verifyOwner, false); + } + // Hermetic test hook for the exact native RPC profile. It does not qualify a + // binary, platform or writer. Production callers use nativeQueueOnce instead. + static async connectNativeQueueFixture(scope: AppServerScope, transport: MetadataOnlyTransport, deadlineMs: number, + verifyOwner: (scope: AppServerScope) => Promise, beforeEffect: () => Promise, + nativeVersion: string = APP_SERVER_EVIDENCE.version): Promise { + return this.connectWire(scope, transport, deadlineMs, verifyOwner, true, beforeEffect, nativeVersion); + } + static async connectNativeQueueWire(scope: AppServerScope, transport: NativeQueueTransport, deadlineMs: number, + verifyOwner: (scope: AppServerScope) => Promise, beforeEffect: () => Promise, + nativeVersion: string = APP_SERVER_EVIDENCE.version): Promise { + return this.connectWire(scope, transport, deadlineMs, verifyOwner, true, beforeEffect, nativeVersion); + } + private static async connectWire(scope: AppServerScope, transport: ClientTransport, deadlineMs: number, + verifyOwner: (scope: AppServerScope) => Promise, nativeQueue: boolean, + beforeEffect: () => Promise = async () => {}, nativeVersion: string = APP_SERVER_EVIDENCE.version): Promise { + let original: AppServerScope; + try { original = scopeValue(scope); } catch { await disposeTransport(transport); throw new AppServerFault('invalid_scope', false); } + if (transport.qualification !== 'hermetic_fixture_only' && !(nativeQueue && transport.qualification === 'native_queue_only_stdio')) { + await disposeTransport(transport); throw new AppServerFault('unsupported_transport', false); + } + const remaining = deadlineMs - performance.now(); + if (!Number.isFinite(remaining) || remaining <= 0 || remaining > APP_SERVER_LIMITS.lifetimeMs) { + await disposeTransport(transport); throw new AppServerFault('deadline', false); + } + const client = new BoundedAppServerClient(original, transport, deadlineMs, verifyOwner, nativeQueue, beforeEffect); + try { + await client.owner(); + if (nativeQueue) { + const notifications = nativeNotifications(nativeVersion); + const hello = object(await client.request('initialize', { + clientInfo: { name: 'session-peer-native-queue', version: '1' }, + capabilities: { experimentalApi: true, optOutNotificationMethods: [...notifications] }, + }), ['codexHome', 'platformFamily', 'platformOs', 'userAgent']); + if (hello.codexHome !== original.codexHome || hello.platformFamily !== 'unix' || hello.platformOs !== 'macos' || + !safeId(hello.userAgent, 4096)) throw new AppServerFault('owner_changed', false); + // The queue process itself reports its compiled build; the earlier + // pathname's --version process cannot qualify a replacement process. + if (!(hello.userAgent as string).startsWith(`session-peer-native-queue/${nativeVersion} (`)) + throw new AppServerFault('unsupported_version', false); + client.notification('initialized'); + } else { + const hello = object(await client.request('session-peer/metadata/initialize', { scope: original }), ['scope', 'metadataOnly']); + if (hello.metadataOnly !== true || !matches(scopeValue(hello.scope), original)) throw new AppServerFault('owner_changed', false); + } + return client; + } catch (error) { client.fail(error instanceof AppServerFault ? error.code : 'frame_invalid'); await client.close(); throw client.fault; } + } + private check(): void { + if (this.fault) throw this.fault; + if (performance.now() >= this.deadlineMs) { this.fail('deadline'); throw this.fault; } + } + private async owner(): Promise { + this.check(); + // The external verifier is the authority; echoed protocol tuples cannot + // qualify an endpoint or prove the live writer's ownership by themselves. + const result = await this.bounded(this.verifyOwner(this.scope)); + this.check(); if (!result) { this.fail('owner_changed'); throw this.fault; } + } + private bounded(promise: Promise, failureCode: AppServerFaultCode = 'owner_changed'): Promise { + return new Promise((resolve, reject) => { + const wait = { reject }; + this.operations.add(wait); + promise.then(value => { this.operations.delete(wait); resolve(value); }, () => { + this.operations.delete(wait); this.fail(failureCode); reject(this.fault); + }); + }); + } + private readonly ended = () => { this.fail(this.buffer.length ? 'frame_invalid' : 'connection_closed'); }; + private readonly errored = () => { this.fail('connection_closed'); }; + private readonly writeErrored = () => { this.fail('write_failed'); }; + private readonly receive = (chunk: Buffer | string): void => { + if (this.fault) return; + const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + this.received += bytes.length; + if (this.received > APP_SERVER_LIMITS.receivedBytes) { this.fail('traffic_limit'); return; } + let start = 0; + while (start < bytes.length && !this.fault) { + const end = bytes.indexOf(10, start), stop = end < 0 ? bytes.length : end + 1; + if (this.buffer.length + stop - start > APP_SERVER_LIMITS.frameBytes) { this.fail('frame_limit'); return; } + this.buffer = Buffer.concat([this.buffer, bytes.subarray(start, stop)]); start = stop; + if (end >= 0) { + const frame = this.buffer; this.buffer = Buffer.alloc(0); + try { + if (++this.frames > APP_SERVER_LIMITS.frames) { this.fail('traffic_limit'); return; } + this.frame(parseFrame(frame)); + } catch (error) { this.fail(error instanceof AppServerFault ? error.code : 'frame_invalid'); } + } + } + }; + private frame(value: unknown): void { + if (value && typeof value === 'object' && Object.hasOwn(value, 'method')) { + if (this.nativeQueue) throw new AppServerFault('unsupported_transport', this.attempted); + const event = object(value, ['method', 'params']); + if (event.method !== 'session-peer/metadata/event') throw new Error('invalid'); + if (++this.notifications > APP_SERVER_LIMITS.notifications) { this.fail('notification_limit'); return; } + const metadata = parseMetadataEvent(event.params, this.scope); + if (metadata.clientUserMessageId !== this.clientId) return; + if (metadata.kind === 'injected') { + if (this.observed.injectionObserved) { + if (this.injectedItemId !== metadata.itemId || this.observed.turn?.id !== metadata.turnId) throw new Error('invalid'); + return; + } + this.injectedItemId = metadata.itemId; + this.observed = { injectionObserved: true, clientUserMessageId: this.clientId, turn: { id: metadata.turnId, status: 'unknown' } }; + } else if (this.observed.injectionObserved && this.observed.turn?.id === metadata.turnId) { + this.observed = { ...this.observed, turn: { id: metadata.turnId, status: metadata.status } }; + } else return; // Turn completion or a queue disappearance never proves injection. + for (const observer of this.observers) observer.resolve(this.snapshot()); + this.observers.clear(); return; + } + const response = object(value, Object.hasOwn(value as object, 'error') ? ['id', 'error'] : ['id', 'result']); + if (typeof response.id !== 'number' || !Number.isSafeInteger(response.id) || response.id < 1) throw new Error('invalid'); + const pending = this.pending.get(response.id); if (!pending) throw new Error('invalid'); + if (Object.hasOwn(response, 'error')) { + if (!this.nativeQueue) { + const error = object(response.error, ['code']); + if (!['unsupported', 'refused', 'failed'].includes(String(error.code))) throw new Error('invalid'); + } // Native RPC error text/data may be sensitive and is never exposed. + this.pending.delete(response.id); pending.reject(new AppServerFault('remote_refused', this.attempted)); + } else { this.pending.delete(response.id); pending.resolve(response.result); } + } + private notification(method: string): void { + this.check(); const bytes = Buffer.from(JSON.stringify({ method }) + '\n'); this.sent += bytes.length; + if (this.sent > APP_SERVER_LIMITS.sentBytes) { this.fail('frame_limit'); throw this.fault; } + try { this.transport.output.write(bytes, error => { if (error) this.fail('write_failed'); }); } + catch { this.fail('write_failed'); throw this.fault; } + } + private async request(method: string, params: ObjectValue, effect = false): Promise { + this.check(); + if (++this.requests > APP_SERVER_LIMITS.requests) { this.fail('request_limit'); return Promise.reject(this.fault); } + const id = this.requests, bytes = Buffer.from(JSON.stringify({ id, method, params }) + '\n'); + this.sent += bytes.length; + if (bytes.length > APP_SERVER_LIMITS.frameBytes || this.sent > APP_SERVER_LIMITS.sentBytes) { this.fail('frame_limit'); return Promise.reject(this.fault); } + if (effect) { + try { await this.bounded(this.beforeEffect(), 'effect_guard_failed'); } catch { this.fail('effect_guard_failed'); throw this.fault; } + // Durable intent/effect fencing can await I/O. Recheck the ORIGINAL + // owner after that await, before marking an attempt or writing the RPC. + // The ledger may already be fenced; this never authorizes a new effect. + await this.owner(); + this.check(); + } + return new Promise((resolve, reject) => { + if (effect) this.attempted = true; // Before write, after local frame/budget validation. + this.pending.set(id, { resolve, reject }); + try { this.transport.output.write(bytes, error => { if (error) this.fail('write_failed'); }); } + catch { this.fail('write_failed'); } + }); + } + async queueOnce(input: { clientUserMessageId: string; message: string }): Promise<{ queueId: string; clientUserMessageId: string }> { + this.check(); + validateQueueInput(input, this.attempted); + if (this.reserved) throw new AppServerFault('already_attempted', true); + this.reserved = true; + try { + await this.owner(); this.check(); + this.clientId = input.clientUserMessageId; + if (this.nativeQueue) { + const result = object(await this.request('thread/queue/add', { + threadId: this.scope.threadId, clientUserMessageId: input.clientUserMessageId, + input: [{ type: 'text', text: input.message, text_elements: [] }], + }, true), ['queuedSubmission']); + const queued = object(result.queuedSubmission, ['id', 'input', 'clientUserMessageId']); + if (!safeId(queued.id) || queued.clientUserMessageId !== input.clientUserMessageId || !Array.isArray(queued.input) || queued.input.length !== 1) throw new Error('invalid'); + const ownInput = object(queued.input[0], Object.hasOwn(queued.input[0], 'text_elements') ? ['type', 'text', 'text_elements'] : ['type', 'text']); + if (ownInput.type !== 'text' || ownInput.text !== input.message || (Object.hasOwn(ownInput, 'text_elements') && + (!Array.isArray(ownInput.text_elements) || ownInput.text_elements.length))) throw new Error('invalid'); + return { queueId: queued.id, clientUserMessageId: input.clientUserMessageId }; + } + const result = object(await this.request('session-peer/metadata/queue', { + scope: this.scope, clientUserMessageId: input.clientUserMessageId, input: [{ type: 'text', text: input.message }], + }, true), ['scope', 'queueId', 'clientUserMessageId']); + if (!matches(scopeValue(result.scope), this.scope) || result.clientUserMessageId !== this.clientId || !safeId(result.queueId)) throw new Error('invalid'); + return { queueId: result.queueId, clientUserMessageId: this.clientId }; + } catch (error) { this.fail(error instanceof AppServerFault ? error.code : 'frame_invalid'); await this.close(); throw this.fault; } + } + private snapshot(): MetadataObservation { return { ...this.observed, ...(this.observed.turn ? { turn: { ...this.observed.turn } } : {}) }; } + async observe(clientUserMessageId: string): Promise { + this.check(); + if (this.nativeQueue) throw new AppServerFault('unsupported_transport', this.attempted); + if (!safeId(clientUserMessageId) || clientUserMessageId !== this.clientId || !this.attempted) throw new AppServerFault('invalid_input', this.attempted); + if (++this.observations > APP_SERVER_LIMITS.observations) { this.fail('request_limit'); throw this.fault; } + if (this.observed.injectionObserved) return this.snapshot(); + return new Promise((resolve, reject) => { this.observers.add({ resolve, reject }); }); + } + private fail(code: AppServerFaultCode): void { + if (this.fault) return; + this.fault = new AppServerFault(code, this.attempted); clearTimeout(this.timer); + for (const wait of this.pending.values()) wait.reject(this.fault); this.pending.clear(); + for (const observer of this.observers) observer.reject(this.fault); this.observers.clear(); + for (const operation of this.operations) operation.reject(this.fault); this.operations.clear(); + void this.close(); + } + close(): Promise { + if (this.closing) return this.closing; + clearTimeout(this.timer); + if (!this.fault) { this.fault = new AppServerFault('connection_closed', this.attempted); + for (const wait of this.pending.values()) wait.reject(this.fault); this.pending.clear(); + for (const observer of this.observers) observer.reject(this.fault); this.observers.clear(); + for (const operation of this.operations) operation.reject(this.fault); this.operations.clear(); } + this.transport.input.removeListener('data', this.receive); + this.transport.input.removeListener('end', this.ended); this.transport.input.removeListener('error', this.errored); + // Keep a safe error listener during disposal so pipe shutdown cannot become + // an uncaught exception. Never retain native stderr or message text. + this.transport.input.on('error', () => {}); + this.transport.output.removeListener('error', this.writeErrored); this.transport.output.on('error', () => {}); + this.closing = disposeTransport(this.transport); + return this.closing; + } +} + +async function beforeDeadline(deadlineMs: number, operation: () => Promise, code: AppServerFaultCode): Promise { + const remaining = deadlineMs - performance.now(); + if (!Number.isFinite(remaining) || remaining <= 0 || remaining > APP_SERVER_LIMITS.lifetimeMs) throw new AppServerFault('deadline', false); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new AppServerFault('deadline', false)), remaining); + Promise.resolve().then(operation).then(value => { clearTimeout(timer); resolve(value); }, () => { + clearTimeout(timer); reject(new AppServerFault(code, false)); + }); + }); +} +async function verifyOwnedSqliteBinding(pid: number | undefined, home: string, deadlineMs: number): Promise { + if (!pid || pid < 2 || performance.now() >= deadlineMs) throw new AppServerFault('storage_binding_unverified', false); + // Only the owned app-server PID is queried, never a session inventory/PID scan. + // An exact managed requirement can override both env and CLI sqlite_home. + // initialize.codexHome does not attest to effective SQLite storage; require + // actual opened state/queue paths before authorizing the root effect guard. + const remaining = deadlineMs - performance.now(); + if (remaining <= 0) throw new AppServerFault('deadline', false); + const result = await run('/usr/sbin/lsof', ['-a', '-p', String(pid), '-F0fDin'], { + timeout: remaining, limit: 65536, + env: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LC_ALL: 'C', LANG: 'C' }, + }); + if (performance.now() >= deadlineMs) throw new AppServerFault('deadline', false); + if (result.code !== 0 || result.interrupted) throw new AppServerFault('storage_binding_unverified', false); + const required = ['state_5.sqlite', 'queue_1.sqlite']; + const files: { name?: string; dev?: bigint; ino?: bigint }[] = []; + let current: typeof files[number] | undefined; + for (const raw of result.stdout.split('\0')) { + const field = raw.replace(/^\n/, ''); + if (field.startsWith('f')) { current = {}; files.push(current); } + else if (current && field.startsWith('n')) current.name = lsofFilename(field.slice(1)); + else if (current && /^D0x[0-9a-f]+$/i.test(field)) current.dev = BigInt(field.slice(1)); + else if (current && /^i[0-9]+$/.test(field)) current.ino = BigInt(field.slice(1)); + } + for (const file of required) { + const expected = join(home, file); + const opened = files.filter(row => row.name && [file, file + '-wal', file + '-shm'].includes(basename(row.name))); + if (!opened.some(row => row.name === expected)) throw new AppServerFault('storage_binding_unverified', false); + for (const row of opened) { + if (![expected, expected + '-wal', expected + '-shm'].includes(row.name!)) throw new AppServerFault('storage_binding_unverified', false); + try { + const canonical = lstatSync(row.name!, { bigint: true }); + if (!canonical.isFile() || canonical.isSymbolicLink() || row.dev !== canonical.dev || row.ino !== canonical.ino) + throw new Error('storage identity changed'); + } catch { throw new AppServerFault('storage_binding_unverified', false); } + } + } + +} +// lsof field output escapes backslashes and non-ASCII bytes even with NUL field +// delimiters. Decode the protected system tool's filename representation, then +// compare exact canonical paths; never accept a prefix/substring match. +function lsofFilename(raw: string): string { + const bytes: number[] = []; + for (let i = 0; i < raw.length; i++) { + if (raw[i] !== '\\') { + const char = String.fromCodePoint(raw.codePointAt(i)!); + bytes.push(...Buffer.from(char)); i += char.length - 1; continue; + } + const next = raw[++i]; + if (next === '\\') { bytes.push(92); continue; } + if (next === 'x' && /^[0-9a-f]{2}$/i.test(raw.slice(i + 1, i + 3))) { + bytes.push(parseInt(raw.slice(i + 1, i + 3), 16)); i += 2; continue; + } + throw new AppServerFault('storage_binding_unverified', false); + } + try { return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(bytes)); } + catch { throw new AppServerFault('storage_binding_unverified', false); } +} +function executableIdentity(binary: string): string { + try { + const path = realpathSync(binary), info = statSync(path, { bigint: true }); + if (!info.isFile()) throw new Error('not a file'); + return JSON.stringify([path, info.dev.toString(), info.ino.toString(), info.size.toString(), info.mtimeNs.toString(), info.ctimeNs.toString()]); + } catch { throw new AppServerFault('unsupported_version', false); } +} +function verifyExecutableIdentity(binary: string, expected: string): void { + if (executableIdentity(binary) !== expected) throw new AppServerFault('unsupported_version', false); +} +// Queue mechanics were verified only for the exact listed versions/platform +// in a network-denied synthetic home. This is not a live delivery/ACK claim. +// Root integration owns generation, canonical home, storage and writer guards. +// This function never invokes thread start/resume/read/list or a model turn. +export async function nativeQueueOnce(options: NativeQueueOptions): Promise<{ queueId: string; clientUserMessageId: string }> { + const platform = `${process.platform}-${process.arch}`; + if (platform !== APP_SERVER_EVIDENCE.inspectedPlatform) throw new AppServerFault('unsupported_transport', false); + let original: AppServerScope; + try { original = scopeValue(options.scope); } catch { throw new AppServerFault('invalid_scope', false); } + if (!isAbsolute(options.binary) || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(original.threadId)) throw new AppServerFault('invalid_input', false); + // Reject malformed effect input before even a version probe or bootstrap can + // start the binary and access/create native files in the selected home. + validateQueueInput(options, false); + // In the qualified versions config.sqlite_home precedes CODEX_SQLITE_HOME. Pin BOTH env and + // a quoted CLI value so an ordinary config A->B->A load cannot redirect us. + // Managed policy can still supersede the CLI pin, hence the FD-path check. + const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env), CODEX_HOME: original.codexHome, + CODEX_SQLITE_HOME: original.codexHome }; + if (!await beforeDeadline(options.deadlineMs, () => options.verifyOwner(original), 'owner_changed')) throw new AppServerFault('owner_changed', false); + if (performance.now() >= options.deadlineMs) throw new AppServerFault('deadline', false); + const binaryIdentity = executableIdentity(options.binary); + const probeRemaining = options.deadlineMs - performance.now(); + if (probeRemaining <= 0) throw new AppServerFault('deadline', false); + const version = await run(options.binary, ['--version'], { + env, timeout: probeRemaining, limit: 4096, + }); + if (performance.now() >= options.deadlineMs) throw new AppServerFault('deadline', false); + const nativeVersion = version.stdout.trim().slice('codex-cli '.length); + if (version.interrupted || version.code !== 0 || !queueEvidence(nativeVersion) || version.stdout.trim() !== `codex-cli ${nativeVersion}`) { + throw new AppServerFault('unsupported_version', false); + } + verifyExecutableIdentity(options.binary, binaryIdentity); + if (performance.now() >= options.deadlineMs) throw new AppServerFault('deadline', false); + const transport: NativeQueueTransport = { qualification: 'native_queue_only_stdio', + ...spawnOwnedStdio(options.binary, ['app-server', '--stdio', '-c', 'analytics.enabled=false', + '-c', `sqlite_home=${JSON.stringify(original.codexHome)}`], env, original.codexHome) }; + let client: NativeQueuePort | undefined; + try { + verifyExecutableIdentity(options.binary, binaryIdentity); + let effectGuarded = false; + client = await BoundedAppServerClient.connectNativeQueueWire(original, transport, options.deadlineMs, async scope => { + const valid = await options.verifyOwner(scope); + if (valid && effectGuarded) { + // Run after the LAST asynchronous owner verification. The durable guard + // and owner probe can both await I/O and change canonical file bindings. + verifyExecutableIdentity(options.binary, binaryIdentity); + await verifyOwnedSqliteBinding(transport.pid, original.codexHome, options.deadlineMs); + } + return valid; + }, async () => { await options.beforeEffect(); effectGuarded = true; }, nativeVersion); + await verifyOwnedSqliteBinding(transport.pid, original.codexHome, options.deadlineMs); + return await client.queueOnce({ clientUserMessageId: options.clientUserMessageId, message: options.message }); + } finally { if (client) await client.close(); else await disposeTransport(transport); } +} + +const ownedGroups = new Set(); +const terminationSignals = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const; +function reapOwned(pid: number): void { try { process.kill(-pid, 'SIGKILL'); } catch { /* Already gone. */ } } +function processExit(): void { for (const pid of ownedGroups) reapOwned(pid); } +function terminateOwned(signal: NodeJS.Signals): void { + processExit(); ownedGroups.clear(); + for (const name of terminationSignals) process.removeListener(name, terminateOwned); + process.removeListener('exit', processExit); process.kill(process.pid, signal); +} +function registerOwned(pid: number | undefined): void { + if (!pid || ownedGroups.has(pid)) return; + if (!ownedGroups.size) { + for (const name of terminationSignals) process.on(name, terminateOwned); + process.on('exit', processExit); + } + ownedGroups.add(pid); +} +function releaseOwned(pid: number | undefined): void { + if (!pid || !ownedGroups.delete(pid) || ownedGroups.size) return; + for (const name of terminationSignals) process.removeListener(name, terminateOwned); + process.removeListener('exit', processExit); +} +// POSIX groups reap owned descendants. Only darwin-arm64 has native queue +// mechanics qualification; Windows resource cleanup remains unqualified. +function spawnOwnedStdio(binary: string, args: string[], env: NodeJS.ProcessEnv, cwd?: string): Omit & { pid?: number } { + if (process.platform === 'win32') throw new AppServerFault('unsupported_transport', false); + const child = spawn(binary, args, { env, cwd, shell: false, detached: true, stdio: ['pipe', 'pipe', 'pipe'] }); + let stderrBytes = 0, closed = false, stopped = false; + let lifetime: ReturnType | undefined; + const register = () => { + if (closed || stopped || child.exitCode !== null || child.signalCode !== null) return; + registerOwned(child.pid); + }; + const stopOwned = () => { + if (stopped) return; + stopped = true; child.removeListener('spawn', register); + if (lifetime) clearTimeout(lifetime); + if (child.pid && ownedGroups.has(child.pid)) reapOwned(child.pid); + releaseOwned(child.pid); + }; + register(); child.once('spawn', register); + // Release the lifetime/registry as soon as this child exits; late close calls + // must not reap a former PID again. Reap any remaining owned descendants now. + child.once('exit', stopOwned); + child.stdout.on('error', () => {}); child.stdin.on('error', () => {}); + // Also bound a fixture transport whose caller fails before attaching a client. + lifetime = setTimeout(() => { stopOwned(); child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); }, APP_SERVER_LIMITS.lifetimeMs); + lifetime.unref(); + child.stderr.on('data', (bytes: Buffer) => { + stderrBytes += bytes.length; + if (stderrBytes > APP_SERVER_LIMITS.receivedBytes) { stopOwned(); child.stdout.destroy(new Error('traffic_limit')); } + }); + child.on('error', () => { stopOwned(); child.stdout.destroy(new Error('connection_closed')); }); + return { pid: child.pid, input: child.stdout, output: child.stdin, + close: () => new Promise(resolve => { + if (closed) { resolve(); return; } closed = true; stopOwned(); + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) { resolve(); return; } + const timer = setTimeout(resolve, APP_SERVER_LIMITS.cleanupMs); + child.once('exit', () => { clearTimeout(timer); resolve(); }); + }), + }; +} +// Test-only metadata protocol transport; never inferred from a Codex process. +export function spawnOwnedMetadataFixture(binary: string, args: string[], env: NodeJS.ProcessEnv): MetadataOnlyTransport { + return { qualification: 'hermetic_fixture_only', ...spawnOwnedStdio(binary, args, env) }; +} diff --git a/test/app-server.test.ts b/test/app-server.test.ts new file mode 100644 index 0000000..98707ea --- /dev/null +++ b/test/app-server.test.ts @@ -0,0 +1,607 @@ +// Hermetic transport fixtures only. No Codex process, user home, native queue, +// thread start/resume, real owner endpoint, transcript or account is accessed. +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { PassThrough, Writable } from 'node:stream'; +import { performance } from 'node:perf_hooks'; +import { mkdtempSync, realpathSync, mkdirSync, readFileSync, rmSync, writeFileSync, renameSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import childProcess from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { syncBuiltinESMExports } from 'node:module'; +import { APP_SERVER_EVIDENCE, APP_SERVER_QUEUE_EVIDENCE, APP_SERVER_LIMITS, AppServerFault, BoundedAppServerClient, + appServerCapability, connectAppServer, parseMetadataEvent, spawnOwnedMetadataFixture, nativeQueueCapability, + nativeQueueOnce, NATIVE_QUEUE_NOTIFICATION_OPTOUTS, NATIVE_QUEUE_01621_NOTIFICATION_OPTOUTS, + type AppServerScope, type MetadataOnlyTransport } from '../dist/app-server.js'; + +const scope: AppServerScope = { codexHome: join(tmpdir(), 'fixture-home'), threadId: 'fixture-thread', generation: 'fixture-generation', ownerIdentity: 'fixture-owner' }; +const id = 'fixture-client-id'; +function fixture(t: TestContext, onRequest?: (request: any, reply: (value: unknown) => void) => void) { + const input = new PassThrough(), requests: any[] = []; let closes = 0; + const reply = (value: unknown) => { if (!input.destroyed) input.write(JSON.stringify(value) + '\n'); }; + const output = new Writable({ write(chunk, _encoding, callback) { + const request = JSON.parse(chunk.toString()); requests.push(request); + callback(); + if (onRequest) onRequest(request, reply); + else if (request.method.endsWith('/initialize')) reply({ id: request.id, result: { scope, metadataOnly: true } }); + else reply({ id: request.id, result: { scope, queueId: 'fixture-queue-id', clientUserMessageId: request.params.clientUserMessageId } }); + } }); + const transport: MetadataOnlyTransport = { qualification: 'hermetic_fixture_only', input, output, + close: async () => { closes++; input.destroy(); output.destroy(); } }; + const clients: BoundedAppServerClient[] = []; + t.after(async () => { for (const client of clients) await client.close(); input.destroy(); output.destroy(); }); + async function connect(timeout = 1000, owner: (value: AppServerScope) => Promise = async () => true) { + const client = await BoundedAppServerClient.connectMetadataFixture(scope, transport, performance.now() + timeout, owner); + clients.push(client); return client; + } + const event = (params: unknown) => reply({ method: 'session-peer/metadata/event', params }); + return { transport, input, output, requests, reply, event, connect, closes: () => closes }; +} +const injected = (changes: object = {}) => ({ kind: 'injected', scope, clientUserMessageId: id, itemId: 'fixture-item', turnId: 'fixture-turn', ...changes }); +function fault(code: string, attempted: boolean) { + return (error: unknown) => { + assert.ok(error instanceof AppServerFault); assert.equal(error.code, code); assert.equal(error.attempted, attempted); + assert.equal(error.fallbackEligible, !attempted && ['unsupported_transport', 'unsupported_version'].includes(code)); return true; + }; +} + +test('production metadata observer remains unsupported without opening any endpoint', async () => { + const context = { version: '0.160.1', platform: 'darwin-arm64', scope }; + assert.deepEqual(appServerCapability(context), { supported: false, schema: 'version_qualified', route: 'route_unsupported', reason: 'metadata_only_owner_route_unvalidated', attempted: false, queueSupported: true }); + let opens = 0; + const result = await connectAppServer(context, async () => { opens++; throw new Error('must not open'); }, performance.now() + 1000); + assert.equal(result.supported, false); assert.equal(opens, 0); + for (const patch of [{ version: '0.160.2' }, { platform: 'linux-x64' }, { platform: 'win32-x64' }]) { + assert.equal(appServerCapability({ ...context, ...patch }).schema, 'version_unqualified'); + } +}); + +test('native queue capability qualifies exact mechanics only, never live delivery or observation', () => { + assert.deepEqual(nativeQueueCapability({ version: '0.160.1', platform: 'darwin-arm64', scope }), { + queueSupported: true, observationSupported: false, qualification: 'mechanics_from_isolated_fixture', reason: 'queue_only', + }); + for (const patch of [{ version: '0.160.2' }, { platform: 'linux-x64' }, { platform: 'win32-x64' }]) { + assert.equal(nativeQueueCapability({ version: '0.160.1', platform: 'darwin-arm64', scope, ...patch }).queueSupported, false); + } + assert.equal(nativeQueueCapability({ version: '0.162.1', platform: 'darwin-arm64', scope }).queueSupported, true); + assert.equal(appServerCapability({ version: '0.162.1', platform: 'darwin-arm64', scope }).supported, false); + for (const version of ['0.161.0', '0.162.0', '0.162.2', '0.163.0', '0.162.1-rc.1', 'v0.162.1']) { + assert.equal(nativeQueueCapability({ version, platform: 'darwin-arm64', scope }).queueSupported, false); + } + for (const platform of ['darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64']) { + assert.equal(nativeQueueCapability({ version: '0.162.1', platform, scope }).queueSupported, false); + } + assert.equal(nativeQueueCapability({ version: undefined, platform: undefined, scope } as any).queueSupported, false); +}); + +test('generated schema evidence explicitly records body-bearing APIs as unsupported', () => { + const evidence = JSON.parse(readFileSync(new URL('./fixtures/app-server-schema-evidence.json', import.meta.url), 'utf8')); + assert.equal(evidence.version, APP_SERVER_EVIDENCE.version); assert.equal(evidence.qualification, 'generated_schema_only'); + assert.equal(evidence.methods.queueAdd.responseSha256, APP_SERVER_EVIDENCE.queueAddResponseSha256); + assert.deepEqual(evidence.methods.queueAdd.bodyPath, ['queuedSubmission', 'input']); + assert.deepEqual(evidence.methods.itemStarted.bodyPath, ['item', 'content']); + assert.deepEqual(evidence.methods.turnCompleted.bodyPath, ['turn', 'items']); + assert.equal(evidence.productionRouteQualified, false); assert.equal(evidence.liveExperimentPerformed, false); +}); + +test('fixture initializes an immutable original scope and writes own input only once', async t => { + const f = fixture(t), client = await f.connect(); + assert.ok(Object.isFrozen(client.scope)); + const queued = await client.queueOnce({ clientUserMessageId: id, message: 'PRIVATE-OWN-INPUT' }); + assert.deepEqual(queued, { queueId: 'fixture-queue-id', clientUserMessageId: id }); + assert.equal(JSON.stringify(queued).includes('PRIVATE-OWN-INPUT'), false); + assert.deepEqual(f.requests.map(r => r.method), ['session-peer/metadata/initialize', 'session-peer/metadata/queue']); + assert.equal(f.requests[1].params.input[0].text, 'PRIVATE-OWN-INPUT'); + await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'again' }), fault('already_attempted', true)); + assert.equal(f.requests.length, 2); await client.close(); await client.close(); assert.equal(f.closes(), 1); +}); + +test('concurrent queue calls cannot issue a second effect', async t => { + const f = fixture(t), client = await f.connect(); + const results = await Promise.allSettled([client.queueOnce({ clientUserMessageId: id, message: 'one' }), client.queueOnce({ clientUserMessageId: 'other', message: 'two' })]); + assert.equal(results[0]!.status, 'fulfilled'); assert.equal(results[1]!.status, 'rejected'); + assert.equal(f.requests.filter(r => r.method.endsWith('/queue')).length, 1); +}); + +test('owner revalidation fails before effect and closes the connection', async t => { + const f = fixture(t); let checks = 0; + const client = await f.connect(1000, async original => { assert.deepEqual(original, scope); return ++checks === 1; }); + await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'never sent' }), fault('owner_changed', false)); + assert.equal(f.requests.length, 1); assert.equal(f.closes(), 1); +}); + +test('a stalled owner check shares the original deadline and is disposed', async t => { + const f = fixture(t); + await assert.rejects(f.connect(35, () => new Promise(() => {})), fault('deadline', false)); + assert.equal(f.requests.length, 0); assert.equal(f.closes(), 1); +}); + +test('lost queue response is attempted/unknown and never authorizes fallback', async t => { + const f = fixture(t, (r, reply) => { if (r.method.endsWith('/initialize')) reply({ id: r.id, result: { scope, metadataOnly: true } }); }); + const client = await f.connect(60); + await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'one' }), fault('deadline', true)); + assert.equal(f.requests.length, 2); assert.equal(f.closes(), 1); +}); + +test('response errors never return native error/body details after an attempt', async t => { + const f = fixture(t, (r, reply) => { + if (r.method.endsWith('/initialize')) reply({ id: r.id, result: { scope, metadataOnly: true } }); + else reply({ id: r.id, error: { code: 'failed', message: 'PRIVATE-NATIVE-ERROR' } }); + }); + const client = await f.connect(); + const error = await client.queueOnce({ clientUserMessageId: id, message: 'PRIVATE-OWN-INPUT' }).catch(e => e); + fault('frame_invalid', true)(error); + assert.equal(String(error).includes('PRIVATE'), false); assert.equal(JSON.stringify(error).includes('PRIVATE'), false); + assert.equal(f.closes(), 1); +}); + +test('attempt marker precedes even a synchronous queue write failure', async t => { + const f = fixture(t), client = await f.connect(); + t.mock.method(f.output, 'write', () => { throw new Error('PRIVATE-WRITE-DETAIL'); }); + await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'one' }), fault('write_failed', true)); + assert.equal(f.requests.length, 1); assert.equal(f.closes(), 1); +}); + +test('encoded effect frame limits are checked before marking a native attempt', async t => { + const f = fixture(t), client = await f.connect(); + await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: '\x01'.repeat(APP_SERVER_LIMITS.messageBytes) }), fault('frame_limit', false)); + assert.equal(f.requests.length, 1); assert.equal(f.closes(), 1); +}); + +test('injection evidence is scoped to the exact original client ID and generation', async t => { + const f = fixture(t), client = await f.connect(); await client.queueOnce({ clientUserMessageId: id, message: 'one' }); + f.event(injected({ clientUserMessageId: 'unrelated' })); + const pending = client.observe(id); f.event(injected()); + assert.deepEqual(await pending, { injectionObserved: true, clientUserMessageId: id, turn: { id: 'fixture-turn', status: 'unknown' } }); + f.event({ kind: 'turn', scope, clientUserMessageId: id, turnId: 'fixture-turn', status: 'failed' }); + f.event(injected()); // Duplicate injection cannot erase known turn facts. + assert.equal((await client.observe(id)).turn!.status, 'failed'); + assert.equal('acknowledged' in await client.observe(id), false); +}); + +test('a completed turn without injection is not delivered or acknowledged', async t => { + const f = fixture(t), client = await f.connect(60); await client.queueOnce({ clientUserMessageId: id, message: 'one' }); + f.event({ kind: 'turn', scope, clientUserMessageId: id, turnId: 'fixture-turn', status: 'completed' }); + await assert.rejects(client.observe(id), fault('deadline', true)); assert.equal(f.requests.length, 2); +}); + +test('home, thread, generation and owner substitutions cannot advance observation', async t => { + for (const patch of [{ codexHome: join(tmpdir(), 'other') }, { threadId: 'other' }, { generation: 'new' }, { ownerIdentity: 'other' }]) { + const f = fixture(t), client = await f.connect(); await client.queueOnce({ clientUserMessageId: id, message: 'one' }); + const pending = client.observe(id); f.event(injected({ scope: { ...scope, ...patch } })); + await assert.rejects(pending, fault('owner_changed', true)); assert.equal(f.closes(), 1); + } +}); + +test('pure metadata parser rejects bodies, queue deletion and unsafe IDs', () => { + assert.deepEqual(parseMetadataEvent(injected(), scope), injected()); + for (const event of [injected({ content: 'PRIVATE' }), injected({ text: 'PRIVATE' }), injected({ turnId: '\ud800' }), injected({ kind: 'queue_removed' }), injected({ scope: { ...scope, generation: null } })]) { + assert.throws(() => parseMetadataEvent(event, scope), AppServerFault); + } +}); + +test('unknown/body-bearing notifications fail closed without returning text', async t => { + const f = fixture(t), client = await f.connect(); await client.queueOnce({ clientUserMessageId: id, message: 'one' }); + const pending = client.observe(id); + f.reply({ method: 'item/started', params: { threadId: scope.threadId, item: { type: 'userMessage', content: 'PRIVATE-BODY' } } }); + await assert.rejects(pending, fault('frame_invalid', true)); assert.equal(f.closes(), 1); +}); + +test('strict framing rejects duplicates, float IDs, invalid UTF-8 and trailing payloads', async t => { + for (const raw of ['{"id":1,"id":1,"result":{}}\n', '{"id":1e0,"result":{}}\n', '{"id":1.0,"result":{}}\n', '{}{}\n', Buffer.from([0xff, 10])]) { + const f = fixture(t, () => {}); + const pending = f.connect(); setImmediate(() => f.input.write(raw)); + await assert.rejects(pending, fault('frame_invalid', false)); assert.equal(f.closes(), 1); + } +}); + +test('frame boundary includes newline and a stream without newline remains bounded', async t => { + const base = JSON.stringify({ id: 1, result: { scope, metadataOnly: true } }); + const good = fixture(t, () => {}), pending = good.connect(); + setImmediate(() => good.input.write(base + ' '.repeat(APP_SERVER_LIMITS.frameBytes - Buffer.byteLength(base) - 1) + '\n')); + const client = await pending; await client.close(); + const bad = fixture(t, () => {}), rejected = bad.connect(); + setImmediate(() => bad.input.write(' '.repeat(APP_SERVER_LIMITS.frameBytes + 1))); + await assert.rejects(rejected, fault('frame_limit', false)); assert.equal(bad.closes(), 1); +}); + +test('notification and total byte caps close a flooding transport', async t => { + const f = fixture(t), client = await f.connect(); await client.queueOnce({ clientUserMessageId: id, message: 'one' }); + const pending = client.observe(id); + for (let i = 0; i <= APP_SERVER_LIMITS.notifications; i++) f.event(injected({ clientUserMessageId: 'unrelated' })); + await assert.rejects(pending, fault('notification_limit', true)); + const bytes = fixture(t, () => {}), connecting = bytes.connect(); + setImmediate(() => bytes.input.write(Buffer.alloc(APP_SERVER_LIMITS.receivedBytes + 1))); + await assert.rejects(connecting, fault('traffic_limit', false)); assert.equal(bytes.closes(), 1); +}); + +test('pending observation callers are bounded and rejected when the quota fills', async t => { + const f = fixture(t), client = await f.connect(); await client.queueOnce({ clientUserMessageId: id, message: 'one' }); + const waits = Array.from({ length: APP_SERVER_LIMITS.observations }, () => client.observe(id).catch(error => error)); + await assert.rejects(client.observe(id), fault('request_limit', true)); + for (const error of await Promise.all(waits)) fault('request_limit', true)(error); + assert.equal(f.closes(), 1); assert.equal(f.requests.length, 2); +}); + +test('invalid scope/input and generation null cannot cause an effect', async t => { + const f = fixture(t); + await assert.rejects(BoundedAppServerClient.connectMetadataFixture({ ...scope, generation: null } as any, + f.transport, performance.now() + 1000, async () => true), fault('invalid_scope', false)); assert.equal(f.requests.length, 0); + const next = fixture(t), client = await next.connect(); + for (const message of ['\0', '\ud800', 'x'.repeat(APP_SERVER_LIMITS.messageBytes + 1)]) { + await assert.rejects(client.queueOnce({ clientUserMessageId: id, message }), fault('invalid_input', false)); + } + assert.equal(next.requests.length, 1); +}); + +test('a partial queue response followed by EOF remains attempted/unknown', async t => { + const f = fixture(t, (r, reply) => { + if (r.method.endsWith('/initialize')) reply({ id: r.id, result: { scope, metadataOnly: true } }); + else { f.input.write('{"id":2'); f.input.end(); } + }); + const client = await f.connect(); + await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'one' }), fault('frame_invalid', true)); + assert.equal(f.requests.length, 2); assert.equal(f.closes(), 1); +}); + +test('owned POSIX fixture processes are reaped on explicit close and queue deadline', { skip: process.platform === 'win32' }, async t => { + const root = mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-app-server-')); + mkdirSync(join(root, 'home')); mkdirSync(join(root, 'codex')); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const loseReply of [false, true]) { + const pidFile = join(root, loseReply ? 'deadline.pid' : 'close.pid'); + const script = ` + const fs=require('node:fs');fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid)); + let buffer='';setInterval(()=>{},1000); + process.stdin.on('data',chunk=>{buffer+=chunk;let end;while((end=buffer.indexOf('\\n'))>=0){ + const r=JSON.parse(buffer.slice(0,end));buffer=buffer.slice(end+1); + if(r.method.endsWith('/initialize'))process.stdout.write(JSON.stringify({id:r.id,result:{scope:r.params.scope,metadataOnly:true}})+'\\n'); + else if(!${loseReply})process.stdout.write(JSON.stringify({id:r.id,result:{scope:r.params.scope,queueId:'q',clientUserMessageId:r.params.clientUserMessageId}})+'\\n'); + }});`; + const transport = spawnOwnedMetadataFixture(process.execPath, ['-e', script], { PATH: process.env.PATH, + HOME: join(root, 'home'), CODEX_HOME: join(root, 'codex'), SESSION_PEER_TAILSCALE: 'off' }); + const client = await BoundedAppServerClient.connectMetadataFixture(scope, transport, performance.now() + 300, async () => true); + if (loseReply) await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'fixture' }), fault('deadline', true)); + else { await client.queueOnce({ clientUserMessageId: id, message: 'fixture' }); await client.close(); } + const pid = Number(readFileSync(pidFile, 'utf8')); + assert.throws(() => process.kill(pid, 0), (error: any) => error.code === 'ESRCH'); + } +}); + +test('immediate close cannot re-register a former PID from a delayed spawn event or queued callback', { + skip: process.platform === 'win32', +}, async t => { + const names = ['SIGINT', 'SIGTERM', 'SIGHUP', 'exit'] as const; + const listeners = (name: typeof names[number]): Array<(...args: any[]) => void> => + name === 'exit' ? process.listeners('exit') : process.listeners(name); + const baseline = new Map(names.map(name => [name, listeners(name)])); + const fakePid = 2147480000; // No OS process is spawned or signalled. + const child = Object.assign(new EventEmitter(), { pid: fakePid, exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough() }); + const kills: Array<{ pid: number; signal: string | number | undefined }> = []; + t.mock.method(childProcess, 'spawn', () => child as any); + t.mock.method(process, 'kill', (pid: number, signal?: string | number) => { + kills.push({ pid, signal }); if (pid === -fakePid) child.exitCode = 0; return true; + }); + syncBuiltinESMExports(); + t.after(() => { + // A negative-control failure must not leave fake ownership behind. Invoke + // only the newly installed owned handler while process.kill is still mocked. + for (const handler of process.listeners('SIGTERM')) { + if (!baseline.get('SIGTERM')!.includes(handler)) handler('SIGTERM'); + } + t.mock.restoreAll(); syncBuiltinESMExports(); + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + }); + const transport = spawnOwnedMetadataFixture('unused-fixture-binary', [], {}); + const queuedSpawnCallbacks = child.listeners('spawn'); + const ownedExitCallbacks = process.listeners('exit').filter(handler => !baseline.get('exit')!.includes(handler)); + assert.equal(queuedSpawnCallbacks.length, 1); assert.equal(ownedExitCallbacks.length, 1); + await transport.close(); + const spawnListenersAfterClose = child.listenerCount('spawn'); + // Pending child state may still look live when a previously queued callback + // arrives. The closed/stopped latch must win independently of exitCode. + child.exitCode = null; + await new Promise(resolve => setImmediate(() => { + child.emit('spawn'); for (const callback of queuedSpawnCallbacks) callback(); resolve(); + })); + const listenersAfterDelayedSpawn = new Map(names.map(name => [name, listeners(name)])); + // Even the captured former exit handler cannot reap a reused PID afterwards. + for (const callback of ownedExitCallbacks) callback(0); + child.emit('exit', 0); await transport.close(); + assert.deepEqual(kills, [{ pid: -fakePid, signal: 'SIGKILL' }]); + assert.equal(spawnListenersAfterClose, 0); + for (const name of names) assert.deepEqual(listenersAfterDelayedSpawn.get(name), baseline.get(name), name); +}); + +test('closing an owned POSIX fixture also kills its owned process-group descendant', { skip: process.platform === 'win32' }, async t => { + const root = mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-app-server-group-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const pidFile = join(root, 'pids.json'); + const script = ` + const fs=require('node:fs'),{spawn}=require('node:child_process'); + const descendant=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); + fs.writeFileSync(${JSON.stringify(pidFile)},JSON.stringify({parent:process.pid,descendant:descendant.pid})); + let buffer='';process.stdin.on('data',chunk=>{buffer+=chunk;let end;while((end=buffer.indexOf('\\n'))>=0){ + const r=JSON.parse(buffer.slice(0,end));buffer=buffer.slice(end+1); + process.stdout.write(JSON.stringify({id:r.id,result:{scope:r.params.scope,metadataOnly:true}})+'\\n'); + }});`; + const transport = spawnOwnedMetadataFixture(process.execPath, ['-e', script], { PATH: process.env.PATH, HOME: root, CODEX_HOME: root }); + const client = await BoundedAppServerClient.connectMetadataFixture(scope, transport, performance.now() + 1000, async () => true); + const pids = JSON.parse(readFileSync(pidFile, 'utf8')); + process.kill(pids.parent, 0); process.kill(pids.descendant, 0); + await client.close(); + function running(pid: number): boolean { + try { + process.kill(pid, 0); + // Linux may briefly retain an already killed orphan as a zombie. + if (process.platform === 'linux' && /\) Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8'))) return false; + return true; + } catch (error) { + if (['ESRCH', 'ENOENT'].includes((error as NodeJS.ErrnoException).code ?? '')) return false; + throw error; + } + } + for (let i = 0; i < 20 && running(pids.descendant); i++) await new Promise(resolve => setTimeout(resolve, 10)); + assert.equal(running(pids.parent), false); assert.equal(running(pids.descendant), false); +}); + +function nativeFixture(t: TestContext, queueReply?: (r: any, reply: (v: unknown) => void) => void, nativeVersion = '0.160.1') { + const f = fixture(t, (r, reply) => { + if (r.method === 'initialize') reply({ id: r.id, result: { codexHome: scope.codexHome, platformFamily: 'unix', platformOs: 'macos', userAgent: `session-peer-native-queue/${nativeVersion} (fixture)` } }); + else if (r.method === 'initialized') return; + else if (queueReply) queueReply(r, reply); + else reply({ id: r.id, result: { queuedSubmission: { id: 'fixture-native-q', input: r.params.input, clientUserMessageId: r.params.clientUserMessageId } } }); + }); + async function connect(beforeEffect: () => Promise = async () => {}, timeout = 1000, + owner: (value: AppServerScope) => Promise = async () => true) { + const client = await BoundedAppServerClient.connectNativeQueueFixture(scope, f.transport, performance.now() + timeout, owner, beforeEffect, nativeVersion); + t.after(() => client.close()); return client; + } + return { ...f, connect }; +} + +test('native fixture opts out all 83 notifications before one queue RPC and validates only its own echo', async t => { + const f = nativeFixture(t); let guards = 0; + const client = await f.connect(async () => { guards++; assert.equal(f.requests.length, 2); }); + assert.equal(NATIVE_QUEUE_NOTIFICATION_OPTOUTS.length, 83); + assert.equal(new Set(NATIVE_QUEUE_NOTIFICATION_OPTOUTS).size, 83); + assert.deepEqual(f.requests[0].params.capabilities.optOutNotificationMethods, NATIVE_QUEUE_NOTIFICATION_OPTOUTS); + const result = await client.queueOnce({ clientUserMessageId: id, message: 'PRIVATE-OWN-ECHO' }); + assert.deepEqual(result, { queueId: 'fixture-native-q', clientUserMessageId: id }); assert.equal(guards, 1); + assert.equal(JSON.stringify(result).includes('PRIVATE'), false); + assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'initialized', 'thread/queue/add']); + await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'again' }), fault('already_attempted', true)); +}); + +test('qualified 0.162.1 fixture opts out all 84 notifications without claiming an observer', async t => { + const evidence = JSON.parse(readFileSync(new URL('./fixtures/app-server-schema-evidence.json', import.meta.url), 'utf8')); + const profile = evidence.additionalQueueOnlyProfiles[0]; + assert.equal(profile.version, '0.162.1'); assert.equal(profile.platform, 'darwin-arm64'); + const codeEvidence = APP_SERVER_QUEUE_EVIDENCE.find(value => value.version === profile.version)!; + assert.equal(profile.officialSourceCommit, codeEvidence.officialSourceCommit); + assert.equal(profile.methods.queueAdd.paramsSha256, codeEvidence.queueAddParamsSha256); + assert.equal(profile.methods.queueAdd.responseSha256, codeEvidence.queueAddResponseSha256); + assert.equal(profile.methods.itemStarted.responseSha256, codeEvidence.itemStartedSha256); + assert.equal(profile.methods.turnCompleted.responseSha256, codeEvidence.turnCompletedSha256); + assert.deepEqual(new Set(profile.optOutNotificationMethods), new Set(NATIVE_QUEUE_01621_NOTIFICATION_OPTOUTS)); + assert.equal(NATIVE_QUEUE_01621_NOTIFICATION_OPTOUTS.length, 84); + assert.equal(new Set(NATIVE_QUEUE_01621_NOTIFICATION_OPTOUTS).size, 84); + assert.equal(profile.nativeFixture.queueAttempts, 1); assert.equal(profile.nativeFixture.loadedBefore, 0); + assert.equal(profile.nativeFixture.loadedAfter, 0); assert.equal(profile.productionObservationRouteQualified, false); + assert.deepEqual(profile.beforeEffectNativeApiFixture.fault, + { code: 'effect_guard_failed', attempted: false, fallbackEligible: false }); + assert.equal(profile.beforeEffectNativeApiFixture.queueRows, 0); + const f = nativeFixture(t, undefined, '0.162.1'), client = await f.connect(); + assert.deepEqual(f.requests[0].params.capabilities.optOutNotificationMethods, NATIVE_QUEUE_01621_NOTIFICATION_OPTOUTS); + assert.deepEqual(await client.queueOnce({ clientUserMessageId: id, message: 'own echo' }), + { queueId: 'fixture-native-q', clientUserMessageId: id }); + assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'initialized', 'thread/queue/add']); +}); + +test('unqualified native fixture versions close before initialize and cannot invoke effect guards', async t => { + const f = nativeFixture(t, undefined, '0.162.2'); let guards = 0; + await assert.rejects(f.connect(async () => { guards++; }), fault('unsupported_version', false)); + assert.equal(guards, 0); assert.equal(f.requests.length, 0); assert.equal(f.closes(), 1); +}); + +test('root effect-guard refusal cannot authorize any native queue or fallback', async t => { + const f = nativeFixture(t), client = await f.connect(async () => { throw new Error('PRIVATE-LEDGER-DETAIL'); }); + await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'one' }), fault('effect_guard_failed', false)); + assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'initialized']); assert.equal(f.closes(), 1); +}); + +test('root effect guard shares original deadline and cannot write after it', async t => { + const f = nativeFixture(t), client = await f.connect(() => new Promise(() => {}), 40); + await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'one' }), fault('deadline', false)); + assert.equal(f.requests.length, 2); assert.equal(f.closes(), 1); +}); + +test('owner changes during an awaited durable effect guard refuse before the queue write', async t => { + const f = nativeFixture(t); let currentOwner = true, guards = 0, checks = 0; + let releaseGuard!: () => void; + const guardPending = new Promise(resolve => { releaseGuard = resolve; }); + let enteredGuard!: () => void; + const guardEntered = new Promise(resolve => { enteredGuard = resolve; }); + const client = await f.connect(async () => { guards++; enteredGuard(); await guardPending; }, 1000, + async original => { assert.deepEqual(original, scope); checks++; return currentOwner; }); + const queue = client.queueOnce({ clientUserMessageId: id, message: 'must-not-be-written' }); + await guardEntered; + assert.equal(checks, 2); assert.equal(guards, 1); + currentOwner = false; releaseGuard(); + await assert.rejects(queue, fault('owner_changed', false)); + assert.equal(checks, 3); assert.equal(f.closes(), 1); + assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'initialized']); + await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'no-retry' }), fault('owner_changed', false)); + assert.equal(guards, 1); assert.equal(f.requests.length, 2); +}); + +test('post-guard owner verification retains the original deadline and cannot issue a late queue', async t => { + const f = nativeFixture(t); let checks = 0, finishOwner!: (value: boolean) => void; + const finalOwner = new Promise(resolve => { finishOwner = resolve; }); + const client = await f.connect(async () => {}, 80, async () => ++checks < 3 || await finalOwner); + await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'no-late-effect' }), fault('deadline', false)); + assert.equal(checks, 3); assert.equal(f.closes(), 1); + finishOwner(true); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'initialized']); +}); + +test('native invalid message or client ID is refused before owner checks and binary bootstrap', { + skip: process.platform !== 'darwin' || process.arch !== 'arm64', +}, async () => { + let owners = 0, guards = 0; + for (const input of [ + { message: '', clientUserMessageId: id }, + { message: 'x\0y', clientUserMessageId: id }, + { message: '\ud800', clientUserMessageId: id }, + { message: 'x'.repeat(APP_SERVER_LIMITS.messageBytes + 1), clientUserMessageId: id }, + { message: 'valid', clientUserMessageId: '' }, + { message: 'valid', clientUserMessageId: '\ud800' }, + { message: 'valid', clientUserMessageId: 'x'.repeat(129) }, + ]) { + await assert.rejects(nativeQueueOnce({ binary: process.execPath, + scope: { ...scope, threadId: '01950000-0000-7000-8000-000000000071' }, ...input, + deadlineMs: performance.now() + 1000, + verifyOwner: async () => { owners++; return true; }, beforeEffect: async () => { guards++; } }), fault('invalid_input', false)); + } + assert.equal(owners, 0); assert.equal(guards, 0); +}); + +test('native own-input echo conflicts are post-attempt unknown, not observer evidence', async t => { + for (const patch of [{ clientUserMessageId: 'wrong' }, { input: [{ type: 'text', text: 'PRIVATE-UNEXPECTED' }] }, { input: [] }]) { + const f = nativeFixture(t, (r, reply) => reply({ id: r.id, result: { queuedSubmission: { id: 'q', input: r.params.input, clientUserMessageId: r.params.clientUserMessageId, ...patch } } })); + const client = await f.connect(); await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'one' }), fault('frame_invalid', true)); + assert.equal(f.requests.length, 3); assert.equal(f.closes(), 1); + } +}); + +test('unrequested native notification after opt-out aborts without inspecting or returning its body', async t => { + const f = nativeFixture(t, (_r, reply) => reply({ method: 'item/started', params: { item: { content: 'PRIVATE-NATIVE-BODY' } } })); + const client = await f.connect(); await assert.rejects(client.queueOnce({ clientUserMessageId: id, message: 'one' }), fault('unsupported_transport', true)); + assert.equal(f.requests.length, 3); assert.equal(f.closes(), 1); +}); + +test('native negative RPC error codes retain attempted state and never expose stderr/message', async t => { + const f = nativeFixture(t, (r, reply) => reply({ id: r.id, error: { code: -32601, message: 'PRIVATE-RPC-ERROR', data: { body: 'PRIVATE' } } })); + const client = await f.connect(); const error = await client.queueOnce({ clientUserMessageId: id, message: 'one' }).catch(e => e); + fault('remote_refused', true)(error); assert.equal(String(error).includes('PRIVATE'), false); +}); + +test('nativeQueueOnce pins env/CLI SQLite home, checks opened paths and selected binary before a guarded effect', { + skip: process.platform !== 'darwin' || process.arch !== 'arm64', +}, async t => { + const root = realpathSync(mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-native-queue-stub-'))); + t.after(() => rmSync(root, { recursive: true, force: true })); + const log = join(root, 'metadata.jsonl'), binary = join(root, 'codex-fixture'); + const selected = join(root, 'selected "quoted" \\home'), other = join(root, 'other'); + mkdirSync(selected, { mode: 0o700 }); mkdirSync(other, { mode: 0o700 }); + const original = { ...scope, codexHome: selected, threadId: '01950000-0000-7000-8000-000000000071' }; + const script = `#!${process.execPath}\n + const fs=require('node:fs'),log=${JSON.stringify(log)}; + fs.appendFileSync(log,JSON.stringify({argv:process.argv.slice(2),pid:process.pid,home:process.env.CODEX_HOME,sqliteHome:process.env.CODEX_SQLITE_HOME})+'\\n'); + if(process.argv.includes('--version')){if(process.env.FIXTURE_REPLACE_BINARY)fs.renameSync(process.env.FIXTURE_REPLACE_BINARY,__filename);if(process.env.FIXTURE_RETARGET_LINK){fs.unlinkSync(process.env.FIXTURE_RETARGET_LINK);fs.symlinkSync(process.env.FIXTURE_LINK_TARGET,process.env.FIXTURE_RETARGET_LINK);}console.log('codex-cli '+(process.env.FIXTURE_VERSION||'0.160.1'));process.exit(0);} + // Synthetic descriptors model the service's already resolved SQLite home; + // the negative case represents an exact policy overriding CLI/env pins. + const storage=process.env.FIXTURE_STORAGE_HOME||process.env.CODEX_SQLITE_HOME; + fs.openSync(require('node:path').join(storage,'state_5.sqlite'),'w+'); + fs.openSync(require('node:path').join(storage,'queue_1.sqlite'),'w+'); + let buffer='';setInterval(()=>{},1000);process.stderr.write('PRIVATE-STDERR-NOT-RETURNED'); + process.stdin.on('data',chunk=>{buffer+=chunk;let end;while((end=buffer.indexOf('\\n'))>=0){const r=JSON.parse(buffer.slice(0,end));buffer=buffer.slice(end+1); + fs.appendFileSync(log,JSON.stringify({method:r.method,guardExists:fs.existsSync(${JSON.stringify(join(root, 'guard'))})})+'\\n'); + if(r.method==='initialize')process.stdout.write(JSON.stringify({id:r.id,result:{codexHome:process.env.CODEX_HOME,platformFamily:'unix',platformOs:'macos',userAgent:'session-peer-native-queue/'+(process.env.FIXTURE_RUNTIME_VERSION||'0.160.1')+' (fixture)'}})+'\\n'); + if(r.method==='thread/queue/add')process.stdout.write(JSON.stringify({id:r.id,result:{queuedSubmission:{id:'fixture-q',clientUserMessageId:r.params.clientUserMessageId,input:r.params.input}}})+'\\n'); + }});`; + writeFileSync(binary, script, { mode: 0o700 }); + let guards = 0; + const options = { binary, scope: original, message: 'PRIVATE-OWN-EFFECT', clientUserMessageId: id, deadlineMs: performance.now() + 2000, + verifyOwner: async (value: AppServerScope) => { assert.deepEqual(value, original); return true; }, + beforeEffect: async () => { guards++; writeFileSync(join(root, 'guard'), 'committed'); }, + env: { HOME: root, CODEX_HOME: 'must-be-replaced', CODEX_SQLITE_HOME: 'must-be-replaced', PATH: process.env.PATH } }; + assert.deepEqual(await nativeQueueOnce(options), { queueId: 'fixture-q', clientUserMessageId: id }); assert.equal(guards, 1); + const rows = readFileSync(log, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + assert.equal(rows.length, 5); assert.deepEqual(rows.filter(r => r.method).map(r => r.method), ['initialize', 'initialized', 'thread/queue/add']); + for (const row of rows.filter(r => r.pid)) { assert.equal(row.home, selected); assert.equal(row.sqliteHome, selected); + assert.throws(() => process.kill(row.pid, 0), (e: any) => e.code === 'ESRCH'); } + assert.deepEqual(rows[1].argv.slice(-2), ['-c', `sqlite_home=${JSON.stringify(selected)}`]); + assert.equal(rows.at(-1).guardExists, true); + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 1000, + env: { ...options.env, FIXTURE_VERSION: '0.160.2' } }), fault('unsupported_version', false)); + assert.equal(guards, 1); + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + env: { ...options.env, FIXTURE_STORAGE_HOME: other } }), fault('storage_binding_unverified', false)); + assert.equal(guards, 1); + const guardsBefore = guards; + await t.test('storage replaced during durable guard cannot reach queue', async () => { + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + beforeEffect: async () => { + guards++; renameSync(join(selected, 'state_5.sqlite'), join(selected, 'detached-state.sqlite')); + writeFileSync(join(selected, 'state_5.sqlite'), 'replacement'); + } }), fault('owner_changed', false)); + }); + assert.equal(guards, guardsBefore + 1); + await t.test('same canonical descriptor paths with wrong inode are refused after the guard', async () => { + const originalSpawn = childProcess.spawn; let guarded = false; + t.mock.method(childProcess, 'spawn', (binary: string, args: string[], options: any) => { + if (binary === '/usr/sbin/lsof' && guarded) { + // Model unchanged reported paths while opened inode identities differ. + const output = ['state_5.sqlite', 'queue_1.sqlite'].map((file, index) => + `f${index + 3}\0D0x1\0i1\0n${join(selected, file)}\0\n`).join(''); + return originalSpawn(process.execPath, ['-e', 'process.stdout.write(process.argv[1])', output], options); + } + return originalSpawn(binary, args, options); + }); + syncBuiltinESMExports(); + try { + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + beforeEffect: async () => { guarded = true; } }), fault('owner_changed', false)); + } finally { t.mock.restoreAll(); syncBuiltinESMExports(); } + }); + await t.test('storage replacement during final async owner verification is checked before queue', async () => { + let fenced = false; + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + beforeEffect: async () => { fenced = true; }, + verifyOwner: async () => { + if (fenced) { + renameSync(join(selected, 'queue_1.sqlite'), join(selected, 'detached-queue.sqlite')); + writeFileSync(join(selected, 'queue_1.sqlite'), 'replacement'); + } + return true; + } }), fault('owner_changed', false)); + }); + await t.test('queue process actual build must equal the probed qualified build', async () => { + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + env: { ...options.env, FIXTURE_RUNTIME_VERSION: '0.160.2' } }), fault('unsupported_version', false)); + }); + await t.test('atomic binary replacement after version probe is refused', async () => { + const replacement = join(root, 'replacement-codex'); writeFileSync(replacement, script, { mode: 0o700 }); + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + env: { ...options.env, FIXTURE_REPLACE_BINARY: replacement } }), fault('unsupported_version', false)); + }); + await t.test('symlink retarget after version probe cannot select a replacement queue binary', async () => { + const link = join(root, 'codex-link'), target = join(root, 'replacement-target'); + writeFileSync(target, script, { mode: 0o700 }); symlinkSync(binary, link); + await assert.rejects(nativeQueueOnce({ ...options, binary: link, deadlineMs: performance.now() + 2000, + env: { ...options.env, FIXTURE_RETARGET_LINK: link, FIXTURE_LINK_TARGET: target } }), fault('unsupported_version', false)); + }); + const allRows = readFileSync(log, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + assert.equal(allRows.filter(r => r.method === 'thread/queue/add').length, 1); + for (const row of allRows.filter(r => r.pid)) assert.throws(() => process.kill(row.pid, 0), (e: any) => e.code === 'ESRCH'); +}); + +test('expired initial owner verification cannot launch even a version probe', { + skip: process.platform !== 'darwin' || process.arch !== 'arm64', +}, async t => { + let now = 0, spawned = 0; + t.mock.method(performance, 'now', () => now); + const originalSpawn = childProcess.spawn; + t.mock.method(childProcess, 'spawn', (...args: Parameters) => { + spawned++; return originalSpawn(...args); + }); + syncBuiltinESMExports(); t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); }); + await assert.rejects(nativeQueueOnce({ binary: process.execPath, + scope: { ...scope, threadId: '01950000-0000-7000-8000-000000000071' }, message: 'fixture', clientUserMessageId: id, + deadlineMs: 100, verifyOwner: async () => { now = 100; return true; }, beforeEffect: async () => {} }), fault('deadline', false)); + assert.equal(spawned, 0); +}); diff --git a/test/fixtures/app-server-schema-evidence.json b/test/fixtures/app-server-schema-evidence.json new file mode 100644 index 0000000..d187d6f --- /dev/null +++ b/test/fixtures/app-server-schema-evidence.json @@ -0,0 +1,413 @@ +{ + "version": "0.160.1", + "platform": "darwin-arm64", + "qualification": "generated_schema_only", + "command": "codex app-server generate-json-schema --experimental --out ", + "officialDocs": "https://learn.chatgpt.com/docs/app-server", + "productionRouteQualified": false, + "liveExperimentPerformed": false, + "methods": { + "queueAdd": { + "schemaPath": "v2/ThreadQueueAddResponse.json", + "responseSha256": "c11b9772788427b19c9f9decf0765e2b41cbdead1aa9c3d39f42fdd70b339314", + "bodyPath": [ + "queuedSubmission", + "input" + ], + "paramsSha256": "60f25b7d3e3357c215bef9d2fe7047200f545e030dca9e00527ac0620cd0c88f" + }, + "queueList": { + "schemaPath": "v2/ThreadQueueListResponse.json", + "responseSha256": "be4e0bfa6fc776101f96582543605d2eff6186309a08903203512b99c5005926", + "bodyPath": [ + "data", + "input" + ] + }, + "itemStarted": { + "schemaPath": "v2/ItemStartedNotification.json", + "responseSha256": "7574788d2a352747f50d44be3ef649011cb8de8069da4c655faff14d0aee32c4", + "bodyPath": [ + "item", + "content" + ] + }, + "turnCompleted": { + "schemaPath": "v2/TurnCompletedNotification.json", + "responseSha256": "016870158603b0f84bd9f8f65f927161c9fd5128e5ec632087616462dc44e085", + "bodyPath": [ + "turn", + "items" + ] + } + }, + "reason": "Metadata-only owner OBSERVATION route remains unqualified. Separate queue-only mechanics qualified for exact0.160.1darwinarm64 via one network-denied syntheticqueue effect. Own effectinputecho validated thenID-only metadata returned; no queue/list/history API is used. Original generation/home/writer checks remain external root guards; no live delivery/ACK claimed.", + "officialSourceCommit": "d27764b82f7118f674371e6d6e76271d9d606edb", + "queueOnlyCandidate": { + "qualified": true, + "ownEffectInputEchoOnly": true, + "savedThreadReadIncludesHistory": false, + "threadStartOrResumeInQueueAdd": false, + "notificationOptOutBeforeTransport": true, + "nativeFixturePending": false, + "sources": { + "codex-rs/app-server/src/request_processors/thread_queue_processor.rs": "c5b6119ad7a5416b4b7b850e15a1f6c0024207975ff8dfa374578cae178698a4", + "codex-rs/ext/queue/src/service.rs": "110253b6c2b74de1ad8610b347ac331479d4f09873a1c8f9793a6921438d6ae1", + "codex-rs/app-server/src/request_processors/initialize_processor.rs": "4535560b07ab10d1538acd461560a55c1bdf0c9b4049e72775d6b770421f117f", + "codex-rs/app-server/src/transport.rs": "5e88d3210a738216a1c8e1dd678a947a8991a486b729685e4089a6a36aa769c8" + }, + "qualification": "mechanics_from_isolated_fixture", + "nativeFixture": { + "reason": "queue_only_fixture_pass", + "version": "0.160.1", + "platform": "darwin-arm64", + "queueAttempts": 1, + "queueRows": 1, + "queueIdMatches": true, + "clientCIDMatches": true, + "ownEffectInputEchoExact": true, + "loadedBefore": 0, + "loadedAfter": 0, + "writerLockBefore": false, + "writerLockAfter": false, + "hasUserEventAfter": 0, + "suppressedNotifications": 83, + "unexpectedNotifications": 0, + "networkDeniedByMacOSSandbox": true, + "authFileCreated": false, + "metadataVerification": "read-only SQL projection of id/thread_id/UserInput.client_id only", + "scope": "synthetic_unloaded_thread_only_not_live_delivery", + "modelTurnStartOrResumeCalled": false, + "networkBlocked": true + }, + "optOutNotificationMethods": [ + "account/gatewayOAuth/changed", + "account/login/completed", + "account/rateLimits/updated", + "account/updated", + "app/list/updated", + "autoApprovalReview/strictReviewRequired", + "command/exec/outputDelta", + "configWarning", + "deprecationNotice", + "error", + "externalAgentConfig/import/completed", + "externalAgentConfig/import/progress", + "fs/changed", + "fuzzyFileSearch/sessionCompleted", + "fuzzyFileSearch/sessionUpdated", + "guardianWarning", + "hook/completed", + "hook/started", + "item/agentMessage/delta", + "item/autoApprovalReview/completed", + "item/autoApprovalReview/started", + "item/commandExecution/outputDelta", + "item/commandExecution/terminalInteraction", + "item/completed", + "item/fileChange/outputDelta", + "item/fileChange/patchUpdated", + "item/mcpToolCall/progress", + "item/plan/delta", + "item/reasoning/summaryPartAdded", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "item/started", + "mcpServer/event/stream/notification", + "mcpServer/oauthLogin/completed", + "mcpServer/startupStatus/updated", + "model/rerouted", + "model/safetyBuffering/updated", + "model/verification", + "modelProvider/authRecoveryCompleted", + "modelProvider/authRecoveryStarted", + "process/exited", + "process/outputDelta", + "project/changed", + "remoteControl/status/changed", + "serverRequest/resolved", + "skills/changed", + "thread/archived", + "thread/attachment/updated", + "thread/closed", + "thread/compacted", + "thread/deleted", + "thread/environment/connected", + "thread/environment/disconnected", + "thread/goal/cleared", + "thread/goal/updated", + "thread/name/updated", + "thread/project/updated", + "thread/queue/changed", + "thread/realtime/closed", + "thread/realtime/error", + "thread/realtime/item/completed", + "thread/realtime/item/started", + "thread/realtime/item/transcript/delta", + "thread/realtime/itemAdded", + "thread/realtime/outputAudio/delta", + "thread/realtime/sdp", + "thread/realtime/started", + "thread/realtime/transcript/delta", + "thread/realtime/transcript/done", + "thread/reverted", + "thread/settings/updated", + "thread/started", + "thread/status/changed", + "thread/tokenUsage/updated", + "thread/unarchived", + "turn/completed", + "turn/diff/updated", + "turn/moderationMetadata", + "turn/plan/updated", + "turn/started", + "warning", + "windows/worldWritableWarning", + "windowsSandbox/setupCompleted" + ] + }, + "storageBindingPin": { + "officialSourceCommit": "d27764b82f7118f674371e6d6e76271d9d606edb", + "precedence": "config.sqlite_home precedes CODEX_SQLITE_HOME; exact managed requirement can override CLI/config", + "environment": "CODEX_HOME and CODEX_SQLITE_HOME are both forced to selected canonical home", + "cliOverride": "sqlite_home=", + "effectiveBindingGuard": "bounded lsof on owned app-server PID requires opened state_5.sqlite and queue_1.sqlite paths in selected home before beforeEffect", + "managedPolicy": "not disabled or bypassed; mismatched opened paths refuse before any queue RPC; native bootstrap file creation is not claimed absent", + "primaryFiles": { + "codex-rs/core/src/config/mod.rs": "854f5a6b184714740500b8d3eae2bb04cb88172efccf11d38a74f2f6189c0341", + "codex-rs/core/src/config/requirements.rs": "756ba19bc88333f9b2a3f05fd4214d91c77d42900f4c3548af98a47725566543", + "codex-rs/state/src/lib.rs": "0e4d12ca4f6d0e3632b85b26f92e9396e9560cad60352b08314d253963eb6d66" + }, + "zeroQueueNativeFixture": { + "version": "0.160.1", + "platform": "darwin-arm64", + "result": "effect_guard_failed", + "beforeEffectCalls": 1, + "queueRows": 0, + "threadRows": 0, + "configAttemptedOtherHome": true, + "inheritedEnvAttemptedOtherHome": true, + "envAndCLIPinnedSelectedHome": true, + "ownedDescriptorBindingVerified": true, + "otherHomeUntouched": true, + "networkDeniedByMacOSSandbox": true, + "authFileCreated": false, + "liveOwnerUsed": false, + "threadStartResumeOrTurnCalled": false + } + }, + "additionalQueueOnlyProfiles": [ + { + "version": "0.162.1", + "platform": "darwin-arm64", + "officialSourceCommit": "092d3acd6bec3e3a14bdc7e7a2810ab628ab759d", + "qualification": "mechanics_from_isolated_fixture", + "productionObservationRouteQualified": false, + "methods": { + "queueAdd": { + "schemaPath": "v2/ThreadQueueAddResponse.json", + "responseSha256": "c11b9772788427b19c9f9decf0765e2b41cbdead1aa9c3d39f42fdd70b339314", + "bodyPath": [ + "queuedSubmission", + "input" + ], + "paramsSha256": "60f25b7d3e3357c215bef9d2fe7047200f545e030dca9e00527ac0620cd0c88f" + }, + "itemStarted": { + "schemaPath": "v2/ItemStartedNotification.json", + "responseSha256": "692c5876f44a4c12a9b9211912860ceddb65d34497ab4fe9aa46cbebe1a63166", + "bodyPath": [ + "item", + "content" + ] + }, + "turnCompleted": { + "schemaPath": "v2/TurnCompletedNotification.json", + "responseSha256": "69413578ec3ecd02691424bdeb9d640c7244b4ca4c5c3ef26f69a2efe7c37db8", + "bodyPath": [ + "turn", + "items" + ] + } + }, + "sourceHashes": { + "codex-rs/app-server/src/request_processors/thread_queue_processor.rs": "4270d4b8560862e854ff960eb161590f8d57da947976da821e035072bc39ec77", + "codex-rs/ext/queue/src/service.rs": "110253b6c2b74de1ad8610b347ac331479d4f09873a1c8f9793a6921438d6ae1", + "codex-rs/app-server/src/request_processors/initialize_processor.rs": "4535560b07ab10d1538acd461560a55c1bdf0c9b4049e72775d6b770421f117f", + "codex-rs/app-server/src/transport.rs": "5e88d3210a738216a1c8e1dd678a947a8991a486b729685e4089a6a36aa769c8", + "codex-rs/core/src/config/mod.rs": "32babb117ff4bf0dc08050c4a755f5996f57f51fcebb82595c81648420394259", + "codex-rs/state/src/lib.rs": "7a2df1f8605e5b8ce19e488e271c8c0a5ee426809783347b3e4a32ea2e46712b" + }, + "optOutNotificationMethods": [ + "error", + "thread/started", + "thread/status/changed", + "thread/archived", + "thread/deleted", + "thread/unarchived", + "thread/closed", + "thread/reverted", + "skills/changed", + "thread/name/updated", + "thread/attachment/updated", + "thread/goal/updated", + "thread/prediction/updated", + "thread/goal/cleared", + "thread/queue/changed", + "project/changed", + "thread/project/updated", + "thread/environment/connected", + "thread/environment/disconnected", + "thread/settings/updated", + "thread/tokenUsage/updated", + "turn/started", + "hook/started", + "turn/completed", + "hook/completed", + "turn/diff/updated", + "turn/plan/updated", + "item/started", + "item/autoApprovalReview/started", + "item/autoApprovalReview/completed", + "autoApprovalReview/strictReviewRequired", + "item/completed", + "item/agentMessage/delta", + "item/plan/delta", + "command/exec/outputDelta", + "process/outputDelta", + "process/exited", + "item/commandExecution/outputDelta", + "item/commandExecution/terminalInteraction", + "item/fileChange/outputDelta", + "item/fileChange/patchUpdated", + "serverRequest/resolved", + "item/mcpToolCall/progress", + "mcpServer/oauthLogin/completed", + "mcpServer/startupStatus/updated", + "mcpServer/event/stream/notification", + "account/updated", + "account/gatewayOAuth/changed", + "account/rateLimits/updated", + "app/list/updated", + "remoteControl/status/changed", + "externalAgentConfig/import/progress", + "externalAgentConfig/import/completed", + "fs/changed", + "item/reasoning/summaryTextDelta", + "item/reasoning/summaryPartAdded", + "item/reasoning/textDelta", + "thread/compacted", + "model/rerouted", + "model/verification", + "modelProvider/authRecoveryStarted", + "modelProvider/authRecoveryCompleted", + "turn/moderationMetadata", + "model/safetyBuffering/updated", + "warning", + "guardianWarning", + "deprecationNotice", + "configWarning", + "fuzzyFileSearch/sessionUpdated", + "fuzzyFileSearch/sessionCompleted", + "thread/realtime/started", + "thread/realtime/itemAdded", + "thread/realtime/item/started", + "thread/realtime/item/transcript/delta", + "thread/realtime/item/completed", + "thread/realtime/transcript/delta", + "thread/realtime/transcript/done", + "thread/realtime/outputAudio/delta", + "thread/realtime/sdp", + "thread/realtime/error", + "thread/realtime/closed", + "windows/worldWritableWarning", + "windowsSandbox/setupCompleted", + "account/login/completed" + ], + "nativeFixture": { + "version": "0.162.1", + "platform": "darwin-arm64", + "scope": "synthetic_unloaded_thread_only_not_live_delivery", + "networkDeniedByMacOSSandbox": true, + "userHomeReadsDeniedExceptNativeExecutable": true, + "queueAttempts": 1, + "loadedBefore": 0, + "echoKeys": [ + "text", + "text_elements", + "type" + ], + "ownEffectInputEchoExact": true, + "loadedAfter": 0, + "queueRows": 1, + "queueIdMatches": true, + "clientCIDMatches": true, + "hasUserEventAfter": 0, + "reason": "queue_only_fixture_pass", + "processReaped": true, + "stderrBytes": 0, + "onlyMethods": [ + "initialize", + "initialized", + "thread/loaded/list", + "thread/queue/add", + "thread/loaded/list" + ], + "suppressedNotifications": 84, + "unexpectedNotifications": 0, + "authFileCreated": false, + "metadataVerification": "read-only SQL projection of id/thread_id/UserInput.client_id only", + "nativeFixtureOnly": true, + "actualLiveWriterUsed": false, + "liveDeliveryAckExperimentPerformed": false + }, + "sandboxNegativeControls": { + "userReadDenied": true, + "networkDenied": true + }, + "initialHarnessFailures": [ + { + "reason": "overly_strict_own_echo_assertion_expected_optional_text_elements_omitted", + "queueAttempts": 1, + "queueRowsAfter": 1, + "newIndependentThreadAndCorrelationUsedForLaterProbe": true, + "submissionNotReplayed": true + }, + { + "reason": "harness_seeded_prior_fixture_home_instead_of_new_home", + "queueAttempts": 1, + "queueRowsInNewHomeAfter": 0, + "errorCode": -32603, + "newIndependentThreadAndCorrelationUsedForLaterProbe": true, + "submissionNotReplayed": true + } + ], + "remainingGates": [ + "live_original_writer_generation_route", + "body_free_native_observation", + "Windows_and_Linux_native_qualification", + "actual_delivery_or_receipt_ack" + ], + "beforeEffectNativeApiFixture": { + "version": "0.162.1", + "platform": "darwin-arm64", + "scope": "synthetic-before-effect-refusal", + "fault": { + "code": "effect_guard_failed", + "attempted": false, + "fallbackEligible": false + }, + "ownerFixtureChecks": 3, + "beforeEffectCalls": 1, + "binaryCalls": 2, + "processesReaped": true, + "nativeOwnedStorageBindingPassed": true, + "liveOwnerUsed": false, + "networkDeniedByMacOSSandbox": true, + "threadRows": 0, + "queueRows": 0, + "authFileCreated": false + } + } + ] +} diff --git a/test/package-smoke.ts b/test/package-smoke.ts index 6c3eafb..798bb78 100644 --- a/test/package-smoke.ts +++ b/test/package-smoke.ts @@ -24,7 +24,7 @@ try { let hash; for (let index = 0; index < 2; index++) { const [packed] = JSON.parse(runNpm(['pack', '--ignore-scripts', '--json', '--pack-destination', task])); - assert.deepEqual(packed.files.map((file: {path: string}) => file.path).sort(), ['UNICODE-LICENSE.txt', 'dist/casefold.js', 'dist/casefold.d.ts', 'dist/help.js', 'dist/help.d.ts', 'dist/output.js', 'dist/output.d.ts', 'CONTRIBUTING.md', 'LICENSE', 'PARITY.md', 'README.ja.md', 'README.ko.md', 'README.md', 'README.zh-CN.md', 'RELEASING.md', 'SECURITY.md', 'VALIDATION.md', 'docs/guide.md', 'docs/guide.ko.md', 'docs/guide.ja.md', 'docs/guide.zh-CN.md', 'docs/api.md', 'docs/design/remote-deployment.md', 'docs/design/ordinary-ssh-responses.md', 'docs/design/typescript-skill-compatibility.md', 'docs/shorthand.md', 'shorthand/sp.sh', 'shorthand/sp.ps1', 'dist/cli.js', 'dist/discovery.js', 'dist/diagnostics.js', 'dist/skill-metadata.js', 'dist/process.js', 'dist/protocol.js', 'dist/replies.js', 'dist/send.js', 'dist/ssh.js', 'dist/ssh-response.js', 'dist/windows.js', 'dist/writer.js', 'dist/index.js', 'dist/index.d.ts', 'dist/cli.d.ts', 'dist/discovery.d.ts', 'dist/diagnostics.d.ts', 'dist/skill-metadata.d.ts', 'dist/process.d.ts', 'dist/protocol.d.ts', 'dist/replies.d.ts', 'dist/send.d.ts', 'dist/ssh.d.ts', 'dist/ssh-response.d.ts', 'dist/windows.d.ts', 'dist/writer.d.ts', 'dist/updates.js', 'dist/updates.d.ts', 'dist/handoff.js', 'dist/handoff.d.ts', 'dist/handoff-ledger.js', 'dist/handoff-ledger.d.ts', 'dist/handoff-operations.js', 'dist/handoff-operations.d.ts', 'package.json'].sort()); + assert.deepEqual(packed.files.map((file: {path: string}) => file.path).sort(), ['UNICODE-LICENSE.txt', 'dist/casefold.js', 'dist/casefold.d.ts', 'dist/help.js', 'dist/help.d.ts', 'dist/output.js', 'dist/output.d.ts', 'CONTRIBUTING.md', 'LICENSE', 'PARITY.md', 'README.ja.md', 'README.ko.md', 'README.md', 'README.zh-CN.md', 'RELEASING.md', 'SECURITY.md', 'VALIDATION.md', 'docs/guide.md', 'docs/guide.ko.md', 'docs/guide.ja.md', 'docs/guide.zh-CN.md', 'docs/api.md', 'docs/design/remote-deployment.md', 'docs/design/ordinary-ssh-responses.md', 'docs/design/typescript-skill-compatibility.md', 'docs/shorthand.md', 'shorthand/sp.sh', 'shorthand/sp.ps1', 'dist/cli.js', 'dist/discovery.js', 'dist/diagnostics.js', 'dist/skill-metadata.js', 'dist/process.js', 'dist/protocol.js', 'dist/replies.js', 'dist/send.js', 'dist/ssh.js', 'dist/ssh-response.js', 'dist/windows.js', 'dist/writer.js', 'dist/index.js', 'dist/index.d.ts', 'dist/cli.d.ts', 'dist/discovery.d.ts', 'dist/diagnostics.d.ts', 'dist/skill-metadata.d.ts', 'dist/process.d.ts', 'dist/protocol.d.ts', 'dist/replies.d.ts', 'dist/send.d.ts', 'dist/ssh.d.ts', 'dist/ssh-response.d.ts', 'dist/windows.d.ts', 'dist/writer.d.ts', 'dist/updates.js', 'dist/updates.d.ts', 'dist/handoff.js', 'dist/handoff.d.ts', 'dist/handoff-ledger.js', 'dist/handoff-ledger.d.ts', 'dist/handoff-operations.js', 'dist/handoff-operations.d.ts', 'dist/app-server.js', 'dist/app-server.d.ts', '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;