From cd16f8d2920ef192dae9a6717e577770de0330a8 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 29 Oct 2026 22:32:20 +0900 Subject: [PATCH 1/8] fix(codex): guard storage layers and maintain scoped version context Address #166, #167 and #168. Classify supported local native loader inputs and revalidate before dispatch, record the current scoped parity decisions, and add deterministic current-version maintenance without rewriting historical evidence or independent pins. --- CONTRIBUTING.md | 26 ++ PARITY.md | 82 ++++- README.ja.md | 2 + README.ko.md | 2 + README.md | 2 + README.zh-CN.md | 2 + docs/api.md | 94 ++++++ docs/guide.ja.md | 4 + docs/guide.ko.md | 4 + docs/guide.md | 4 + docs/guide.zh-CN.md | 4 + package.json | 4 +- scripts/check-repository.mjs | 2 + scripts/current-version-fields.json | 450 ++++++++++++++++++++++++++++ scripts/current-version.md | 22 ++ scripts/current-version.mjs | 108 +++++++ scripts/release.mjs | 2 +- src/app-server.ts | 57 ++-- src/codex-storage.ts | 284 ++++++++++++++++++ src/diagnostics.ts | 9 +- src/discovery.ts | 16 +- src/handoff-operations.ts | 8 +- src/process.ts | 4 +- src/send.ts | 78 ++++- test/app-server.test.ts | 29 +- test/codex-homes.test.ts | 9 +- test/codex-storage.test.ts | 261 ++++++++++++++++ test/current-version.test.ts | 145 +++++++++ test/discovery.test.ts | 6 +- test/handoff-cli.test.ts | 4 +- test/handoff-delivery.test.ts | 24 +- test/linux-observation.test.ts | 7 + test/package-smoke.ts | 2 +- test/ssh-handoff.test.ts | 19 +- test/transport.test.ts | 40 +-- test/windows-contract.test.ts | 63 ++-- 36 files changed, 1735 insertions(+), 144 deletions(-) create mode 100644 scripts/current-version-fields.json create mode 100644 scripts/current-version.md create mode 100644 scripts/current-version.mjs create mode 100644 src/codex-storage.ts create mode 100644 test/codex-storage.test.ts create mode 100644 test/current-version.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 163be52..5c321cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,32 @@ that invokes actual PowerShell; no live sessions or network destinations. Keep `package-lock.json` tracked. Do not commit dist, node_modules, local worktrees, credentials, agent state, transcripts or real test evidence. +## Current version maintenance + +For a source checkout, package.json is the current-version source. Update package +and lock metadata together through the normal npm workflow, then run: + +```sh +npm run version:update +npm run version:check +node scripts/check-repository.mjs +``` + +The explicit `scripts/current-version-fields.json` inventory covers runtime +VERSION and selected current examples in the four READMEs/guides, API/shorthand +references and PARITY's current display. Only `{{version}}` spans change; the +rest of each authored line is checked exactly. Edit the matching inventory entry +when changing an owned translated line. Missing or unexpected matches refuse +before writes. The check is non-mutating and runs in the normal repository CI. +See `scripts/current-version.md` in the source checkout and +[issue #168](https://github.com/abruption/session-peer-ts/issues/168) for the +inventory boundary and synthetic build/package acceptance. + +Dated comparison/release evidence, native/Python/skill versions and hashes, +finite compatibility rows, release policy and installation pins are reviewed +separately. The updater does not approve a release or extend compatibility. +Inspect the diff and rerun build/package checks before proposing a release. + ## Safety and documentation Preserve actual flock/owner evidence, ambiguity rejection, pre-submit revalidation, diff --git a/PARITY.md b/PARITY.md index 815ae5b..02b47a8 100644 --- a/PARITY.md +++ b/PARITY.md @@ -52,7 +52,87 @@ observation and explicit operator-confirmed receipt scope below; #130–#132 remain separate platform/collector extensions. Published skill 0.2.1 remains the installation pin; runtime publication is not companion-skill publication. -## Current comparison — Python 1.1.0 / TypeScript 0.5.0 + +## Current scoped feature matrix + +Current source/package version: **0.5.1**. + +Reviewed **2026-10-29 KST** against TS **0.5.1** source +[`90481b148584926ea328ed956327f3050d9be9cb`](https://github.com/abruption/session-peer-ts/tree/90481b148584926ea328ed956327f3050d9be9cb) +and Python **1.3.2** source +[`f5e7c49554ff02771990a151e00b52f2abf3df0a`](https://github.com/abruption/session-peer/tree/f5e7c49554ff02771990a151e00b52f2abf3df0a), +also the [published Python v1.3.2 release](https://github.com/abruption/session-peer/releases/tag/v1.3.2). +The version display above is maintained separately; changing it does not refresh +this dated source review. This matrix describes the reviewed TS baseline; +branch proposals and later publications require their own evidence. Source and +GitHub-state inspection adds no test, live delivery, ACK or platform qualification. + +**Shipped** means the bounded TS behavior exists in that baseline. **Partial** +means only the stated subset exists. **Intentional difference** is a maintained +contract choice. **Not planned** records an explicit disposition. **Undecided** +means no TS implementation scope has been approved, including deferred evaluation. +An issue or Python feature alone does not authorize implementation. + +| Surface | Reviewed TS behavior and Python comparison | Status / boundary | +| --- | --- | --- | +| Ordinary discovery, input and submission | Unified Claude/Codex list, bounded Codex-home inventory, text/JSON and positional/stdin input, Unicode name matching, Claude inbox and guarded Codex queue submission | Shipped within existing platform/native contracts; submission never proves consumption | +| macOS Claude Orca profiles | TS reads one environment-selected/default registry; Python additionally inventories bounded known macOS Orca profiles when no override is set ([TS source](https://github.com/abruption/session-peer-ts/blob/90481b148584926ea328ed956327f3050d9be9cb/src/discovery.ts#L78-L117), [Python source](https://github.com/abruption/session-peer/blob/f5e7c49554ff02771990a151e00b52f2abf3df0a/session_peer_core/claude.py#L44-L84)) | Partial; [#165](https://github.com/abruption/session-peer-ts/issues/165) is a separate discovery/provenance extension | +| Codex storage classification | TS rejects selected-home root `sqlite_home` and malformed TOML, and strips `CODEX_SQLITE_HOME` from native child environments; Python additionally checks system/requirements, managed-preference and project/cwd layers ([TS source](https://github.com/abruption/session-peer-ts/blob/90481b148584926ea328ed956327f3050d9be9cb/src/discovery.ts#L13-L24), [Python source](https://github.com/abruption/session-peer/blob/f5e7c49554ff02771990a151e00b52f2abf3df0a/session_peer_core/codex.py#L272-L494)) | Partial; [#166](https://github.com/abruption/session-peer-ts/issues/166). Selected-home validation is not effective-storage attestation | +| Doctor and return routes | Read-only local/SSH diagnostics, generated From/Reply-To metadata, explicit reply URI and opt-in return-route probe | Shipped bounded behavior; no executable `Reply:` instruction or Antigravity sender identity | +| Ordered SSH and typed jump | Repeated hosts, allowlisted options, IPv6 and POSIX-client single-hop jump; Windows-client jump refuses before effect | Partial; remaining Windows-client qualification in [#20](https://github.com/abruption/session-peer-ts/issues/20); no arbitrary options or multi-hop | +| Remote deployment | TS requires an installed CLI and exactly matching TS versions; Python streams its standalone source | Intentional difference; [#23](https://github.com/abruption/session-peer-ts/issues/23) / [remote deployment design](docs/design/remote-deployment.md) remain bounded follow-ups, with no automatic deployment/update or version-range compatibility | +| Updates and notice suppression | npm-aware `update --check` and manager guidance; cached runtime notices opt in with `SESSION_PEER_UPDATE_NOTICE=1`, and honor `--no-update-notice` and `SESSION_PEER_NO_UPDATE_NOTICE` ([source](https://github.com/abruption/session-peer-ts/blob/90481b148584926ea328ed956327f3050d9be9cb/src/updates.ts#L289-L291)) | Intentional difference from Python's default-on runtime/skill notices and standalone update paths; existing suppression shipped in #22/#85; no self-update, remote push or skill notices | +| Handoff and observation | Private correlated intents and explicit operator-confirmed receipt; exact macOS arm64 Codex 0.160.1/0.162.1 observation only | Partial; Linux [#130](https://github.com/abruption/session-peer-ts/issues/130), Windows [#131](https://github.com/abruption/session-peer-ts/issues/131) and authenticated receipt producer/collector [#132](https://github.com/abruption/session-peer-ts/issues/132) remain separate. No automatic ACK, consumption or exactly-once guarantee | +| Public execution API | Package root exports pure protocol helpers, not a reusable execution/adapter API | Partial; [#24](https://github.com/abruption/session-peer-ts/issues/24) retains validation, permission and single-attempt boundaries | +| Companion skill | Separately installed TS skill with immutable guidance pin and finite exact metadata profiles | Intentional difference; runtime publication does not publish/update the skill or authenticate its contents | +| Paired devices, relay and recovery | Python exposes optional device/relay/setup workflows; TS has no equivalent | Not planned; [#29](https://github.com/abruption/session-peer-ts/issues/29), [#30](https://github.com/abruption/session-peer-ts/issues/30), [#31](https://github.com/abruption/session-peer-ts/issues/31), [#32](https://github.com/abruption/session-peer-ts/issues/32) are closed as not planned; historical Planned rows below are not the current roadmap | +| Wake, Antigravity and MCP integration | No TS wake/resume, Antigravity adapter or policy-restricted MCP server | Undecided implementation scope; [#27](https://github.com/abruption/session-peer-ts/issues/27), [#28](https://github.com/abruption/session-peer-ts/issues/28), [#26](https://github.com/abruption/session-peer-ts/issues/26) track separate work; no inferred Python feature parity | +| Newer Python convenience workflows | Route presets/saved recipients, explain, explicit trusted SSH config and persistent per-version runtime-notice dismissal are absent from the [TS command/options inventory](https://github.com/abruption/session-peer-ts/blob/90481b148584926ea328ed956327f3050d9be9cb/src/cli.ts#L19-L29) | Undecided eventual scope; explicit defer decisions below | + +The published 0.5.1 storage coverage gap above is separate from the proposed +#166 correction on this development branch. A branch change or fixture result +must not be described as an already published runtime fix or native-loader +qualification. The development guard's [actual scope](docs/api.md#codex-storage-configuration) +classifies source-qualified local layers and retains opened-file evidence. It +refuses ordinary signed-in/possibly cloud-managed homes when classification would +require reading credentials; the owned app-server path needs actual storage binding. +Windows Codex dispatch remains unqualified and refuses before queueing. Python's +broader checks also explicitly exclude some effective +storage sources; they are not a whole-runtime storage attestation. + +### Product decisions for newer Python workflows — 2026-10-29 KST + +These are **defer** decisions for this patch, not promises to port. Any later +implementation needs a focused scope and separate approval; this comparison +creates no new feature implementation or automatic follow-up issue. + +| Workflow / pinned Python evidence | Decision and prerequisite for reconsideration | +| --- | --- | +| [Route presets and saved addresses](https://github.com/abruption/session-peer/blob/f5e7c49554ff02771990a151e00b52f2abf3df0a/session_peer_core/cli.py#L138-L215), [address commands](https://github.com/abruption/session-peer/blob/f5e7c49554ff02771990a151e00b52f2abf3df0a/session_peer_core/cli.py#L318-L340) | Defer. Decide private local storage, route-versus-recipient identity, revision checks and explicit target confirmation before proposing a port. Existing reply URIs are not saved-address support | +| [Delivery/diagnostic explain](https://github.com/abruption/session-peer/blob/f5e7c49554ff02771990a151e00b52f2abf3df0a/session_peer_core/cli.py#L194-L196), [handoff status explain](https://github.com/abruption/session-peer/blob/f5e7c49554ff02771990a151e00b52f2abf3df0a/session_peer_core/cli.py#L228-L229) | Defer. Define a read-only explanation of existing evidence without promoting queue, turn completion or arbitrary replies to delivery/ACK. Current text output and doctor remain available | +| [Operator-trusted SSH config](https://github.com/abruption/session-peer/blob/f5e7c49554ff02771990a151e00b52f2abf3df0a/session_peer_core/cli.py#L258-L263) | Defer. Explicit trust would authorize local executable SSH configuration, including during inspection/dry-run; it needs a separate consent and execution contract. Existing user/system SSH configuration already remains an operator trust boundary. Constrained options and typed jump do not provide Python's additional `--ssh-config` / `--trust-ssh-config` workflow | +| [Persistent per-version runtime-notice dismissal](https://github.com/abruption/session-peer/blob/f5e7c49554ff02771990a151e00b52f2abf3df0a/session_peer_core/cli.py#L245-L255) | Defer. Specify private version-scoped persistence, reset and interaction with opt-in notices first. This differs from the already supported TS `--no-update-notice` and `SESSION_PEER_NO_UPDATE_NOTICE`; no dismissal flag is advertised for TS | + +### Comparison evidence and retained history + +The ordinary test oracle remains Python **1.1.0** / commit +`8ca77cf31254c8287a2593fe484bb30a7dba2727`, scoped to +`ordinary-list-discovery-projection` in +[`python-reference.pin.json`](test/fixtures/python-reference.pin.json). +This Python 1.3.2 static review does not repin that oracle, fixtures, CI or +runtime `referenceVersion`; ordinary listing evidence does not establish +send/handoff/SSH interoperability. The frozen handoff design bytes, hashes and +historical Python 1.0.3 baseline also remain unchanged. + +See the [dated ordinary comparison](#ordinary-python-110-comparison--2026-10-20-kst), +[historical 0.3.x context](#historical-03x-comparison-context), +[historical feature matrix](#feature-and-command-matrix) and +[frozen handoff checkpoint](#published-python-handoff-checkpoint--2026-10-11). +Their dated release, planned-work and test claims retain their original scope. + + + +## Ordinary Python 1.1.0 comparison — 2026-10-20 KST Updated 2026-10-20 KST. TypeScript 0.5.0 uses published Python **1.1.0**, commit diff --git a/README.ja.md b/README.ja.md index ecfabac..ac41d45 100644 --- a/README.ja.md +++ b/README.ja.md @@ -23,6 +23,8 @@ Python 不要の Node.js クライアントです。パッケージ名とコマ +このソースは Codex の追加設定レイヤーを確認します。Windows の Codex 送信・dry-run はローカルストレージ検査が未検証のためキュー提出前に拒否し、通常の Claude 経路とは区別します。導入前に[検証範囲](docs/api.md#codex-storage-configuration)と選択したリリースを確認してください。 + ## Demo ![TypeScript session-peer 0.2.1 による実際の Codex・Claude Code 間の要求と返信](https://raw.githubusercontent.com/abruption/session-peer-ts/main/docs/assets/session-peer-ts-v0.2.1-roundtrip.gif) diff --git a/README.ko.md b/README.ko.md index d6394e6..32e2736 100644 --- a/README.ko.md +++ b/README.ko.md @@ -17,6 +17,8 @@ Python을 설치하지 않아도 Node.js에서 실행되는 TypeScript 클라이 +이 소스는 Codex의 추가 설정 계층을 검사합니다. Windows Codex 전송과 dry-run은 로컬 저장소 검사 방식이 검증될 때까지 큐 제출 전에 거부하며, 일반 Claude 경로는 별도입니다. 설치 전에 [검증 범위](docs/api.md#codex-storage-configuration)와 선택한 릴리스를 확인하세요. + **문서·소스 기준 버전: 0.5.1.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](VALIDATION.md), 릴리스 절차는 [RELEASING.md](RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6과 0.5.1은 서로의 SSH 사전 확인을 거부합니다. diff --git a/README.md b/README.md index 183369e..d1efe22 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ A Node.js client that runs without Python. Package and command: `session-peer`. +Source storage guards now inspect additional Codex configuration layers. Windows Codex send/dry-run refuse before queue submission while local storage classification is unqualified; ordinary Claude behavior is separate. Review the [guard scope](docs/api.md#codex-storage-configuration) and selected release before installation. + ## Demo ![Actual Codex to Claude Code request and reply with TypeScript session-peer 0.2.1](https://raw.githubusercontent.com/abruption/session-peer-ts/main/docs/assets/session-peer-ts-v0.2.1-roundtrip.gif) diff --git a/README.zh-CN.md b/README.zh-CN.md index 1a9c539..275755f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,6 +23,8 @@ +此源码检查 Codex 的更多配置层。Windows 的 Codex 发送和 dry-run 因本地存储检查方式尚未验证而在队列提交前被拒绝;普通 Claude 路径另行处理。安装前请确认[检查范围](docs/api.md#codex-storage-configuration)及所选版本。 + ## Demo ![TypeScript session-peer 0.2.1 的实际 Codex 与 Claude Code 请求和回复](https://raw.githubusercontent.com/abruption/session-peer-ts/main/docs/assets/session-peer-ts-v0.2.1-roundtrip.gif) diff --git a/docs/api.md b/docs/api.md index 7aac65b..3c50141 100644 --- a/docs/api.md +++ b/docs/api.md @@ -36,6 +36,100 @@ The ordinary SSH response correction is documented separately as the [0.3.3 ordi + + +## Codex storage configuration guards + +This development source extends the published selected-home check with bounded +local loader-layer classification. The published 0.5.1 comparison in PARITY is +retained as dated evidence; this branch's source/fixture results do not establish +publication or new native delivery qualification. + +The ordinary queue loader is reviewed at exact native Codex versions **0.160.1, +0.162.1 and 0.165.0**, independently of the narrower observation profile table. +An effectful send probes the explicitly selected binary's reported version and +retains its file identity. Doctor and dry-run never execute the agent binary; +their `loaderVersionVerified:false` result is not native queue authorization. + +Darwin and Linux classify the selected-home `config.toml`, system +`/etc/codex/config.toml`, `requirements.toml` and legacy `managed_config.toml`, +plus project configuration for the physical queue cwd and the saved target +thread's cwd. A live writer additionally needs an observable physical cwd and +opened state-database descriptor matching the selected inode; a queried sender +version does not establish the running consumer's configuration. macOS reads only +forced `config_toml_base64` / `requirements_toml_base64` values for +`com.openai.codex`, checking forced status before and after value retrieval. + +Windows local storage classification is unqualified: Codex send/dry-run refuse +before queue submission until its native known-folder and process/storage +inspection contract is supported. This does not disable ordinary Claude paths +or qualify Linux/Windows observation. A reused native daemon retains startup +storage that these local reads cannot attest to; its existing control-socket +metadata causes refusal without connecting to or stopping it. The separately +owned stdio app-server path must bind its opened SQLite files independently. + +Cloud config bundles can supply configuration and storage requirements. Cache +absence alone is insufficient because native loaders may fetch a bundle. +Without reading credential/cache contents, ordinary queue classification refuses +when selected-home `auth.json` or `cloud-config-bundle-cache.json` metadata is +present, when configuration selects keyring/auto credential storage, or when +external identity markers or a config server make cloud applicability unknown. +**This includes ordinary signed-in file-auth homes even when their actual account +would not receive cloud policy.** The refusal is +`codex_cloud_storage_unverified`; it is not a determination that relocation is +configured. File-auth/cache absence plus source-qualified local selectors permits +the clean ordinary path. The separately requested owned app-server route may +continue only with its actual opened-storage binding; local metadata alone is +not substituted for that proof. + +Nonempty native exec-server/Noise environment markers and selected-home +`environments.toml` metadata cause refusal on both paths. The adapter classifies +local storage only and does not attach to remote workspaces. Alternative V2 user +profiles in the reviewed loaders require explicit `--profile`/`-p` arguments; +these native invocations do not select them. Diagnostic fields identify the local +layer scope and cloud applicability; they never certify complete effective policy. + +Configuration files are bounded regular-file reads (1 MiB per file, 8 MiB total), +with fatal UTF-8/TOML decoding and observable descriptor/path identity and +stability checks. Project ancestry is capped at 256 directories. Explicit custom +project markers use a conservative ancestor superset, which can refuse an +otherwise unused ancestor configuration; the result is not a complete model of +native project trust. System metadata helpers have bounded output/timeouts; +byte bounds do not promise a deadline for arbitrary filesystem I/O. + +Unsupported root `sqlite_home`, malformed/unreadable configuration, unsupported +reported loader versions, conflicting process/file evidence and changes before +dispatch refuse submission. Errors include `unsupported_codex_sqlite_home`, +`codex_config_unreadable`, `codex_storage_config_unverifiable`, +`codex_storage_loader_unsupported`, `codex_daemon_storage_unverified`, +`codex_consumer_context_unverifiable`, `codex_consumer_storage_unverifiable`, +`codex_cloud_storage_unverified` and `codex_storage_changed_before_queue`. +Public `storage` diagnostics contain layer names and classifications; content +hashes, configuration paths, raw TOML and preference values stay private. + +Ordinary, fallback and owned app-server paths recheck applicable evidence before +effect while retaining original writer and deadline rules. A quoted explicit +`sqlite_home` pin enforces the already-classified selected location; it does not +adopt a configured relocation. This is an observable continuity guard, not an +atomic filesystem/configuration snapshot or protection against an undetectable +same-user rewrite. Missing evidence never permits fallback after an attempt or +an automatic retry of an unknown outcome. + +Source review pins: + +| Native tag | Upstream source commit | +| --- | --- | +| 0.160.1 | [`d27764b82f7118f674371e6d6e76271d9d606edb`](https://github.com/openai/codex/tree/d27764b82f7118f674371e6d6e76271d9d606edb/codex-rs/config/src/loader) | +| 0.162.1 | [`092d3acd6bec3e3a14bdc7e7a2810ab628ab759d`](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs/config/src/loader) | +| 0.165.0 | [`360319d8dee62d87e2d59fc0c21805d79519cfed`](https://github.com/openai/codex/tree/360319d8dee62d87e2d59fc0c21805d79519cfed/codex-rs/config/src/loader) | + +All three macOS loader files have SHA-256 +`36dd560f67b3b45bf3d63f4e2b962571456c12dc2f9ca55304f8729d49bee0c5`. +The comparison also inspects Python +[`f5e7c49554ff02771990a151e00b52f2abf3df0a`](https://github.com/abruption/session-peer/blob/f5e7c49554ff02771990a151e00b52f2abf3df0a/session_peer_core/codex.py). +These are static source and isolated-fixture checks; no native Codex queue, +user session, delivery/ACK or remote-host qualification was run for this change. + ## Opt-in handoff CLI — 0.4.0 0.4.0 includes an explicit private POSIX sender ledger. Default sends retain diff --git a/docs/guide.ja.md b/docs/guide.ja.md index 460538a..abf54a8 100644 --- a/docs/guide.ja.md +++ b/docs/guide.ja.md @@ -1,5 +1,9 @@ # ユーザーガイド — session-peer (TypeScript) +このソースは Codex home、システム設定、macOS 管理設定、送信側と対象プロセスの実際の作業ディレクトリに適用されるプロジェクト設定を確認します。[ストレージ検証の範囲と拒否条件](api.md#codex-storage-configuration)を参照してください。Windows の Codex 送信・dry-run は、ローカルストレージ検査方式の検証が済むまでキュー提出前に拒否します。doctor と dry-run は Codex を実行せず、ローダーのバージョンは未確認のままです。 + +現在の機能差と移植方針は[日付を明記した比較表](../PARITY.md#current-scoped-feature-matrix)を参照してください。TypeScriptで公開済みの機能と、別途固定したPythonの比較基準を区別しています。 + [README に戻る](../README.ja.md) [English](guide.md) | [한국어](guide.ko.md) | [日本語](guide.ja.md) | [简体中文](guide.zh-CN.md) diff --git a/docs/guide.ko.md b/docs/guide.ko.md index c6f4395..de31283 100644 --- a/docs/guide.ko.md +++ b/docs/guide.ko.md @@ -1,5 +1,9 @@ # 사용자 가이드 — session-peer (TypeScript) +이 소스는 Codex home·시스템·macOS 관리 설정과 큐 실행 프로세스 및 대상 프로세스의 실제 작업 경로에 적용되는 프로젝트 설정을 검사합니다. [저장소 검증 범위와 거부 조건](api.md#codex-storage-configuration)을 확인하세요. Windows Codex 전송과 dry-run은 로컬 저장소 검사 방식이 검증될 때까지 큐 제출 전에 거부합니다. doctor와 dry-run은 Codex를 실행하지 않으며 로더 버전은 미확인으로 남깁니다. + +현재 기능 차이와 포팅 결정은 [날짜가 명시된 비교표](../PARITY.md#current-scoped-feature-matrix)에서 확인하세요. TypeScript의 공개 기능과 별도로 고정한 Python 비교 기준을 구분합니다. + [README로 돌아가기](../README.ko.md) [English](guide.md) | [한국어](guide.ko.md) | [日本語](guide.ja.md) | [简体中文](guide.zh-CN.md) diff --git a/docs/guide.md b/docs/guide.md index c7bdca7..74ad3e0 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1,5 +1,9 @@ # User guide — session-peer (TypeScript) +This source classifies Codex storage configuration across home, system, managed macOS and physical queue/consumer project contexts. See the [storage guard scope and refusals](api.md#codex-storage-configuration). Windows Codex send/dry-run refuse before queue submission until its local storage adapter is qualified. Doctor and dry-run do not execute Codex and leave its loader version unverified. + +See the [dated feature comparison and porting decisions](../PARITY.md#current-scoped-feature-matrix) for shipped TypeScript behavior and the separately pinned Python comparison baseline. + [Back to README](../README.md) [English](guide.md) | [한국어](guide.ko.md) | [日本語](guide.ja.md) | [简体中文](guide.zh-CN.md) diff --git a/docs/guide.zh-CN.md b/docs/guide.zh-CN.md index 3203203..4ebd2e9 100644 --- a/docs/guide.zh-CN.md +++ b/docs/guide.zh-CN.md @@ -1,5 +1,9 @@ # 用户指南 — session-peer (TypeScript) +此源码检查 Codex home、系统配置、macOS 管理配置,以及队列进程和目标进程实际工作目录所适用的项目配置。详见[存储检查范围与拒绝条件](api.md#codex-storage-configuration)。Windows 的 Codex 发送和 dry-run 在本地存储检查方式通过验证前会拒绝队列提交。doctor 和 dry-run 不执行 Codex,加载器版本仍标为未验证。 + +当前功能差异与移植决策见[注明日期的对照表](../PARITY.md#current-scoped-feature-matrix)。表中区分 TypeScript 已发布的功能与单独固定的 Python 比较基准。 + [返回 README](../README.zh-CN.md) [English](guide.md) | [한국어](guide.ko.md) | [日本語](guide.ja.md) | [简体中文](guide.zh-CN.md) diff --git a/package.json b/package.json index ff97596..4a0ed37 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,11 @@ ], "scripts": { "build": "tsc -p tsconfig.json", + "version:check": "node scripts/current-version.mjs --check", + "version:update": "node scripts/current-version.mjs --update", "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/handoff-core.test.ts test/handoff-cli.test.ts test/handoff-delivery.test.ts test/metadata-observation.test.ts test/handoff-writer-probe.test.ts test/app-server.test.ts test/writer-uid.test.ts test/claude-records.test.ts test/reply-agent-binding.test.ts test/windows.test.ts test/windows-contract.test.ts test/windows-inspection.test.ts test/windows-process-tree.test.ts test/windows-storage.test.ts test/input-boundaries.test.ts test/discovery.test.ts test/codex-homes.test.ts test/diagnostics.test.ts test/cli-usability.test.ts test/skill-guidance.test.ts test/skill-metadata.test.ts test/ssh-hosts.test.ts test/ssh-response.test.ts test/ssh-handoff.test.ts test/replies.test.ts test/updates.test.ts test/ssh-jump-windows.test.ts", + "test:windows": "npm run build && npm run test:types && node --import ./test/fixtures/isolate-env.mjs --import ./test/fixtures/windows-inspection-diagnostic.mjs --experimental-strip-types --test --test-concurrency=1 test/handoff-core.test.ts test/handoff-cli.test.ts test/handoff-delivery.test.ts test/metadata-observation.test.ts test/handoff-writer-probe.test.ts test/app-server.test.ts test/writer-uid.test.ts test/claude-records.test.ts test/reply-agent-binding.test.ts test/windows.test.ts test/windows-contract.test.ts test/windows-inspection.test.ts test/windows-process-tree.test.ts test/windows-storage.test.ts test/input-boundaries.test.ts test/discovery.test.ts test/codex-homes.test.ts test/diagnostics.test.ts test/cli-usability.test.ts test/skill-guidance.test.ts test/skill-metadata.test.ts test/ssh-hosts.test.ts test/ssh-response.test.ts test/ssh-handoff.test.ts test/replies.test.ts test/updates.test.ts test/ssh-jump-windows.test.ts && node --import ./test/fixtures/isolate-env.mjs --experimental-strip-types --test --test-name-pattern=Windows.Codex.send.and.dry-run test/codex-storage.test.ts && node --import ./test/fixtures/isolate-env.mjs --experimental-strip-types --test test/current-version.test.ts", "test:package": "npm run build && node --experimental-strip-types test/package-smoke.ts" }, "devDependencies": { diff --git a/scripts/check-repository.mjs b/scripts/check-repository.mjs index d832dc3..2072c7c 100644 --- a/scripts/check-repository.mjs +++ b/scripts/check-repository.mjs @@ -3,6 +3,7 @@ import { readFileSync, existsSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { currentVersions } from './current-version.mjs'; const root = new URL('../', import.meta.url); process.chdir(fileURLToPath(root)); @@ -26,6 +27,7 @@ assert.equal(lock.packages[''].name, pkg.name); assert.equal(lock.packages[''].version, pkg.version); assert.deepEqual(lock.packages[''].bin, pkg.bin); assert.equal(read('src/protocol.ts').match(/export const VERSION = '([^']+)'/)[1], pkg.version); +currentVersions(); const locales = ['README.md', 'README.ko.md', 'README.ja.md', 'README.zh-CN.md']; const guides = locales.map(file => file.replace('README', 'docs/guide')); diff --git a/scripts/current-version-fields.json b/scripts/current-version-fields.json new file mode 100644 index 0000000..6a8a1a0 --- /dev/null +++ b/scripts/current-version-fields.json @@ -0,0 +1,450 @@ +[ + { + "file": "src/protocol.ts", + "templates": [ + { + "line": "export const VERSION = '{{version}}';", + "occurrences": 1 + } + ] + }, + { + "file": "README.md", + "templates": [ + { + "line": "**Documentation and source version: {{version}}.** Review the selected release and registry metadata before installation. Dated preparation and validation records are in [VALIDATION.md](VALIDATION.md); release procedures are in [RELEASING.md](RELEASING.md). SSH endpoints require the same TypeScript client version; 0.3.6 and {{version}} refuse each other’s SSH preflight.", + "occurrences": 1 + }, + { + "line": "**0.4.0 handoff scope:** opt-in native observation is limited to exact Codex 0.160.1/0.162.1 on macOS arm64; explicit `operator_confirmed` receipts use supported POSIX ledgers. Linux/Windows native observation and automatic ACK collection remain unsupported. The {{version}} source records a non-selectable Linux arm64 Codex 0.162.1 candidate (pinned upstream `aarch64-unknown-linux-musl` executable, schema and one injection test); it does not enable Linux observation. Linux native observation remains unsupported.", + "occurrences": 1 + }, + { + "line": "**Version {{version}} installation:** check registry availability and review the chosen release before running the npm install/update examples. If that version is unavailable, use the [reviewed source build](docs/guide.md#build-from-source).", + "occurrences": 1 + }, + { + "line": "npm view session-peer@{{version}} version dist.integrity", + "occurrences": 1 + }, + { + "line": "npm install --global --ignore-scripts session-peer@{{version}}", + "occurrences": 2 + }, + { + "line": "Expected: `session-peer {{version}} (typescript)`. For isolated installation, source", + "occurrences": 1 + }, + { + "line": "older npm installation to the selected {{version}} release. The CLI does not install updates;", + "occurrences": 1 + } + ] + }, + { + "file": "README.ko.md", + "templates": [ + { + "line": "**문서·소스 기준 버전: {{version}}.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](VALIDATION.md), 릴리스 절차는 [RELEASING.md](RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6과 {{version}}은 서로의 SSH 사전 확인을 거부합니다.", + "occurrences": 1 + }, + { + "line": "**0.4.0 handoff 범위:** 선택 기능인 native 관측은 macOS arm64의 정확한 Codex 0.160.1/0.162.1에 한정되며, 명시적 `operator_confirmed` 수령 확인에는 지원되는 POSIX ledger를 사용합니다. Linux·Windows의 native 관측과 자동 ACK 수집은 미지원입니다. {{version}} 소스는 선택할 수 없는 Linux arm64 Codex 0.162.1 후보(고정된 업스트림 `aarch64-unknown-linux-musl` 실행 파일, 스키마, 주입 테스트 1회)를 기록하며 Linux 관측을 활성화하지 않습니다. Linux native 관측은 계속 미지원입니다.", + "occurrences": 1 + }, + { + "line": "**{{version}} 설치:** npm 설치·업데이트 예시를 실행하기 전에 레지스트리 제공 여부와 선택한 릴리스를 확인하세요. 해당 버전이 제공되지 않으면 [검토한 소스 빌드](docs/guide.ko.md#소스에서-빌드)를 사용하세요.", + "occurrences": 1 + }, + { + "line": "npm view session-peer@{{version}} version dist.integrity", + "occurrences": 1 + }, + { + "line": "npm install --global --ignore-scripts session-peer@{{version}}", + "occurrences": 2 + }, + { + "line": "예상 결과는 `session-peer {{version}} (typescript)`입니다. 별도 경로에 설치하거나,", + "occurrences": 1 + }, + { + "line": "(dist-tag)와 대상 버전을 확인한 뒤 해당 버전을 지정해 설치하세요. 아래 예시는 이전 npm 설치본을 선택한 {{version}}으로", + "occurrences": 1 + } + ] + }, + { + "file": "README.ja.md", + "templates": [ + { + "line": "**文書・ソースのバージョン: {{version}}。** インストール前に、選択したリリースとレジストリのメタデータを確認してください。日付付きの準備・検証記録は [VALIDATION.md](VALIDATION.md)、リリース手順は [RELEASING.md](RELEASING.md) にあります。SSH の両端には同じ TypeScript クライアントのバージョンが必要で、0.3.6 と {{version}} は互いの SSH 事前確認を拒否します。", + "occurrences": 1 + }, + { + "line": "**0.4.0 handoff の範囲:** 任意の native 観測は macOS arm64 の厳密な Codex 0.160.1/0.162.1 に限定し、明示的な `operator_confirmed` 受領確認には対応する POSIX ledger を使います。Linux・Windows の native 観測と自動 ACK 収集は未対応です。 {{version}} のソースは選択できない Linux arm64 の Codex 0.162.1 候補(固定された上流 `aarch64-unknown-linux-musl` 実行ファイル、スキーマ、注入テスト 1 回)を記録しますが、Linux 観測を有効化しません。Linux の native 観測は引き続き未対応です。", + "occurrences": 1 + }, + { + "line": "**{{version}} のインストール:** npm の導入・更新例を実行する前に、レジストリで公開状況と選択したリリースを確認してください。利用できない場合は[レビュー済みソースビルド](docs/guide.ja.md#ソースからビルド)を使います。", + "occurrences": 1 + }, + { + "line": "npm view session-peer@{{version}} version dist.integrity", + "occurrences": 1 + }, + { + "line": "npm install --global --ignore-scripts session-peer@{{version}}", + "occurrences": 2 + }, + { + "line": "期待する出力: `session-peer {{version}} (typescript)`。分離インストール・ソースビルド・", + "occurrences": 1 + }, + { + "line": "正確なバージョンを指定してください。以下は古い npm 版を選択した {{version}} に更新する例です。", + "occurrences": 1 + } + ] + }, + { + "file": "README.zh-CN.md", + "templates": [ + { + "line": "**文档和源码版本:{{version}}。** 安装前,请审核所选发布版本和注册表元数据。按日期记录的准备与验证结果见 [VALIDATION.md](VALIDATION.md),发布流程见 [RELEASING.md](RELEASING.md)。SSH 两端必须使用相同的 TypeScript 客户端版本;0.3.6 与 {{version}} 会拒绝彼此的 SSH 预检。", + "occurrences": 1 + }, + { + "line": "**0.4.0 handoff 范围:** 可选 native 观测仅限 macOS arm64 上精确 Codex 0.160.1/0.162.1 版本;明确的 `operator_confirmed` 收件确认使用受支持的 POSIX ledger。Linux、Windows 的 native 观测和自动 ACK 收集仍不支持。 {{version}} 源码记录了不可选用的 Linux arm64 Codex 0.162.1 候选(固定的上游 `aarch64-unknown-linux-musl` 可执行文件、schema 及一次注入测试),但不启用 Linux 观测;Linux native 观测仍不支持。", + "occurrences": 1 + }, + { + "line": "**安装 {{version}}:** 运行 npm 安装或更新示例前,请确认注册表可用性并审核所选发布版本。如该版本不可用,请使用[已审核的源码构建](docs/guide.zh-CN.md#从源码构建)。", + "occurrences": 1 + }, + { + "line": "npm view session-peer@{{version}} version dist.integrity", + "occurrences": 1 + }, + { + "line": "npm install --global --ignore-scripts session-peer@{{version}}", + "occurrences": 2 + }, + { + "line": "预期输出:`session-peer {{version}} (typescript)`。隔离安装、源码构建、Windows 和", + "occurrences": 1 + }, + { + "line": "下面示例将旧 npm 安装更新至所选的 {{version}}。CLI 不会安装更新,`session-peer update --check` 只做检查。", + "occurrences": 1 + } + ] + }, + { + "file": "docs/guide.md", + "templates": [ + { + "line": "**Documentation and source version: {{version}}.** Review the selected release and registry metadata before installation. Dated preparation and validation records are in [VALIDATION.md](../VALIDATION.md); release procedures are in [RELEASING.md](../RELEASING.md). SSH endpoints require the same TypeScript client version; 0.3.6 and {{version}} refuse each other’s SSH preflight.", + "occurrences": 1 + }, + { + "line": "**0.4.0 handoff scope:** opt-in native observation is limited to exact Codex 0.160.1/0.162.1 on macOS arm64; explicit `operator_confirmed` receipts use supported POSIX ledgers. Linux/Windows native observation and automatic ACK collection remain unsupported. The {{version}} source records a non-selectable Linux arm64 Codex 0.162.1 candidate (pinned upstream `aarch64-unknown-linux-musl` executable, schema and one injection test); it does not enable Linux observation. Linux native observation remains unsupported.", + "occurrences": 1 + }, + { + "line": "This guide covers version {{version}} ([0.3.1 reliability fixes](../PARITY.md#031-reliability-fixes)). Version 0.3.0 introduced multi-host SSH, sender context and return routes, update checks and the optional `sp` shorthand ([0.3.0 changes](../PARITY.md#030-remote-usability-and-maintenance)). [Public 0.3.1 release evidence](../VALIDATION.md#public-031--2026-10-04-kst) records a prior publication. References to 0.1.0 below describe the older release.", + "occurrences": 1 + }, + { + "line": "This guide describes **version {{version}}**. The npm package and CLI command are both **`session-peer`**. This", + "occurrences": 1 + }, + { + "line": "**Version {{version}} installation:** check registry availability and review the chosen release before running the npm install/update examples. If that version is unavailable, use the [reviewed source build](#build-from-source).", + "occurrences": 1 + }, + { + "line": "npm view session-peer@{{version}} version dist.integrity", + "occurrences": 2 + }, + { + "line": "npm install --global --ignore-scripts session-peer@{{version}}", + "occurrences": 1 + }, + { + "line": "session-peer --version # session-peer {{version}} (typescript)", + "occurrences": 1 + }, + { + "line": "On SSH, both ends need the same TypeScript client version ({{version}}).", + "occurrences": 1 + }, + { + "line": "npm install --global --ignore-scripts ./session-peer-{{version}}.tgz", + "occurrences": 1 + }, + { + "line": "Expected for version {{version}}: `session-peer {{version}} (typescript)`. Keep the `./...tgz` path to", + "occurrences": 1 + }, + { + "line": "npm install --prefix \"$env:TEMP\\session-peer-ts-source\" --ignore-scripts .\\session-peer-{{version}}.tgz", + "occurrences": 1 + }, + { + "line": "Public **0.3.2** was verified on 2026-10-07 KST ([immutable checkpoint](../VALIDATION.md#public-032--2026-10-07-kst)). Check current {{version}} availability and npm tags with the registry queries below; dated preparation evidence does not establish a later publication outcome.", + "occurrences": 1 + }, + { + "line": "`npm install --global --ignore-scripts session-peer@{{version}}`; a pnpm, Yarn or Bun", + "occurrences": 1 + }, + { + "line": "Native delivery observation is unsupported on Linux and Windows. The {{version}} source records a non-selectable Linux arm64 Codex 0.162.1 candidate (pinned upstream `aarch64-unknown-linux-musl` executable, schema and one injection test); it does not enable Linux observation. Linux native observation remains unsupported. The supported POSIX ledger can record an explicitly verified receipt as `operator_confirmed`; this is not automatic ACK collection. The 0.4.0 scope is qualified macOS observation and explicit operator confirmation. Linux, Windows and receipt-bootstrap extensions are tracked separately in [#130](https://github.com/abruption/session-peer-ts/issues/130), [#131](https://github.com/abruption/session-peer-ts/issues/131) and [#132](https://github.com/abruption/session-peer-ts/issues/132).", + "occurrences": 1 + } + ] + }, + { + "file": "docs/guide.ko.md", + "templates": [ + { + "line": "**문서·소스 기준 버전: {{version}}.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](../VALIDATION.md), 릴리스 절차는 [RELEASING.md](../RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6과 {{version}}은 서로의 SSH 사전 확인을 거부합니다.", + "occurrences": 1 + }, + { + "line": "**0.4.0 handoff 범위:** 선택 기능인 native 관측은 macOS arm64의 정확한 Codex 0.160.1/0.162.1에 한정되며, 명시적 `operator_confirmed` 수령 확인에는 지원되는 POSIX ledger를 사용합니다. Linux·Windows의 native 관측과 자동 ACK 수집은 미지원입니다. {{version}} 소스는 선택할 수 없는 Linux arm64 Codex 0.162.1 후보(고정된 업스트림 `aarch64-unknown-linux-musl` 실행 파일, 스키마, 주입 테스트 1회)를 기록하며 Linux 관측을 활성화하지 않습니다. Linux native 관측은 계속 미지원입니다.", + "occurrences": 1 + }, + { + "line": "이 가이드는 {{version}}을 다룹니다([0.3.1 신뢰성 수정](../PARITY.md#031-reliability-fixes)). 0.3.0에는 여러 호스트를 통한 SSH 전송, 발신자 정보와 회신 경로, 업데이트 확인, 선택 기능인 `sp` 단축 명령이 추가됐습니다([0.3.0 변경 사항](../PARITY.md#030-remote-usability-and-maintenance)). [0.3.1 공개 릴리스 기록](../VALIDATION.md#public-031--2026-10-04-kst)은 이전 버전의 발행 내역입니다. 아래에서 0.1.0을 언급할 때는 이전 버전을 설명합니다.", + "occurrences": 1 + }, + { + "line": "SSH를 사용할 때는 발신·수신 양쪽에 같은 TypeScript 클라이언트 버전({{version}})을 설치해야", + "occurrences": 1 + }, + { + "line": "확인하세요. 아래 명령은 선택한 {{version}}을 npm으로 설치하는 예입니다.", + "occurrences": 1 + }, + { + "line": "**{{version}} 설치:** npm 설치·업데이트 예시를 실행하기 전에 레지스트리 제공 여부와 선택한 릴리스를 확인하세요. 해당 버전이 제공되지 않으면 [검토한 소스 빌드](#소스에서-빌드)를 사용하세요.", + "occurrences": 1 + }, + { + "line": "npm view session-peer@{{version}} version dist.integrity", + "occurrences": 2 + }, + { + "line": "npm install --global --ignore-scripts session-peer@{{version}}", + "occurrences": 1 + }, + { + "line": "session-peer --version # session-peer {{version}} (typescript)", + "occurrences": 1 + }, + { + "line": "예상 결과는 `session-peer {{version}} (typescript)`입니다.", + "occurrences": 1 + }, + { + "line": "npm install --global --ignore-scripts ./session-peer-{{version}}.tgz", + "occurrences": 1 + }, + { + "line": "{{version}}에서 예상되는 결과는 `session-peer {{version}} (typescript)`입니다. 로컬 빌드", + "occurrences": 1 + }, + { + "line": "npm install --prefix \"$env:TEMP\\session-peer-ts-source\" --ignore-scripts .\\session-peer-{{version}}.tgz", + "occurrences": 1 + }, + { + "line": "아래 레지스트리 조회로 {{version}}의 현재 제공 여부와 npm 태그를 확인하세요. 날짜가 기록된 준비 근거는 이후 발행 결과를 입증하지 않습니다.", + "occurrences": 1 + }, + { + "line": "nvm-windows, fnm. 예: `npm install --global --ignore-scripts session-peer@{{version}}`),", + "occurrences": 1 + }, + { + "line": "Linux·Windows의 native 주입 관측은 미지원입니다. {{version}} 소스는 선택할 수 없는 Linux arm64 Codex 0.162.1 후보(고정된 업스트림 `aarch64-unknown-linux-musl` 실행 파일, 스키마, 주입 테스트 1회)를 기록하며 Linux 관측을 활성화하지 않습니다. Linux native 관측은 계속 미지원입니다. 지원되는 POSIX ledger에서는 운영자가 명시적으로 확인한 수령 사실을 `operator_confirmed`로 기록할 수 있지만, 자동 ACK 수집은 아닙니다. 0.4.0 범위는 검증된 macOS 관측과 명시적 운영자 확인입니다. Linux·Windows 관측과 receipt bootstrap은 각각 [#130](https://github.com/abruption/session-peer-ts/issues/130), [#131](https://github.com/abruption/session-peer-ts/issues/131), [#132](https://github.com/abruption/session-peer-ts/issues/132)에서 후속 작업으로 관리합니다.", + "occurrences": 1 + } + ] + }, + { + "file": "docs/guide.ja.md", + "templates": [ + { + "line": "**文書・ソースのバージョン: {{version}}。** インストール前に、選択したリリースとレジストリのメタデータを確認してください。日付付きの準備・検証記録は [VALIDATION.md](../VALIDATION.md)、リリース手順は [RELEASING.md](../RELEASING.md) にあります。SSH の両端には同じ TypeScript クライアントのバージョンが必要で、0.3.6 と {{version}} は互いの SSH 事前確認を拒否します。", + "occurrences": 1 + }, + { + "line": "**0.4.0 handoff の範囲:** 任意の native 観測は macOS arm64 の厳密な Codex 0.160.1/0.162.1 に限定し、明示的な `operator_confirmed` 受領確認には対応する POSIX ledger を使います。Linux・Windows の native 観測と自動 ACK 収集は未対応です。 {{version}} のソースは選択できない Linux arm64 の Codex 0.162.1 候補(固定された上流 `aarch64-unknown-linux-musl` 実行ファイル、スキーマ、注入テスト 1 回)を記録しますが、Linux 観測を有効化しません。Linux の native 観測は引き続き未対応です。", + "occurrences": 1 + }, + { + "line": "{{version}} の詳細ガイドです([0.3.1 の信頼性の修正](../PARITY.md#031-reliability-fixes))。0.3.0 は複数ホスト SSH、送信者情報と返信経路、更新確認、任意の `sp` 短縮名を追加しました([0.3.0 の変更点](../PARITY.md#030-remote-usability-and-maintenance))。以前の公開記録は [0.3.1 公開リリースの証拠](../VALIDATION.md#public-031--2026-10-04-kst) にあります。以下の 0.1.0 の説明は旧版との比較です。", + "occurrences": 1 + }, + { + "line": "**{{version}} の利用ガイドです。**パッケージ名は `session-peer`、CLI コマンドは **`session-peer`**。Relay サーバーやホスティングサービスは提供しません。", + "occurrences": 1 + }, + { + "line": "対象は既定の `~/.codex`、`CODEX_HOME`、macOS Orca 直下のアカウント home、JSON 配列 `SESSION_PEER_CODEX_HOMES` のみです。`--codex-home` は Codex 一覧を固定して無関係な設定エラーを回避し、`--agent claude` は Codex 探索を省略します。同じ home の別名は統合し、異なる home の同じ UUID は保持します。送信には各行の `codexHome` を使ってください。任意 home の不在はエラーではなく、明示した home の不在・不正は読み取れた行を保持して終了コード 1 を返します。一覧は writer を選択せず、メッセージを提出しません。[一覧契約](../PARITY.md#source-unified-listing-contract--16--020)に順序・診断・`--all`・SSH を記載しています。0.2.0の Codex 送信は `--codex-home` 省略時に一意で安定した live writer を選択します。明示 home でも既知の競合 home をすべて検査します。非アクティブなキュー送信には保存済みスレッド、全候補の非アクティブ検証、および `--codex-home HOME --allow-inactive-codex-home` が必要で、wake/resume は実行しません。Dry-run は提出しません。JSON に診断 `codexHomeResolution` と、ネイティブ出力にある場合のみ `queueId` を追加しますが、消費確認ではありません。[選択契約](../PARITY.md#source-codex-home-selection--17--020)を参照してください。公開 **0.1.0** には明示 live home が必要で、非アクティブ許可オプションはありません。SSH の両端には同じ TypeScript クライアントのバージョン({{version}})が必要です。", + "occurrences": 1 + }, + { + "line": "**{{version}} のインストール:** npm の導入・更新例を実行する前に、レジストリで公開状況と選択したリリースを確認してください。利用できない場合は[レビュー済みソースビルド](#ソースからビルド)を使います。", + "occurrences": 1 + }, + { + "line": "npm view session-peer@{{version}} version dist.integrity", + "occurrences": 2 + }, + { + "line": "npm install --global --ignore-scripts session-peer@{{version}}", + "occurrences": 1 + }, + { + "line": "期待値は `session-peer {{version}} (typescript)`。", + "occurrences": 1 + }, + { + "line": "npm install --global --ignore-scripts ./session-peer-{{version}}.tgz", + "occurrences": 1 + }, + { + "line": "Windows PowerShell では `Get-Command session-peer -All` で既存コマンドを確認します。Python CLI を置換せずに試すには、`npm ci --ignore-scripts`、`npm run build`、`npm pack --ignore-scripts` の後、`npm install --prefix \"$env:TEMP\\session-peer-ts-source\" --ignore-scripts .\\session-peer-{{version}}.tgz` を実行し、`& \"$env:TEMP\\session-peer-ts-source\\node_modules\\.bin\\session-peer.cmd\" --version` で確認します。同じ prefix の `npm uninstall --prefix \"$env:TEMP\\session-peer-ts-source\" session-peer` で削除します。", + "occurrences": 1 + }, + { + "line": "公開 **0.3.2** は 2026-10-07 KST に検証されました([固定された公開記録](../VALIDATION.md#public-032--2026-10-07-kst))。以下のレジストリ照会で {{version}} の現在の公開状況と npm タグを確認してください。日付付きの準備記録は、その後の公開結果を証明しません。", + "occurrences": 1 + }, + { + "line": "`npm install --global --ignore-scripts session-peer@{{version}}`)、マニフェストで", + "occurrences": 1 + }, + { + "line": "Linux・Windows の native 注入観測は未対応です。 {{version}} のソースは選択できない Linux arm64 の Codex 0.162.1 候補(固定された上流 `aarch64-unknown-linux-musl` 実行ファイル、スキーマ、注入テスト 1 回)を記録しますが、Linux 観測を有効化しません。Linux の native 観測は引き続き未対応です。対応する POSIX ledger では、運用者が明示的に確認した受領を `operator_confirmed` として記録できますが、自動 ACK 収集ではありません。0.4.0 の範囲は、検証済みの macOS 観測と明示的な運用者確認です。Linux・Windows 観測と receipt bootstrap は [#130](https://github.com/abruption/session-peer-ts/issues/130)、[#131](https://github.com/abruption/session-peer-ts/issues/131)、[#132](https://github.com/abruption/session-peer-ts/issues/132) の後続作業です。", + "occurrences": 1 + } + ] + }, + { + "file": "docs/guide.zh-CN.md", + "templates": [ + { + "line": "**文档和源码版本:{{version}}。** 安装前,请审核所选发布版本和注册表元数据。按日期记录的准备与验证结果见 [VALIDATION.md](../VALIDATION.md),发布流程见 [RELEASING.md](../RELEASING.md)。SSH 两端必须使用相同的 TypeScript 客户端版本;0.3.6 与 {{version}} 会拒绝彼此的 SSH 预检。", + "occurrences": 1 + }, + { + "line": "**0.4.0 handoff 范围:** 可选 native 观测仅限 macOS arm64 上精确 Codex 0.160.1/0.162.1 版本;明确的 `operator_confirmed` 收件确认使用受支持的 POSIX ledger。Linux、Windows 的 native 观测和自动 ACK 收集仍不支持。 {{version}} 源码记录了不可选用的 Linux arm64 Codex 0.162.1 候选(固定的上游 `aarch64-unknown-linux-musl` 可执行文件、schema 及一次注入测试),但不启用 Linux 观测;Linux native 观测仍不支持。", + "occurrences": 1 + }, + { + "line": "这是 {{version}} 详细指南([0.3.1 可靠性修复](../PARITY.md#031-reliability-fixes))。0.3.0 新增了多主机 SSH、发送者信息与回复路由、更新检查以及可选的 `sp` 简写([0.3.0 变更](../PARITY.md#030-remote-usability-and-maintenance))。此前的发布记录见 [0.3.1 公开发布证据](../VALIDATION.md#public-031--2026-10-04-kst);下文 0.1.0 的说明用于旧版本对比。", + "occurrences": 1 + }, + { + "line": "**这是 {{version}} 使用指南。**包名为 `session-peer`,CLI 命令为 **`session-peer`**。本项目不提供 Relay 服务器或托管服务。", + "occurrences": 1 + }, + { + "line": "范围仅限默认 `~/.codex`、`CODEX_HOME`、macOS Orca 下的直接账户 home,以及 JSON 数组 `SESSION_PEER_CODEX_HOMES`。`--codex-home` 固定 Codex 列表并绕过无关配置错误;`--agent claude` 完全跳过 Codex 探索。同一 home 的别名合并,不同 home 的相同 UUID 保留。发送时使用每行的 `codexHome`。可选 home 缺失不是错误;显式 home 缺失或无效会保留成功读取的行,并返回退出码 1。列表不会选择 writer 或提交消息。[列表契约](../PARITY.md#source-unified-listing-contract--16--020)说明排序、诊断、`--all` 和 SSH 行为。0.2.0的 Codex 发送在省略 `--codex-home` 时选择唯一且稳定的 live writer;显式 home 仍会检查所有已知竞争 home。非活动队列提交需要已保存的线程、所有候选均确认非活动,以及 `--codex-home HOME --allow-inactive-codex-home`,不会执行 wake/resume。Dry-run 不提交。JSON 新增诊断 `codexHomeResolution`,且仅在原生输出提供时包含 `queueId`;两者均不代表消费确认。请参阅[选择契约](../PARITY.md#source-codex-home-selection--17--020)。公开 **0.1.0** 仍需要显式 live home,且没有非活动许可选项。SSH 两端需要相同的 TypeScript 客户端版本({{version}})。", + "occurrences": 1 + }, + { + "line": "**安装 {{version}}:** 运行 npm 安装或更新示例前,请确认注册表可用性并审核所选发布版本。如该版本不可用,请使用[已审核的源码构建](#从源码构建)。", + "occurrences": 1 + }, + { + "line": "npm view session-peer@{{version}} version dist.integrity", + "occurrences": 2 + }, + { + "line": "npm install --global --ignore-scripts session-peer@{{version}}", + "occurrences": 1 + }, + { + "line": "预期输出:`session-peer {{version}} (typescript)`。", + "occurrences": 1 + }, + { + "line": "npm install --global --ignore-scripts ./session-peer-{{version}}.tgz", + "occurrences": 1 + }, + { + "line": "在 Windows PowerShell 中用 `Get-Command session-peer -All` 检查已有命令。为了不替换 Python CLI,可先执行 `npm ci --ignore-scripts`、`npm run build`、`npm pack --ignore-scripts`,再用 `npm install --prefix \"$env:TEMP\\session-peer-ts-source\" --ignore-scripts .\\session-peer-{{version}}.tgz` 安装到隔离目录。以 `& \"$env:TEMP\\session-peer-ts-source\\node_modules\\.bin\\session-peer.cmd\" --version` 验证,并可用 `npm uninstall --prefix \"$env:TEMP\\session-peer-ts-source\" session-peer` 卸载。", + "occurrences": 1 + }, + { + "line": "公开 **0.3.2** 于 2026-10-07 KST 完成验证([不可变公开记录](../VALIDATION.md#public-032--2026-10-07-kst))。请用下方注册表查询确认 {{version}} 的当前可用性和 npm 标签;带日期的准备证据不能证明后续发布结果。", + "occurrences": 1 + }, + { + "line": "`npm install --global --ignore-scripts session-peer@{{version}}`),清单中声明了", + "occurrences": 1 + }, + { + "line": "Linux、Windows 的 native 注入观察尚不支持。 {{version}} 源码记录了不可选用的 Linux arm64 Codex 0.162.1 候选(固定的上游 `aarch64-unknown-linux-musl` 可执行文件、schema 及一次注入测试),但不启用 Linux 观测;Linux native 观测仍不支持。支持的 POSIX ledger 可将操作人员明确确认的收件事实记录为 `operator_confirmed`,但这不是自动 ACK 收集。0.4.0 范围是已验证的 macOS 观察和操作人员的明确确认。Linux、Windows 观察及 receipt bootstrap 分别作为 [#130](https://github.com/abruption/session-peer-ts/issues/130)、[#131](https://github.com/abruption/session-peer-ts/issues/131)、[#132](https://github.com/abruption/session-peer-ts/issues/132) 的后续工作。", + "occurrences": 1 + } + ] + }, + { + "file": "docs/api.md", + "templates": [ + { + "line": "This reference describes source/package version **{{version}}**. Check registry availability and review the selected release before installation. Dated release evidence is recorded in [VALIDATION.md](../VALIDATION.md); before publication use a reviewed source artifact.", + "occurrences": 1 + }, + { + "line": "locally with `npm install --ignore-scripts session-peer@{{version}}` before importing it.", + "occurrences": 1 + }, + { + "line": "console.log(VERSION); // {{version}}", + "occurrences": 1 + }, + { + "line": "console.log(VERSION_LINE); // session-peer {{version}} (typescript)", + "occurrences": 1 + }, + { + "line": "the same reviewed {{version}} implementation. This workflow was introduced in", + "occurrences": 1 + }, + { + "line": "The {{version}} source records a Linux arm64 Codex 0.162.1 candidate (pinned musl", + "occurrences": 1 + }, + { + "line": "The {{version}} source preserves this supported handoff scope; its Linux and Windows", + "occurrences": 1 + } + ] + }, + { + "file": "docs/shorthand.md", + "templates": [ + { + "line": "This reference describes source/package version {{version}}. Check registry availability before using a version-pinned npm installation; before publication use a reviewed source artifact. The shorthand forwards to the explicitly selected CLI and does not install or upgrade it.", + "occurrences": 1 + } + ] + }, + { + "file": "PARITY.md", + "templates": [ + { + "line": "Current source/package version: **{{version}}**.", + "occurrences": 1 + } + ] + } +] diff --git a/scripts/current-version.md b/scripts/current-version.md new file mode 100644 index 0000000..f268070 --- /dev/null +++ b/scripts/current-version.md @@ -0,0 +1,22 @@ +The current-version inventory is `scripts/current-version-fields.json`. Package metadata is the source: `package.json.version` selects the version; both root version fields in `package-lock.json` must already agree. The updater does not change package metadata or decide which release is approved. + +Run `npm run version:check` to check for drift without writes, or `npm run version:update` after updating package metadata through the usual npm workflow. Both commands accept no destination paths. `scripts/check-repository.mjs` also runs the same non-mutating check in CI; its existing metadata, language, link and anchor checks remain in force. + +Each inventory entry has exactly `file` and `templates`. Each template has exactly `line` and a positive `occurrences` count: + +```json +{ + "file": "src/protocol.ts", + "templates": [ + { "line": "export const VERSION = '{{version}}';", "occurrences": 1 } + ] +} +``` + +A template describes one complete, authored line. Only `{{version}}` spans vary; every other character remains literal, including translated text and unrelated versions on that line. Multiple placeholders on the same line select multiple current-version spans. Repeated identical lines require their exact count. Missing, extra or overlapping matches fail before any file is written. When editing an inventoried line, update its template in the same change. Review every newly selected span; adding a version to a document does not automatically make it current. + +The closed file set is the runtime `VERSION` declaration, four READMEs, four guides, the API reference, the shorthand reference and PARITY. The inventory explicitly selects the current source descriptions, current installation/update commands, tarball filenames, expected banners and SSH version examples within those documents. PARITY's current summary is selected separately from its dated records. The runtime declaration is the only managed slot in `src/protocol.ts`. Every managed file must appear exactly once in the inventory; the updater rejects unknown paths and symlinks, including parent directory symlinks. It validates the complete plan before writing. A subsequent filesystem write failure can still leave a partial update; rerun the check to inspect the result. + +Everything outside these explicit spans remains authored content. In particular, dated preparation/publication evidence in PARITY and VALIDATION, historical release descriptions and anchors, source hashes, release policy, finite skill compatibility rows, independent skill/native/Python versions and pin files are not generated. A version update does not approve new compatibility, qualification, publication or installation pins. Current prose that describes a capability must still be reviewed when behavior changes. + +`test/current-version.test.ts` exercises the real inventory against isolated copies: read-only checks, a synthetic next patch, byte-idempotence, preserved historical records and independent pins, malformed/overlapping slots, unknown paths, symlinks and validation before writes. Build and package acceptance also require a separate isolated synthetic-version run: update package and lock metadata in the copied tree, run the updater and repository checker, build, then run `npm run test:package`. Keep production release policies and finite compatibility tables unchanged in that experiment. Record the commands actually run and their results in the change's validation evidence; unit fixtures alone are not evidence of a successful synthetic build or package smoke. diff --git a/scripts/current-version.mjs b/scripts/current-version.mjs new file mode 100644 index 0000000..f0853b7 --- /dev/null +++ b/scripts/current-version.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repository = resolve(dirname(realpathSync(fileURLToPath(import.meta.url))), '..'); +const allowedFiles = new Set([ + 'src/protocol.ts', 'README.md', 'README.ko.md', 'README.ja.md', 'README.zh-CN.md', + 'docs/guide.md', 'docs/guide.ko.md', 'docs/guide.ja.md', 'docs/guide.zh-CN.md', + 'docs/api.md', 'docs/shorthand.md', 'PARITY.md', +]); +const numeric = '(?:0|[1-9]\\d*)'; +const prerelease = `(?:${numeric}|\\d*[A-Za-z-][0-9A-Za-z-]*)`; +const versionPattern = `${numeric}\\.${numeric}\\.${numeric}(?:-${prerelease}(?:\\.${prerelease})*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?`; +const escape = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// Only the explicitly inventoried version spans are replaced. Authored text, +// historical versions and independently versioned pins remain literal context. +export function planCurrentVersions(version, files, inventory) { + assert.match(version, new RegExp(`^${versionPattern}$`), 'package.json: unsupported version'); + assert.ok(Array.isArray(inventory) && inventory.length > 0, 'empty current-version inventory'); + const seen = new Set(); + const changes = []; + for (const entry of inventory) { + assert.deepEqual(Object.keys(entry).sort(), ['file', 'templates'], 'invalid inventory entry keys'); + assert.ok(allowedFiles.has(entry.file), `unmanaged current-version path: ${entry.file}`); + assert.ok(!seen.has(entry.file), `duplicate inventory file: ${entry.file}`); + seen.add(entry.file); + assert.ok(Array.isArray(entry.templates) && entry.templates.length > 0, `${entry.file}: empty templates`); + const original = files.get(entry.file); + assert.equal(typeof original, 'string', `${entry.file}: missing input`); + const replacements = []; + const matchedLines = new Set(); + for (const slot of entry.templates) { + assert.deepEqual(Object.keys(slot).sort(), ['line', 'occurrences'], `${entry.file}: invalid slot keys`); + assert.ok(Number.isSafeInteger(slot.occurrences) && slot.occurrences > 0, `${entry.file}: invalid slot count`); + assert.ok(typeof slot.line === 'string' && !/[\r\n]/.test(slot.line) && slot.line.includes('{{version}}'), + `${entry.file}: expected a single-line version template`); + const parts = slot.line.split('{{version}}'); + const pattern = new RegExp(`^${parts.map(escape).join(`(${versionPattern})`)}(?=\\r?$)`, 'gm'); + const matches = [...original.matchAll(pattern)]; + assert.equal(matches.length, slot.occurrences, `${entry.file}: expected ${slot.occurrences} matches for ${slot.line}`); + for (const match of matches) { + assert.ok(!matchedLines.has(match.index), `${entry.file}: overlapping version slots`); + matchedLines.add(match.index); + replacements.push({ index: match.index, length: match[0].length, text: parts.join(version) }); + } + } + let updated = original; + for (const replacement of replacements.sort((a, b) => b.index - a.index)) { + updated = updated.slice(0, replacement.index) + replacement.text + updated.slice(replacement.index + replacement.length); + } + if (updated !== original) changes.push({ file: entry.file, before: original, after: updated }); + } + assert.ok(seen.has('src/protocol.ts'), 'runtime VERSION must be inventoried'); + assert.deepEqual([...seen].sort(), [...allowedFiles].sort(), 'inventory must cover the fixed current-version file set'); + const runtime = inventory.find(entry => entry.file === 'src/protocol.ts'); + assert.deepEqual(runtime.templates, [{ line: "export const VERSION = '{{version}}';", occurrences: 1 }], 'only runtime VERSION is managed in src/protocol.ts'); + return changes; +} + +function readRegular(root, file) { + let path = root; + for (const part of file.split('/')) { + path = join(path, part); + assert.ok(!lstatSync(path).isSymbolicLink(), `${file}: symlinks are not managed`); + } + assert.ok(lstatSync(path).isFile(), `${file}: expected regular file`); + return readFileSync(path, 'utf8'); +} + +export function currentVersions(root = repository, mode = '--check') { + assert.ok(mode === '--check' || mode === '--update', 'usage: node scripts/current-version.mjs --check|--update'); + const pkg = JSON.parse(readRegular(root, 'package.json')); + const lock = JSON.parse(readRegular(root, 'package-lock.json')); + assert.equal(lock.version, pkg.version, 'package-lock.json version must match package.json; update package metadata first'); + assert.equal(lock.packages[''].version, pkg.version, 'package-lock.json root version must match package.json'); + const inventory = JSON.parse(readRegular(root, 'scripts/current-version-fields.json')); + // Validate the full plan, including every destination, before the first write. + const files = new Map(); + for (const entry of inventory) { + assert.ok(allowedFiles.has(entry.file), `unmanaged current-version path: ${entry.file}`); + files.set(entry.file, readRegular(root, entry.file)); + } + const changes = planCurrentVersions(pkg.version, files, inventory); + if (mode === '--check') { + assert.equal(changes.length, 0, `current-version drift: ${changes.map(change => change.file).join(', ')}; run npm run version:update`); + } else { + for (const change of changes) writeFileSync(join(root, change.file), change.after); + } + return changes.map(change => change.file); +} + +function isEntryPoint() { + try { return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } + catch { return false; } +} + +if (isEntryPoint()) { + try { + assert.equal(process.argv.length, 3, 'usage: node scripts/current-version.mjs --check|--update'); + const changed = currentVersions(repository, process.argv[2]); + console.log(process.argv[2] === '--check' ? 'Current-version inventory: OK' : `Current-version update: ${changed.length} files`); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/release.mjs b/scripts/release.mjs index e66dd91..2d4b5a5 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -16,7 +16,7 @@ const verifiedPreviewIntegrity = 'sha512-h4SMvrQ/LWA9osd4EHIs9rSTqv1u+S3MXQAmn+y export const packageFiles = ['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/shorthand.md', 'shorthand/sp.sh', 'shorthand/sp.ps1', 'docs/design/remote-deployment.md', 'docs/design/ordinary-ssh-responses.md', 'docs/design/typescript-skill-compatibility.md', - '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/cli.js', 'dist/discovery.js', 'dist/codex-storage.js', 'dist/codex-storage.d.ts', 'dist/diagnostics.js', 'dist/skill-metadata.js', 'dist/process.js', 'dist/protocol.js', 'dist/replies.js', 'dist/send.js', 'dist/ssh.js', 'dist/ssh-response.js', 'dist/windows.js', 'dist/writer.js', 'dist/index.js', 'dist/index.d.ts', 'dist/cli.d.ts', 'dist/discovery.d.ts', 'dist/diagnostics.d.ts', 'dist/skill-metadata.d.ts', 'dist/process.d.ts', 'dist/protocol.d.ts', 'dist/replies.d.ts', 'dist/send.d.ts', 'dist/ssh.d.ts', 'dist/ssh-response.d.ts', 'dist/windows.d.ts', 'dist/writer.d.ts', 'dist/updates.js', 'dist/updates.d.ts', 'dist/handoff.js', 'dist/handoff.d.ts', 'dist/handoff-ledger.js', 'dist/handoff-ledger.d.ts', 'dist/handoff-operations.js', 'dist/handoff-operations.d.ts', 'dist/app-server.js', 'dist/app-server.d.ts', 'dist/metadata-observation.js', 'dist/metadata-observation.d.ts', 'dist/handoff-observer-probe.js', 'dist/handoff-observer-probe.d.ts', 'dist/handoff-writer-probe.js', 'dist/handoff-writer-probe.d.ts', 'dist/native-profiles.js', 'dist/native-profiles.d.ts', 'dist/linux-proc.js', 'dist/linux-proc.d.ts', 'dist/linux-native-probe.js', 'dist/linux-native-probe.d.ts', 'package.json'].sort(); diff --git a/src/app-server.ts b/src/app-server.ts index 5a59504..ebdbc13 100644 --- a/src/app-server.ts +++ b/src/app-server.ts @@ -14,6 +14,7 @@ import { performance } from 'node:perf_hooks'; import type { Readable, Writable } from 'node:stream'; import { TextDecoder } from 'node:util'; import { run } from './process.js'; +import { classifyCodexStorage, codexConsumerCwd, decodeLsofFilename } from './codex-storage.js'; import { CANDIDATE_PROFILES, NATIVE_PROFILES, platformProfiled, selectNativeProfile, validateNativeProfiles, type NativeProfile } from './native-profiles.js'; import { procOwnedFiles, type OpenedFile, type ProcReader } from './linux-proc.js'; import { fileURLToPath } from 'node:url'; @@ -114,7 +115,8 @@ 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; + verifyOwner: (scope: AppServerScope) => Promise; beforeEffect: () => Promise; + verifyStorage?: () => Promise; env?: NodeJS.ProcessEnv; }; export function nativeQueueCapability(context: AppServerContext): { queueSupported: boolean; observationSupported: false; @@ -541,7 +543,10 @@ async function darwinOpenedFiles(pid: number, deadlineMs: number): Promise= deadlineMs) throw new AppServerFault('deadline', false); verifyOpenedSqlite(files, home); } -// 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 }); @@ -668,18 +653,33 @@ export async function nativeQueueOnceWithProfiles(options: NativeQueueOptions, // 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); + 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 consumerCwd = await codexConsumerCwd(original.codexHome, original.threadId).catch(() => { + throw new AppServerFault('storage_binding_unverified', false); + }); + const checkStorage = (version?: string) => { + const value = classifyCodexStorage({ home: original.codexHome, queueCwd: original.codexHome, + consumerCwds: [consumerCwd], ownedAppServer: true, version, environment: options.env ?? process.env }); + if (value.status !== 'classified') throw new AppServerFault('storage_binding_unverified', false); + return value; + }; + const initialStorage = checkStorage(); + const revalidateStorage = async (version?: string) => { + if (await codexConsumerCwd(original.codexHome, original.threadId).catch(() => '') !== consumerCwd || + checkStorage(version).fingerprint !== initialStorage.fingerprint) throw new AppServerFault('storage_binding_unverified', 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 inherited = Object.fromEntries(Object.entries(options.env ?? process.env).filter(([key]) => !['CODEX_HOME', 'CODEX_SQLITE_HOME'].includes(key.toUpperCase()))); + const env: NodeJS.ProcessEnv = { ...inherited, CODEX_HOME: original.codexHome, + CODEX_SQLITE_HOME: original.codexHome, PWD: initialStorage.queueCwd! }; 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, + env, cwd: initialStorage.queueCwd, timeout: probeRemaining, limit: 4096, }); if (performance.now() >= options.deadlineMs) throw new AppServerFault('deadline', false); const nativeVersion = version.stdout.trim().slice('codex-cli '.length); @@ -688,6 +688,7 @@ export async function nativeQueueOnceWithProfiles(options: NativeQueueOptions, throw new AppServerFault('unsupported_version', false); } verifyExecutableIdentity(options.binary, binaryIdentity); + await revalidateStorage(nativeVersion); if (performance.now() >= options.deadlineMs) throw new AppServerFault('deadline', false); if (profile.artifact) { // Pinned executable bytes, hashed within the original deadline; the stable @@ -714,8 +715,10 @@ export async function nativeQueueOnceWithProfiles(options: NativeQueueOptions, 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. + await options.verifyStorage?.(); verifyExecutableIdentity(options.binary, binaryIdentity); await bind(transport.pid); + await revalidateStorage(nativeVersion); } return valid; }, async () => { await options.beforeEffect(); effectGuarded = true; }, profile); diff --git a/src/codex-storage.ts b/src/codex-storage.ts new file mode 100644 index 0000000..afaed83 --- /dev/null +++ b/src/codex-storage.ts @@ -0,0 +1,284 @@ +// Source-qualified LOCAL loader layers, independent of observation profiles. +// See docs: rust-v0.160.1, rust-v0.162.1 and rust-v0.165.0 config/src/loader. +// Never return TOML, preference values, native diagnostics or configuration paths. +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { closeSync, constants, fstatSync, lstatSync, openSync, readlinkSync, readSync, realpathSync, statSync, type BigIntStats } from 'node:fs'; +import { basename, dirname, isAbsolute, join } from 'node:path'; +import { TextDecoder } from 'node:util'; +import { parse } from 'smol-toml'; + +export const STORAGE_LOADER_VERSIONS = ['0.160.1', '0.162.1', '0.165.0'] as const; +const LIMIT = 1024 * 1024; +type Layer = 'home' | 'system' | 'requirements' | 'legacy' | 'preferences' | 'project' | 'context' | 'daemon' | 'cloud'; +export type StorageCheck = { + status: 'classified' | 'unsupported' | 'unknown'; code: string; + verification: 'local_loader_layers'; loaderVersion: string | null; + loaderVersionVerified: boolean; localLoaderPlatformSupported: boolean; + layers: Layer[]; consumerContext: 'inspected' | 'not_provided'; + cloudConfiguration: 'ineligible_by_metadata' | 'requires_owned_storage_binding' | 'unclassified'; + // Private continuity evidence; callers expose only storageDiagnostic(). + fingerprint: string; queueCwd?: string; +}; +export type StorageOptions = { + home: string; queueCwd?: string; consumerCwds?: string[]; version?: string; + platform?: string; ownedAppServer?: boolean; + // Dependency injection for hermetic fixtures, never environment-controlled. + systemBase?: string; preferences?: () => { forced: boolean; value: unknown }[]; + environment?: NodeJS.ProcessEnv; +}; +const absent = (e: unknown) => (e as NodeJS.ErrnoException).code === 'ENOENT'; +class Invalid extends Error { constructor(readonly layer: Layer, readonly configured = false) { super(); } } +function metadata(s: BigIntStats): string { + return [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs].join(':'); +} +// Never follow a config symlink or open a special file; a racing FIFO cannot +// block this read. Missing parents that are not directories are unclassifiable. +function bytes(path: string, layer: Layer): Buffer | undefined { + let before; + try { before = lstatSync(path, { bigint: true }); } catch (e) { if (absent(e)) return; throw new Invalid(layer); } + let fd: number | undefined; + try { + if (!before.isFile() || before.size > BigInt(LIMIT)) throw new Error(); + fd = openSync(path, constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK)); + const opened = fstatSync(fd, { bigint: true }); + if (!opened.isFile() || metadata(opened) !== metadata(before)) throw new Error(); + const out = Buffer.alloc(LIMIT + 1); let length = 0; + while (length < out.length) { const n = readSync(fd, out, length, out.length - length, length); if (!n) break; length += n; } + const after = fstatSync(fd, { bigint: true }), named = lstatSync(path, { bigint: true }); + if (length > LIMIT || BigInt(length) !== opened.size || !after.isFile() || !named.isFile() || + metadata(after) !== metadata(before) || metadata(named) !== metadata(before)) throw new Error(); + return out.subarray(0, length); + } catch { throw new Invalid(layer); } finally { if (fd !== undefined) closeSync(fd); } +} +// Both 0.160.1 and 0.162.1 (also 0.165.0) require forced values, before AND +// after CopyAppValue. Do not import the older Python wake-version policy. +export function managedStoragePreferences(): { forced: boolean; value: unknown }[] { + const script = `ObjC.import('CoreFoundation'); +var domain=$('com.openai.codex'); +if (!$.CFPreferencesAppSynchronize(domain)) throw Error('unavailable'); +var out=[]; +['config_toml_base64','requirements_toml_base64'].forEach(function(name){ + var key=$(name); if (!$.CFPreferencesAppValueIsForced(key,domain)) return; + var raw=$.CFPreferencesCopyAppValue(key,domain); + if (!$.CFPreferencesAppValueIsForced(key,domain)) return; + var value=ObjC.deepUnwrap(raw); + if(typeof value !== 'string' || value.length>2097152) throw Error('invalid'); + out.push({forced:true,value:value}); +}); JSON.stringify(out);`; + try { + const value: unknown = JSON.parse(execFileSync('/usr/bin/osascript', ['-l', 'JavaScript', '-e', script], + { encoding: 'utf8', timeout: 3000, maxBuffer: 5 * LIMIT, stdio: ['ignore', 'pipe', 'pipe'] })); + if (!Array.isArray(value) || value.length > 2) throw new Error(); + return value; + } catch { throw new Invalid('preferences'); } +} +export function storageDiagnostic(check: StorageCheck): Omit { + const { fingerprint: _private, queueCwd: _cwd, ...diagnostic } = check; return diagnostic; +} +export function classifyCodexStorage(options: StorageOptions): StorageCheck { + const platform = options.platform ?? process.platform; + const layers = new Set(), evidence: unknown[] = []; + const qualified = platform === 'darwin' || platform === 'linux'; + const versionOK = options.version === undefined || (STORAGE_LOADER_VERSIONS as readonly string[]).includes(options.version); + let queueCwd: string | undefined, total = 0; + let cloudConfiguration: StorageCheck['cloudConfiguration'] = 'unclassified'; + const result = (status: StorageCheck['status'], code: string): StorageCheck => ({ status, code, + verification: 'local_loader_layers', loaderVersion: options.version ?? null, + loaderVersionVerified: options.version !== undefined && versionOK, localLoaderPlatformSupported: qualified, + layers: [...layers], consumerContext: options.consumerCwds?.length ? 'inspected' : 'not_provided', + cloudConfiguration, + fingerprint: createHash('sha256').update(JSON.stringify(evidence)).digest('hex'), ...(queueCwd ? { queueCwd } : {}) }); + if (!qualified) return result('unsupported', 'codex_storage_platform_unqualified'); + if (!versionOK) return result('unsupported', 'codex_storage_loader_unsupported'); + try { + const environment = options.environment ?? process.env; + const remoteMarkers = ['CODEX_EXEC_SERVER_URL', 'CODEX_EXEC_SERVER_NOISE_REGISTRY_URL', + 'CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID', 'CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN', 'CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID']; + if (remoteMarkers.some(name => Boolean(environment[name]?.trim()))) throw new Invalid('context'); + try { lstatSync(join(options.home, 'environments.toml')); throw new Invalid('context'); } + catch (e) { if (!absent(e)) throw e instanceof Invalid ? e : new Invalid('context'); } + let customMarkers = false, nonFileAuthStore = false, configServer = false; + const config = (data: Buffer, layer: Layer) => { + total += data.length; if (total > 8 * LIMIT) throw new Invalid(layer); + let value; + try { value = parse(new TextDecoder('utf-8', { fatal: true }).decode(data)); } catch { throw new Invalid(layer); } + if (Object.hasOwn(value, 'sqlite_home')) throw new Invalid(layer, true); + customMarkers ||= Object.hasOwn(value, 'project_root_markers'); + // A legacy profile can select auth storage. Inspect this selector without + // reading any credential values, including inactive profiles conservatively. + const pending: unknown[] = [value]; let count = 0; + while (pending.length) { + if (++count > 65536) throw new Invalid(layer); + const item = pending.pop(); + if (!item || typeof item !== 'object') continue; + configServer ||= Object.hasOwn(item, 'config_server'); + if (Object.hasOwn(item, 'cli_auth_credentials_store') && + (item as Record).cli_auth_credentials_store !== 'file' && + (item as Record).cli_auth_credentials_store !== 'ephemeral') nonFileAuthStore = true; + pending.push(...Object.values(item)); + } + }; + const visited = new Set(); + const inspect = (path: string, layer: Layer) => { + if (visited.has(path)) return; visited.add(path); layers.add(layer); + const data = bytes(path, layer); + evidence.push([layer, path, data ? createHash('sha256').update(data).digest('hex') : null, + data ? metadata(lstatSync(path, { bigint: true })) : null]); + if (data) config(data, layer); + }; + inspect(join(options.home, 'config.toml'), 'home'); + const base = options.systemBase ?? '/etc/codex'; + for (const [name, layer] of [['config.toml', 'system'], ['requirements.toml', 'requirements'], ['managed_config.toml', 'legacy']] as const) inspect(join(base, name), layer); + if (platform === 'darwin') { + layers.add('preferences'); + const values = (options.preferences ?? managedStoragePreferences)(); + if (!Array.isArray(values) || values.length > 2) throw new Invalid('preferences'); + for (const entry of values) { + if (!entry || typeof entry.forced !== 'boolean') throw new Invalid('preferences'); + if (!entry.forced) continue; + if (typeof entry.value !== 'string' || entry.value.length > 2 * LIMIT) throw new Invalid('preferences'); + const encoded = entry.value.trim(); + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) throw new Invalid('preferences'); + const data = Buffer.from(encoded, 'base64'); + if (data.length > LIMIT) throw new Invalid('preferences'); + evidence.push(['preferences', createHash('sha256').update(data).digest('hex')]); config(data, 'preferences'); + } + evidence.push(['preferences_end']); + } + layers.add('cloud'); + // Source: cloud-config/service.rs only loads bundles for eligible backend + // auth; cache absence alone is insufficient because it fetches on a miss. + // Default auth storage is File (config/types.rs). Never read auth.json or + // keychain contents: possible auth/cache/keyring/external identity instead + // requires the owned process's actual opened-SQLite binding, or refusal. + let authMetadataPresent = false; + for (const name of ['auth.json', 'cloud-config-bundle-cache.json']) { + try { const info = lstatSync(join(options.home, name), { bigint: true }); authMetadataPresent = true; + if (!options.ownedAppServer) evidence.push(['cloud_metadata', name, metadata(info)]); } + catch (e) { if (!absent(e)) throw new Invalid('cloud'); if (!options.ownedAppServer) evidence.push(['cloud_metadata', name, null]); } + } + const externalAuth = ['CODEX_ACCESS_TOKEN', 'OPENAI_FEDERATION_RULE_ID', 'OPENAI_IDENTITY_TOKEN_FILE', 'OPENAI_WORKLOAD_IDENTITY_CONTEXT'] + .some(name => Object.hasOwn(environment, name)); + evidence.push(['external_auth_marker', externalAuth]); + const possibleCloud = () => authMetadataPresent || nonFileAuthStore || externalAuth || configServer; + const physical = (cwd: string) => { + try { if (!isAbsolute(cwd)) throw new Error(); const path = realpathSync(cwd); if (!statSync(path).isDirectory()) throw new Error(); return path; } + catch { throw new Invalid('context'); } + }; + queueCwd = physical(options.queueCwd ?? process.cwd()); + const scanAllAncestors = customMarkers || possibleCloud(); + const contexts = [queueCwd, ...(options.consumerCwds ?? []).map(physical)]; + const inspectContexts = (scanAllAncestors: boolean) => { + for (const cwd of new Set(contexts)) { + layers.add('context'); evidence.push(['cwd', cwd]); + const directories: string[] = []; let dir = cwd; + for (;;) { + if (directories.length >= 256) throw new Invalid('context'); directories.push(dir); + if (!scanAllAncestors) { + try { + const marker = lstatSync(join(dir, '.git')); + if (marker.isSymbolicLink()) throw new Invalid('project'); + if (!marker.isDirectory()) break; + try { statSync(join(dir, '.git', 'HEAD')); break; } catch (e) { if (!absent(e)) throw new Invalid('project'); } + } catch (e) { if (!absent(e)) throw e instanceof Invalid ? e : new Invalid('project'); } + } + const parent = dirname(dir); if (dir === parent) { if (!scanAllAncestors) directories.splice(1); break; } dir = parent; + } + evidence.push(['project_roots', directories]); + for (const directory of directories) inspect(join(directory, '.codex', 'config.toml'), 'project'); + } + }; + inspectContexts(scanAllAncestors); + if (!scanAllAncestors && possibleCloud()) inspectContexts(true); + cloudConfiguration = possibleCloud() ? 'requires_owned_storage_binding' : 'ineligible_by_metadata'; + evidence.push(['cloud_gate', possibleCloud()]); + if (possibleCloud() && !options.ownedAppServer) { cloudConfiguration = 'unclassified'; throw new Invalid('cloud'); } + // A reused daemon retains its startup QueueStore. Disk config/read cannot + // attest to it; owned stdio has a separate opened-FD binding guard. + if (!options.ownedAppServer) { + layers.add('daemon'); + try { lstatSync(join(options.home, 'app-server-control', 'app-server-control.sock')); throw new Invalid('daemon'); } + catch (e) { if (!absent(e)) throw e instanceof Invalid ? e : new Invalid('daemon'); } + evidence.push(['daemon', 'absent']); + } + return result('classified', 'codex_local_storage_classified'); + } catch (e) { + const error = e instanceof Invalid ? e : new Invalid('context'); + layers.add(error.layer); + return result(error.configured ? 'unsupported' : 'unknown', error.configured ? 'unsupported_codex_sqlite_home' : + error.layer === 'home' ? 'codex_config_unreadable' : error.layer === 'daemon' ? 'codex_daemon_storage_unverified' : + error.layer === 'cloud' ? 'codex_cloud_storage_unverified' : 'codex_storage_config_unverifiable'); + } +} + +// Only the saved thread's cwd column is inspected: never transcripts, accounts, +// credentials, preference files or arbitrary environment variables. +export async function codexConsumerCwd(home: string, threadId: string): Promise { + try { + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(join(home, 'state_5.sqlite'), { readOnly: true }); + try { + db.exec('PRAGMA query_only=ON; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=3000'); + const row = db.prepare('SELECT cwd FROM threads WHERE id=? LIMIT 1').get(threadId) as { cwd?: unknown } | undefined; + if (typeof row?.cwd !== 'string' || row.cwd.length > 32768 || !isAbsolute(row.cwd)) throw new Error(); + return row.cwd; + } finally { db.close(); } + } catch { throw new Error('codex_consumer_context_unverifiable'); } +} + +// Optional live consumer context complements the persisted thread cwd. A PID +// comes only from the already retained writer evidence, never from discovery. +export function codexWriterCwd(pid: number): string { + try { + if (!Number.isSafeInteger(pid) || pid < 2) throw new Error(); + if (process.platform === 'linux') return realpathSync(readlinkSync(`/proc/${pid}/cwd`)); + if (process.platform !== 'darwin') throw new Error(); + const output = execFileSync('/usr/sbin/lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn0'], + { encoding: 'utf8', timeout: 3000, maxBuffer: 65536, stdio: ['ignore', 'pipe', 'pipe'] }); + const paths = output.split(/[\0\n]/).filter(item => item.startsWith('n')).map(item => item.slice(1)); + if (paths.length !== 1 || !isAbsolute(paths[0]!)) throw new Error(); + return realpathSync(decodeLsofFilename(paths[0]!)); + } catch { throw new Error('codex_consumer_context_unverifiable'); } +} + +export function decodeLsofFilename(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 Error('codex_storage_binding_unverifiable'); + } + return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(bytes)); +} +// A sender version never qualifies a running consumer's environment/loader. +// Instead bind the retained writer PID's CURRENT opened state database inode. +// Observe metadata only; no process environment, DB contents or executable run. +// Queue descriptors, when present, must agree as well. Missing state evidence +// or another SQLite namespace is unclassifiable and refuses submission. +export function codexWriterStorage(pid: number, home: string): void { + try { + if (!Number.isSafeInteger(pid) || pid < 2 || !['darwin', 'linux'].includes(process.platform)) throw new Error(); + const binary = process.platform === 'darwin' ? '/usr/sbin/lsof' : '/usr/bin/lsof'; + const out = execFileSync(binary, ['-a', '-p', String(pid), '-F0fDin'], + { encoding: 'utf8', timeout: 3000, maxBuffer: 65536, stdio: ['ignore', 'pipe', 'pipe'], + env: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LC_ALL: 'C', LANG: 'C' } }); + const files: { name?: string; dev?: bigint; ino?: bigint }[] = []; let current: typeof files[number] | undefined; + for (const raw of out.split('\0')) { + const field = raw.replace(/^\n/, ''); + if (field.startsWith('f')) { current = {}; files.push(current); } + else if (current && field.startsWith('n')) current.name = decodeLsofFilename(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)); + } + if (!files.some(file => file.name === join(home, 'state_5.sqlite'))) throw new Error(); + for (const file of files) { + if (!file.name || !/^(?:state_5|queue_1)\.sqlite(?:-(?:wal|shm))?(?: \(deleted\))?$/.test(basename(file.name))) continue; + if (file.name !== join(home, basename(file.name)) || file.name.endsWith(' (deleted)')) throw new Error(); + const info = lstatSync(file.name, { bigint: true }); + if (!info.isFile() || info.dev !== file.dev || info.ino !== file.ino) throw new Error(); + } + } catch { throw new Error('codex_consumer_storage_unverifiable'); } +} diff --git a/src/diagnostics.ts b/src/diagnostics.ts index c8f825e..f50b854 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -3,7 +3,8 @@ import { accessSync, constants, readdirSync, readFileSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { delimiter, isAbsolute, join } from 'node:path'; -import { listingCandidates, sqliteHome } from './discovery.js'; +import { listingCandidates } from './discovery.js'; +import { classifyCodexStorage, storageDiagnostic } from './codex-storage.js'; import { inspectSkill } from './skill-metadata.js'; import { batchShim } from './process.js'; import { windowsProcessStart } from './windows.js'; @@ -59,9 +60,9 @@ export async function diagnoseCodex(home?: string, binary?: string): Promise item.status === 'available' || (!item.required && item.status === 'missing')); diff --git a/src/discovery.ts b/src/discovery.ts index 71ca049..3f0bf2f 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -1,28 +1,14 @@ // Read-only discovery: no inbox connections, writer selection, queue or DB writes. // Windows Claude metadata uses native process inspection. -import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readlinkSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs'; +import { closeSync, constants, fstatSync, lstatSync, openSync, readlinkSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; -import { parse as parseTOML } from 'smol-toml'; import { windowsProcessStart } from './windows.js'; type Row = Record; export class Refusal extends Error { constructor(readonly code: string, readonly exitCode = 2) { super(code); } } -// Codex can relocate state_5.sqlite/queue_1.sqlite with `sqlite_home`; validation -// reads /state_5.sqlite, so any configured relocation is unsupported. -export function sqliteHome(home: string): 'default' | 'configured' | 'unknown' { - let text: string; - try { - const file = join(home, 'config.toml'), stat = statSync(file); - if (!stat.isFile() || stat.size > 1024 * 1024) return 'unknown'; - text = readFileSync(file, 'utf8'); - } catch (error) { return ['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '') ? 'default' : 'unknown'; } - try { return Object.hasOwn(parseTOML(text), 'sqlite_home') ? 'configured' : 'default'; } - catch { return 'unknown'; } -} - function missing(error: unknown): boolean { return ['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? ''); } diff --git a/src/handoff-operations.ts b/src/handoff-operations.ts index f0c6d0b..a667706 100644 --- a/src/handoff-operations.ts +++ b/src/handoff-operations.ts @@ -156,18 +156,20 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO return correlationEnvelope(options.message, handoff.correlationId); }, beforeEffect: authorize, - async submitCodex(context, message) { + async submitCodex(context, message, storage) { if (!deliverySupported) { // Best-effort unsupported is chosen before ANY app-server attempt. + await storage.revalidate(false); await authorize(); + await storage.revalidate(false); const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toUpperCase() !== 'CODEX_SQLITE_HOME')); - const done = await run(executable(options.codexBin ?? 'codex'), ['queue','--thread',context.target,`--message=${message}`], {env:{...inherited,CODEX_HOME:context.home},timeout:Math.max(1,budget.observationRemainingMs())}); + const done = await run(executable(options.codexBin ?? 'codex'), ['queue','--thread',context.target,`--message=${message}`,'-c',`sqlite_home=${JSON.stringify(context.home)}`], {env:{...inherited,CODEX_HOME:context.home,PWD:storage.cwd},cwd:storage.cwd,timeout:Math.max(1,budget.observationRemainingMs())}); if (!done.spawned) throw new Refusal('native_spawn_failed',1); if (done.interrupted || done.code !== 0) throw new UnknownOutcome(); const receipt = queueId(done.stdout,context.target); return {queueId:receipt,clientUserMessageId:handoff!.correlationId}; } - try { return await nativeQueueOnce({binary:executable(options.codexBin ?? 'codex'),scope:queueScope!,message,clientUserMessageId:handoff!.correlationId,deadlineMs:budget.cutoff,verifyOwner: async () => verifyOwner(scope!),beforeEffect: authorize}); } + try { return await nativeQueueOnce({binary:executable(options.codexBin ?? 'codex'),scope:queueScope!,message,clientUserMessageId:handoff!.correlationId,deadlineMs:budget.cutoff,verifyOwner: async () => verifyOwner(scope!),beforeEffect: authorize,verifyStorage: () => storage.revalidate(true)}); } catch(error) { if (error instanceof AppServerFault && error.attempted) throw new UnknownOutcome(); if(error instanceof AppServerFault) throw new Refusal(`app_server_${error.code}`,1); throw error; } }, timeoutMs() { budget.beforeEffect(); return Math.max(1, Math.floor(budget.observationRemainingMs())); }, diff --git a/src/process.ts b/src/process.ts index 029c575..9836873 100644 --- a/src/process.ts +++ b/src/process.ts @@ -47,13 +47,13 @@ function forward(signal: NodeJS.Signals) { process.kill(process.pid, signal); } export function run(binary: string, args: string[], options: { - env?: NodeJS.ProcessEnv; input?: string; timeout?: number; limit?: number; rawStdout?: boolean; + env?: NodeJS.ProcessEnv; cwd?: string; input?: string; timeout?: number; limit?: number; rawStdout?: boolean; } = {}): Promise { return new Promise(resolve => { let stdout = '', stderr = '', bytes = 0, stderrBytes = 0, spawned = false, interrupted = false, finished = false, stopped = false; let stopReason: StopReason | undefined; const chunks: Buffer[] = [], outputDecoder = new StringDecoder('utf8'), diagnosticDecoder = new StringDecoder('utf8'); - const child = spawn(binary, args, { shell: false, env: options.env ?? process.env, stdio: ['pipe', 'pipe', 'pipe'], detached: posix }); + const child = spawn(binary, args, { shell: false, env: options.env ?? process.env, cwd: options.cwd, stdio: ['pipe', 'pipe', 'pipe'], detached: posix }); const release = () => { if (child.pid === undefined || !groups.delete(child.pid) || groups.size) return; for (const name of signals) process.removeListener(name, forward); diff --git a/src/send.ts b/src/send.ts index 0e1cdf1..157abfd 100644 --- a/src/send.ts +++ b/src/send.ts @@ -4,19 +4,21 @@ import { createConnection } from 'node:net'; import { closeSync, constants, fstatSync, lstatSync, openSync, type BigIntStats } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { claude, Refusal, sqliteHome } from './discovery.js'; +import { claude, Refusal } from './discovery.js'; +import { classifyCodexStorage, codexConsumerCwd, codexWriterCwd, codexWriterStorage, storageDiagnostic, type StorageCheck } from './codex-storage.js'; import { executable, run, UnknownOutcome } from './process.js'; import { HomeRefusal, resolveWriter, revalidateWriter, uuid, type HomeResolution } from './writer.js'; import { postWindowsPipe } from './windows.js'; export type SendContext = { agent: 'claude' | 'codex'; target: string; home?: string; generation: string | null; writerIdentity?: string }; +export type CodexStorageGuard = { cwd: string; revalidate: (ownedAppServer?: boolean) => Promise }; export type SendHooks = { // Only explicitly opted-in callers resolve this additional context. Default // send output, inspection count and native execution remain unchanged. resolved: (context: SendContext, snapshot: Record) => Promise | string | void; beforeEffect?: () => Promise | void; timeoutMs?: () => number; - submitCodex?: (context: SendContext, message: string) => Promise<{ queueId?: string; clientUserMessageId: string }>; + submitCodex?: (context: SendContext, message: string, storage: CodexStorageGuard) => Promise<{ queueId?: string; clientUserMessageId: string }>; }; export type SendOptions = { to: string; home?: string; codexBin?: string; message: string; dryRun?: boolean; allowInactive?: boolean; hooks?: SendHooks }; export class CodexUnknownOutcome extends UnknownOutcome { @@ -90,12 +92,40 @@ export async function send(options: SendOptions): Promise { + try { const info = lstatSync(binary, { bigint: true }); if (!info.isFile()) throw new Error(); + return [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(':'); } + catch { throw new Refusal('codex_storage_loader_unverifiable', 1); } + }; + const binaryEvidence = executableEvidence(); const selection = await resolveWriter(options.home, id, options.allowInactive); const { home } = selection; - const storage = sqliteHome(home); - if (storage !== 'default') throw new HomeRefusal(storage === 'configured' ? 'unsupported_codex_sqlite_home' : 'codex_config_unreadable', selection.resolution); + if (!['darwin', 'linux'].includes(process.platform)) { + const platformStorage = classifyCodexStorage({ home }); + throw new HomeRefusal(platformStorage.code, selection.resolution); + } + const refuseStorage = (check: StorageCheck) => { + if (check.status !== 'classified') throw new HomeRefusal(check.code, selection.resolution); + return check; + }; + let consumerCwd: string; + try { consumerCwd = await codexConsumerCwd(home, id); } + catch { throw new HomeRefusal('codex_consumer_context_unverifiable', selection.resolution); } + let writerPid: number | undefined, writerCwd: string | undefined; + try { + const proof = JSON.parse(selection.evidence) as { fingerprints: { home: string; writer: string }[] }; + writerPid = JSON.parse(proof.fingerprints.find(item => item.home === home)!.writer).pid; + if (writerPid !== undefined) { writerCwd = codexWriterCwd(writerPid); codexWriterStorage(writerPid, home); } + } catch { throw new HomeRefusal('codex_consumer_context_unverifiable', selection.resolution); } + const consumerCwds = [consumerCwd, ...(writerCwd ? [writerCwd] : [])]; + const queueCwd = process.cwd(); + // Hooks may choose an owned app-server; that path binds its opened SQLite + // descriptors independently. Its fallback must revalidate the ordinary plan. + const ownedAppServer = Boolean(options.hooks?.submitCodex); + const storage = refuseStorage(classifyCodexStorage({ home, queueCwd, consumerCwds, ownedAppServer })); const result = { ...base, target: { agent: 'codex', id }, codexHome: home, - status: options.dryRun ? 'validated' : 'queued', codexHomeResolution: selection.resolution }; + status: options.dryRun ? 'validated' : 'queued', codexHomeResolution: selection.resolution, + storage: storageDiagnostic(storage) }; let context: SendContext | undefined; if (options.hooks) { // Bind the identity already captured by selection, not a second sample @@ -114,22 +144,42 @@ export async function send(options: SendOptions): Promise !['CODEX_SQLITE_HOME', 'CODEX_HOME'].includes(key.toUpperCase()))); + const env = { ...inherited, CODEX_HOME: home, PWD: storage.queueCwd! }; + const version = await run(binary, ['--version'], { env, cwd: storage.queueCwd, timeout: Math.min(3000, options.hooks?.timeoutMs?.() ?? 3000), limit: 4096 }); + const nativeVersion = !version.interrupted && version.code === 0 && /^codex-cli \d+\.\d+\.\d+\s*$/.test(version.stdout) ? version.stdout.trim().slice(10) : ''; + const qualified = refuseStorage(classifyCodexStorage({ home, queueCwd, consumerCwds, ownedAppServer, version: nativeVersion })); + if (qualified.fingerprint !== storage.fingerprint) throw new HomeRefusal('codex_storage_changed_before_queue', selection.resolution); + result.storage = storageDiagnostic(qualified); + const guard: CodexStorageGuard = { cwd: qualified.queueCwd!, async revalidate(owned = ownedAppServer) { + await revalidateWriter(selection, options.home, id, options.allowInactive); + if (executableEvidence() !== binaryEvidence) throw new HomeRefusal('codex_storage_loader_changed', selection.resolution); + let currentCwd: string; + try { currentCwd = await codexConsumerCwd(home, id); } + catch { throw new HomeRefusal('codex_consumer_context_unverifiable', selection.resolution); } + if (currentCwd !== consumerCwd) throw new HomeRefusal('codex_storage_changed_before_queue', selection.resolution); + if (writerPid !== undefined) { + try { if (codexWriterCwd(writerPid) !== writerCwd) throw new Error(); codexWriterStorage(writerPid, home); } + catch { throw new HomeRefusal('codex_consumer_context_unverifiable', selection.resolution); } + } + const current = refuseStorage(classifyCodexStorage({ home, queueCwd, consumerCwds, ownedAppServer, version: nativeVersion })); + if (current.fingerprint !== qualified.fingerprint) throw new HomeRefusal('codex_storage_changed_before_queue', selection.resolution); + if (owned !== ownedAppServer) refuseStorage(classifyCodexStorage({ home, queueCwd, consumerCwds, ownedAppServer: owned, version: nativeVersion })); + } }; + await guard.revalidate(); // `--message=` keeps a body starting with `-` from being parsed as an option. // An inherited CODEX_SQLITE_HOME would queue outside the validated home. // Windows environment names are case-insensitive. if (options.hooks?.submitCodex) { - const receipt = await options.hooks.submitCodex(context!, options.message); + const receipt = await options.hooks.submitCodex(context!, options.message, guard); return { ...result, ...(receipt.queueId === undefined ? {} : {queueId:receipt.queueId}) }; } - const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toUpperCase() !== 'CODEX_SQLITE_HOME')); await options.hooks?.beforeEffect?.(); - const done = await run(binary, ['queue', '--thread', id, `--message=${options.message}`], - { env: { ...inherited, CODEX_HOME: home }, timeout: options.hooks?.timeoutMs?.() ?? 30000 }); + await guard.revalidate(); + const done = await run(binary, ['queue', '--thread', id, `--message=${options.message}`, '-c', `sqlite_home=${JSON.stringify(home)}`], + { env, cwd: guard.cwd, timeout: options.hooks?.timeoutMs?.() ?? 30000 }); if (!done.spawned) throw new HomeRefusal('native_spawn_failed', selection.resolution); if (done.interrupted || done.code !== 0) throw new CodexUnknownOutcome(selection.resolution); const queuedId = queueId(done.stdout, id); diff --git a/test/app-server.test.ts b/test/app-server.test.ts index 98707ea..5c35278 100644 --- a/test/app-server.test.ts +++ b/test/app-server.test.ts @@ -10,6 +10,7 @@ import { tmpdir } from 'node:os'; import childProcess from 'node:child_process'; import { EventEmitter } from 'node:events'; import { syncBuiltinESMExports } from 'node:module'; +import { DatabaseSync } from 'node:sqlite'; 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, @@ -497,7 +498,13 @@ test('nativeQueueOnce pins env/CLI SQLite home, checks opened paths and selected 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 }); + // Synthetic auth metadata makes cloud policy possible. No credential content + // is read: owned stdio must prove the actual state/queue descriptors instead. + writeFileSync(join(selected, 'auth.json'), 'fixture-auth-metadata-never-read'); const original = { ...scope, codexHome: selected, threadId: '01950000-0000-7000-8000-000000000071' }; + const state = new DatabaseSync(join(selected, 'state_5.sqlite')); + state.exec('CREATE TABLE threads(id TEXT,cwd TEXT)'); + state.prepare('INSERT INTO threads VALUES(?,?)').run(original.threadId, selected); state.close(); 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'); @@ -505,7 +512,7 @@ test('nativeQueueOnce pins env/CLI SQLite home, checks opened paths and selected // 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,'state_5.sqlite'),storage===process.env.CODEX_SQLITE_HOME?'r+':'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); @@ -517,7 +524,8 @@ test('nativeQueueOnce pins env/CLI SQLite home, checks opened paths and selected 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'); }, + beforeEffect: async () => { guards++; writeFileSync(join(root, 'guard'), 'committed'); + writeFileSync(join(selected, 'cloud-config-bundle-cache.json'), 'fixture-cache-refresh-never-read'); }, 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)); @@ -533,12 +541,29 @@ test('nativeQueueOnce pins env/CLI SQLite home, checks opened paths and selected env: { ...options.env, FIXTURE_STORAGE_HOME: other } }), fault('storage_binding_unverified', false)); assert.equal(guards, 1); const guardsBefore = guards; + await t.test('configuration mutation during durable guard cannot reach queue', async () => { + const config = join(selected, 'config.toml'); + try { + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + beforeEffect: async () => { writeFileSync(config, 'model="changed-during-guard"'); } }), fault('owner_changed', false)); + } finally { rmSync(config, { force: true }); } + }); + await t.test('consumer cwd mutation during final owner verification cannot reach queue', async () => { + let fenced = false; + const updateCwd = (cwd: string) => { const state = new DatabaseSync(join(selected, 'state_5.sqlite')); state.prepare('UPDATE threads SET cwd=?').run(cwd); state.close(); }; + try { + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + beforeEffect: async () => { fenced = true; }, verifyOwner: async () => { if (fenced) updateCwd(other); return true; } }), fault('owner_changed', false)); + } finally { updateCwd(selected); } + }); 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)); + rmSync(join(selected, 'state_5.sqlite')); + renameSync(join(selected, 'detached-state.sqlite'), join(selected, 'state_5.sqlite')); }); assert.equal(guards, guardsBefore + 1); await t.test('same canonical descriptor paths with wrong inode are refused after the guard', async () => { diff --git a/test/codex-homes.test.ts b/test/codex-homes.test.ts index 0f611f1..1983d0d 100644 --- a/test/codex-homes.test.ts +++ b/test/codex-homes.test.ts @@ -18,8 +18,8 @@ function fixture(t: TestContext) { function database(home: string, savedThread = true) { mkdirSync(home, { recursive: true }); const db = new DatabaseSync(join(home, 'state_5.sqlite')); - db.exec('CREATE TABLE threads(id TEXT)'); - if (savedThread) db.prepare('INSERT INTO threads VALUES (?)').run(id); + db.exec('CREATE TABLE threads(id TEXT, cwd TEXT)'); + if (savedThread) db.prepare('INSERT INTO threads VALUES (?, ?)').run(id, root); db.close(); return home; } const home = database(join(root, '.codex')); @@ -106,12 +106,13 @@ test('CLI validates inactive option scope and stdio dry-run includes sanitized d function invoke(extra: string[], wire = false) { const all = [...args, ...extra]; const result = spawnSync(process.execPath, [resolve('dist/cli.js'), ...(wire ? ['--stdio-request'] : all)], { - encoding: 'utf8', input: wire ? JSON.stringify({ schemaVersion: 1, args: all }) : undefined }); + cwd: f.root, encoding: 'utf8', input: wire ? JSON.stringify({ schemaVersion: 1, args: all }) : undefined }); return JSON.parse(result.stdout); } assert.equal(invoke(['--allow-inactive-codex-home']).error, 'inactive_opt_in_requires_explicit_home'); const result = invoke(['--codex-home', f.home, '--allow-inactive-codex-home', '--dry-run'], true); - assert.equal(result.status, 'validated'); assert.equal(result.submitted, false); + assert.equal(result.status, process.platform === 'win32' ? 'refused' : 'validated'); assert.equal(result.submitted, false); + if (process.platform === 'win32') assert.equal(result.error, 'codex_storage_platform_unqualified'); assert.equal(result.codexHomeResolution.reason, 'explicit_inactive_opt_in'); assert.equal(result.queueId, undefined); for (const c of result.codexHomeResolution.candidates) assert.deepEqual(Object.keys(c).sort(), ['activity', 'codexHome', 'reason', 'savedThread', 'sources', 'writerLock']); diff --git a/test/codex-storage.test.ts b/test/codex-storage.test.ts new file mode 100644 index 0000000..7df6af0 --- /dev/null +++ b/test/codex-storage.test.ts @@ -0,0 +1,261 @@ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import fs, { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import childProcess from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { classifyCodexStorage, codexWriterStorage, storageDiagnostic, STORAGE_LOADER_VERSIONS } from '../dist/codex-storage.js'; +import { send } from '../dist/send.js'; +import { diagnoseCodex } from '../dist/diagnostics.js'; + +const id = '11111111-1111-4111-8111-111111111111'; +function fixture(t: TestContext) { + const root = realpathSync(mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-storage-'))); + const home = join(root, 'home'), systemBase = join(root, 'system'), cwd = join(root, 'work'), consumer = join(root, 'consumer'); + for (const path of [home, systemBase, cwd, consumer]) mkdirSync(path); + const previous = { ...process.env }; + Object.assign(process.env, { HOME: root, USERPROFILE: root, CODEX_HOME: '', SESSION_PEER_CODEX_HOMES: '[]' }); + const options = { home, systemBase, queueCwd: cwd, consumerCwds: [consumer], platform: 'linux', version: '0.165.0' }; + t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); + for (const key of Object.keys(process.env)) if (!(key in previous)) delete process.env[key]; + Object.assign(process.env, previous); rmSync(root, { recursive: true, force: true }); }); + const config = (path: string, text: string | Buffer) => { mkdirSync(join(path, '..'), { recursive: true }); writeFileSync(path, text); }; + return { root, home, systemBase, cwd, consumer, options, config }; +} +test('exact source loader tags are separate from observation qualification; unsupported platforms/versions close', t => { + const f = fixture(t); + for (const version of STORAGE_LOADER_VERSIONS) { + const check = classifyCodexStorage({ ...f.options, version }); + assert.equal(check.status, 'classified'); assert.equal(check.loaderVersionVerified, true); + } + for (const version of ['', '0.165.1', '0.162.1-vendor']) assert.equal(classifyCodexStorage({ ...f.options, version }).code, 'codex_storage_loader_unsupported'); + assert.equal(classifyCodexStorage({ ...f.options, platform: 'win32' }).code, 'codex_storage_platform_unqualified'); + const check = classifyCodexStorage({ ...f.options, version: undefined }); + assert.equal(check.loaderVersionVerified, false); assert.equal(check.loaderVersion, null); + assert.equal(JSON.stringify(storageDiagnostic(check)).includes(f.root), false); + assert.equal('fingerprint' in storageDiagnostic(check), false); +}); +test('home/system/requirements/legacy each reject semantic escaped root keys, malformed UTF8/TOML and unreadable files', t => { + const f = fixture(t); + const paths = [join(f.home, 'config.toml'), ...['config.toml', 'requirements.toml', 'managed_config.toml'].map(name => join(f.systemBase, name))]; + for (const path of paths) { + for (const text of ['sqlite_home="SECRET-LOCATION"', String.raw`"sqlite\u005fhome"="SECRET-LOCATION"`, String.raw`"sqlite\U0000005fhome"="SECRET-LOCATION"`]) { + f.config(path, text); const result = classifyCodexStorage(f.options); + assert.equal(result.code, 'unsupported_codex_sqlite_home'); assert.equal(JSON.stringify(result).includes('SECRET'), false); + } + for (const text of ['sqlite_home=[', 'setting=3e++20', Buffer.from([0xff])]) { + f.config(path, text); assert.equal(classifyCodexStorage(f.options).status, 'unknown'); + } + f.config(path, '[profiles.p]\nsqlite_home="nested-only"\n'); assert.equal(classifyCodexStorage(f.options).status, 'classified'); + rmSync(path); + } + f.config(paths[1]!, 'model="ok"'); + const open = fs.openSync; + t.mock.method(fs, 'openSync', ((path: any, ...args: any[]) => { + if (path === paths[1]) throw Object.assign(new Error('SECRET-detail'), { code: 'EACCES' }); + return (open as any)(path, ...args); + }) as typeof fs.openSync); syncBuiltinESMExports(); + const result = classifyCodexStorage(f.options); assert.equal(result.status, 'unknown'); assert.equal(JSON.stringify(result).includes('SECRET'), false); +}); +test('configuration reads reject symlinks, directories, oversized files, replacements and FIFO without opening', t => { + const f = fixture(t), path = join(f.home, 'config.toml'), target = join(f.root, 'target'); + writeFileSync(target, 'model="ok"'); symlinkSync(target, path); + assert.equal(classifyCodexStorage(f.options).status, 'unknown'); rmSync(path); + mkdirSync(path); assert.equal(classifyCodexStorage(f.options).status, 'unknown'); rmSync(path, { recursive: true }); + if (process.platform !== 'win32') { + childProcess.execFileSync('/usr/bin/mkfifo', [path]); + assert.equal(classifyCodexStorage(f.options).status, 'unknown'); rmSync(path); + } + writeFileSync(path, 'x'.repeat(1024 * 1024 + 1)); assert.equal(classifyCodexStorage(f.options).status, 'unknown'); + writeFileSync(path, 'model="ok"'); + const open = fs.openSync; + t.mock.method(fs, 'openSync', ((file: any, ...args: any[]) => { + if (file === path) { rmSync(path); writeFileSync(path, 'model="replacement"'); } + return (open as any)(file, ...args); + }) as typeof fs.openSync); syncBuiltinESMExports(); + assert.equal(classifyCodexStorage(f.options).status, 'unknown'); +}); +test('project layers inspect physical queue and consumer cwd, nearest git boundaries and custom markers', t => { + const f = fixture(t), inner = join(f.cwd, 'nested'); mkdirSync(inner); + const alias = join(f.root, 'alias'); symlinkSync(inner, alias); + f.config(join(f.cwd, '.git', 'HEAD'), 'ref: refs/heads/main'); + f.config(join(f.cwd, '.codex', 'config.toml'), 'sqlite_home="SECRET-project"'); + assert.equal(classifyCodexStorage({ ...f.options, queueCwd: alias }).status, 'unsupported'); + rmSync(join(f.cwd, '.codex'), { recursive: true }); + f.config(join(f.consumer, '.codex', 'config.toml'), 'sqlite_home="SECRET-consumer"'); + assert.equal(classifyCodexStorage({ ...f.options, queueCwd: alias }).status, 'unsupported'); + rmSync(join(f.consumer, '.codex'), { recursive: true }); + f.config(join(f.root, '.codex', 'config.toml'), 'sqlite_home="above-default-boundary"'); + assert.equal(classifyCodexStorage(f.options).status, 'classified'); + f.config(join(f.home, 'config.toml'), 'project_root_markers=["custom"]'); + assert.equal(classifyCodexStorage(f.options).status, 'unsupported'); + assert.equal(classifyCodexStorage({ ...f.options, queueCwd: join(f.root, 'missing') }).status, 'unknown'); +}); +test('an early EOF never classifies an observed nonempty configuration; the descriptor closes exactly once', t => { + const f = fixture(t); f.config(join(f.home, 'config.toml'), 'sqlite_home="SECRET"'); + const close = fs.closeSync; let closes = 0; + t.mock.method(fs, 'readSync', () => 0); + t.mock.method(fs, 'closeSync', (fd: number) => { closes++; close(fd); }); syncBuiltinESMExports(); + const result = classifyCodexStorage(f.options); + assert.equal(result.status, 'unknown'); assert.equal(closes, 1); +}); +test('configuration identity uses exact bigint inode values beyond Number precision and closes once on mismatch', t => { + const f = fixture(t), file = join(f.home, 'config.toml'); f.config(file, 'model="fixture"'); + const lstat = fs.lstatSync, fstat = fs.fstatSync, close = fs.closeSync; let closes = 0; + t.mock.method(fs, 'lstatSync', ((path: any, ...args: any[]) => { + const info = (lstat as any)(path, ...args); + return path === file ? Object.assign(Object.create(info), { ino: 9007199254740992n }) : info; + }) as typeof fs.lstatSync); + t.mock.method(fs, 'fstatSync', ((...args: any[]) => Object.assign(Object.create((fstat as any)(...args)), { ino: 9007199254740993n })) as typeof fs.fstatSync); + t.mock.method(fs, 'closeSync', (fd: number) => { closes++; close(fd); }); syncBuiltinESMExports(); + assert.equal(classifyCodexStorage(f.options).status, 'unknown'); assert.equal(closes, 1); +}); +test('live consumer storage uses retained PID opened-inode evidence independently of sender loader version', t => { + const f = fixture(t), file = join(f.home, 'state_5.sqlite'); writeFileSync(file, 'fixture-metadata'); + const info = fs.lstatSync(file, { bigint: true }); let reported = file, inode = info.ino; + t.mock.method(childProcess, 'execFileSync', (() => `p123\0f3\0D0x${info.dev.toString(16)}\0i${inode}\0n${reported}\0`) as unknown as typeof childProcess.execFileSync); syncBuiltinESMExports(); + assert.doesNotThrow(() => codexWriterStorage(123, f.home)); + reported = join(f.root, 'elsewhere', 'state_5.sqlite'); assert.throws(() => codexWriterStorage(123, f.home)); + reported = file; inode++; assert.throws(() => codexWriterStorage(123, f.home)); + reported = file + ' (deleted)'; assert.throws(() => codexWriterStorage(123, f.home)); +}); +test('all supported macOS tags ignore ordinary preferences and inspect forced config/requirements with bounded base64 TOML', t => { + const f = fixture(t), encoded = (text: string) => Buffer.from(text).toString('base64'); + for (const version of STORAGE_LOADER_VERSIONS) { + const options = { ...f.options, platform: 'darwin', version }; + assert.equal(classifyCodexStorage({ ...options, preferences: () => [{ forced: false, value: encoded('sqlite_home="ordinary"') }] }).status, 'classified'); + for (const value of [encoded('sqlite_home="forced"'), encoded(String.raw`"sqlite\u005fhome"="forced"`)]) { + assert.equal(classifyCodexStorage({ ...options, preferences: () => [{ forced: true, value }] }).status, 'unsupported'); + } + for (const value of [1, 'not-base64!', encoded('sqlite_home=['), 'A'.repeat(2 * 1024 * 1024 + 1)]) { + assert.equal(classifyCodexStorage({ ...options, preferences: () => [{ forced: true, value }] }).status, 'unknown'); + } + assert.equal(classifyCodexStorage({ ...options, preferences: () => { throw new Error('SECRET'); } }).status, 'unknown'); + } +}); +test('ordinary queue refuses a retained daemon; owned stdio classification is separate', t => { + const f = fixture(t); f.config(join(f.home, 'app-server-control', 'app-server-control.sock'), 'metadata-only'); + assert.equal(classifyCodexStorage(f.options).code, 'codex_daemon_storage_unverified'); + assert.equal(classifyCodexStorage({ ...f.options, ownedAppServer: true }).status, 'classified'); +}); +test('possible cloud policy requires owned binding: metadata, keyring/auto, external identity and config server', t => { + const f = fixture(t); + for (const name of ['auth.json', 'cloud-config-bundle-cache.json']) { + const path = join(f.home, name); writeFileSync(path, 'SECRET-NEVER-READ'); + assert.equal(classifyCodexStorage(f.options).code, 'codex_cloud_storage_unverified'); + const owned = classifyCodexStorage({ ...f.options, ownedAppServer: true }); + assert.equal(owned.status, 'classified'); assert.equal(owned.cloudConfiguration, 'requires_owned_storage_binding'); rmSync(path); + symlinkSync(join(f.root, 'missing-target'), path); + assert.equal(classifyCodexStorage(f.options).code, 'codex_cloud_storage_unverified'); rmSync(path); + } + for (const mode of ['keyring', 'auto']) { + f.config(join(f.home, 'config.toml'), `cli_auth_credentials_store="${mode}"`); + assert.equal(classifyCodexStorage(f.options).code, 'codex_cloud_storage_unverified'); + } + f.config(join(f.home, 'config.toml'), 'cli_auth_credentials_store="file"'); + assert.equal(classifyCodexStorage(f.options).status, 'classified'); + f.config(join(f.home, 'config.toml'), '[profiles.selected]\ncli_auth_credentials_store="keyring"'); + assert.equal(classifyCodexStorage(f.options).code, 'codex_cloud_storage_unverified'); + f.config(join(f.home, 'config.toml'), '[config_server]\nurl="https://fixture.invalid"'); + assert.equal(classifyCodexStorage(f.options).code, 'codex_cloud_storage_unverified'); rmSync(join(f.home, 'config.toml')); + for (const name of ['CODEX_ACCESS_TOKEN', 'OPENAI_FEDERATION_RULE_ID', 'OPENAI_IDENTITY_TOKEN_FILE', 'OPENAI_WORKLOAD_IDENTITY_CONTEXT']) { + const check = classifyCodexStorage({ ...f.options, environment: { [name]: 'SECRET-MARKER' } }); + assert.equal(check.code, 'codex_cloud_storage_unverified'); assert.equal(JSON.stringify(check).includes('SECRET'), false); + } +}); +test('unreadable auth metadata fails closed without reading credentials; remote environment markers and environment files refuse', t => { + const f = fixture(t); + for (const name of ['CODEX_EXEC_SERVER_URL', 'CODEX_EXEC_SERVER_NOISE_REGISTRY_URL', 'CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID', 'CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN', 'CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID']) { + for (const ownedAppServer of [false, true]) assert.equal(classifyCodexStorage({ ...f.options, ownedAppServer, environment: { [name]: 'SECRET-REMOTE' } }).status, 'unknown'); + } + f.config(join(f.home, 'environments.toml'), 'SECRET-NEVER-READ'); + assert.equal(classifyCodexStorage(f.options).status, 'unknown'); rmSync(join(f.home, 'environments.toml')); + const lstat = fs.lstatSync; + t.mock.method(fs, 'lstatSync', ((path: any, ...args: any[]) => { + if (path === join(f.home, 'auth.json')) throw Object.assign(new Error('SECRET-error'), { code: 'EACCES' }); + return (lstat as any)(path, ...args); + }) as typeof fs.lstatSync); syncBuiltinESMExports(); + assert.equal(classifyCodexStorage(f.options).code, 'codex_cloud_storage_unverified'); +}); + +function dispatchFixture(t: TestContext) { + const f = fixture(t), log = join(f.root, 'native-log'), binary = join(f.root, 'codex-fixture'); + const db = new DatabaseSync(join(f.home, 'state_5.sqlite')); + db.exec('CREATE TABLE threads(id TEXT,cwd TEXT)'); db.prepare('INSERT INTO threads VALUES(?,?)').run(id, f.consumer); db.close(); + writeFileSync(binary, `#!${process.execPath}\nif(process.argv.includes('--version')){console.log('codex-cli 0.165.0');process.exit(0)}require('node:fs').appendFileSync(${JSON.stringify(log)},JSON.stringify({argv:process.argv.slice(2),cwd:process.cwd(),sqlite:process.env.CODEX_SQLITE_HOME})+'\\n');console.log('Queued message fixture-q for thread ${id}.');`, { mode: 0o700 }); + const lstat = fs.lstatSync, open = fs.openSync, exec = childProcess.execFileSync; + t.mock.method(fs, 'lstatSync', ((path: any, ...args: any[]) => { + if (typeof path === 'string' && path.startsWith('/etc/codex/')) return (lstat as any)(join(f.systemBase, path.slice('/etc/codex/'.length)), ...args); + return (lstat as any)(path, ...args); + }) as typeof fs.lstatSync); + t.mock.method(fs, 'openSync', ((path: any, ...args: any[]) => (open as any)(typeof path === 'string' && path.startsWith('/etc/codex/') ? join(f.systemBase, path.slice('/etc/codex/'.length)) : path, ...args)) as typeof fs.openSync); + t.mock.method(childProcess, 'execFileSync', ((file: any, ...args: any[]) => file === '/usr/bin/osascript' ? '[]' : (exec as any)(file, ...args)) as typeof childProcess.execFileSync); + syncBuiltinESMExports(); + return { ...f, binary, log, sendOptions: { to: `codex:${id}`, home: f.home, codexBin: binary, message: 'fixture', allowInactive: true } }; +} +test('ordinary dispatch submits once with inspected cwd and stripped storage override; dry-run never executes binary', async t => { + const f = dispatchFixture(t); process.env.CODEX_SQLITE_HOME = 'SECRET-inherited'; + await send({ ...f.sendOptions, dryRun: true }); assert.equal(existsSync(f.log), false); + const result = await send(f.sendOptions); assert.equal(result.submitted, true); + const rows = fs.readFileSync(f.log, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + assert.equal(rows.length, 1); assert.equal(rows[0].cwd, realpathSync(process.cwd())); assert.equal(rows[0].sqlite, undefined); + assert.equal(JSON.stringify(result).includes('SECRET'), false); +}); +test('each unsupported layer and a config mutation after beforeEffect makes zero native queue submissions', async t => { + const f = dispatchFixture(t); + for (const path of [join(f.home, 'config.toml'), join(f.systemBase, 'config.toml'), join(f.systemBase, 'requirements.toml'), join(f.systemBase, 'managed_config.toml'), join(f.consumer, '.codex', 'config.toml')]) { + f.config(path, '"sqlite\\u005fhome"="SECRET-location"'); + await assert.rejects(send(f.sendOptions), { code: 'unsupported_codex_sqlite_home' }); assert.equal(existsSync(f.log), false); rmSync(path); + } + await assert.rejects(send({ ...f.sendOptions, hooks: { resolved() {}, beforeEffect() { f.config(join(f.home, 'config.toml'), 'model="changed-during-guard"'); } } }), { code: 'codex_storage_changed_before_queue' }); + assert.equal(existsSync(f.log), false); +}); +test('doctor and dispatch share system classification while doctor never executes the agent binary', async t => { + const f = dispatchFixture(t); f.config(join(f.systemBase, 'requirements.toml'), 'sqlite_home="SECRET-system"'); + const result = await diagnoseCodex(f.home, f.binary) as any; + assert.equal(result.homes[0].storage.code, 'unsupported_codex_sqlite_home'); assert.equal(result.tool.executed, false); + assert.equal(result.homes[0].storage.loaderVersionVerified, false); + assert.equal(existsSync(f.log), false); assert.equal(JSON.stringify(result).includes('SECRET'), false); +}); +test('unsupported loader version and executable replacement never submit a native queue', async t => { + const f = dispatchFixture(t); + writeFileSync(f.binary, `#!${process.execPath}\nconsole.log('codex-cli 0.165.1');`, { mode: 0o700 }); + await assert.rejects(send(f.sendOptions), { code: 'codex_storage_loader_unsupported' }); assert.equal(existsSync(f.log), false); + writeFileSync(f.binary, `#!${process.execPath}\nif(process.argv.includes('--version')){console.log('codex-cli 0.165.0');process.exit(0)}require('node:fs').writeFileSync(${JSON.stringify(f.log)},'unexpected');`, { mode: 0o700 }); + await assert.rejects(send({ ...f.sendOptions, hooks: { resolved() {}, beforeEffect() { fs.appendFileSync(f.binary, '\n// replacement'); } } }), { code: 'codex_storage_loader_changed' }); + assert.equal(existsSync(f.log), false); +}); +test('consumer cwd and same-content config replacement after validation refuse before submission', async t => { + const f = dispatchFixture(t), config = join(f.home, 'config.toml'); f.config(config, 'model="fixture"'); + await assert.rejects(send({ ...f.sendOptions, hooks: { resolved() {}, beforeEffect() { + const replacement = join(f.home, 'config-replacement.toml'); writeFileSync(replacement, 'model="fixture"'); fs.renameSync(replacement, config); + } } }), { code: 'codex_storage_changed_before_queue' }); assert.equal(existsSync(f.log), false); + await assert.rejects(send({ ...f.sendOptions, hooks: { resolved() {}, beforeEffect() { + const db = new DatabaseSync(join(f.home, 'state_5.sqlite')); db.prepare('UPDATE threads SET cwd=?').run(f.cwd); db.close(); + } } }), { code: 'codex_storage_changed_before_queue' }); assert.equal(existsSync(f.log), false); +}); +test('ordinary auth/cache/credential-route changes refuse with zero native queue calls and no credential reads', { skip: process.platform === 'win32' }, async t => { + const f = dispatchFixture(t), open = fs.openSync; + t.mock.method(fs, 'openSync', ((path: any, ...args: any[]) => { + assert.notEqual(path, join(f.home, 'auth.json')); assert.notEqual(path, join(f.home, 'cloud-config-bundle-cache.json')); + return (open as any)(path, ...args); + }) as typeof fs.openSync); syncBuiltinESMExports(); + for (const name of ['auth.json', 'cloud-config-bundle-cache.json']) { + const path = join(f.home, name); writeFileSync(path, 'SECRET-NEVER-READ'); + await assert.rejects(send(f.sendOptions), { code: 'codex_cloud_storage_unverified' }); assert.equal(existsSync(f.log), false); rmSync(path); + await assert.rejects(send({ ...f.sendOptions, hooks: { resolved() {}, beforeEffect() { writeFileSync(path, 'SECRET-NEVER-READ'); } } }), { code: 'codex_cloud_storage_unverified' }); + assert.equal(existsSync(f.log), false); rmSync(path); + } + for (const mode of ['keyring', 'auto']) { + f.config(join(f.home, 'config.toml'), `cli_auth_credentials_store="${mode}"`); + await assert.rejects(send(f.sendOptions), { code: 'codex_cloud_storage_unverified' }); assert.equal(existsSync(f.log), false); + } +}); +test('Windows Codex send and dry-run refuse an unqualified storage adapter before any native execution', { skip: process.platform !== 'win32' }, async t => { + const f = dispatchFixture(t); let calls = 0; + t.mock.method(childProcess, 'spawn', () => { calls++; throw new Error('unexpected native execution'); }); syncBuiltinESMExports(); + for (const dryRun of [false, true]) await assert.rejects(send({ ...f.sendOptions, codexBin: process.execPath, dryRun }), { code: 'codex_storage_platform_unqualified' }); + assert.equal(calls, 0); +}); diff --git a/test/current-version.test.ts b/test/current-version.test.ts new file mode 100644 index 0000000..c759e74 --- /dev/null +++ b/test/current-version.test.ts @@ -0,0 +1,145 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { currentVersions, planCurrentVersions } from '../scripts/current-version.mjs'; + +type Inventory = { file: string; templates: { line: string; occurrences: number }[] }[]; +const root = fileURLToPath(new URL('../', import.meta.url)); +const read = (file: string) => readFileSync(join(root, file), 'utf8'); +const inventory: Inventory = JSON.parse(read('scripts/current-version-fields.json')); +const version: string = JSON.parse(read('package.json')).version; +const files = () => new Map(inventory.map(entry => [entry.file, read(entry.file)])); +const immutable = ['VALIDATION.md', 'RELEASING.md', 'docs/design/typescript-skill-compatibility.md', + 'src/skill-metadata.ts', 'src/native-profiles.ts', 'test/fixtures/python-reference.pin.json']; +const sentinel = '\nHistorical release 0.5.1; Python 1.1.0; skill 0.3.0; Codex 0.162.1.\n' + + 'sha256 00c6fc343fdbeb8f6dc1b1ee1b730f04cef71bbfb08205a7be3559202f1d8804\n'; + +function fixture(t: { after: (fn: () => void) => void }) { + const path = mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-current-version-')); + t.after(() => rmSync(path, { recursive: true, force: true })); + const write = (file: string, text: string) => { + mkdirSync(dirname(join(path, file)), { recursive: true }); + writeFileSync(join(path, file), text); + }; + for (const [file, text] of files()) write(file, text + sentinel); + for (const file of immutable) write(file, read(file)); + write('scripts/current-version-fields.json', JSON.stringify(inventory)); + const pkg = JSON.parse(read('package.json')); + const [major, minor, patch] = version.split('.').map(Number); + assert.ok(Number.isSafeInteger(patch), 'synthetic fixture expects a stable source version'); + pkg.version = `${major}.${minor}.${patch + 1}`; + const lock = JSON.parse(read('package-lock.json')); + lock.version = lock.packages[''].version = pkg.version; + write('package.json', JSON.stringify(pkg)); + write('package-lock.json', JSON.stringify(lock)); + const get = (file: string) => readFileSync(join(path, file), 'utf8'); + return { path, write, get, version: pkg.version as string }; +} + +test('current inventory checks without writes and is idempotent at package version', () => { + assert.deepEqual(planCurrentVersions(version, files(), inventory), []); + assert.deepEqual(currentVersions(root, '--check'), []); + const crlf = new Map([...files()].map(([file, text]) => [file, text.replace(/\r?\n/g, '\r\n')])); + assert.deepEqual(planCurrentVersions(version, crlf, inventory), [], 'CRLF checkout remains unchanged'); +}); + +test('synthetic patch update changes only explicit slots and preserves history and pins', t => { + const f = fixture(t); + const paths = [...inventory.map(entry => entry.file), ...immutable, 'package.json', 'package-lock.json']; + const before = new Map(paths.map(file => [file, f.get(file)])); + assert.throws(() => currentVersions(f.path, '--check'), /current-version drift/); + for (const [file, text] of before) assert.equal(f.get(file), text, `${file}: check must be read-only`); + const expected = planCurrentVersions(f.version, new Map(inventory.map(entry => [entry.file, f.get(entry.file)])), inventory); + assert.equal(expected.length, inventory.length, 'synthetic update must exercise every managed file'); + assert.deepEqual(currentVersions(f.path, '--update').sort(), expected.map(change => change.file).sort()); + for (const change of expected) { + assert.equal(f.get(change.file), change.after); + assert.ok(f.get(change.file).endsWith(sentinel), `${change.file}: history, source hash and independent pins preserved`); + assert.ok(!before.get(change.file)!.includes(f.version), 'next version must be unique to the fixture update'); + assert.equal(f.get(change.file).replaceAll(f.version, version), before.get(change.file), + `${change.file}: every authored character outside version spans remains identical`); + } + for (const file of [...immutable, 'package.json', 'package-lock.json']) assert.equal(f.get(file), before.get(file), file); + assert.deepEqual(currentVersions(f.path, '--check'), []); + assert.deepEqual(currentVersions(f.path, '--update'), [], 'second update must be byte-idempotent'); +}); + +test('invalid final slot prevents every write', t => { + const f = fixture(t); + const malformed: Inventory = structuredClone(inventory); + malformed.at(-1)!.templates.push({ line: 'missing {{version}} slot', occurrences: 1 }); + f.write('scripts/current-version-fields.json', JSON.stringify(malformed)); + const before = inventory.map(entry => [entry.file, f.get(entry.file)]); + assert.throws(() => currentVersions(f.path, '--update'), /expected 1 matches/); + for (const [file, text] of before) assert.equal(f.get(file), text); +}); + +test('inventory rejects missing, duplicate, overlapping, unexpected and escaping slots', () => { + const inputs = files(); + assert.throws(() => planCurrentVersions(version, inputs, inventory.slice(1))); + assert.throws(() => planCurrentVersions(version, inputs, [...inventory, inventory[0]]), /duplicate/); + const overlap = structuredClone(inventory); + overlap[0].templates.push(overlap[0].templates[0]); + assert.throws(() => planCurrentVersions(version, inputs, overlap), /overlapping/); + const escape = structuredClone(inventory); + escape[0].file = '../README.md'; + assert.throws(() => planCurrentVersions(version, inputs, escape), /unmanaged/); + const ambiguous = structuredClone(inventory); + ambiguous[0].templates[0].occurrences++; + assert.throws(() => planCurrentVersions(version, inputs, ambiguous), /matches/); + const unknown = structuredClone(inventory) as (Inventory[number] & { output?: string })[]; + unknown[0].output = '/tmp/arbitrary'; + assert.throws(() => planCurrentVersions(version, inputs, unknown), /invalid inventory entry keys/); + assert.throws(() => planCurrentVersions('0.5.2; command', inputs, inventory), /unsupported version/); +}); + +test('lock drift and symlink destinations are rejected before any update', t => { + const f = fixture(t); + const initial = f.get('src/protocol.ts'); + const lock = JSON.parse(f.get('package-lock.json')); + lock.version = version; + f.write('package-lock.json', JSON.stringify(lock)); + assert.throws(() => currentVersions(f.path, '--update'), /package-lock/); + assert.equal(f.get('src/protocol.ts'), initial); + lock.version = f.version; + f.write('package-lock.json', JSON.stringify(lock)); + // Creating an arbitrary symlink requires privileges on Windows. The updater's + // regular-file guard is exercised on POSIX; lock validation runs everywhere. + if (process.platform === 'win32') return; + const target = join(f.path, 'outside.md'); + writeFileSync(target, f.get('PARITY.md')); + rmSync(join(f.path, 'PARITY.md')); + symlinkSync(target, join(f.path, 'PARITY.md')); + assert.throws(() => currentVersions(f.path, '--update'), /symlinks/); + assert.equal(f.get('src/protocol.ts'), initial); + assert.equal(readFileSync(target, 'utf8'), read('PARITY.md') + sentinel); +}); + +test('CLI accepts only check/update and no arbitrary root or output arguments', () => { + const script = join(root, 'scripts/current-version.mjs'); + const good = execFileSync(process.execPath, [script, '--check'], { encoding: 'utf8' }); + assert.match(good, /Current-version inventory: OK/); + for (const args of [[], ['--unknown'], ['--update', '--root', '/tmp']]) { + const result = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' }); + assert.equal(result.status, 1); + assert.match(result.stderr, /usage:/); + } +}); + + +test('CLI check runs through symlink entries with both Node resolution modes', t => { + if (process.platform === 'win32') { t.skip('symlink privilege is not required on Windows'); return; } + const dir = mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-version-entry-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const link = join(dir, 'version.mjs'); + symlinkSync(join(root, 'scripts/current-version.mjs'), link); + for (const flags of [[], ['--preserve-symlinks-main']]) { + const result = spawnSync(process.execPath, [...flags, link, '--check'], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Current-version inventory: OK/); + } +}); diff --git a/test/discovery.test.ts b/test/discovery.test.ts index 3235577..68b5511 100644 --- a/test/discovery.test.ts +++ b/test/discovery.test.ts @@ -32,13 +32,13 @@ function fixture(t: TestContext) { const db = new DatabaseSync(join(path, 'state_5.sqlite')); try { db.exec('CREATE TABLE threads(id TEXT, title TEXT, cwd TEXT, updated_at INTEGER, archived INTEGER, rollout_path TEXT)'); - db.prepare('INSERT INTO threads VALUES (?,?,?,?,?,?)').run(thread, 'fixture', '/project', updated, archived, 'unused'); + db.prepare('INSERT INTO threads VALUES (?,?,?,?,?,?)').run(thread, 'fixture', root, updated, archived, 'unused'); } finally { db.close(); } return path; } function invoke(args: string[] = ['list'], env: NodeJS.ProcessEnv = process.env, wire?: string) { const child = spawnSync(process.execPath, [cli, ...args, ...(wire === undefined ? ['--json'] : [])], - { env, input: wire, encoding: 'utf8', timeout: 45000 }); + { env, cwd: env.HOME, input: wire, encoding: 'utf8', timeout: 45000 }); assert.ifError(child.error); assert.equal(child.signal, null); const value = JSON.parse(child.stdout); assert.equal(value.schemaVersion, 1); assert.equal(value.command, 'list'); @@ -48,7 +48,7 @@ function fixture(t: TestContext) { function oracle(args: string[], node: any) { if (!process.env.SESSION_PEER_PYTHON_ROOT) return; // Python oracle is POSIX CI only. const child = spawnSync(process.env.PYTHON ?? 'python3', [join(process.env.SESSION_PEER_PYTHON_ROOT, 'session_peer.py'), - ...args, '--json', '--no-update-notice'], { env: process.env, encoding: 'utf8', timeout: 15000 }); + ...args, '--json', '--no-update-notice'], { env: process.env, cwd: root, encoding: 'utf8', timeout: 15000 }); assert.ifError(child.error); const reference = JSON.parse(child.stdout); assert.equal(node.ok, reference.ok); assert.deepEqual(node.sessions, reference.sessions); diff --git a/test/handoff-cli.test.ts b/test/handoff-cli.test.ts index fc4bc7a..3f6420f 100644 --- a/test/handoff-cli.test.ts +++ b/test/handoff-cli.test.ts @@ -152,9 +152,9 @@ test('prepared inactive Codex binding retains lock and saved-home evidence witho for (const change of ['unchanged','lock','database'] as const) await t.test(change, async t=>{ const f=await fixture(t),home=join(f.path,'codex-home'),locks=join(home,'thread-writer-locks');mkdirSync(locks,{recursive:true}); const dbPath=join(home,'state_5.sqlite'); - const database=()=>{const db=new DatabaseSync(dbPath);try{db.exec('CREATE TABLE threads(id TEXT)');db.prepare('INSERT INTO threads VALUES (?)').run(id);}finally{db.close();}};database(); + const database=()=>{const db=new DatabaseSync(dbPath);try{db.exec('CREATE TABLE threads(id TEXT, cwd TEXT)');db.prepare('INSERT INTO threads VALUES (?, ?)').run(id,home);}finally{db.close();}};database(); const log=join(f.path,'queue-calls');writeFileSync(log,'');f.env.FIXTURE_QUEUE_LOG=log;f.env.SESSION_PEER_CODEX_HOMES='[]'; - const binary=join(f.path,'fake-codex');writeFileSync(binary,`#!${process.execPath}\nrequire('node:fs').appendFileSync(process.env.FIXTURE_QUEUE_LOG,'call\\n');console.log('Queued message inactive-queue for thread '+process.argv[4]+'.');`,{mode:0o700}); + const binary=join(f.path,'fake-codex');writeFileSync(binary,`#!${process.execPath}\nif(process.argv[2]==='--version'){console.log('codex-cli 0.165.0');process.exit(0);}require('node:fs').appendFileSync(process.env.FIXTURE_QUEUE_LOG,'call\\n');console.log('Queued message inactive-queue for thread '+process.argv[4]+'.');`,{mode:0o700}); const target=['--to','codex:'+id,'--codex-home',home,'--codex-bin',binary,'--allow-inactive-codex-home','--message-file',f.file]; const before=await inspectWriter(home,id);assert.equal(before.activity,'inactive');assert.equal(before.writerLock,'absent'); assert.equal((await f.invoke(['handoff','init'])).code,0); diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index c345b9f..fa66842 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -20,18 +20,23 @@ async function fixture(t:TestContext,mode='inject',version='0.160.1',warm=true){ const root=realpathSync(mkdtempSync(join(process.env.TASK_TEMP??tmpdir(),'codex-hd-'))),home=join(root,'home'),bin=join(root,'bin');chmodSync(root,0o700);mkdirSync(home,{mode:0o700});mkdirSync(bin,{mode:0o700});mkdirSync(join(home,'thread-writer-locks'),{mode:0o700}); let history:DatabaseSync|undefined; t.after(()=>{try{history?.close();}finally{rmSync(root,{recursive:true,force:true});}}); - const state=new DatabaseSync(join(home,'state_5.sqlite'));state.exec('CREATE TABLE threads(id TEXT)');state.prepare('INSERT INTO threads VALUES(?)').run(thread);state.close(); + const state=new DatabaseSync(join(home,'state_5.sqlite'));state.exec('CREATE TABLE threads(id TEXT, cwd TEXT)');state.prepare('INSERT INTO threads VALUES(?,?)').run(thread,home);state.close(); // Model a live warm WAL store: keep the owner connection open so other // fixture connections cannot remove/recreate WAL and SHM between probes. // DELETE-mode writes can expose a rollback journal that the observer refuses. if(warm){const db=history=new DatabaseSync(join(home,'thread_history_1.sqlite'));db.exec('PRAGMA journal_mode=WAL');db.exec(schema);db.prepare('INSERT INTO thread_turns VALUES(?,?,?)').run(thread,'turn-fixture','completed');db.prepare('INSERT INTO thread_history_projection_state VALUES(?,?,?)').run(thread,100,100);} const lock=join(home,'thread-writer-locks',thread+'.lock'),locks=createRequire(import.meta.url).resolve('fs-ext-extra-prebuilt'); - const held=spawn(process.execPath,['-e',"const f=require('fs').openSync(process.argv[1],'w',0o600);require(process.argv[2]).flockSync(f,'exnb');console.log('ready');setInterval(()=>{},1000)",lock,locks]);held.stderr.resume();await once(held.stdout,'data');t.after(async()=>{if(held.exitCode===null&&held.signalCode===null){held.kill('SIGKILL');await once(held,'close');}}); + // The retained writer really opens this home's state inode and runs from an + // existing cwd, so the production storage binding guard needs no fixture bypass. + const held=spawn(process.execPath,['-e',"const f=require('fs').openSync(process.argv[1],'w',0o600);require(process.argv[2]).flockSync(f,'exnb');const db=require('fs').openSync(process.argv[3],'r');console.log('ready');setInterval(()=>{},1000)",lock,locks,join(home,'state_5.sqlite')],{cwd:home});held.stderr.resume();await once(held.stdout,'data');t.after(async()=>{if(held.exitCode===null&&held.signalCode===null){held.kill('SIGKILL');await once(held,'close');}}); // The real tools are native executables. Do not spend the bounded owner // probe's budget on four extra Node startups under parallel CI load. // Owner data stays in a separate file, never interpolated into shell code. const lsof=join(bin,'lsof');writeFileSync(lsof+'.data','p'+held.pid+'\0ccodex-fixture\0u'+process.getuid?.()+'\0',{mode:0o600}); - writeFileSync(lsof,'#!/bin/sh\nexec /bin/cat "$0.data"\n',{mode:0o700}); + writeFileSync(lsof+'.cwd','p'+held.pid+'\0fcwd\0n'+home+'\0',{mode:0o600}); + const stateInfo=statSync(join(home,'state_5.sqlite'),{bigint:true}); + writeFileSync(lsof+'.fds','p'+held.pid+'\0f4\0D0x'+stateInfo.dev.toString(16)+'\0i'+stateInfo.ino+'\0n'+join(home,'state_5.sqlite')+'\0',{mode:0o600}); + writeFileSync(lsof,'#!/bin/sh\ncase " $* " in\n *" -d cwd "*) exec /bin/cat "$0.cwd";;\n *" -F0fDin "*) exec /bin/cat "$0.fds";;\n *) exec /bin/cat "$0.data";;\nesac\n',{mode:0o700}); writeFileSync(join(bin,'ps'),'#!/bin/sh\nprintf "Fixture stable start\\n"\n',{mode:0o700}); const log=join(root,'queue-log-\'"\\\n\u2028\u2029'),nativeLog=join(root,'native-calls.jsonl');writeFileSync(log,'');writeFileSync(nativeLog,'');const codex=join(bin,'codex'); const initializeReady=join(root,'initialize-ready'),initializeRelease=join(root,'initialize-release'); @@ -161,7 +166,7 @@ test('initialize readiness observes persisted fixture evidence when filesystem n assert.equal(readFileSync(join(f.root,'initialize-ready'),'utf8'),'ready'); }); test('initialize readiness reports a send that exits before initialization with its current outcome',qualified,async t=>{ - const f=await fixture(t,'concurrent-stop','0.163.0'); + const f=await fixture(t,'concurrent-stop','0.165.0'); const active=f.start([...f.args,'--wait-for','delivered']); await assert.rejects(f.initialized(),error=>{ assert.ok(error instanceof Error);assert.match(error.message,/send exited before initialize evidence/); @@ -172,7 +177,7 @@ test('initialize readiness reports a send that exits before initialization with assert.deepEqual(f.nativeCalls().filter((row:{command:string})=>row.command==='app-server'),[]); }); test('queue readiness reports a send that exits before submission with its current outcome',qualified,async t=>{ - const f=await fixture(t,'none','0.163.0'); + const f=await fixture(t,'none','0.165.0'); const active=f.start([...f.args,'--wait-for','delivered']); await assert.rejects(f.queued(),error=>{ assert.ok(error instanceof Error);assert.match(error.message,/send exited before queue evidence/); @@ -212,8 +217,9 @@ test('positive native acceptance survives a subsequent ledger failure without an assert.equal(duplicate.code,1);assert.equal(duplicate.value.error,'handoff_already_attempted'); assert.equal(f.calls().length,1); }); -test('current unqualified native version cannot enter the app-server or required delivery path',qualified,async t=>{ - const f=await fixture(t,'inject','0.163.0'); +test('storage-qualified but observation-unqualified native version cannot enter the app-server or required delivery path',qualified,async t=>{ + // 0.165.0 has reviewed local loader semantics but no delivery observation row. + const f=await fixture(t,'inject','0.165.0'); const required=await f.invoke([...f.args,'--wait-for','delivered']); assert.equal(required.code,1);assert.equal(required.value.submitted,false); assert.equal(required.value.handoff.wait.status,'unsupported');assert.equal(f.calls().length,0); @@ -223,8 +229,8 @@ test('current unqualified native version cannot enter the app-server or required assert.equal(best.value.handoff.observation.injectionObserved,false); assert.deepEqual(f.calls(),[{method:'legacy'}]); }); -test('unsupported version/absent warm store refuse required goals, best-effort fallback is chosen before app-server effect',qualified,async t=>{ - const f=await fixture(t,'inject','0.159.0');const r=await f.invoke([...f.args,'--wait-for','delivered']);assert.equal(r.code,1);assert.equal(r.value.submitted,false);assert.equal(r.value.handoff.wait.status,'unsupported');assert.equal(f.calls().length,0); +test('unsupported observation version/absent warm store refuse required goals, best-effort fallback is chosen before app-server effect',qualified,async t=>{ + const f=await fixture(t,'inject','0.165.0');const r=await f.invoke([...f.args,'--wait-for','delivered']);assert.equal(r.code,1);assert.equal(r.value.submitted,false);assert.equal(r.value.handoff.wait.status,'unsupported');assert.equal(f.calls().length,0); const best=await f.invoke([...f.args,'--observe-delivery']);assert.equal(best.code,0);assert.equal(best.value.handoff.observation.status,'unsupported');assert.deepEqual(f.calls(),[{method:'legacy'}]); const missing=await fixture(t,'inject','0.160.1',false);const blocked=await missing.invoke([...missing.args,'--wait-for','delivered']);assert.equal(blocked.code,1);assert.equal(blocked.value.submitted,false);assert.equal(missing.calls().length,0); }); diff --git a/test/linux-observation.test.ts b/test/linux-observation.test.ts index 8c28b81..597cc5c 100644 --- a/test/linux-observation.test.ts +++ b/test/linux-observation.test.ts @@ -416,6 +416,13 @@ function syntheticRow(binary: string, patch: Partial = {}): Nativ async function compiledRun(t: TestContext) { const root = temp(t, 'codex-compiled-native-'), home = join(root, 'home'), other = join(root, 'other'), log = join(root, 'events.jsonl'); mkdirSync(home, { mode: 0o700 }); mkdirSync(other, { mode: 0o700 }); writeFileSync(log, ''); + // The owned queue binds the saved consumer's real cwd before probing its + // binary. The compiled stand-in retains this same state inode when started. + const state = new DatabaseSync(join(home, 'state_5.sqlite')); + try { + state.exec('CREATE TABLE threads(id TEXT PRIMARY KEY, cwd TEXT NOT NULL)'); + state.prepare('INSERT INTO threads VALUES (?, ?)').run(thread, root); + } finally { state.close(); } const binary = compiledFixture(t, root); if (!binary) return undefined; let guards = 0; const options = { binary, scope: { codexHome: home, threadId: thread, generation: 'g', ownerIdentity: 'o' }, message: 'own echo', diff --git a/test/package-smoke.ts b/test/package-smoke.ts index c3f41a0..6195c2f 100644 --- a/test/package-smoke.ts +++ b/test/package-smoke.ts @@ -24,7 +24,7 @@ try { let hash; for (let index = 0; index < 2; index++) { const [packed] = JSON.parse(runNpm(['pack', '--ignore-scripts', '--json', '--pack-destination', task])); - assert.deepEqual(packed.files.map((file: {path: string}) => file.path).sort(), ['UNICODE-LICENSE.txt', 'dist/casefold.js', 'dist/casefold.d.ts', 'dist/help.js', 'dist/help.d.ts', 'dist/output.js', 'dist/output.d.ts', 'CONTRIBUTING.md', 'LICENSE', 'PARITY.md', 'README.ja.md', 'README.ko.md', 'README.md', 'README.zh-CN.md', 'RELEASING.md', 'SECURITY.md', 'VALIDATION.md', 'docs/guide.md', 'docs/guide.ko.md', 'docs/guide.ja.md', 'docs/guide.zh-CN.md', 'docs/api.md', 'docs/design/remote-deployment.md', 'docs/design/ordinary-ssh-responses.md', 'docs/design/typescript-skill-compatibility.md', 'docs/shorthand.md', 'shorthand/sp.sh', 'shorthand/sp.ps1', 'dist/cli.js', 'dist/discovery.js', 'dist/diagnostics.js', 'dist/skill-metadata.js', 'dist/process.js', 'dist/protocol.js', 'dist/replies.js', 'dist/send.js', 'dist/ssh.js', 'dist/ssh-response.js', 'dist/windows.js', 'dist/writer.js', 'dist/index.js', 'dist/index.d.ts', 'dist/cli.d.ts', 'dist/discovery.d.ts', 'dist/diagnostics.d.ts', 'dist/skill-metadata.d.ts', 'dist/process.d.ts', 'dist/protocol.d.ts', 'dist/replies.d.ts', 'dist/send.d.ts', 'dist/ssh.d.ts', 'dist/ssh-response.d.ts', 'dist/windows.d.ts', 'dist/writer.d.ts', 'dist/updates.js', 'dist/updates.d.ts', 'dist/handoff.js', 'dist/handoff.d.ts', 'dist/handoff-ledger.js', 'dist/handoff-ledger.d.ts', 'dist/handoff-operations.js', 'dist/handoff-operations.d.ts', 'dist/app-server.js', 'dist/app-server.d.ts', 'dist/metadata-observation.js', 'dist/metadata-observation.d.ts', 'dist/handoff-observer-probe.js', 'dist/handoff-observer-probe.d.ts', 'dist/handoff-writer-probe.js', 'dist/handoff-writer-probe.d.ts', 'dist/native-profiles.js', 'dist/native-profiles.d.ts', 'dist/linux-proc.js', 'dist/linux-proc.d.ts', 'dist/linux-native-probe.js', 'dist/linux-native-probe.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/codex-storage.js', 'dist/codex-storage.d.ts', 'dist/diagnostics.js', 'dist/skill-metadata.js', 'dist/process.js', 'dist/protocol.js', 'dist/replies.js', 'dist/send.js', 'dist/ssh.js', 'dist/ssh-response.js', 'dist/windows.js', 'dist/writer.js', 'dist/index.js', 'dist/index.d.ts', 'dist/cli.d.ts', 'dist/discovery.d.ts', 'dist/diagnostics.d.ts', 'dist/skill-metadata.d.ts', 'dist/process.d.ts', 'dist/protocol.d.ts', 'dist/replies.d.ts', 'dist/send.d.ts', 'dist/ssh.d.ts', 'dist/ssh-response.d.ts', 'dist/windows.d.ts', 'dist/writer.d.ts', 'dist/updates.js', 'dist/updates.d.ts', 'dist/handoff.js', 'dist/handoff.d.ts', 'dist/handoff-ledger.js', 'dist/handoff-ledger.d.ts', 'dist/handoff-operations.js', 'dist/handoff-operations.d.ts', 'dist/app-server.js', 'dist/app-server.d.ts', 'dist/metadata-observation.js', 'dist/metadata-observation.d.ts', 'dist/handoff-observer-probe.js', 'dist/handoff-observer-probe.d.ts', 'dist/handoff-writer-probe.js', 'dist/handoff-writer-probe.d.ts', 'dist/native-profiles.js', 'dist/native-profiles.d.ts', 'dist/linux-proc.js', 'dist/linux-proc.d.ts', 'dist/linux-native-probe.js', 'dist/linux-native-probe.d.ts', 'package.json'].sort()); const next = createHash('sha256').update(readFileSync(join(task, packed.filename))).digest('hex'); if (hash) assert.equal(next, hash, 'same build must produce identical tarball'); hash = next; diff --git a/test/ssh-handoff.test.ts b/test/ssh-handoff.test.ts index 72ba56a..888999c 100644 --- a/test/ssh-handoff.test.ts +++ b/test/ssh-handoff.test.ts @@ -6,7 +6,7 @@ import { once } from 'node:events'; import { createRequire } from 'node:module'; import { createServer } from 'node:net'; import { DatabaseSync } from 'node:sqlite'; -import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { delimiter, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -128,7 +128,7 @@ fs.appendFileSync(process.env.FIXTURE_LOG,JSON.stringify(request)+'\\n'); if(process.env.FIXTURE_PARTIAL_PREPARE&&request.handoff?.preparation){process.stdout.write('{');process.exit(0);} const env={...process.env,HOME:process.env.FIXTURE_DEST,USERPROFILE:process.env.FIXTURE_DEST,CLAUDE_CONFIG_DIR:process.env.FIXTURE_DEST+'/.claude',SESSION_PEER_HANDOFF_HOME:process.env.FIXTURE_DEST+'/state/handoff'}; const selector=args.at(-1).match(/ --handoff-remaining-ms=([1-9][0-9]{0,4})$/); -const child=cp.spawn(process.execPath,[process.env.FIXTURE_CLI,'--stdio-request',...(selector?['--handoff-remaining-ms='+selector[1]]:[])],{env,stdio:['pipe','pipe','inherit']}); +const child=cp.spawn(process.execPath,[process.env.FIXTURE_CLI,'--stdio-request',...(selector?['--handoff-remaining-ms='+selector[1]]:[])],{env,cwd:env.HOME,stdio:['pipe','pipe','inherit']}); let output='';child.stdout.on('data',part=>{output+=part;process.stdout.write(part);}); child.stdin.end(input);child.on('close',(code,signal)=>{if(request.handoff?.preparation)fs.appendFileSync(process.env.FIXTURE_PREPARATIONS,output);if(signal)process.kill(process.pid,signal);else process.exitCode=request.handoff?.preparation&&process.env.FIXTURE_PREPARE_EXIT255?255:code;}); `, { mode: 0o700 }); @@ -137,7 +137,7 @@ child.stdin.end(input);child.on('close',(code,signal)=>{if(request.handoff?.prep const destEnv = { ...common, HOME: destination, USERPROFILE: destination, CLAUDE_CONFIG_DIR: join(destination, '.claude'), SESSION_PEER_HANDOFF_HOME: join(destination, 'state', 'handoff') }; const originEnv = { ...common, HOME: origin, USERPROFILE: origin, CLAUDE_CONFIG_DIR: join(origin, '.claude'), SESSION_PEER_HANDOFF_HOME: join(origin, 'state', 'handoff') }; async function invoke(args: string[], destinationSide = false, input?: string, extra: NodeJS.ProcessEnv = {}) { - const child = spawn(process.execPath, [cli, ...args], { env: { ...(destinationSide ? destEnv : originEnv), ...extra } }); + const child = spawn(process.execPath, [cli, ...args], { env: { ...(destinationSide ? destEnv : originEnv), ...extra }, cwd: destinationSide ? destination : origin }); let stdout = '', stderr = ''; child.stdout.on('data', part => { stdout += part; }); child.stderr.on('data', part => { stderr += part; }); child.stdin.end(input); // Harness margin permits cleanup/reporting; the request budget is unchanged. const timer = setTimeout(() => child.kill('SIGKILL'), 20000); @@ -154,7 +154,7 @@ async function codexFixture(t: TestContext, mode: 'inject' | 'none' | 'loss') { mkdirSync(home, { mode: 0o700 }); mkdirSync(bin, { mode: 0o700 }); mkdirSync(join(home, 'thread-writer-locks'), { mode: 0o700 }); const state = new DatabaseSync(join(home, 'state_5.sqlite')); - state.exec('CREATE TABLE threads(id TEXT)'); state.prepare('INSERT INTO threads VALUES(?)').run(thread); state.close(); + state.exec('CREATE TABLE threads(id TEXT, cwd TEXT)'); state.prepare('INSERT INTO threads VALUES(?,?)').run(thread, home); state.close(); const warm = new DatabaseSync(join(home, 'thread_history_1.sqlite')); warm.exec(`CREATE TABLE thread_turns(thread_id TEXT NOT NULL,turn_id TEXT NOT NULL,status TEXT NOT NULL,PRIMARY KEY(thread_id,turn_id)); CREATE TABLE thread_items(thread_id TEXT NOT NULL,turn_id TEXT NOT NULL,item_id TEXT NOT NULL,rollout_ordinal INTEGER NOT NULL,item_json TEXT NOT NULL,item_type TEXT NOT NULL,PRIMARY KEY(thread_id,turn_id,item_id)); @@ -162,8 +162,10 @@ CREATE INDEX idx_thread_items_user_messages ON thread_items(thread_id,rollout_or CREATE TABLE thread_history_projection_state(thread_id TEXT PRIMARY KEY,next_rollout_byte_offset INTEGER NOT NULL,next_rollout_ordinal INTEGER NOT NULL);`); warm.prepare('INSERT INTO thread_turns VALUES(?,?,?)').run(thread, 'ssh-turn-fixture', 'completed'); warm.prepare('INSERT INTO thread_history_projection_state VALUES(?,?,?)').run(thread, 100, 100); warm.close(); - const holder = fileURLToPath(new URL('./fixtures/ssh-codex-lock-holder.cjs', import.meta.url)); - const held = spawn(process.execPath, [holder, join(home, 'thread-writer-locks', thread + '.lock'), createRequire(import.meta.url).resolve('fs-ext-extra-prebuilt')]); + // Keep the retained writer's actual cwd and opened state inode bound to this home. + // Paths are arguments, never interpolated into executable fixture source. + const held = spawn(process.execPath, ['-e', "const fs=require('node:fs');const lock=fs.openSync(process.argv[1],'w',0o600);require(process.argv[2]).flockSync(lock,'exnb');const state=fs.openSync(process.argv[3],'r');console.log('ready');setInterval(()=>{},1000);", + join(home, 'thread-writer-locks', thread + '.lock'), createRequire(import.meta.url).resolve('fs-ext-extra-prebuilt'), join(home, 'state_5.sqlite')], { cwd: home }); held.stderr.resume(); t.after(async () => { if (held.exitCode === null && held.signalCode === null) { held.kill('SIGKILL'); await once(held, 'close'); } }); const readyTimer = setTimeout(() => held.kill('SIGKILL'), 3000); @@ -171,7 +173,10 @@ CREATE TABLE thread_history_projection_state(thread_id TEXT PRIMARY KEY,next_rol finally { clearTimeout(readyTimer); } const lsof = join(bin, 'lsof'); writeFileSync(lsof + '.data', 'p' + held.pid + '\0ccodex-fixture\0u' + process.getuid?.() + '\0', { mode: 0o600 }); - writeFileSync(lsof, '#!/bin/sh\nexec /bin/cat "$0.data"\n', { mode: 0o700 }); + writeFileSync(lsof + '.cwd', 'p' + held.pid + '\0fcwd\0n' + home + '\0', { mode: 0o600 }); + const stateInfo = statSync(join(home, 'state_5.sqlite'), { bigint: true }); + writeFileSync(lsof + '.fds', 'p' + held.pid + '\0f4\0D0x' + stateInfo.dev.toString(16) + '\0i' + stateInfo.ino + '\0n' + join(home, 'state_5.sqlite') + '\0', { mode: 0o600 }); + writeFileSync(lsof, '#!/bin/sh\ncase " $* " in\n *" -d cwd "*) exec /bin/cat "$0.cwd";;\n *" -F0fDin "*) exec /bin/cat "$0.fds";;\n *) exec /bin/cat "$0.data";;\nesac\n', { mode: 0o700 }); writeFileSync(join(bin, 'ps'), '#!/bin/sh\nprintf "Fixture stable start\\n"\n', { mode: 0o700 }); const queueLog = join(f.root, 'codex-queue.jsonl'), nativeLog = join(f.root, 'codex-native.jsonl'), codex = join(bin, 'codex'); writeFileSync(queueLog, ''); writeFileSync(nativeLog, ''); diff --git a/test/transport.test.ts b/test/transport.test.ts index c80abcc..01320c2 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { mkdtempSync, realpathSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, delimiter } from 'node:path'; +import { join, dirname, delimiter } from 'node:path'; import { createServer } from 'node:net'; import { once } from 'node:events'; import { fileURLToPath } from 'node:url'; @@ -14,6 +14,8 @@ import { reply, envelope } from '../dist/protocol.js'; const cli = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); // Answers `ssh -G` user metadata lookups without logging them as SSH dispatches. const sshConfigUser = "if(process.argv.includes('-G')){console.log('user fixture-user');process.exit(0);}"; +// Agent version inspection is metadata only; counters below record queue effects. +const codexVersion = "if(process.argv.length===3&&process.argv[2]==='--version'){console.log('codex-cli 0.165.0');process.exit(0);}"; const python = process.env.PYTHON ?? 'python3'; const id = '11111111-1111-4111-8111-111111111111'; function setup(t: TestContext) { @@ -24,7 +26,7 @@ function setup(t: TestContext) { return { path, env }; } async function invoke(args: string[], env: NodeJS.ProcessEnv) { - const child = spawn(process.execPath, [cli, ...args, '--json'], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(process.execPath, [cli, ...args, '--json'], { env, cwd: env.HOME, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; child.stdout.on('data', x => { stdout += x; }); child.stderr.resume(); const timer = setTimeout(() => child.kill('SIGKILL'), 15000); const [code, signal] = await once(child, 'close'); clearTimeout(timer); @@ -69,17 +71,17 @@ test('structured replies are inert data and reject conflicting/unsafe fields', ( function database(home: string) { mkdirSync(join(home, 'thread-writer-locks'), { recursive: true }); const result = spawnSync(python, ['-c', - 'import sqlite3,sys\nc=sqlite3.connect(sys.argv[1]); c.execute("CREATE TABLE threads(id TEXT)"); c.execute("INSERT INTO threads VALUES (?)",(sys.argv[2],)); c.commit(); c.close()', - join(home, 'state_5.sqlite'), id]); + 'import sqlite3,sys\nc=sqlite3.connect(sys.argv[1]); c.execute("CREATE TABLE threads(id TEXT, cwd TEXT)"); c.execute("INSERT INTO threads VALUES (?,?)",(sys.argv[2],sys.argv[3])); c.commit(); c.close()', + join(home, 'state_5.sqlite'), id, dirname(home)]); assert.equal(result.status, 0, result.stderr.toString()); } async function holder(t: TestContext, path: string, home: string, name = 'codex-fixture') { // Actual OS flock held by a separate native process, not a mocked lock probe. const source = join(path, 'holder.c'), binary = join(path, name); - writeFileSync(source, '#include \n#include \n#include \n#include \nint main(int argc,char**argv){int f=open(argv[1],O_RDWR|O_CREAT,0600); if(f<0||flock(f,LOCK_EX|LOCK_NB))return 1;puts("ready");fflush(stdout); sleep(60);return 0;}\n'); + writeFileSync(source, '#include \n#include \n#include \n#include \nint main(int argc,char**argv){int state=open(argv[2],O_RDONLY);if(state<0)return 2;int f=open(argv[1],O_RDWR|O_CREAT,0600); if(f<0||flock(f,LOCK_EX|LOCK_NB))return 1;puts("ready");fflush(stdout); sleep(60);return 0;}\n'); const built = spawnSync('cc', [source, '-o', binary]); assert.equal(built.status, 0, built.stderr.toString()); - const child = spawn(binary, [join(home, 'thread-writer-locks', `${id}.lock`)], { stdio: ['ignore', 'pipe', 'inherit'] }); + const child = spawn(binary, [join(home, 'thread-writer-locks', `${id}.lock`), join(home, 'state_5.sqlite')], { cwd: path, stdio: ['ignore', 'pipe', 'inherit'] }); t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } }); await once(child.stdout, 'data'); return child; @@ -97,7 +99,7 @@ test('actual kernel lock + owner correlation; symlink and non-Codex owner fail c test('Codex guarded queue runs once, refuses inactive or competing live writers', async t => { const { path, env } = setup(t), home = join(path, 'home'); database(home); const queue = join(path, 'queue'), count = join(path, 'count'); - writeFileSync(queue, `#!${process.execPath}\nrequire('node:fs').appendFileSync(${JSON.stringify(count)},'1'); console.log('Queued message queue-17 for thread '+process.argv[4]+'.');`, { mode: 0o700 }); + writeFileSync(queue, `#!${process.execPath}\n${codexVersion}require('node:fs').appendFileSync(${JSON.stringify(count)},'1'); console.log('Queued message queue-17 for thread '+process.argv[4]+'.');`, { mode: 0o700 }); const args = ['send', '--to', `codex:${id}`, '--codex-home', home, '--codex-bin', queue, '--message', 'fixture', '--no-from']; assert.equal((await invoke(args, env)).error, 'inactive_writer'); const inactive = await invoke([...args, '--allow-inactive-codex-home'], env); @@ -127,7 +129,7 @@ test('Codex guarded queue runs once, refuses inactive or competing live writers' test('queue failure after spawn is unknown, never retried or leaked', async t => { const { path, env } = setup(t), home = join(path, 'home'); database(home); await holder(t, path, home); const queue = join(path, 'queue'), count = join(path, 'count'); - writeFileSync(queue, `#!${process.execPath}\nrequire('node:fs').appendFileSync(${JSON.stringify(count)},'1'); console.error('SECRET-SENTINEL');process.exit(1);`, { mode: 0o700 }); + writeFileSync(queue, `#!${process.execPath}\n${codexVersion}require('node:fs').appendFileSync(${JSON.stringify(count)},'1'); console.error('SECRET-SENTINEL');process.exit(1);`, { mode: 0o700 }); const result = await invoke(['send', '--to', `codex:${id}`, '--codex-home', home, '--codex-bin', queue, '--message', 'SECRET-SENTINEL'], env); assert.equal(result.status, 'unknown'); assert.equal(result.submitted, null); assert.equal(result.retryAllowed, false); assert.equal(result.codexHomeResolution.reason, 'explicit_live_writer'); @@ -203,7 +205,7 @@ test('SSH resolves Codex homes at destination and preserves resolution/queue met const { path, env } = setup(t), home = join(path, 'remote-home'); database(home); const owner = await holder(t, path, home); const queue = join(path, 'queue'), count = join(path, 'count'); - writeFileSync(queue, `#!${process.execPath}\nrequire('node:fs').appendFileSync(${JSON.stringify(count)},'1'); if(process.env.QUEUE_FAIL){console.error('SECRET-SENTINEL');process.exit(1);} console.log('Queued message ssh-17 for thread ${id}.');`, { mode: 0o700 }); + writeFileSync(queue, `#!${process.execPath}\n${codexVersion}require('node:fs').appendFileSync(${JSON.stringify(count)},'1'); if(process.env.QUEUE_FAIL){console.error('SECRET-SENTINEL');process.exit(1);} console.log('Queued message ssh-17 for thread ${id}.');`, { mode: 0o700 }); writeFileSync(join(path, 'ssh'), `#!${process.execPath}\n${sshConfigUser}const {spawnSync}=require('node:child_process'); const flag=process.argv.at(-1).endsWith('--version')?'--version':'--stdio-request'; const r=spawnSync(${JSON.stringify(process.execPath)},[${JSON.stringify(cli)},flag],{input:require('node:fs').readFileSync(0),encoding:'utf8',env:{...process.env,CODEX_HOME:${JSON.stringify(home)},SESSION_PEER_CODEX_HOMES:'[]'}});process.stdout.write(r.stdout);process.exit(r.status);`, { mode: 0o700 }); const local = { ...env, PATH: path + delimiter + env.PATH, SESSION_PEER_CODEX_HOMES: '{invalid-local' }; const args = ['send', '--host', 'fixture', '--to', `codex:${id}`, '--codex-bin', queue, '--message', 'fixture', '--no-from']; @@ -231,21 +233,21 @@ test('SSH resolves Codex homes at destination and preserves resolution/queue met test('Codex queue passes dash bodies as one --message= argv and drops inherited CODEX_SQLITE_HOME', async t => { const { path, env } = setup(t), home = join(path, 'home'); database(home); await holder(t, path, home); const queue = join(path, 'queue'), calls = join(path, 'calls'); - writeFileSync(queue, `#!${process.execPath}\nrequire('node:fs').appendFileSync(${JSON.stringify(calls)},JSON.stringify({args:process.argv.slice(2),sqlite:process.env.CODEX_SQLITE_HOME??null})+'\\n'); console.log('Queued message q-1 for thread '+process.argv[4]+'.');`, { mode: 0o700 }); + writeFileSync(queue, `#!${process.execPath}\n${codexVersion}require('node:fs').appendFileSync(${JSON.stringify(calls)},JSON.stringify({args:process.argv.slice(2),sqlite:process.env.CODEX_SQLITE_HOME??null})+'\\n'); console.log('Queued message q-1 for thread '+process.argv[4]+'.');`, { mode: 0o700 }); const bodies = ['- first item', '--help', '-x', '--', '-1', '-\nsecond line']; for (const body of bodies) { const sent = await invoke(['send', '--to', `codex:${id}`, '--codex-home', home, '--codex-bin', queue, `--message=${body}`, '--no-from'], { ...env, CODEX_SQLITE_HOME: join(path, 'elsewhere') }); assert.equal(sent.status, 'queued', JSON.stringify(sent)); } const recorded = readFileSync(calls, 'utf8').trim().split('\n').map(line => JSON.parse(line)); - assert.deepEqual(recorded.map(call => call.args), bodies.map(body => ['queue', '--thread', id, `--message=${body}`])); + assert.deepEqual(recorded.map(call => call.args), bodies.map(body => ['queue', '--thread', id, `--message=${body}`, '-c', `sqlite_home=${JSON.stringify(home)}`])); assert.ok(recorded.every(call => call.sqlite === null)); }); test('a configured Codex sqlite_home is refused before dry-run or queue', async t => { const { path, env } = setup(t), home = join(path, 'home'); database(home); await holder(t, path, home); const queue = join(path, 'queue'), count = join(path, 'count'); - writeFileSync(queue, `#!${process.execPath}\nrequire('node:fs').appendFileSync(${JSON.stringify(count)},'1');`, { mode: 0o700 }); + writeFileSync(queue, `#!${process.execPath}\n${codexVersion}require('node:fs').appendFileSync(${JSON.stringify(count)},'1');`, { mode: 0o700 }); const args = ['send', '--to', `codex:${id}`, '--codex-home', home, '--codex-bin', queue, '--message', 'fixture', '--no-from']; writeFileSync(join(home, 'config.toml'), 'model = "x"\n# sqlite_home = "/commented"\n[profiles.p]\nmodel = "y"\n'); assert.equal((await invoke([...args, '--dry-run'], env)).status, 'validated'); @@ -268,7 +270,7 @@ test('a saved inactive home needs opt-in and still rejects relocated or unreadab const inactive = await inspectWriter(home, id); assert.equal(inactive.activity, 'inactive'); assert.equal(inactive.writerLock, 'absent'); const queue = join(path, 'inactive-queue'), calls = join(path, 'inactive-calls'); - writeFileSync(queue, `#!${process.execPath}\nconst fs=require('node:fs');fs.appendFileSync(${JSON.stringify(calls)},JSON.stringify(process.argv.slice(2))+'\\n');console.log('Queued message inactive-queue-96 for thread '+process.argv[4]+'.');`, { mode: 0o700 }); + writeFileSync(queue, `#!${process.execPath}\n${codexVersion}const fs=require('node:fs');fs.appendFileSync(${JSON.stringify(calls)},JSON.stringify(process.argv.slice(2))+'\\n');console.log('Queued message inactive-queue-96 for thread '+process.argv[4]+'.');`, { mode: 0o700 }); const recorded = (): string[][] => { try { return readFileSync(calls, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; } @@ -300,7 +302,7 @@ test('a saved inactive home needs opt-in and still rejects relocated or unreadab assert.equal(sent.codexHomeResolution.reason, 'explicit_inactive_opt_in'); assertSelectedInactive(sent); assert.equal(sent.queueId, 'inactive-queue-96'); - assert.deepEqual(recorded(), [['queue', '--thread', id, '--message=inactive fixture']]); + assert.deepEqual(recorded(), [['queue', '--thread', id, '--message=inactive fixture', '-c', `sqlite_home=${JSON.stringify(home)}`]]); const baselineCalls = recorded().length; for (const key of ['sqlite_home', String.raw`"sqlite\u005fhome"`, String.raw`"sqlite\U0000005fhome"`]) { const text = `${key} = "/fixture/relocated"\n`; writeFileSync(config, text); @@ -358,10 +360,14 @@ test('Windows --remote-bin doubles every PowerShell single-quote variant', async }); test('opt-in handoff context stays bound to selected generation and rechecks storage before fencing', async t => { - const {path} = setup(t), home = join(path,'home'); database(home); await holder(t,path,home); + const {path,env} = setup(t), home = join(path,'home'); database(home); await holder(t,path,home); + t.mock.method(process,'cwd',()=>path); + const previous = {...process.env}; Object.assign(process.env,env); + t.after(()=>{for(const key of Object.keys(process.env))if(!(key in previous))delete process.env[key];Object.assign(process.env,previous);}); + const queue = join(path,'queue'); writeFileSync(queue,`#!${process.execPath}\n${codexVersion}`,{mode:0o700}); const owner = await inspectWriter(home,id); const crypto = await import('node:crypto'); const expected = crypto.createHash('sha256').update(owner.identity).digest('hex'); let effects = 0; - await assert.rejects(send({ to:`codex:${id}`,home,codexBin:process.execPath,message:'fixture',hooks:{ + await assert.rejects(send({ to:`codex:${id}`,home,codexBin:queue,message:'fixture',hooks:{ resolved(context) {assert.equal(context.generation,expected);writeFileSync(join(home,'config.toml'),'sqlite_home = "/fixture/relocated"');}, beforeEffect() {effects++;} }}), /unsupported_codex_sqlite_home/); @@ -383,7 +389,7 @@ test('malformed exponent configuration refuses Codex dry-run and send before the const { path, env } = setup(t), home = join(path, 'home'); database(home); await holder(t, path, home); const queue = join(path, 'queue'), count = join(path, 'count'), config = join(home, 'config.toml'); writeFileSync(count, ''); - writeFileSync(queue, `#!${process.execPath}\nrequire('node:fs').appendFileSync(${JSON.stringify(count)},'1'); console.log('Queued message toml-051 for thread '+process.argv[4]+'.');`, { mode: 0o700 }); + writeFileSync(queue, `#!${process.execPath}\n${codexVersion}require('node:fs').appendFileSync(${JSON.stringify(count)},'1'); console.log('Queued message toml-051 for thread '+process.argv[4]+'.');`, { mode: 0o700 }); const args = ['send', '--to', `codex:${id}`, '--codex-home', home, '--codex-bin', queue, '--message', 'fixture', '--no-from', '--no-reply-to']; // Valid single exponent signs remain acceptable; dry-run never invokes queue. for (const number of ['3e+20', '3e-20']) { diff --git a/test/windows-contract.test.ts b/test/windows-contract.test.ts index b9fcf15..9dc6ea7 100644 --- a/test/windows-contract.test.ts +++ b/test/windows-contract.test.ts @@ -4,12 +4,11 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import { once } from 'node:events'; import timers from 'node:timers/promises'; import { syncBuiltinESMExports } from 'node:module'; -import { send } from '../dist/send.js'; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; -import { inspectWriter, probeLock, resolveWriter } from '../dist/writer.js'; +import { inspectWriter, probeLock, resolveWriter, revalidateWriter } from '../dist/writer.js'; import { inspectWindows, windowsProcessStart } from '../dist/windows.js'; import { VERSION_LINE } from '../dist/protocol.js'; @@ -52,7 +51,7 @@ async function fixture(t: TestContext) { mkdirSync(join(path, 'thread-writer-locks'), { recursive: true }); const db = new DatabaseSync(join(path, 'state_5.sqlite')); db.exec('CREATE TABLE threads(id TEXT, title TEXT, cwd TEXT, updated_at INTEGER, archived INTEGER, rollout_path TEXT)'); - db.prepare('INSERT INTO threads VALUES (?,?,?,?,?,?)').run(id, 'fixture', '/project', 1, 0, 'unused'); db.close(); + db.prepare('INSERT INTO threads VALUES (?,?,?,?,?,?)').run(id, 'fixture', path, 1, 0, 'unused'); db.close(); } database(home); const lock = join(home, 'thread-writer-locks', id + '.lock'); @@ -151,9 +150,10 @@ test('Windows native writer, CLI and SSH contracts', { skip: process.platform != const owner = await f.holder(); const sample = samplingBarrier(t); const before = f.log('FIXTURE_QUEUE_LOG').length; - const rejected = assert.rejects(send({ to: `codex:${id}`, home: f.home, - codexBin: f.binary, message: 'pre-submit owner race' }), /active_writer_unverified/); + const initial = resolveWriter(f.home, id); (await sample())(); // Complete first resolveWriter's two native samples. + const selected = await initial; + const rejected = assert.rejects(revalidateWriter(selected, f.home, id), /active_writer_unverified/); const resume = await sample(); // Revalidation's first native sample completed. await f.stop(owner); const replacement = await f.holder(); @@ -163,18 +163,18 @@ test('Windows native writer, CLI and SSH contracts', { skip: process.platform != assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before); await f.stop(replacement); }); - await t.test('dry-run submits nothing; stable owner queues once; inactive and ambiguous refuse', async () => { + await t.test('Windows storage adapter refuses send and dry-run without queueing; writer refusals remain precise', async () => { assert.equal((await f.invoke(f.args)).error, 'inactive_writer'); assert.equal(f.log('FIXTURE_QUEUE_LOG').length, 0); const owner = await f.holder(); - assert.equal((await f.invoke([...f.args, '--dry-run'])).submitted, false); assert.equal(f.log('FIXTURE_QUEUE_LOG').length, 0); + const dry = await f.invoke([...f.args, '--dry-run']); + assert.equal(dry.error, 'codex_storage_platform_unqualified'); assert.equal(dry.submitted, false); assert.equal(f.log('FIXTURE_QUEUE_LOG').length, 0); const sent = await f.invoke(f.args); - assert.equal(sent.status, 'queued'); assert.equal(sent.submitted, true); assert.equal(sent.consumptionConfirmed, false); - const calls = f.log('FIXTURE_QUEUE_LOG'); assert.equal(calls.length, 1); - assert.deepEqual(calls[0].args, ['queue', '--thread', id, '--message=fixture 🚀']); assert.equal(calls[0].home, f.home); + assert.equal(sent.error, 'codex_storage_platform_unqualified'); assert.equal(sent.status, 'refused'); assert.equal(sent.submitted, false); assert.equal(sent.consumptionConfirmed, false); + assert.equal(sent.codexHomeResolution.reason, 'explicit_live_writer'); assert.equal(f.log('FIXTURE_QUEUE_LOG').length, 0); const other = join(f.root, 'other'); f.database(other); const competing = await f.holder(join(other, 'thread-writer-locks', id + '.lock')); const refused = await f.invoke(f.args, { SESSION_PEER_CODEX_HOMES: JSON.stringify([other]) }); - assert.equal(refused.error, 'multiple_live_writers'); assert.equal(refused.submitted, false); assert.equal(f.log('FIXTURE_QUEUE_LOG').length, 1); + assert.equal(refused.error, 'multiple_live_writers'); assert.equal(refused.submitted, false); assert.equal(f.log('FIXTURE_QUEUE_LOG').length, 0); await f.stop(competing); await f.stop(owner); }); await t.test('implicit selection, explicit conflict, unsaved first turn and inactive opt-in', async () => { @@ -182,7 +182,7 @@ test('Windows native writer, CLI and SSH contracts', { skip: process.platform != const owner = await f.holder(); const implicit = f.args.filter((_, index) => index !== 3 && index !== 4); const dry = await f.invoke([...implicit, '--dry-run'], { CODEX_HOME: f.home }); - assert.equal(dry.status, 'validated'); assert.equal(dry.codexHomeResolution.reason, 'single_stable_live_writer'); + assert.equal(dry.error, 'codex_storage_platform_unqualified'); assert.equal(dry.submitted, false); assert.equal(dry.codexHomeResolution.reason, 'single_stable_live_writer'); const other = join(f.root, 'inactive-17'); f.database(other); const conflict = await f.invoke([...f.args.slice(0, 4), other, ...f.args.slice(5)], { CODEX_HOME: f.home }); assert.equal(conflict.error, 'explicit_home_conflicts_with_live_writer'); @@ -191,22 +191,22 @@ test('Windows native writer, CLI and SSH contracts', { skip: process.platform != assert.equal(unsaved.error, 'thread_not_yet_persisted'); assert.equal(unsaved.submitted, false); assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before); const restored = new DatabaseSync(join(f.home, 'state_5.sqlite')); - restored.prepare('INSERT INTO threads(id) VALUES (?)').run(id); restored.close(); + restored.prepare('INSERT INTO threads(id,cwd) VALUES (?,?)').run(id, f.home); restored.close(); await f.stop(owner); const queued = await f.invoke([...f.args, '--allow-inactive-codex-home']); - assert.equal(queued.status, 'queued'); assert.equal(queued.queueId, 'fixture-17'); + assert.equal(queued.error, 'codex_storage_platform_unqualified'); assert.equal(queued.status, 'refused'); assert.equal(queued.submitted, false); assert.equal(queued.codexHomeResolution.reason, 'explicit_inactive_opt_in'); - assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before + 1); + assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before); }); - await t.test('nonzero and timeout after queue spawn stay unknown without retry or stderr leak', async () => { + await t.test('native failure and timeout modes cannot bypass the unqualified Windows storage adapter', async () => { const owner = await f.holder(); for (const mode of ['fail', 'timeout']) { const before = f.log('FIXTURE_QUEUE_LOG').length; const result = await f.invoke(f.args, { FIXTURE_QUEUE_MODE: mode }); - assert.equal(result.status, 'unknown'); assert.equal(result.submitted, null); assert.equal(result.retryAllowed, false); + assert.equal(result.error, 'codex_storage_platform_unqualified'); assert.equal(result.status, 'refused'); assert.equal(result.submitted, false); assert.equal(result.retryAllowed, false); assert.equal(result.codexHomeResolution.reason, 'explicit_live_writer'); assert.equal(JSON.stringify(result).includes('SECRET-SENTINEL'), false); - assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before + 1); + assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before); } await f.stop(owner); }); @@ -261,9 +261,9 @@ test('Windows native writer, CLI and SSH contracts', { skip: process.platform != '--to', `codex:${id}`, '--codex-home', f.home, '--codex-bin', f.binary, '--message', 'SSH SECRET-SENTINEL 🚀', '--no-from']; const owner = await f.holder(); const before = f.log('FIXTURE_QUEUE_LOG').length; - const sent = await f.invoke(args); assert.equal(sent.status, 'queued', JSON.stringify(sent)); assert.equal(sent.consumptionConfirmed, false); - assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before + 1); - assert.equal(f.log('FIXTURE_QUEUE_LOG').at(-1).args.at(-1), '--message=SSH SECRET-SENTINEL 🚀'); + const sent = await f.invoke(args); assert.equal(sent.status, 'refused', JSON.stringify(sent)); assert.equal(sent.error, 'codex_storage_platform_unqualified'); + assert.equal(sent.submitted, false); assert.equal(sent.consumptionConfirmed, false); + assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before); const calls = f.log('FIXTURE_SSH_LOG'); assert.equal(calls.length, 2); for (const call of calls) { assert.ok(call.args.includes('BatchMode=yes')); assert.ok(call.args.includes('StrictHostKeyChecking=yes')); @@ -283,14 +283,14 @@ test('Windows native writer, CLI and SSH contracts', { skip: process.platform != } const lost = await f.invoke(args, { FIXTURE_SSH_MODE: 'loss' }); assert.equal(lost.status, 'unknown'); assert.equal(lost.submitted, null); assert.equal(lost.retryAllowed, false); - assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before + 1); + assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before); const sshBeforeComplete = f.log('FIXTURE_SSH_LOG').length; const complete = await f.invoke(args, { FIXTURE_SSH_MODE: 'complete255' }); - assert.equal(complete.ok, true); assert.equal(complete.status, 'queued'); assert.equal(complete.submitted, true); - assert.deepEqual(complete.target, sent.target); assert.equal(complete.queueId, 'fixture-17'); + assert.equal(complete.ok, false); assert.equal(complete.status, 'refused'); assert.equal(complete.submitted, false); + assert.equal(complete.error, 'codex_storage_platform_unqualified'); assert.equal(complete.codexHomeResolution.reason, 'explicit_live_writer'); assert.equal(complete.consumptionConfirmed, false); assert.equal(complete.retryAllowed, false); assert.deepEqual(complete.sshTransport, { status: 'failed', reason: 'ssh_exit_nonzero', exitCode: 255 }); - assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before + 2); // Exactly one additional native fixture call, never a retry. + assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before); // Complete refusal evidence survives exit 255 without a native queue or retry. assert.equal(f.log('FIXTURE_SSH_LOG').length, sshBeforeComplete + 2); // Preflight plus one request. const remoteProfile = join(f.root, 'remote-user'); const remoteHome = join(remoteProfile, '.codex'); f.database(remoteHome); @@ -302,13 +302,14 @@ test('Windows native writer, CLI and SSH contracts', { skip: process.platform != assert.equal(listed.discovery.claude.status, 'ok'); assert.equal(listed.discovery.codex.homes.at(-1).code, 'state_db_missing'); assert.equal(listed.sshHost, 'fixture'); assert.equal('submitted' in listed, false); - assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before + 2); + assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before); const diagnosed = await f.invoke(['doctor', '--host', 'fixture', '--remote-platform', 'win32', '--remote-bin', remote, '--agent', 'codex', '--codex-bin', f.binary], { FIXTURE_REMOTE_PROFILE: remoteProfile, FIXTURE_REMOTE_HOMES: '[]' }); - assert.equal(diagnosed.ok, true); assert.equal(diagnosed.ready, true); assert.equal(diagnosed.command, 'doctor'); + assert.equal(diagnosed.ok, true); assert.equal(diagnosed.ready, false); assert.equal(diagnosed.command, 'doctor'); assert.equal(diagnosed.agents.codex.homes[0].codexHome, remoteHome); + assert.equal(diagnosed.agents.codex.homes[0].code, 'codex_storage_platform_unqualified'); assert.equal(diagnosed.agents.codex.tool.executed, false); assert.equal(diagnosed.sshHost, 'fixture'); - assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before + 2); + assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before); // #20: repeated hosts run in order through the same encoded PowerShell path. const sshBefore = f.log('FIXTURE_SSH_LOG').length; const pair = await f.invoke(['list', '--agent', 'codex', '--host', 'fixture', '--host', 'user@[2001:db8::1]', '--remote-platform', 'win32', @@ -319,7 +320,7 @@ test('Windows native writer, CLI and SSH contracts', { skip: process.platform != const pairCalls = f.log('FIXTURE_SSH_LOG').slice(sshBefore); assert.deepEqual(pairCalls.map(call => call.args[call.args.indexOf('--') + 1]), ['fixture', 'fixture', 'user@2001:db8::1', 'user@2001:db8::1']); for (const call of pairCalls) assert.deepEqual(call.args.slice(call.args.indexOf('-p'), call.args.indexOf('-p') + 2), ['-p', '2222']); - assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before + 2); + assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before); // #22 with #20: an opted-in client notice (fresh cache, no refresh) never enters the // repeated --host array; text gets one stderr line; one flat host carries the field. const cacheDir = join(f.root, 'update-cache'); mkdirSync(cacheDir, { recursive: true }); @@ -351,10 +352,10 @@ test('Windows native writer, CLI and SSH contracts', { skip: process.platform != const quietText = await raw([...pairArgs, '--output-format', 'text', '--no-update-notice']); assert.equal(quietText.code, noticedText.code); assert.equal(noticedText.stdout, quietText.stdout); assert.equal(quietText.stderr, ''); assert.deepEqual(readdirSync(cacheDir), ['npm-update.json']); - assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before + 2); + assert.equal(f.log('FIXTURE_QUEUE_LOG').length, before); await f.stop(owner); const inactive = await f.invoke([...args, '--allow-inactive-codex-home']); - assert.equal(inactive.status, 'queued'); assert.equal(inactive.queueId, 'fixture-17'); + assert.equal(inactive.error, 'codex_storage_platform_unqualified'); assert.equal(inactive.status, 'refused'); assert.equal(inactive.submitted, false); assert.equal(inactive.codexHomeResolution.reason, 'explicit_inactive_opt_in'); assert.ok(JSON.parse(f.log('FIXTURE_SSH_LOG').at(-1).input).args.includes('--allow-inactive-codex-home')); }); From a59634a1e6dc4e9c290a3bf364b70f3a9e7fa299 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 29 Oct 2026 23:01:32 +0900 Subject: [PATCH 2/8] fix(codex): retain executable and storage evidence through final dispatch Pin the selected canonical binary, close async revalidation gaps with a synchronous pre-write barrier, and recheck fallback deadlines. Preserve zero-submit refusals with deterministic regression fixtures. Refs #166. --- docs/api.md | 11 ++++++++ src/app-server.ts | 32 ++++++++++++++++------- src/codex-storage.ts | 22 +++++++++++++--- src/handoff-operations.ts | 7 +++-- src/send.ts | 42 ++++++++++++++++++++---------- test/app-server.test.ts | 32 +++++++++++++++++++++++ test/codex-storage.test.ts | 53 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 169 insertions(+), 30 deletions(-) diff --git a/docs/api.md b/docs/api.md index 3c50141..6970057 100644 --- a/docs/api.md +++ b/docs/api.md @@ -115,6 +115,17 @@ atomic filesystem/configuration snapshot or protection against an undetectable same-user rewrite. Missing evidence never permits fallback after an attempt or an automatic retry of an unknown outcome. +The reviewed project-discovery implementation reads `dir/.codex/config.toml`; +its older summary comment mentioning `cwd/config.toml` is not the implemented +path. Git files (including worktree markers) stop root discovery; a `.git` +directory needs its HEAD marker. The ordinary startup path uses embedded +packaged defaults, whose reviewed TOML contains no `sqlite_home`; it does not +supply the programmatic packaged-defaults-file override. Legacy root `profile` +selection is rejected by the reviewed core, and `ConfigProfile` has no +`sqlite_home` member or storage flattening. A nested profile key is not promoted +to a root storage override. The global `-c` option is present in all three +reviewed CLI parsers and can follow the queue message argument. + Source review pins: | Native tag | Upstream source commit | diff --git a/src/app-server.ts b/src/app-server.ts index ebdbc13..b7eefb5 100644 --- a/src/app-server.ts +++ b/src/app-server.ts @@ -14,7 +14,7 @@ import { performance } from 'node:perf_hooks'; import type { Readable, Writable } from 'node:stream'; import { TextDecoder } from 'node:util'; import { run } from './process.js'; -import { classifyCodexStorage, codexConsumerCwd, decodeLsofFilename } from './codex-storage.js'; +import { classifyCodexStorage, codexConsumerCwd, codexConsumerSnapshotSync, decodeLsofFilename } from './codex-storage.js'; import { CANDIDATE_PROFILES, NATIVE_PROFILES, platformProfiled, selectNativeProfile, validateNativeProfiles, type NativeProfile } from './native-profiles.js'; import { procOwnedFiles, type OpenedFile, type ProcReader } from './linux-proc.js'; import { fileURLToPath } from 'node:url'; @@ -116,7 +116,7 @@ export type NativeQueuePort = Pick; export type NativeQueueOptions = { binary: string; scope: AppServerScope; message: string; clientUserMessageId: string; deadlineMs: number; verifyOwner: (scope: AppServerScope) => Promise; beforeEffect: () => Promise; - verifyStorage?: () => Promise; env?: NodeJS.ProcessEnv; + verifyStorage?: () => Promise; verifyStorageSync?: () => void; env?: NodeJS.ProcessEnv; }; export function nativeQueueCapability(context: AppServerContext): { queueSupported: boolean; observationSupported: false; @@ -286,7 +286,8 @@ export class BoundedAppServerClient implements AppServerPort { 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 () => {}) { + private readonly nativeQueue = false, private readonly beforeEffect: () => Promise = async () => {}, + private readonly beforeWrite: () => void = () => {}) { 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); @@ -309,12 +310,12 @@ export class BoundedAppServerClient implements AppServerPort { } static async connectNativeQueueWire(scope: AppServerScope, transport: NativeQueueTransport, deadlineMs: number, verifyOwner: (scope: AppServerScope) => Promise, beforeEffect: () => Promise, - profile: NativeProfile): Promise { - return this.connectWire(scope, transport, deadlineMs, verifyOwner, true, beforeEffect, profile); + profile: NativeProfile, beforeWrite: () => void): Promise { + return this.connectWire(scope, transport, deadlineMs, verifyOwner, true, beforeEffect, profile, beforeWrite); } private static async connectWire(scope: AppServerScope, transport: ClientTransport, deadlineMs: number, verifyOwner: (scope: AppServerScope) => Promise, nativeQueue: boolean, - beforeEffect: () => Promise = async () => {}, profile?: NativeProfile): Promise { + beforeEffect: () => Promise = async () => {}, profile?: NativeProfile, beforeWrite?: () => void): 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')) { @@ -324,7 +325,7 @@ export class BoundedAppServerClient implements AppServerPort { 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); + const client = new BoundedAppServerClient(original, transport, deadlineMs, verifyOwner, nativeQueue, beforeEffect, beforeWrite); try { await client.owner(); if (nativeQueue) { @@ -440,6 +441,7 @@ export class BoundedAppServerClient implements AppServerPort { // 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.beforeWrite(); this.check(); } return new Promise((resolve, reject) => { @@ -665,6 +667,7 @@ export async function nativeQueueOnceWithProfiles(options: NativeQueueOptions, return value; }; const initialStorage = checkStorage(); + const consumerDatabase = codexConsumerSnapshotSync(original.codexHome, original.threadId).database; const revalidateStorage = async (version?: string) => { if (await codexConsumerCwd(original.codexHome, original.threadId).catch(() => '') !== consumerCwd || checkStorage(version).fingerprint !== initialStorage.fingerprint) throw new AppServerFault('storage_binding_unverified', false); @@ -716,12 +719,21 @@ export async function nativeQueueOnceWithProfiles(options: NativeQueueOptions, // Run after the LAST asynchronous owner verification. The durable guard // and owner probe can both await I/O and change canonical file bindings. await options.verifyStorage?.(); - verifyExecutableIdentity(options.binary, binaryIdentity); - await bind(transport.pid); await revalidateStorage(nativeVersion); + await bind(transport.pid); } return valid; - }, async () => { await options.beforeEffect(); effectGuarded = true; }, profile); + }, async () => { await options.beforeEffect(); effectGuarded = true; }, profile, () => { + // The effect writer calls this after its final await, before setting the + // attempted marker. No async callback follows these continuity checks. + try { + options.verifyStorageSync?.(); + if (checkStorage(nativeVersion).fingerprint !== initialStorage.fingerprint) throw new Error(); + const current = codexConsumerSnapshotSync(original.codexHome, original.threadId); + if (current.database !== consumerDatabase || current.cwd !== consumerCwd) throw new Error(); + verifyExecutableIdentity(options.binary, binaryIdentity); + } catch (error) { throw error instanceof AppServerFault ? error : new AppServerFault('storage_binding_unverified', false); } + }); await bind(transport.pid); return await client.queueOnce({ clientUserMessageId: options.clientUserMessageId, message: options.message }); } finally { if (client) await client.close(); else await disposeTransport(transport); } diff --git a/src/codex-storage.ts b/src/codex-storage.ts index afaed83..e437ae7 100644 --- a/src/codex-storage.ts +++ b/src/codex-storage.ts @@ -214,18 +214,32 @@ export function classifyCodexStorage(options: StorageOptions): StorageCheck { // Only the saved thread's cwd column is inspected: never transcripts, accounts, // credentials, preference files or arbitrary environment variables. -export async function codexConsumerCwd(home: string, threadId: string): Promise { +let sqlite: typeof import('node:sqlite') | undefined; +export function codexStateIdentity(home: string): string { + const info = lstatSync(join(home, 'state_5.sqlite'), { bigint: true }); + if (!info.isFile()) throw new Error('codex_consumer_context_unverifiable'); + return `${info.dev}:${info.ino}`; +} +// Initialized lazily by the first Codex-only read. The final dispatch barrier +// then queries metadata synchronously, without importing SQLite for Claude. +export function codexConsumerSnapshotSync(home: string, threadId: string): { cwd: string; database: string } { try { - const { DatabaseSync } = await import('node:sqlite'); - const db = new DatabaseSync(join(home, 'state_5.sqlite'), { readOnly: true }); + if (!sqlite) throw new Error(); + const database = codexStateIdentity(home); + const db = new sqlite.DatabaseSync(join(home, 'state_5.sqlite'), { readOnly: true }); try { db.exec('PRAGMA query_only=ON; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=3000'); const row = db.prepare('SELECT cwd FROM threads WHERE id=? LIMIT 1').get(threadId) as { cwd?: unknown } | undefined; if (typeof row?.cwd !== 'string' || row.cwd.length > 32768 || !isAbsolute(row.cwd)) throw new Error(); - return row.cwd; + if (codexStateIdentity(home) !== database) throw new Error(); + return { cwd: row.cwd, database }; } finally { db.close(); } } catch { throw new Error('codex_consumer_context_unverifiable'); } } +export async function codexConsumerCwd(home: string, threadId: string): Promise { + sqlite ??= await import('node:sqlite'); + return codexConsumerSnapshotSync(home, threadId).cwd; +} // Optional live consumer context complements the persisted thread cwd. A PID // comes only from the already retained writer evidence, never from discovery. diff --git a/src/handoff-operations.ts b/src/handoff-operations.ts index a667706..d32d0fb 100644 --- a/src/handoff-operations.ts +++ b/src/handoff-operations.ts @@ -163,13 +163,16 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO await authorize(); await storage.revalidate(false); const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toUpperCase() !== 'CODEX_SQLITE_HOME')); - const done = await run(executable(options.codexBin ?? 'codex'), ['queue','--thread',context.target,`--message=${message}`,'-c',`sqlite_home=${JSON.stringify(context.home)}`], {env:{...inherited,CODEX_HOME:context.home,PWD:storage.cwd},cwd:storage.cwd,timeout:Math.max(1,budget.observationRemainingMs())}); + storage.finalCheck(false); + const timeout = Math.max(1, budget.observationRemainingMs()); + budget.beforeEffect(); + const done = await run(storage.binary, ['queue','--thread',context.target,`--message=${message}`,'-c',`sqlite_home=${JSON.stringify(context.home)}`], {env:{...inherited,CODEX_HOME:context.home,PWD:storage.cwd},cwd:storage.cwd,timeout}); if (!done.spawned) throw new Refusal('native_spawn_failed',1); if (done.interrupted || done.code !== 0) throw new UnknownOutcome(); const receipt = queueId(done.stdout,context.target); return {queueId:receipt,clientUserMessageId:handoff!.correlationId}; } - try { return await nativeQueueOnce({binary:executable(options.codexBin ?? 'codex'),scope:queueScope!,message,clientUserMessageId:handoff!.correlationId,deadlineMs:budget.cutoff,verifyOwner: async () => verifyOwner(scope!),beforeEffect: authorize,verifyStorage: () => storage.revalidate(true)}); } + try { return await nativeQueueOnce({binary:storage.binary,scope:queueScope!,message,clientUserMessageId:handoff!.correlationId,deadlineMs:budget.cutoff,verifyOwner: async () => verifyOwner(scope!),beforeEffect: authorize,verifyStorage: () => storage.revalidate(true),verifyStorageSync: () => storage.finalCheck(true)}); } catch(error) { if (error instanceof AppServerFault && error.attempted) throw new UnknownOutcome(); if(error instanceof AppServerFault) throw new Refusal(`app_server_${error.code}`,1); throw error; } }, timeoutMs() { budget.beforeEffect(); return Math.max(1, Math.floor(budget.observationRemainingMs())); }, diff --git a/src/send.ts b/src/send.ts index 157abfd..fdc1c4e 100644 --- a/src/send.ts +++ b/src/send.ts @@ -5,16 +5,17 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, type BigIntStats import { homedir } from 'node:os'; import { join } from 'node:path'; import { claude, Refusal } from './discovery.js'; -import { classifyCodexStorage, codexConsumerCwd, codexWriterCwd, codexWriterStorage, storageDiagnostic, type StorageCheck } from './codex-storage.js'; +import { classifyCodexStorage, codexConsumerCwd, codexConsumerSnapshotSync, codexWriterCwd, codexWriterStorage, storageDiagnostic, type StorageCheck } from './codex-storage.js'; import { executable, run, UnknownOutcome } from './process.js'; import { HomeRefusal, resolveWriter, revalidateWriter, uuid, type HomeResolution } from './writer.js'; import { postWindowsPipe } from './windows.js'; export type SendContext = { agent: 'claude' | 'codex'; target: string; home?: string; generation: string | null; writerIdentity?: string }; -export type CodexStorageGuard = { cwd: string; revalidate: (ownedAppServer?: boolean) => Promise }; +export type CodexStorageGuard = { readonly binary: string; readonly cwd: string; + revalidate: (ownedAppServer?: boolean) => Promise; finalCheck: (ownedAppServer?: boolean) => void }; export type SendHooks = { - // Only explicitly opted-in callers resolve this additional context. Default - // send output, inspection count and native execution remain unchanged. + // Opted-in callers resolve additional handoff context and fencing. Codex + // sends share the storage guard without creating an implicit handoff ledger. resolved: (context: SendContext, snapshot: Record) => Promise | string | void; beforeEffect?: () => Promise | void; timeoutMs?: () => number; @@ -111,10 +112,13 @@ export async function send(options: SendOptions): Promise item.home === home)!.writer).pid; + const proof = JSON.parse(selection.evidence) as { fingerprints: { home: string; writer: string; database: string }[] }; + const selected = proof.fingerprints.find(item => item.home === home)!; + selectedDatabase = selected.database; + if (codexConsumerSnapshotSync(home, id).database !== selectedDatabase) throw new Error(); + writerPid = JSON.parse(selected.writer).pid; if (writerPid !== undefined) { writerCwd = codexWriterCwd(writerPid); codexWriterStorage(writerPid, home); } } catch { throw new HomeRefusal('codex_consumer_context_unverifiable', selection.resolution); } const consumerCwds = [consumerCwd, ...(writerCwd ? [writerCwd] : [])]; @@ -153,13 +157,19 @@ export async function send(options: SendOptions): Promise { + try { + const current = codexConsumerSnapshotSync(home, id); + if (current.database !== selectedDatabase || current.cwd !== consumerCwd) throw new Error(); + } catch { throw new HomeRefusal('codex_storage_changed_before_queue', selection.resolution); } + }; + checkConsumer(); if (writerPid !== undefined) { try { if (codexWriterCwd(writerPid) !== writerCwd) throw new Error(); codexWriterStorage(writerPid, home); } catch { throw new HomeRefusal('codex_consumer_context_unverifiable', selection.resolution); } @@ -167,7 +177,9 @@ export async function send(options: SendOptions): Promise { + const originalSpawn = childProcess.spawn; let fenced = false, swapped = false; + t.mock.method(childProcess, 'spawn', (...args: Parameters) => { + const child = originalSpawn(...args); + if (args[0] === '/usr/sbin/lsof' && fenced && !swapped) child.once('close', () => { + queueMicrotask(() => { if (!swapped) { swapped = true; writeFileSync(binary, script + '\n// late replacement'); } }); + }); + return child; + }); syncBuiltinESMExports(); + try { + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + beforeEffect: async () => { fenced = true; } }), fault('unsupported_version', false)); + assert.equal(swapped, true); + } finally { writeFileSync(binary, script); t.mock.restoreAll(); syncBuiltinESMExports(); } + }); + await t.test('retained consumer changes during final async binding are checked synchronously at the RPC write', async t => { + const originalSpawn = childProcess.spawn; let fenced = false, moved = false, currentCwd = selected, finalChecks = 0; + t.mock.method(childProcess, 'spawn', (...args: Parameters) => { + const child = originalSpawn(...args); + if (args[0] === '/usr/sbin/lsof' && fenced && !moved) child.once('close', () => { + queueMicrotask(() => { moved = true; currentCwd = other; }); + }); return child; + }); syncBuiltinESMExports(); + try { + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + beforeEffect: async () => { fenced = true; }, + verifyStorage: async () => { assert.equal(currentCwd, selected); }, + verifyStorageSync: () => { finalChecks++; if (currentCwd !== selected) throw new AppServerFault('storage_binding_unverified', false); }, + }), fault('storage_binding_unverified', false)); + assert.equal(moved, true); assert.equal(finalChecks, 1); + } finally { t.mock.restoreAll(); syncBuiltinESMExports(); } + }); await t.test('configuration mutation during durable guard cannot reach queue', async () => { const config = join(selected, 'config.toml'); try { diff --git a/test/codex-storage.test.ts b/test/codex-storage.test.ts index 7df6af0..7c163bf 100644 --- a/test/codex-storage.test.ts +++ b/test/codex-storage.test.ts @@ -9,6 +9,9 @@ import { DatabaseSync } from 'node:sqlite'; import { classifyCodexStorage, codexWriterStorage, storageDiagnostic, STORAGE_LOADER_VERSIONS } from '../dist/codex-storage.js'; import { send } from '../dist/send.js'; import { diagnoseCodex } from '../dist/diagnostics.js'; +import { handoffSend } from '../dist/handoff-operations.js'; +import { HandoffBudget } from '../dist/handoff.js'; +import { HandoffLedger } from '../dist/handoff-ledger.js'; const id = '11111111-1111-4111-8111-111111111111'; function fixture(t: TestContext) { @@ -259,3 +262,53 @@ test('Windows Codex send and dry-run refuse an unqualified storage adapter befor for (const dryRun of [false, true]) await assert.rejects(send({ ...f.sendOptions, codexBin: process.execPath, dryRun }), { code: 'codex_storage_platform_unqualified' }); assert.equal(calls, 0); }); +test('binary replacement during the final consumer-query await refuses before native submission', { skip: process.platform === 'win32' }, async t => { + const f = dispatchFixture(t), prepare = DatabaseSync.prototype.prepare; let armed = false, mutated = false; + t.mock.method(DatabaseSync.prototype, 'prepare', function(this: DatabaseSync, sql: string) { + if (armed && !mutated && sql.startsWith('SELECT cwd FROM threads')) { + mutated = true; queueMicrotask(() => fs.appendFileSync(f.binary, '\n// swapped during consumer await')); + } + return prepare.call(this, sql); + }); + await assert.rejects(send({ ...f.sendOptions, hooks: { resolved() {}, beforeEffect() { armed = true; } } }), { code: 'codex_storage_loader_changed' }); + assert.equal(mutated, true); assert.equal(existsSync(f.log), false); +}); +test('same-cwd inactive state database replacement during the final consumer-query await refuses before submission', { skip: process.platform === 'win32' }, async t => { + const f = dispatchFixture(t), replacement = join(f.root, 'replacement.sqlite'), prepare = DatabaseSync.prototype.prepare; + fs.copyFileSync(join(f.home, 'state_5.sqlite'), replacement); + let armed = false, mutated = false; + t.mock.method(DatabaseSync.prototype, 'prepare', function(this: DatabaseSync, sql: string) { + if (armed && !mutated && sql.startsWith('SELECT cwd FROM threads')) { + mutated = true; queueMicrotask(() => fs.renameSync(replacement, join(f.home, 'state_5.sqlite'))); + } + return prepare.call(this, sql); + }); + await assert.rejects(send({ ...f.sendOptions, hooks: { resolved() {}, beforeEffect() { armed = true; } } }), { code: 'codex_storage_changed_before_queue' }); + assert.equal(mutated, true); assert.equal(existsSync(f.log), false); +}); +test('handoff fallback executes the retained canonical binary when its input symlink changes during authorization', { skip: process.platform === 'win32' }, async t => { + const f = dispatchFixture(t), link = join(f.root, 'selected-binary'), other = join(f.root, 'unverified-binary'), otherLog = join(f.root, 'unverified-log'); + fs.copyFileSync(f.binary, other); fs.chmodSync(other, 0o700); + writeFileSync(other, fs.readFileSync(other, 'utf8').replaceAll(f.log, otherLog)); + symlinkSync(f.binary, link); + new HandoffLedger(join(f.root, 'ledger')).init(); + const commit = HandoffLedger.prototype.commitEffect; let retargeted = false; + t.mock.method(HandoffLedger.prototype, 'commitEffect', function(this: HandoffLedger, ...args: Parameters) { + const value = commit.apply(this, args); + queueMicrotask(() => { rmSync(link); symlinkSync(other, link); retargeted = true; }); return value; + }); + const result = await handoffSend({ ...f.sendOptions, codexBin: link }, { ledgerPath: join(f.root, 'ledger'), seconds: 30 }); + assert.equal(result.value.submitted, true); assert.equal(retargeted, true); + assert.equal(fs.readFileSync(f.log, 'utf8').trim().split('\n').length, 1); assert.equal(existsSync(otherLog), false); +}); +test('handoff fallback rechecks the original cutoff after durable authorization and never queues late', { skip: process.platform === 'win32' }, async t => { + const f = dispatchFixture(t); let now = 0; + new HandoffLedger(join(f.root, 'ledger')).init(); + const budget = new HandoffBudget(30, { monotonic: () => now, utc: () => 100000 }); + const commit = HandoffLedger.prototype.commitEffect; let committed = false; + t.mock.method(HandoffLedger.prototype, 'commitEffect', function(this: HandoffLedger, ...args: Parameters) { + const value = commit.apply(this, args); committed = true; now = budget.cutoff; return value; + }); + const result = await handoffSend(f.sendOptions, { ledgerPath: join(f.root, 'ledger'), budget }); + assert.equal(committed, true); assert.equal(result.value.error, 'deadline_before_effect'); assert.equal(existsSync(f.log), false); +}); From 92a678b604d046803874df589d52c38b0784aed2 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 29 Oct 2026 23:57:59 +0900 Subject: [PATCH 3/8] fix(codex): finalize route-aware dispatch guards and dry-run invariants Close final-await and timeout-callback gaps, retain the selected binary in qualification, use TOML-safe path pins, and keep dry-run helpers free of agent execution. Preserve actual-route cloud refusal and unchanged handoff bindings. Refs #166, #167, #168. --- README.ja.md | 2 ++ README.ko.md | 2 ++ README.md | 2 ++ README.zh-CN.md | 2 ++ docs/api.md | 10 ++++++ docs/guide.ja.md | 2 ++ docs/guide.ko.md | 2 ++ docs/guide.md | 2 ++ docs/guide.zh-CN.md | 2 ++ scripts/current-version.md | 21 +++++++++++++ src/app-server.ts | 16 +++++----- src/codex-storage.ts | 11 +++++-- src/handoff-operations.ts | 12 +++++--- src/send.ts | 36 +++++++++++++++++----- test/app-server.test.ts | 19 +++++++----- test/codex-storage.test.ts | 58 +++++++++++++++++++++++++++++++++-- test/handoff-delivery.test.ts | 45 +++++++++++++++++++++++++++ 17 files changed, 214 insertions(+), 30 deletions(-) diff --git a/README.ja.md b/README.ja.md index ac41d45..1079cca 100644 --- a/README.ja.md +++ b/README.ja.md @@ -23,6 +23,8 @@ Python 不要の Node.js クライアントです。パッケージ名とコマ +通常の Codex 送信は、ログイン済み home などクラウドポリシーの適用範囲を判定できない場合も拒否します。経路を選ぶ前に[ストレージ検証の範囲](docs/api.md#codex-storage-configuration)を確認してください。 + このソースは Codex の追加設定レイヤーを確認します。Windows の Codex 送信・dry-run はローカルストレージ検査が未検証のためキュー提出前に拒否し、通常の Claude 経路とは区別します。導入前に[検証範囲](docs/api.md#codex-storage-configuration)と選択したリリースを確認してください。 ## Demo diff --git a/README.ko.md b/README.ko.md index 32e2736..a32e17f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -17,6 +17,8 @@ Python을 설치하지 않아도 Node.js에서 실행되는 TypeScript 클라이 +일반 Codex 전송은 로그인 home 등 클라우드 정책 적용 여부를 분류할 수 없는 경우에도 거부합니다. 경로를 선택하기 전에 [저장소 검증 범위](docs/api.md#codex-storage-configuration)를 확인하세요. + 이 소스는 Codex의 추가 설정 계층을 검사합니다. Windows Codex 전송과 dry-run은 로컬 저장소 검사 방식이 검증될 때까지 큐 제출 전에 거부하며, 일반 Claude 경로는 별도입니다. 설치 전에 [검증 범위](docs/api.md#codex-storage-configuration)와 선택한 릴리스를 확인하세요. diff --git a/README.md b/README.md index d1efe22..44e3e1e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ A Node.js client that runs without Python. Package and command: `session-peer`. +Ordinary Codex sends also refuse signed-in or other cloud-unclassifiable homes; see the [storage scope](docs/api.md#codex-storage-configuration) before choosing a route. + Source storage guards now inspect additional Codex configuration layers. Windows Codex send/dry-run refuse before queue submission while local storage classification is unqualified; ordinary Claude behavior is separate. Review the [guard scope](docs/api.md#codex-storage-configuration) and selected release before installation. ## Demo diff --git a/README.zh-CN.md b/README.zh-CN.md index 275755f..90881ba 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,6 +23,8 @@ +普通 Codex 发送也会拒绝登录 home 等无法判定云端策略适用范围的上下文。选择路径前请确认[存储检查范围](docs/api.md#codex-storage-configuration)。 + 此源码检查 Codex 的更多配置层。Windows 的 Codex 发送和 dry-run 因本地存储检查方式尚未验证而在队列提交前被拒绝;普通 Claude 路径另行处理。安装前请确认[检查范围](docs/api.md#codex-storage-configuration)及所选版本。 ## Demo diff --git a/docs/api.md b/docs/api.md index 6970057..449d49f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -74,6 +74,8 @@ Without reading credential/cache contents, ordinary queue classification refuses when selected-home `auth.json` or `cloud-config-bundle-cache.json` metadata is present, when configuration selects keyring/auto credential storage, or when external identity markers or a config server make cloud applicability unknown. +The conservative selector scan also includes inactive/nested tables; it does not +claim that native legacy profiles apply those selectors. **This includes ordinary signed-in file-auth homes even when their actual account would not receive cloud policy.** The refusal is `codex_cloud_storage_unverified`; it is not a determination that relocation is @@ -97,6 +99,14 @@ otherwise unused ancestor configuration; the result is not a complete model of native project trust. System metadata helpers have bounded output/timeouts; byte bounds do not promise a deadline for arbitrary filesystem I/O. +Ordinary queue processes keep the sender's physical cwd. A malformed or unsafe +project config in that directory can therefore refuse a send independently of +the target's cwd. The guard also inspects conservative consumer/ancestor context +supersets without reproducing the native project-trust decision, so it can reject +configuration the native loader would ignore as untrusted. Missing/deleted saved +thread cwd, config symlinks, an unclassifiable selected DB or unavailable fixed +system metadata helpers also refuse; these are explicit availability limits. + Unsupported root `sqlite_home`, malformed/unreadable configuration, unsupported reported loader versions, conflicting process/file evidence and changes before dispatch refuse submission. Errors include `unsupported_codex_sqlite_home`, diff --git a/docs/guide.ja.md b/docs/guide.ja.md index abf54a8..9373ca1 100644 --- a/docs/guide.ja.md +++ b/docs/guide.ja.md @@ -1,5 +1,7 @@ # ユーザーガイド — session-peer (TypeScript) +通常の Codex キュー提出も、`auth.json` があるログイン済み home など、クラウドポリシーの適用範囲を判定できない場合は `codex_cloud_storage_unverified` で拒否します。認証情報やキャッシュの内容は読まず、別途検証する owned app-server 経路では実際のストレージとの結び付きを確認する必要があります。 + このソースは Codex home、システム設定、macOS 管理設定、送信側と対象プロセスの実際の作業ディレクトリに適用されるプロジェクト設定を確認します。[ストレージ検証の範囲と拒否条件](api.md#codex-storage-configuration)を参照してください。Windows の Codex 送信・dry-run は、ローカルストレージ検査方式の検証が済むまでキュー提出前に拒否します。doctor と dry-run は Codex を実行せず、ローダーのバージョンは未確認のままです。 現在の機能差と移植方針は[日付を明記した比較表](../PARITY.md#current-scoped-feature-matrix)を参照してください。TypeScriptで公開済みの機能と、別途固定したPythonの比較基準を区別しています。 diff --git a/docs/guide.ko.md b/docs/guide.ko.md index de31283..5a1f28f 100644 --- a/docs/guide.ko.md +++ b/docs/guide.ko.md @@ -1,5 +1,7 @@ # 사용자 가이드 — session-peer (TypeScript) +일반 Codex 큐는 `auth.json`이 있는 로그인 home 등에서 클라우드 정책 적용 여부를 분류할 수 없으면 `codex_cloud_storage_unverified`로 거부합니다. 자격정보·캐시 내용은 읽지 않으며, 별도로 검증하는 owned app-server 경로에는 실제 저장소 바인딩 증거가 필요합니다. + 이 소스는 Codex home·시스템·macOS 관리 설정과 큐 실행 프로세스 및 대상 프로세스의 실제 작업 경로에 적용되는 프로젝트 설정을 검사합니다. [저장소 검증 범위와 거부 조건](api.md#codex-storage-configuration)을 확인하세요. Windows Codex 전송과 dry-run은 로컬 저장소 검사 방식이 검증될 때까지 큐 제출 전에 거부합니다. doctor와 dry-run은 Codex를 실행하지 않으며 로더 버전은 미확인으로 남깁니다. 현재 기능 차이와 포팅 결정은 [날짜가 명시된 비교표](../PARITY.md#current-scoped-feature-matrix)에서 확인하세요. TypeScript의 공개 기능과 별도로 고정한 Python 비교 기준을 구분합니다. diff --git a/docs/guide.md b/docs/guide.md index 74ad3e0..30c70ef 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1,5 +1,7 @@ # User guide — session-peer (TypeScript) +Ordinary Codex queue submission also refuses cloud-unclassifiable contexts, including signed-in file-auth homes with `auth.json`, with `codex_cloud_storage_unverified`. Credential/cache contents are not read; the separately qualified owned app-server route still requires actual storage binding. + This source classifies Codex storage configuration across home, system, managed macOS and physical queue/consumer project contexts. See the [storage guard scope and refusals](api.md#codex-storage-configuration). Windows Codex send/dry-run refuse before queue submission until its local storage adapter is qualified. Doctor and dry-run do not execute Codex and leave its loader version unverified. See the [dated feature comparison and porting decisions](../PARITY.md#current-scoped-feature-matrix) for shipped TypeScript behavior and the separately pinned Python comparison baseline. diff --git a/docs/guide.zh-CN.md b/docs/guide.zh-CN.md index 4ebd2e9..fae23de 100644 --- a/docs/guide.zh-CN.md +++ b/docs/guide.zh-CN.md @@ -1,5 +1,7 @@ # 用户指南 — session-peer (TypeScript) +普通 Codex 队列也会拒绝无法判定云端策略适用范围的上下文,包括存在 `auth.json` 的登录 home,并返回 `codex_cloud_storage_unverified`。不会读取凭据或缓存内容;另行验证的 owned app-server 路径仍需实际存储绑定证据。 + 此源码检查 Codex home、系统配置、macOS 管理配置,以及队列进程和目标进程实际工作目录所适用的项目配置。详见[存储检查范围与拒绝条件](api.md#codex-storage-configuration)。Windows 的 Codex 发送和 dry-run 在本地存储检查方式通过验证前会拒绝队列提交。doctor 和 dry-run 不执行 Codex,加载器版本仍标为未验证。 当前功能差异与移植决策见[注明日期的对照表](../PARITY.md#current-scoped-feature-matrix)。表中区分 TypeScript 已发布的功能与单独固定的 Python 比较基准。 diff --git a/scripts/current-version.md b/scripts/current-version.md index f268070..cadf622 100644 --- a/scripts/current-version.md +++ b/scripts/current-version.md @@ -17,6 +17,27 @@ A template describes one complete, authored line. Only `{{version}}` spans vary; The closed file set is the runtime `VERSION` declaration, four READMEs, four guides, the API reference, the shorthand reference and PARITY. The inventory explicitly selects the current source descriptions, current installation/update commands, tarball filenames, expected banners and SSH version examples within those documents. PARITY's current summary is selected separately from its dated records. The runtime declaration is the only managed slot in `src/protocol.ts`. Every managed file must appear exactly once in the inventory; the updater rejects unknown paths and symlinks, including parent directory symlinks. It validates the complete plan before writing. A subsequent filesystem write failure can still leave a partial update; rerun the check to inspect the result. +The inventory classification is explicit rather than inferred from every +version-looking string. At baseline source `90481b148584926ea328ed956327f3050d9be9cb` +there were 131 occurrences of 0.5.1 across 14 Markdown files; that audit count +includes evidence and policy and is not an update list. This inventory selects +105 current lines (115 version spans) across 11 Markdown files plus protocol.ts: + +| Area | Update rule | +| --- | --- | +| Four READMEs and four guides | Current source display, install/update examples, banners and selected SSH version examples only | +| API and shorthand references | Current package/source examples only; native protocol/feature introduction versions remain literal | +| PARITY | Only `Current source/package version: **{{version}}**.`; dated comparison sources and release evidence stay fixed | +| Runtime protocol | Only the exact exported VERSION declaration | +| VALIDATION, RELEASING and skill compatibility design | Manually reviewed dated evidence, publication policy and finite compatibility history; never generated | +| SECURITY | Manually reviewed reporting/support policy, not a package-version display (no 0.5.1 literal at that baseline) | +| Native/Python/skill pins and fixtures | Independent immutable contracts, not current package metadata | + +Some owned lines describe source capabilities as well as a version. Updating +the number does not revalidate the capability: reviewers must inspect those +claims whenever behavior changes. The updater intentionally preserves every +other authored character; it cannot decide whether a feature remains supported. + Everything outside these explicit spans remains authored content. In particular, dated preparation/publication evidence in PARITY and VALIDATION, historical release descriptions and anchors, source hashes, release policy, finite skill compatibility rows, independent skill/native/Python versions and pin files are not generated. A version update does not approve new compatibility, qualification, publication or installation pins. Current prose that describes a capability must still be reviewed when behavior changes. `test/current-version.test.ts` exercises the real inventory against isolated copies: read-only checks, a synthetic next patch, byte-idempotence, preserved historical records and independent pins, malformed/overlapping slots, unknown paths, symlinks and validation before writes. Build and package acceptance also require a separate isolated synthetic-version run: update package and lock metadata in the copied tree, run the updater and repository checker, build, then run `npm run test:package`. Keep production release policies and finite compatibility tables unchanged in that experiment. Record the commands actually run and their results in the change's validation evidence; unit fixtures alone are not evidence of a successful synthetic build or package smoke. diff --git a/src/app-server.ts b/src/app-server.ts index b7eefb5..d5aa30c 100644 --- a/src/app-server.ts +++ b/src/app-server.ts @@ -14,7 +14,7 @@ import { performance } from 'node:perf_hooks'; import type { Readable, Writable } from 'node:stream'; import { TextDecoder } from 'node:util'; import { run } from './process.js'; -import { classifyCodexStorage, codexConsumerCwd, codexConsumerSnapshotSync, decodeLsofFilename } from './codex-storage.js'; +import { classifyCodexStorage, codexConsumerCwd, codexConsumerSnapshotSync, codexStoragePin, decodeLsofFilename } from './codex-storage.js'; import { CANDIDATE_PROFILES, NATIVE_PROFILES, platformProfiled, selectNativeProfile, validateNativeProfiles, type NativeProfile } from './native-profiles.js'; import { procOwnedFiles, type OpenedFile, type ProcReader } from './linux-proc.js'; import { fileURLToPath } from 'node:url'; @@ -364,8 +364,8 @@ export class BoundedAppServerClient implements AppServerPort { 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); + promise.then(value => { this.operations.delete(wait); resolve(value); }, error => { + this.operations.delete(wait); this.fail(error instanceof AppServerFault ? error.code : failureCode); reject(this.fault); }); }); } @@ -708,7 +708,7 @@ export async function nativeQueueOnceWithProfiles(options: NativeQueueOptions, verifyOwnedSqliteBinding(pid, original.codexHome, options.deadlineMs); 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) }; + '-c', codexStoragePin(original.codexHome)], env, original.codexHome) }; let client: NativeQueuePort | undefined; try { verifyExecutableIdentity(options.binary, binaryIdentity); @@ -718,9 +718,11 @@ export async function nativeQueueOnceWithProfiles(options: NativeQueueOptions, 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. - await options.verifyStorage?.(); - await revalidateStorage(nativeVersion); - await bind(transport.pid); + try { + await options.verifyStorage?.(); + await revalidateStorage(nativeVersion); + await bind(transport.pid); + } catch (error) { throw error instanceof AppServerFault ? error : new AppServerFault('storage_binding_unverified', false); } } return valid; }, async () => { await options.beforeEffect(); effectGuarded = true; }, profile, () => { diff --git a/src/codex-storage.ts b/src/codex-storage.ts index e437ae7..21fe2d9 100644 --- a/src/codex-storage.ts +++ b/src/codex-storage.ts @@ -9,6 +9,12 @@ import { TextDecoder } from 'node:util'; import { parse } from 'smol-toml'; export const STORAGE_LOADER_VERSIONS = ['0.160.1', '0.162.1', '0.165.0'] as const; +// JSON string escapes are TOML-compatible except raw DEL, which TOML forbids. +// C0 characters are escaped by JSON.stringify; escape DEL explicitly so Rust's +// override parser never falls back to a literal containing doubled slashes. +export function codexStoragePin(home: string): string { + return `sqlite_home=${JSON.stringify(home).replace(/\u007f/g, '\\u007f')}`; +} const LIMIT = 1024 * 1024; type Layer = 'home' | 'system' | 'requirements' | 'legacy' | 'preferences' | 'project' | 'context' | 'daemon' | 'cloud'; export type StorageCheck = { @@ -105,8 +111,9 @@ export function classifyCodexStorage(options: StorageOptions): StorageCheck { try { value = parse(new TextDecoder('utf-8', { fatal: true }).decode(data)); } catch { throw new Invalid(layer); } if (Object.hasOwn(value, 'sqlite_home')) throw new Invalid(layer, true); customMarkers ||= Object.hasOwn(value, 'project_root_markers'); - // A legacy profile can select auth storage. Inspect this selector without - // reading any credential values, including inactive profiles conservatively. + // Native auth storage is a root selector. This deliberately conservative + // superset also refuses selectors in inactive/nested tables; it does not + // claim the native loader applies those nested selectors. const pending: unknown[] = [value]; let count = 0; while (pending.length) { if (++count > 65536) throw new Invalid(layer); diff --git a/src/handoff-operations.ts b/src/handoff-operations.ts index d32d0fb..157124f 100644 --- a/src/handoff-operations.ts +++ b/src/handoff-operations.ts @@ -13,6 +13,7 @@ import { Refusal } from './discovery.js'; import { HomeRefusal } from './writer.js'; import { executable, run, UnknownOutcome } from './process.js'; import { currentPlatform, selectNativeProfile, validateNativeProfiles, type NativeProfile } from './native-profiles.js'; +import { codexStoragePin } from './codex-storage.js'; export type HandoffOptions = { correlationId?: string; requestAck?: boolean; observeDelivery?: boolean; remote?: boolean; waitFor?: Exclude; seconds?: number; ledgerPath?: string; payload?: string; budget?: HandoffBudget; resultOverhead?: number }; @@ -39,7 +40,8 @@ export function privateMessage(path: string): string { } finally { closeSync(fd); } } function binding(context: SendContext, message: string, destination = hostname()): HandoffBinding { - return { ...context, destination, payloadDigest: payloadDigest(message) }; + const { binary: _privateBinary, ...retained } = context; + return { ...retained, destination, payloadDigest: payloadDigest(message) }; } export function correlationEnvelope(message: string, id: string): string { if (!canonicalId(id)) throw new Refusal('invalid_correlation_id', 2); @@ -137,10 +139,11 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO // Account for the fixed correlation envelope before allocating an intent. checkMessage(correlationEnvelope(options.message, '00000000-0000-4000-8000-000000000000'), context.agent === 'codex'); const wantsDelivery = handoffOptions.observeDelivery || handoffOptions.waitFor === 'delivered'; - if (wantsDelivery && context.agent === 'codex' && context.home && context.generation && budget.observationRemainingMs() > 0) { + if (!options.dryRun && wantsDelivery && context.agent === 'codex' && context.home && context.generation && budget.observationRemainingMs() > 0) { scope = { home: context.home, threadId: context.target, generation: context.generation }; queueScope = { codexHome: context.home, threadId: context.target, generation: context.generation, ownerIdentity: context.writerIdentity! }; - const version = await run(executable(options.codexBin ?? 'codex'), ['--version'], { timeout: Math.min(1000, budget.observationRemainingMs()), limit: 4096 }); + if (!context.binary) throw new Refusal('codex_storage_loader_unverifiable', 1); + const version = await run(context.binary, ['--version'], { timeout: Math.min(1000, budget.observationRemainingMs()), limit: 4096 }); const nativeVersion = !version.interrupted && version.code === 0 ? version.stdout.trim().replace(/^codex-cli /, '') : ''; // Exact host platform and native version select one profile row, or none. qualification = observationQualification(nativeVersion, currentPlatform(), handoffOptions.remote === true); ownerArgs = ownerProbeArgs(qualification); @@ -156,6 +159,7 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO return correlationEnvelope(options.message, handoff.correlationId); }, beforeEffect: authorize, + codexRoute: () => deliverySupported ? 'owned_app_server' : 'ordinary', async submitCodex(context, message, storage) { if (!deliverySupported) { // Best-effort unsupported is chosen before ANY app-server attempt. @@ -166,7 +170,7 @@ export async function handoffSend(options: SendOptions, handoffOptions: HandoffO storage.finalCheck(false); const timeout = Math.max(1, budget.observationRemainingMs()); budget.beforeEffect(); - const done = await run(storage.binary, ['queue','--thread',context.target,`--message=${message}`,'-c',`sqlite_home=${JSON.stringify(context.home)}`], {env:{...inherited,CODEX_HOME:context.home,PWD:storage.cwd},cwd:storage.cwd,timeout}); + const done = await run(storage.binary, ['queue','--thread',context.target,`--message=${message}`,'-c',codexStoragePin(context.home!)], {env:{...inherited,CODEX_HOME:context.home,PWD:storage.cwd},cwd:storage.cwd,timeout}); if (!done.spawned) throw new Refusal('native_spawn_failed',1); if (done.interrupted || done.code !== 0) throw new UnknownOutcome(); const receipt = queueId(done.stdout,context.target); diff --git a/src/send.ts b/src/send.ts index fdc1c4e..c033156 100644 --- a/src/send.ts +++ b/src/send.ts @@ -5,12 +5,13 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, type BigIntStats import { homedir } from 'node:os'; import { join } from 'node:path'; import { claude, Refusal } from './discovery.js'; -import { classifyCodexStorage, codexConsumerCwd, codexConsumerSnapshotSync, codexWriterCwd, codexWriterStorage, storageDiagnostic, type StorageCheck } from './codex-storage.js'; +import { classifyCodexStorage, codexConsumerCwd, codexConsumerSnapshotSync, codexStoragePin, codexWriterCwd, codexWriterStorage, storageDiagnostic, type StorageCheck } from './codex-storage.js'; import { executable, run, UnknownOutcome } from './process.js'; import { HomeRefusal, resolveWriter, revalidateWriter, uuid, type HomeResolution } from './writer.js'; import { postWindowsPipe } from './windows.js'; -export type SendContext = { agent: 'claude' | 'codex'; target: string; home?: string; generation: string | null; writerIdentity?: string }; +export type SendContext = { agent: 'claude' | 'codex'; target: string; home?: string; generation: string | null; + writerIdentity?: string; readonly binary?: string }; export type CodexStorageGuard = { readonly binary: string; readonly cwd: string; revalidate: (ownedAppServer?: boolean) => Promise; finalCheck: (ownedAppServer?: boolean) => void }; export type SendHooks = { @@ -19,6 +20,7 @@ export type SendHooks = { resolved: (context: SendContext, snapshot: Record) => Promise | string | void; beforeEffect?: () => Promise | void; timeoutMs?: () => number; + codexRoute?: () => 'ordinary' | 'owned_app_server'; submitCodex?: (context: SendContext, message: string, storage: CodexStorageGuard) => Promise<{ queueId?: string; clientUserMessageId: string }>; }; export type SendOptions = { to: string; home?: string; codexBin?: string; message: string; dryRun?: boolean; allowInactive?: boolean; hooks?: SendHooks }; @@ -125,8 +127,9 @@ export async function send(options: SendOptions): Promise item.codexHome === home)?.activity === 'live_writer'; if (!identity) throw new HomeRefusal('active_writer_unverified', selection.resolution); const generation = live ? createHash('sha256').update(identity).digest('hex') : null; - context = { agent: 'codex', target: id, home, generation, + context = { agent: 'codex', target: id, home, generation, binary, // Inactive evidence still binds a prepared intent across invocations. This // private fingerprint is not a native incarnation or public generation. writerIdentity: generation ?? createHash('sha256').update(JSON.stringify(selected)).digest('hex') }; const body = await options.hooks.resolved(context, result); if (body !== undefined) { checkMessage(body, true); options = { ...options, message: body }; result.chars = [...body].length; } } + // Recheck the provisional evidence after asynchronous resolution, then + // classify the route actually selected by the handoff. Dry-run has no + // owned process/FD proof and always uses the ordinary metadata policy. + const actualOwned = !options.dryRun && Boolean(options.hooks?.submitCodex) && + (options.hooks?.codexRoute ? options.hooks.codexRoute() === 'owned_app_server' : true); + const continuity = classifyCodexStorage({ home, queueCwd, consumerCwds, ownedAppServer: provisionalOwned }); + result.storage = storageDiagnostic(continuity); + refuseStorage(continuity); + if (continuity.fingerprint !== storage.fingerprint) throw new HomeRefusal('codex_storage_changed_before_queue', selection.resolution); + const actualStorage = classifyCodexStorage({ home, queueCwd, consumerCwds, ownedAppServer: actualOwned }); + result.storage = storageDiagnostic(actualStorage); + storage = refuseStorage(actualStorage); ownedAppServer = actualOwned; if (options.dryRun) return result; // Diagnostic probe only on an effectful send. Doctor and dry-run never // execute an agent binary. Exact tags qualify loader semantics, not delivery. @@ -190,9 +205,14 @@ export async function send(options: SendOptions): Promise { + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + beforeEffect: async () => {}, verifyStorage: async () => { throw new Error('SECRET-storage-detail'); }, + }), error => { assert.equal(JSON.stringify(error).includes('SECRET'), false); return fault('storage_binding_unverified', false)(error); }); + }); await t.test('binary replacement inside the final async binding cannot reach the RPC write', async t => { const originalSpawn = childProcess.spawn; let fenced = false, swapped = false; t.mock.method(childProcess, 'spawn', (...args: Parameters) => { @@ -577,7 +582,7 @@ test('nativeQueueOnce pins env/CLI SQLite home, checks opened paths and selected const config = join(selected, 'config.toml'); try { await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, - beforeEffect: async () => { writeFileSync(config, 'model="changed-during-guard"'); } }), fault('owner_changed', false)); + beforeEffect: async () => { writeFileSync(config, 'model="changed-during-guard"'); } }), fault('storage_binding_unverified', false)); } finally { rmSync(config, { force: true }); } }); await t.test('consumer cwd mutation during final owner verification cannot reach queue', async () => { @@ -585,7 +590,7 @@ test('nativeQueueOnce pins env/CLI SQLite home, checks opened paths and selected const updateCwd = (cwd: string) => { const state = new DatabaseSync(join(selected, 'state_5.sqlite')); state.prepare('UPDATE threads SET cwd=?').run(cwd); state.close(); }; try { await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, - beforeEffect: async () => { fenced = true; }, verifyOwner: async () => { if (fenced) updateCwd(other); return true; } }), fault('owner_changed', false)); + beforeEffect: async () => { fenced = true; }, verifyOwner: async () => { if (fenced) updateCwd(other); return true; } }), fault('storage_binding_unverified', false)); } finally { updateCwd(selected); } }); await t.test('storage replaced during durable guard cannot reach queue', async () => { @@ -593,7 +598,7 @@ test('nativeQueueOnce pins env/CLI SQLite home, checks opened paths and selected 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)); + } }), fault('storage_binding_unverified', false)); rmSync(join(selected, 'state_5.sqlite')); renameSync(join(selected, 'detached-state.sqlite'), join(selected, 'state_5.sqlite')); }); @@ -604,15 +609,15 @@ test('nativeQueueOnce pins env/CLI SQLite home, checks opened paths and selected 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); + `f${index + 3}\0D0x1\0i1\0n${join(selected, file).replaceAll('\\', '\\\\')}\0\n`).join(''); + return originalSpawn(process.execPath, ['-e', "process.stdout.write(Buffer.from(process.argv[1],'base64'))", Buffer.from(output).toString('base64')], 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)); + beforeEffect: async () => { guarded = true; } }), fault('storage_binding_unverified', false)); } finally { t.mock.restoreAll(); syncBuiltinESMExports(); } }); await t.test('storage replacement during final async owner verification is checked before queue', async () => { @@ -625,7 +630,7 @@ test('nativeQueueOnce pins env/CLI SQLite home, checks opened paths and selected writeFileSync(join(selected, 'queue_1.sqlite'), 'replacement'); } return true; - } }), fault('owner_changed', false)); + } }), fault('storage_binding_unverified', false)); }); await t.test('queue process actual build must equal the probed qualified build', async () => { await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, diff --git a/test/codex-storage.test.ts b/test/codex-storage.test.ts index 7c163bf..3c6d5f4 100644 --- a/test/codex-storage.test.ts +++ b/test/codex-storage.test.ts @@ -6,10 +6,12 @@ import { syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; -import { classifyCodexStorage, codexWriterStorage, storageDiagnostic, STORAGE_LOADER_VERSIONS } from '../dist/codex-storage.js'; +import { performance } from 'node:perf_hooks'; +import { parse as parseTOML } from 'smol-toml'; +import { classifyCodexStorage, codexStoragePin, codexWriterStorage, storageDiagnostic, STORAGE_LOADER_VERSIONS } from '../dist/codex-storage.js'; import { send } from '../dist/send.js'; import { diagnoseCodex } from '../dist/diagnostics.js'; -import { handoffSend } from '../dist/handoff-operations.js'; +import { handoffSend, prepareHandoff } from '../dist/handoff-operations.js'; import { HandoffBudget } from '../dist/handoff.js'; import { HandoffLedger } from '../dist/handoff-ledger.js'; @@ -40,6 +42,13 @@ test('exact source loader tags are separate from observation qualification; unsu assert.equal(JSON.stringify(storageDiagnostic(check)).includes(f.root), false); assert.equal('fingerprint' in storageDiagnostic(check), false); }); +test('storage CLI pins round-trip every C0/DEL character, quotes and backslashes as semantic TOML', () => { + const controls = Array.from({ length: 32 }, (_, n) => String.fromCharCode(n)).join('') + '\x7f'; + for (const home of ['/fixture/"quoted"/\\backslash\x7f', '/fixture/' + controls, 'C:\\fixture\\DEL\x7f\\"quote"']) { + const pin = codexStoragePin(home); assert.equal(parseTOML(pin).sqlite_home, home); + assert.equal(/[\x00-\x1f\x7f]/.test(pin), false); + } +}); test('home/system/requirements/legacy each reject semantic escaped root keys, malformed UTF8/TOML and unreadable files', t => { const f = fixture(t); const paths = [join(f.home, 'config.toml'), ...['config.toml', 'requirements.toml', 'managed_config.toml'].map(name => join(f.systemBase, name))]; @@ -312,3 +321,48 @@ test('handoff fallback rechecks the original cutoff after durable authorization const result = await handoffSend(f.sendOptions, { ledgerPath: join(f.root, 'ledger'), budget }); assert.equal(committed, true); assert.equal(result.value.error, 'deadline_before_effect'); assert.equal(existsSync(f.log), false); }); +test('the outer dispatch barrier catches a mutation queued at the end of the inner async guard', { skip: process.platform === 'win32' }, async t => { + const f = dispatchFixture(t), lstat = fs.lstatSync; let armed = false, scheduled = false, mutated = false; + t.mock.method(fs, 'lstatSync', ((path: any, ...args: any[]) => { + const info = (lstat as any)(path, ...args); + if (path === f.binary && armed && !scheduled) { + scheduled = true; queueMicrotask(() => { mutated = true; fs.appendFileSync(f.binary, '\n// between inner and outer guard'); }); + } return info; + }) as typeof fs.lstatSync); syncBuiltinESMExports(); + await assert.rejects(send({ ...f.sendOptions, hooks: { resolved() {}, beforeEffect() { armed = true; } } }), { code: 'codex_storage_loader_changed' }); + assert.equal(mutated, true); assert.equal(existsSync(f.log), false); +}); +test('a submission timeout callback cannot mutate the selected binary after its last validation', { skip: process.platform === 'win32' }, async t => { + const f = dispatchFixture(t); let armed = false, finalCalls = 0; + await assert.rejects(send({ ...f.sendOptions, hooks: { resolved() {}, beforeEffect() { armed = true; }, timeoutMs() { + if (armed) { finalCalls++; fs.appendFileSync(f.binary, '\n// timeout callback mutation'); } return 3000; + } } }), { code: 'codex_storage_loader_changed' }); + assert.equal(finalCalls, 1); assert.equal(existsSync(f.log), false); +}); +test('the final synchronous guard is charged to the captured caller deadline without calling the budget again', { skip: process.platform === 'win32' }, async t => { + const f = dispatchFixture(t), lstat = fs.lstatSync; let armed = false, captured = false, now = 0, finalCalls = 0; + t.mock.method(performance, 'now', () => now); + t.mock.method(fs, 'lstatSync', ((path: any, ...args: any[]) => { + if (path === f.binary && captured) now = 101; + return (lstat as any)(path, ...args); + }) as typeof fs.lstatSync); syncBuiltinESMExports(); + await assert.rejects(send({ ...f.sendOptions, hooks: { resolved() {}, beforeEffect() { armed = true; }, timeoutMs() { + if (armed) { finalCalls++; captured = true; return 100; } return 3000; + } } }), { code: 'deadline_before_effect' }); + assert.equal(finalCalls, 1); assert.equal(existsSync(f.log), false); +}); +test('handoff fallback and prepared dry-run use ordinary cloud classification rather than unproven owned storage', { skip: process.platform === 'win32' }, async t => { + const f = dispatchFixture(t), path = join(f.root, 'ledger'); new HandoffLedger(path).init(); + const prepared = await prepareHandoff(f.sendOptions, path); + const correlationId = (prepared.value.handoff as { correlationId: string }).correlationId; + writeFileSync(join(f.home, 'auth.json'), 'fixture-credentials-never-read'); + const before = fs.readFileSync(join(path, 'ledger.json'), 'utf8'); + await assert.rejects(prepareHandoff(f.sendOptions, path), { code: 'codex_cloud_storage_unverified' }); + await assert.rejects(handoffSend({ ...f.sendOptions, dryRun: true }, { ledgerPath: path, correlationId }), { code: 'codex_cloud_storage_unverified' }); + assert.equal(fs.readFileSync(join(path, 'ledger.json'), 'utf8'), before); + let snapshot: Record | undefined, submitted = false; + await assert.rejects(send({ ...f.sendOptions, hooks: { resolved(_context, result) { snapshot = result; }, codexRoute: () => 'ordinary', + async submitCodex() { submitted = true; return { clientUserMessageId: id }; }, + } }), { code: 'codex_cloud_storage_unverified' }); + assert.equal(snapshot?.storage.code, 'codex_cloud_storage_unverified'); assert.equal(submitted, false); assert.equal(existsSync(f.log), false); +}); diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index fa66842..bc58bdc 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -10,6 +10,7 @@ import {join,delimiter,resolve} from 'node:path'; import {tmpdir} from 'node:os'; import {validateHandoff} from '../dist/handoff.js'; import {HandoffLedger,payloadDigest} from '../dist/handoff-ledger.js'; +import {handoffSend} from '../dist/handoff-operations.js'; const qualified={skip:process.platform!=='darwin'||process.arch!=='arm64'}; const thread='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',cli=resolve('dist/cli.js'); const schema=`CREATE TABLE thread_turns(thread_id TEXT NOT NULL,turn_id TEXT NOT NULL,status TEXT NOT NULL,PRIMARY KEY(thread_id,turn_id)); @@ -123,6 +124,50 @@ console.log(JSON.stringify({id:r.id,result:{queuedSubmission:{id:'queue-fixture' function inject(clientId:string){const db=new DatabaseSync(join(home,'thread_history_1.sqlite'));try{db.prepare('INSERT INTO thread_items VALUES(?,?,?,?,?,?)').run(thread,'turn-fixture','after-submit-item',1,JSON.stringify({type:'userMessage',id:'after-submit-item',clientId,content:[{text:'PRIVATE_BODY_SENTINEL'}]}),'userMessage');}finally{db.close();}} return{root,home,codex,env,calls,nativeCalls,args,invoke,start,queued,initialized,ledger,ledgerFile,pendingWait,inject,foreignOwner:()=>writeFileSync(lsof+'.data','p'+held.pid+'\0ccodex-fixture\0u'+((process.getuid?.()??0)+1)+'\0',{mode:0o600}),releaseInitialize:()=>writeFileSync(initializeRelease,'release')}; } +async function directEnvironment(f:{root:string;env:NodeJS.ProcessEnv},action:()=>Promise):Promise{ + const saved={...process.env},cwd=process.cwd();Object.assign(process.env,f.env);process.chdir(f.root); + try{return await action();}finally{process.chdir(cwd);for(const key of Object.keys(process.env))if(!(key in saved))delete process.env[key];Object.assign(process.env,saved);} +} +test('direct handoff dry-run never probes a live agent for observation or required delivery',qualified,async t=>{ + const f=await fixture(t),before=readFileSync(f.ledgerFile,'utf8'); + await directEnvironment(f,async()=>{ + for(const intent of [{observeDelivery:true},{waitFor:'delivered' as const}]){ + await assert.rejects(handoffSend({to:'codex:'+thread,home:f.home,codexBin:f.codex,message:'direct dry-run',dryRun:true}, + {...intent,ledgerPath:f.env.SESSION_PEER_HANDOFF_HOME}),{code:'incompatible_handoff_dry_run'}); + assert.deepEqual(f.nativeCalls(),[]);assert.deepEqual(f.calls(),[]); + } + }); + assert.equal(readFileSync(f.ledgerFile,'utf8'),before); +}); +test('qualified owned handoff binds cloud-possible storage while fallback remains unqualified',qualified,async t=>{ + const f=await fixture(t);writeFileSync(join(f.home,'auth.json'),'fixture-credentials-never-read',{mode:0o600}); + const sent=await f.invoke([...f.args,'--observe-delivery']); + assert.equal(sent.code,0,sent.stdout);assert.equal(sent.value.submitted,true); + assert.equal(sent.value.storage.cloudConfiguration,'requires_owned_storage_binding'); + assert.equal(sent.value.handoff.observation.injectionObserved,true);assert.equal(f.calls().length,1); +}); +test('direct handoff qualification and queue both retain the canonical binary across an alias change during selection',qualified,async t=>{ + const f=await fixture(t),alias=join(f.root,'selected-codex'),other=join(f.root,'other-codex'),otherLog=join(f.root,'other-native-log'); + fs.copyFileSync(f.codex,other);chmodSync(other,0o700); + const config=JSON.parse(readFileSync(f.codex+'.json','utf8'));config.version='0.162.1';config.nativeLog=otherLog;config.log=join(f.root,'other-queue-log'); + writeFileSync(other+'.json',JSON.stringify(config),{mode:0o600});fs.symlinkSync(f.codex,alias); + const lstat=fs.lstatSync;let changed=false; + t.mock.method(fs,'lstatSync',((path:any,...args:any[])=>{const value=(lstat as any)(path,...args); + if(path===join(f.home,'thread-writer-locks',thread+'.lock')&&!changed){changed=true;fs.unlinkSync(alias);fs.symlinkSync(other,alias);}return value; + }) as typeof fs.lstatSync);syncBuiltinESMExports(); + try{ + await directEnvironment(f,async()=>{ + const sent=await handoffSend({to:'codex:'+thread,home:f.home,codexBin:alias,message:'pinned qualification'}, + {observeDelivery:true,ledgerPath:f.env.SESSION_PEER_HANDOFF_HOME}); + assert.equal(sent.exitCode,0,JSON.stringify(sent.value));assert.equal(sent.value.submitted,true); + const handoff=sent.value.handoff as {correlationId:string},record=f.ledger().data.records[handoff.correlationId]; + assert.equal(record.observationQualification.version,'0.160.1');assert.equal('binary' in record.binding,false); + assert.equal(JSON.stringify(sent.value).includes(f.codex),false); + }); + assert.equal(changed,true);assert.equal(existsSync(otherLog),false);assert.equal(f.calls().length,1); + assert.ok(f.nativeCalls().some(row=>row.command==='--version'));assert.ok(f.nativeCalls().some(row=>row.command==='app-server')); + }finally{t.mock.restoreAll();syncBuiltinESMExports();} +}); test('warm history fixture keeps WAL sidecar identities stable across synthetic injection',{skip:process.platform==='win32'},async t=>{ const f=await fixture(t,'none'),database=join(f.home,'thread_history_1.sqlite'); const mode=new DatabaseSync(database,{readOnly:true}); From 5a0e0d85e7b9e27e3024c3a18636cfd62f364411 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 30 Oct 2026 00:31:54 +0900 Subject: [PATCH 4/8] fix: seal owned queue bindings and stabilize Korean version templates Retain FD-backed state/queue and sidecar evidence through the final RPC write; reject late replacements before any attempt. Use fixed Korean nouns around generated versions and check unsafe particle templates. Refs #166, #167, #168. --- PARITY.md | 10 +++--- README.ko.md | 4 +-- docs/api.md | 16 +++++++--- docs/guide.ko.md | 6 ++-- scripts/current-version-fields.json | 10 +++--- scripts/current-version.md | 2 ++ scripts/current-version.mjs | 6 ++++ src/app-server.ts | 47 ++++++++++++++++++++++++----- src/diagnostics.ts | 3 +- test/app-server.test.ts | 39 ++++++++++++++++++++++++ test/current-version.test.ts | 12 ++++++++ 11 files changed, 128 insertions(+), 27 deletions(-) diff --git a/PARITY.md b/PARITY.md index 02b47a8..e16fc09 100644 --- a/PARITY.md +++ b/PARITY.md @@ -77,7 +77,7 @@ An issue or Python feature alone does not authorize implementation. | --- | --- | --- | | Ordinary discovery, input and submission | Unified Claude/Codex list, bounded Codex-home inventory, text/JSON and positional/stdin input, Unicode name matching, Claude inbox and guarded Codex queue submission | Shipped within existing platform/native contracts; submission never proves consumption | | macOS Claude Orca profiles | TS reads one environment-selected/default registry; Python additionally inventories bounded known macOS Orca profiles when no override is set ([TS source](https://github.com/abruption/session-peer-ts/blob/90481b148584926ea328ed956327f3050d9be9cb/src/discovery.ts#L78-L117), [Python source](https://github.com/abruption/session-peer/blob/f5e7c49554ff02771990a151e00b52f2abf3df0a/session_peer_core/claude.py#L44-L84)) | Partial; [#165](https://github.com/abruption/session-peer-ts/issues/165) is a separate discovery/provenance extension | -| Codex storage classification | TS rejects selected-home root `sqlite_home` and malformed TOML, and strips `CODEX_SQLITE_HOME` from native child environments; Python additionally checks system/requirements, managed-preference and project/cwd layers ([TS source](https://github.com/abruption/session-peer-ts/blob/90481b148584926ea328ed956327f3050d9be9cb/src/discovery.ts#L13-L24), [Python source](https://github.com/abruption/session-peer/blob/f5e7c49554ff02771990a151e00b52f2abf3df0a/session_peer_core/codex.py#L272-L494)) | Partial; [#166](https://github.com/abruption/session-peer-ts/issues/166). Selected-home validation is not effective-storage attestation | +| Codex storage classification | TS rejects selected-home root `sqlite_home` and malformed TOML, and strips `CODEX_SQLITE_HOME` from native child environments; Python additionally checks system/requirements, managed-preference and project/cwd layers ([TS source](https://github.com/abruption/session-peer-ts/blob/90481b148584926ea328ed956327f3050d9be9cb/src/discovery.ts#L13-L24), [Python source](https://github.com/abruption/session-peer/blob/f5e7c49554ff02771990a151e00b52f2abf3df0a/session_peer_core/codex.py#L272-L494)) | Partial in published baseline; post-baseline #166 source adds bounded local-layer classification and refusal of unclassifiable storage ([scope](docs/api.md#codex-storage-configuration)). Selected-home or local-layer validation is not whole effective-storage attestation | | Doctor and return routes | Read-only local/SSH diagnostics, generated From/Reply-To metadata, explicit reply URI and opt-in return-route probe | Shipped bounded behavior; no executable `Reply:` instruction or Antigravity sender identity | | Ordered SSH and typed jump | Repeated hosts, allowlisted options, IPv6 and POSIX-client single-hop jump; Windows-client jump refuses before effect | Partial; remaining Windows-client qualification in [#20](https://github.com/abruption/session-peer-ts/issues/20); no arbitrary options or multi-hop | | Remote deployment | TS requires an installed CLI and exactly matching TS versions; Python streams its standalone source | Intentional difference; [#23](https://github.com/abruption/session-peer-ts/issues/23) / [remote deployment design](docs/design/remote-deployment.md) remain bounded follow-ups, with no automatic deployment/update or version-range compatibility | @@ -89,10 +89,10 @@ An issue or Python feature alone does not authorize implementation. | Wake, Antigravity and MCP integration | No TS wake/resume, Antigravity adapter or policy-restricted MCP server | Undecided implementation scope; [#27](https://github.com/abruption/session-peer-ts/issues/27), [#28](https://github.com/abruption/session-peer-ts/issues/28), [#26](https://github.com/abruption/session-peer-ts/issues/26) track separate work; no inferred Python feature parity | | Newer Python convenience workflows | Route presets/saved recipients, explain, explicit trusted SSH config and persistent per-version runtime-notice dismissal are absent from the [TS command/options inventory](https://github.com/abruption/session-peer-ts/blob/90481b148584926ea328ed956327f3050d9be9cb/src/cli.ts#L19-L29) | Undecided eventual scope; explicit defer decisions below | -The published 0.5.1 storage coverage gap above is separate from the proposed -#166 correction on this development branch. A branch change or fixture result -must not be described as an already published runtime fix or native-loader -qualification. The development guard's [actual scope](docs/api.md#codex-storage-configuration) +The published 0.5.1 storage coverage gap above is separate from the #166 +implementation added after baseline `90481b1`. Its public availability is +determined by the selected release record; source/fixture evidence is not a +publication or native-loader qualification. The source guard's [actual scope](docs/api.md#codex-storage-configuration) classifies source-qualified local layers and retains opened-file evidence. It refuses ordinary signed-in/possibly cloud-managed homes when classification would require reading credentials; the owned app-server path needs actual storage binding. diff --git a/README.ko.md b/README.ko.md index a32e17f..fb9ed8a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -23,7 +23,7 @@ Python을 설치하지 않아도 Node.js에서 실행되는 TypeScript 클라이 -**문서·소스 기준 버전: 0.5.1.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](VALIDATION.md), 릴리스 절차는 [RELEASING.md](RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6과 0.5.1은 서로의 SSH 사전 확인을 거부합니다. +**문서·소스 기준 버전: 0.5.1.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](VALIDATION.md), 릴리스 절차는 [RELEASING.md](RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6 버전과 0.5.1 버전은 서로의 SSH 사전 확인을 거부합니다. **0.4.0 handoff 범위:** 선택 기능인 native 관측은 macOS arm64의 정확한 Codex 0.160.1/0.162.1에 한정되며, 명시적 `operator_confirmed` 수령 확인에는 지원되는 POSIX ledger를 사용합니다. Linux·Windows의 native 관측과 자동 ACK 수집은 미지원입니다. 0.5.1 소스는 선택할 수 없는 Linux arm64 Codex 0.162.1 후보(고정된 업스트림 `aarch64-unknown-linux-musl` 실행 파일, 스키마, 주입 테스트 1회)를 기록하며 Linux 관측을 활성화하지 않습니다. Linux native 관측은 계속 미지원입니다. @@ -89,7 +89,7 @@ session-peer --version ### 업데이트 npm으로 설치한 클라이언트는 사용자가 직접 npm으로 업데이트합니다. 사용 가능한 배포 태그 -(dist-tag)와 대상 버전을 확인한 뒤 해당 버전을 지정해 설치하세요. 아래 예시는 이전 npm 설치본을 선택한 0.5.1으로 +(dist-tag)와 대상 버전을 확인한 뒤 해당 버전을 지정해 설치하세요. 아래 예시는 이전 npm 설치본을 선택한 0.5.1 버전으로 업데이트합니다. `session-peer` CLI는 업데이트를 설치하지 않습니다. `session-peer update --check`는 새 버전이 있는지 확인할 뿐입니다. diff --git a/docs/api.md b/docs/api.md index 449d49f..4c1252e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -40,10 +40,11 @@ The ordinary SSH response correction is documented separately as the [0.3.3 ordi ## Codex storage configuration guards -This development source extends the published selected-home check with bounded -local loader-layer classification. The published 0.5.1 comparison in PARITY is -retained as dated evidence; this branch's source/fixture results do not establish -publication or new native delivery qualification. +The source implementation added after baseline `90481b1` extends its +selected-home check with bounded local loader-layer classification. The published +0.5.1 comparison in PARITY is retained as dated evidence. Availability is determined +by the selected release record; source/fixture results do not establish publication +or new native delivery qualification. The ordinary queue loader is reviewed at exact native Codex versions **0.160.1, 0.162.1 and 0.165.0**, independently of the narrower observation profile table. @@ -114,6 +115,13 @@ dispatch refuse submission. Errors include `unsupported_codex_sqlite_home`, `codex_storage_loader_unsupported`, `codex_daemon_storage_unverified`, `codex_consumer_context_unverifiable`, `codex_consumer_storage_unverifiable`, `codex_cloud_storage_unverified` and `codex_storage_changed_before_queue`. +Doctor readiness describes ordinary local classification. A signed-in or other +cloud-unclassifiable home can report `ready:false` and omit the selected DB +schema/count check, even when a separately qualified owned route could later +supply stronger storage evidence. Reader failure is not proof of a missing DB. +Entry-file identity continuity covers the selected executable entry, not every +transitive binary a trusted wrapper might launch. + Public `storage` diagnostics contain layer names and classifications; content hashes, configuration paths, raw TOML and preference values stay private. diff --git a/docs/guide.ko.md b/docs/guide.ko.md index 5a1f28f..c5ef733 100644 --- a/docs/guide.ko.md +++ b/docs/guide.ko.md @@ -10,11 +10,11 @@ [English](guide.md) | [한국어](guide.ko.md) | [日本語](guide.ja.md) | [简体中文](guide.zh-CN.md) -**문서·소스 기준 버전: 0.5.1.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](../VALIDATION.md), 릴리스 절차는 [RELEASING.md](../RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6과 0.5.1은 서로의 SSH 사전 확인을 거부합니다. +**문서·소스 기준 버전: 0.5.1.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](../VALIDATION.md), 릴리스 절차는 [RELEASING.md](../RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6 버전과 0.5.1 버전은 서로의 SSH 사전 확인을 거부합니다. **0.4.0 handoff 범위:** 선택 기능인 native 관측은 macOS arm64의 정확한 Codex 0.160.1/0.162.1에 한정되며, 명시적 `operator_confirmed` 수령 확인에는 지원되는 POSIX ledger를 사용합니다. Linux·Windows의 native 관측과 자동 ACK 수집은 미지원입니다. 0.5.1 소스는 선택할 수 없는 Linux arm64 Codex 0.162.1 후보(고정된 업스트림 `aarch64-unknown-linux-musl` 실행 파일, 스키마, 주입 테스트 1회)를 기록하며 Linux 관측을 활성화하지 않습니다. Linux native 관측은 계속 미지원입니다. -이 가이드는 0.5.1을 다룹니다([0.3.1 신뢰성 수정](../PARITY.md#031-reliability-fixes)). 0.3.0에는 여러 호스트를 통한 SSH 전송, 발신자 정보와 회신 경로, 업데이트 확인, 선택 기능인 `sp` 단축 명령이 추가됐습니다([0.3.0 변경 사항](../PARITY.md#030-remote-usability-and-maintenance)). [0.3.1 공개 릴리스 기록](../VALIDATION.md#public-031--2026-10-04-kst)은 이전 버전의 발행 내역입니다. 아래에서 0.1.0을 언급할 때는 이전 버전을 설명합니다. +이 가이드는 0.5.1 버전을 다룹니다([0.3.1 신뢰성 수정](../PARITY.md#031-reliability-fixes)). 0.3.0에는 여러 호스트를 통한 SSH 전송, 발신자 정보와 회신 경로, 업데이트 확인, 선택 기능인 `sp` 단축 명령이 추가됐습니다([0.3.0 변경 사항](../PARITY.md#030-remote-usability-and-maintenance)). [0.3.1 공개 릴리스 기록](../VALIDATION.md#public-031--2026-10-04-kst)은 이전 버전의 발행 내역입니다. 아래에서 0.1.0을 언급할 때는 이전 버전을 설명합니다. @@ -142,7 +142,7 @@ Claude Code에는 받은 메시지함에 접근할 수 있는 실행 중 TUI(터 Node 22.x의 22.13 이상 또는 24.x를 사용하세요. Python CLI도 같은 `session-peer` 명령 이름을 쓰므로, 설치 전에 PATH(명령 검색 경로)에서 어떤 실행 파일을 찾는지 -확인하세요. 아래 명령은 선택한 0.5.1을 npm으로 설치하는 예입니다. +확인하세요. 아래 명령은 선택한 0.5.1 버전을 npm으로 설치하는 예입니다. CLI 자체는 패키지를 설치하거나 업데이트하지 않습니다. **0.5.1 설치:** npm 설치·업데이트 예시를 실행하기 전에 레지스트리 제공 여부와 선택한 릴리스를 확인하세요. 해당 버전이 제공되지 않으면 [검토한 소스 빌드](#소스에서-빌드)를 사용하세요. diff --git a/scripts/current-version-fields.json b/scripts/current-version-fields.json index 6a8a1a0..3b822f8 100644 --- a/scripts/current-version-fields.json +++ b/scripts/current-version-fields.json @@ -45,7 +45,7 @@ "file": "README.ko.md", "templates": [ { - "line": "**문서·소스 기준 버전: {{version}}.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](VALIDATION.md), 릴리스 절차는 [RELEASING.md](RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6과 {{version}}은 서로의 SSH 사전 확인을 거부합니다.", + "line": "**문서·소스 기준 버전: {{version}}.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](VALIDATION.md), 릴리스 절차는 [RELEASING.md](RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6 버전과 {{version}} 버전은 서로의 SSH 사전 확인을 거부합니다.", "occurrences": 1 }, { @@ -69,7 +69,7 @@ "occurrences": 1 }, { - "line": "(dist-tag)와 대상 버전을 확인한 뒤 해당 버전을 지정해 설치하세요. 아래 예시는 이전 npm 설치본을 선택한 {{version}}으로", + "line": "(dist-tag)와 대상 버전을 확인한 뒤 해당 버전을 지정해 설치하세요. 아래 예시는 이전 npm 설치본을 선택한 {{version}} 버전으로", "occurrences": 1 } ] @@ -209,7 +209,7 @@ "file": "docs/guide.ko.md", "templates": [ { - "line": "**문서·소스 기준 버전: {{version}}.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](../VALIDATION.md), 릴리스 절차는 [RELEASING.md](../RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6과 {{version}}은 서로의 SSH 사전 확인을 거부합니다.", + "line": "**문서·소스 기준 버전: {{version}}.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](../VALIDATION.md), 릴리스 절차는 [RELEASING.md](../RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6 버전과 {{version}} 버전은 서로의 SSH 사전 확인을 거부합니다.", "occurrences": 1 }, { @@ -217,7 +217,7 @@ "occurrences": 1 }, { - "line": "이 가이드는 {{version}}을 다룹니다([0.3.1 신뢰성 수정](../PARITY.md#031-reliability-fixes)). 0.3.0에는 여러 호스트를 통한 SSH 전송, 발신자 정보와 회신 경로, 업데이트 확인, 선택 기능인 `sp` 단축 명령이 추가됐습니다([0.3.0 변경 사항](../PARITY.md#030-remote-usability-and-maintenance)). [0.3.1 공개 릴리스 기록](../VALIDATION.md#public-031--2026-10-04-kst)은 이전 버전의 발행 내역입니다. 아래에서 0.1.0을 언급할 때는 이전 버전을 설명합니다.", + "line": "이 가이드는 {{version}} 버전을 다룹니다([0.3.1 신뢰성 수정](../PARITY.md#031-reliability-fixes)). 0.3.0에는 여러 호스트를 통한 SSH 전송, 발신자 정보와 회신 경로, 업데이트 확인, 선택 기능인 `sp` 단축 명령이 추가됐습니다([0.3.0 변경 사항](../PARITY.md#030-remote-usability-and-maintenance)). [0.3.1 공개 릴리스 기록](../VALIDATION.md#public-031--2026-10-04-kst)은 이전 버전의 발행 내역입니다. 아래에서 0.1.0을 언급할 때는 이전 버전을 설명합니다.", "occurrences": 1 }, { @@ -225,7 +225,7 @@ "occurrences": 1 }, { - "line": "확인하세요. 아래 명령은 선택한 {{version}}을 npm으로 설치하는 예입니다.", + "line": "확인하세요. 아래 명령은 선택한 {{version}} 버전을 npm으로 설치하는 예입니다.", "occurrences": 1 }, { diff --git a/scripts/current-version.md b/scripts/current-version.md index cadf622..3b71c97 100644 --- a/scripts/current-version.md +++ b/scripts/current-version.md @@ -41,3 +41,5 @@ other authored character; it cannot decide whether a feature remains supported. Everything outside these explicit spans remains authored content. In particular, dated preparation/publication evidence in PARITY and VALIDATION, historical release descriptions and anchors, source hashes, release policy, finite skill compatibility rows, independent skill/native/Python versions and pin files are not generated. A version update does not approve new compatibility, qualification, publication or installation pins. Current prose that describes a capability must still be reviewed when behavior changes. `test/current-version.test.ts` exercises the real inventory against isolated copies: read-only checks, a synthetic next patch, byte-idempotence, preserved historical records and independent pins, malformed/overlapping slots, unknown paths, symlinks and validation before writes. Build and package acceptance also require a separate isolated synthetic-version run: update package and lock metadata in the copied tree, run the updater and repository checker, build, then run `npm run test:package`. Keep production release policies and finite compatibility tables unchanged in that experiment. Record the commands actually run and their results in the change's validation evidence; unit fixtures alone are not evidence of a successful synthetic build or package smoke. + +Korean generated spans use a fixed noun before pronunciation-dependent particles, for example `{{version}} 버전은` and `{{version}} 버전으로`. The planner rejects direct number-bound particles, including after Markdown emphasis/code markers, so a patch-number change cannot silently break that grammar. diff --git a/scripts/current-version.mjs b/scripts/current-version.mjs index f0853b7..9c2411b 100644 --- a/scripts/current-version.mjs +++ b/scripts/current-version.mjs @@ -36,6 +36,12 @@ export function planCurrentVersions(version, files, inventory) { assert.ok(Number.isSafeInteger(slot.occurrences) && slot.occurrences > 0, `${entry.file}: invalid slot count`); assert.ok(typeof slot.line === 'string' && !/[\r\n]/.test(slot.line) && slot.line.includes('{{version}}'), `${entry.file}: expected a single-line version template`); + // A generated number cannot choose Korean particles by its pronunciation. + // Use a fixed noun (for example, '{{version}} 버전은') in authored templates. + if (entry.file.endsWith('.ko.md')) { + assert.ok(!/\{\{version\}\}(?:\*\*|__|`+)?(?:으로|로|은|는|이|가|을|를|과|와)/.test(slot.line), + `${entry.file}: Korean generated versions need a fixed noun before inflected particles`); + } const parts = slot.line.split('{{version}}'); const pattern = new RegExp(`^${parts.map(escape).join(`(${versionPattern})`)}(?=\\r?$)`, 'gm'); const matches = [...original.matchAll(pattern)]; diff --git a/src/app-server.ts b/src/app-server.ts index d5aa30c..5d05bdf 100644 --- a/src/app-server.ts +++ b/src/app-server.ts @@ -523,9 +523,10 @@ async function beforeDeadline(deadlineMs: number, operation: () => Promise }); }); } -async function verifyOwnedSqliteBinding(pid: number | undefined, home: string, deadlineMs: number): Promise { +type SqliteBindingProof = readonly Readonly<{ path: string; dev: bigint | null; ino: bigint | null }>[]; +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); - verifyOpenedSqlite(await darwinOpenedFiles(pid, deadlineMs), home); + return verifyOpenedSqlite(await darwinOpenedFiles(pid, deadlineMs), home); } async function darwinOpenedFiles(pid: number, deadlineMs: number): Promise { // Only the owned app-server PID is queried, never a session inventory/PID scan. @@ -556,8 +557,9 @@ async function darwinOpenedFiles(pid: number, deadlineMs: number): Promise[], home: string): void { +function verifyOpenedSqlite(files: Partial[], home: string): SqliteBindingProof { const required = ['state_5.sqlite', 'queue_1.sqlite']; + const proof = new Map>(); 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))); @@ -568,9 +570,38 @@ function verifyOpenedSqlite(files: Partial[], home: string): void { 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'); + const prior = proof.get(row.name!); + if (prior && (prior.dev !== row.dev || prior.ino !== row.ino)) throw new Error('conflicting opened identities'); + // Retain the opened FD's identity, not a later sample of the pathname. + proof.set(row.name!, Object.freeze({ path: row.name!, dev: row.dev, ino: row.ino })); } catch { throw new AppServerFault('storage_binding_unverified', false); } } } + // The fixed WAL/SHM paths may not be opened yet. Retain their named presence + // and identity too, without claiming FD evidence for these additional paths. + for (const file of required) for (const suffix of ['', '-wal', '-shm']) { + const path = join(home, file + suffix); if (proof.has(path)) continue; + try { + const info = lstatSync(path, { bigint: true }); + if (!info.isFile() || info.isSymbolicLink()) throw new Error(); + proof.set(path, Object.freeze({ path, dev: info.dev, ino: info.ino })); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new AppServerFault('storage_binding_unverified', false); + proof.set(path, Object.freeze({ path, dev: null, ino: null })); + } + } + return Object.freeze([...proof.values()]); +} +function revalidateSqliteBinding(proof: SqliteBindingProof | undefined): void { + if (!proof) throw new AppServerFault('storage_binding_unverified', false); + for (const expected of proof) { + try { + const info = lstatSync(expected.path, { bigint: true }); + if (!info.isFile() || info.isSymbolicLink() || info.dev !== expected.dev || info.ino !== expected.ino) throw new Error(); + } catch (error) { + if (expected.dev !== null || (error as NodeJS.ErrnoException).code !== 'ENOENT') throw new AppServerFault('storage_binding_unverified', false); + } + } } // Synchronous native-profile I/O (executable digest, procfs walk) runs in an // owned worker. Supervisor refusal boundary for that worker only: run() @@ -603,7 +634,7 @@ export async function artifactDigest(path: string, deadlineMs: number, maxBytes // refused rather than trusted to have exec'd the right storage owner. An // injected reader (hermetic tests) runs in-process; production uses the worker. export async function verifyProcfsSqliteBinding(pid: number | undefined, home: string, deadlineMs: number, binary: string, - reader?: ProcReader): Promise { + reader?: ProcReader): Promise { if (!pid || pid < 2 || performance.now() >= deadlineMs) throw new AppServerFault('storage_binding_unverified', false); let files: OpenedFile[]; const uid = process.getuid?.(); @@ -624,7 +655,7 @@ export async function verifyProcfsSqliteBinding(pid: number | undefined, home: s } catch { throw new AppServerFault('storage_binding_unverified', false); } } if (performance.now() >= deadlineMs) throw new AppServerFault('deadline', false); - verifyOpenedSqlite(files, home); + return verifyOpenedSqlite(files, home); } function executableIdentity(binary: string): string { try { @@ -710,6 +741,7 @@ export async function nativeQueueOnceWithProfiles(options: NativeQueueOptions, ...spawnOwnedStdio(options.binary, ['app-server', '--stdio', '-c', 'analytics.enabled=false', '-c', codexStoragePin(original.codexHome)], env, original.codexHome) }; let client: NativeQueuePort | undefined; + let lastBinding: SqliteBindingProof | undefined; try { verifyExecutableIdentity(options.binary, binaryIdentity); let effectGuarded = false; @@ -721,7 +753,7 @@ export async function nativeQueueOnceWithProfiles(options: NativeQueueOptions, try { await options.verifyStorage?.(); await revalidateStorage(nativeVersion); - await bind(transport.pid); + lastBinding = await bind(transport.pid); } catch (error) { throw error instanceof AppServerFault ? error : new AppServerFault('storage_binding_unverified', false); } } return valid; @@ -733,10 +765,11 @@ export async function nativeQueueOnceWithProfiles(options: NativeQueueOptions, if (checkStorage(nativeVersion).fingerprint !== initialStorage.fingerprint) throw new Error(); const current = codexConsumerSnapshotSync(original.codexHome, original.threadId); if (current.database !== consumerDatabase || current.cwd !== consumerCwd) throw new Error(); + revalidateSqliteBinding(lastBinding); verifyExecutableIdentity(options.binary, binaryIdentity); } catch (error) { throw error instanceof AppServerFault ? error : new AppServerFault('storage_binding_unverified', false); } }); - await bind(transport.pid); + lastBinding = await bind(transport.pid); return await client.queueOnce({ clientUserMessageId: options.clientUserMessageId, message: options.message }); } finally { if (client) await client.close(); else await disposeTransport(transport); } } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index f50b854..da3b4c1 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -1,4 +1,5 @@ -// Diagnostics inspect bounded metadata only. Never probe/connect to an inbox, +// Diagnostics read bounded configuration and metadata, returning sanitized checks. +// Never probe/connect to an inbox, // acquire a writer lock, execute an agent binary, or write configuration. import { accessSync, constants, readdirSync, readFileSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; diff --git a/test/app-server.test.ts b/test/app-server.test.ts index a12dfa1..8ff6925 100644 --- a/test/app-server.test.ts +++ b/test/app-server.test.ts @@ -5,6 +5,7 @@ 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 fs from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import childProcess from 'node:child_process'; @@ -514,6 +515,7 @@ test('nativeQueueOnce pins env/CLI SQLite home, checks opened paths and selected const storage=process.env.FIXTURE_STORAGE_HOME||process.env.CODEX_SQLITE_HOME; fs.openSync(require('node:path').join(storage,'state_5.sqlite'),storage===process.env.CODEX_SQLITE_HOME?'r+':'w+'); fs.openSync(require('node:path').join(storage,'queue_1.sqlite'),'w+'); + if(process.env.FIXTURE_OPEN_SIDECAR)fs.openSync(require('node:path').join(storage,process.env.FIXTURE_OPEN_SIDECAR),'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'); @@ -541,6 +543,43 @@ test('nativeQueueOnce pins env/CLI SQLite home, checks opened paths and selected env: { ...options.env, FIXTURE_STORAGE_HOME: other } }), fault('storage_binding_unverified', false)); assert.equal(guards, 1); const guardsBefore = guards; + for (const file of ['queue_1.sqlite', 'queue_1.sqlite-wal']) await t.test(`${file} replacement after the last bind retains the original FD proof and cannot reach queue`, async t => { + const path = join(selected, file), detached = path + '.detached-last-bind', lstat = fs.lstatSync; + let fenced = false, scheduled = false, replaced = false; + t.mock.method(fs, 'lstatSync', ((item: any, ...args: any[]) => { + const info = (lstat as any)(item, ...args); + if (item === path && fenced && !scheduled) { + scheduled = true; queueMicrotask(() => { renameSync(path, detached); writeFileSync(path, 'replacement after bind'); replaced = true; }); + } return info; + }) as typeof fs.lstatSync); syncBuiltinESMExports(); + try { + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + env: { ...options.env, ...(file.endsWith('-wal') ? { FIXTURE_OPEN_SIDECAR: file } : {}) }, + beforeEffect: async () => { fenced = true; }, + }), fault('storage_binding_unverified', false)); + assert.equal(replaced, true); + } finally { + t.mock.restoreAll(); syncBuiltinESMExports(); + if (replaced) { rmSync(path, { force: true }); renameSync(detached, path); } + if (file.endsWith('-wal')) rmSync(path, { force: true }); + } + }); + await t.test('a fixed sidecar created after its absence was bound cannot reach queue', async t => { + const path = join(selected, 'queue_1.sqlite-shm'), lstat = fs.lstatSync; + let fenced = false, scheduled = false, created = false; + t.mock.method(fs, 'lstatSync', ((item: any, ...args: any[]) => { + const info = (lstat as any)(item, ...args); + if (item === join(selected, 'queue_1.sqlite') && fenced && !scheduled) { + scheduled = true; queueMicrotask(() => { writeFileSync(path, 'late sidecar'); created = true; }); + } return info; + }) as typeof fs.lstatSync); syncBuiltinESMExports(); + try { + await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, + beforeEffect: async () => { fenced = true; }, + }), fault('storage_binding_unverified', false)); + assert.equal(created, true); + } finally { t.mock.restoreAll(); syncBuiltinESMExports(); rmSync(path, { force: true }); } + }); await t.test('an asynchronous root storage refusal retains its storage code without leaking native detail', async () => { await assert.rejects(nativeQueueOnce({ ...options, deadlineMs: performance.now() + 2000, beforeEffect: async () => {}, verifyStorage: async () => { throw new Error('SECRET-storage-detail'); }, diff --git a/test/current-version.test.ts b/test/current-version.test.ts index c759e74..5ed0000 100644 --- a/test/current-version.test.ts +++ b/test/current-version.test.ts @@ -143,3 +143,15 @@ test('CLI check runs through symlink entries with both Node resolution modes', t assert.match(result.stdout, /Current-version inventory: OK/); } }); + + +test('Korean templates use a fixed noun instead of number-dependent particles', () => { + const bad = structuredClone(inventory); + const entry = bad.find(value => value.file === 'README.ko.md')!; + const slot = entry.templates.find(value => value.line.includes('{{version}} 버전은'))!; + const before = slot.line; + slot.line = before.replace('{{version}} 버전은', '**{{version}}**은'); + const data = files(); + data.set(entry.file, data.get(entry.file)!.replace(before.replaceAll('{{version}}', version), slot.line.replaceAll('{{version}}', version))); + assert.throws(() => planCurrentVersions(version, data, bad), /Korean generated versions need a fixed noun/); +}); From 323e45fc8b9de3f0ba335c75eccdfe342902578f Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 30 Oct 2026 00:38:58 +0900 Subject: [PATCH 5/8] fix(docs): reject formatted number-bound Korean particles --- scripts/current-version.mjs | 2 +- test/current-version.test.ts | 23 +++++++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/scripts/current-version.mjs b/scripts/current-version.mjs index 9c2411b..d861861 100644 --- a/scripts/current-version.mjs +++ b/scripts/current-version.mjs @@ -39,7 +39,7 @@ export function planCurrentVersions(version, files, inventory) { // A generated number cannot choose Korean particles by its pronunciation. // Use a fixed noun (for example, '{{version}} 버전은') in authored templates. if (entry.file.endsWith('.ko.md')) { - assert.ok(!/\{\{version\}\}(?:\*\*|__|`+)?(?:으로|로|은|는|이|가|을|를|과|와)/.test(slot.line), + assert.ok(!/\{\{version\}\}[*_`~]*(?:으로|로|은|는|이|가|을|를|과|와|나|며|라|랑|야|여|예요)/.test(slot.line), `${entry.file}: Korean generated versions need a fixed noun before inflected particles`); } const parts = slot.line.split('{{version}}'); diff --git a/test/current-version.test.ts b/test/current-version.test.ts index 5ed0000..5115319 100644 --- a/test/current-version.test.ts +++ b/test/current-version.test.ts @@ -146,12 +146,19 @@ test('CLI check runs through symlink entries with both Node resolution modes', t test('Korean templates use a fixed noun instead of number-dependent particles', () => { - const bad = structuredClone(inventory); - const entry = bad.find(value => value.file === 'README.ko.md')!; - const slot = entry.templates.find(value => value.line.includes('{{version}} 버전은'))!; - const before = slot.line; - slot.line = before.replace('{{version}} 버전은', '**{{version}}**은'); - const data = files(); - data.set(entry.file, data.get(entry.file)!.replace(before.replaceAll('{{version}}', version), slot.line.replaceAll('{{version}}', version))); - assert.throws(() => planCurrentVersions(version, data, bad), /Korean generated versions need a fixed noun/); + for (const span of [ + '{{version}}은', '**{{version}}**은', '*{{version}}*은', + '_{{version}}_을', '***{{version}}***으로', '__{{version}}__를', + '`{{version}}`과', '``{{version}}``와', '~~{{version}}~~는', + '**`{{version}}`**이', '{{version}}예요', '{{version}}나', + ]) { + const bad = structuredClone(inventory); + const entry = bad.find(value => value.file === 'README.ko.md')!; + const slot = entry.templates.find(value => value.line.includes('{{version}} 버전은'))!; + const before = slot.line; + slot.line = before.replace('{{version}} 버전은', span); + const data = files(); + data.set(entry.file, data.get(entry.file)!.replace(before.replaceAll('{{version}}', version), slot.line.replaceAll('{{version}}', version))); + assert.throws(() => planCurrentVersions(version, data, bad), /Korean generated versions need a fixed noun/, span); + } }); From 0ac7faedc14aaac405d64a6182529d12dc83885e Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 30 Oct 2026 00:40:45 +0900 Subject: [PATCH 6/8] docs: define version lint scope and preserve valid Korean forms --- scripts/current-version.md | 2 +- test/current-version.test.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/current-version.md b/scripts/current-version.md index 3b71c97..db1aef3 100644 --- a/scripts/current-version.md +++ b/scripts/current-version.md @@ -42,4 +42,4 @@ Everything outside these explicit spans remains authored content. In particular, `test/current-version.test.ts` exercises the real inventory against isolated copies: read-only checks, a synthetic next patch, byte-idempotence, preserved historical records and independent pins, malformed/overlapping slots, unknown paths, symlinks and validation before writes. Build and package acceptance also require a separate isolated synthetic-version run: update package and lock metadata in the copied tree, run the updater and repository checker, build, then run `npm run test:package`. Keep production release policies and finite compatibility tables unchanged in that experiment. Record the commands actually run and their results in the change's validation evidence; unit fixtures alone are not evidence of a successful synthetic build or package smoke. -Korean generated spans use a fixed noun before pronunciation-dependent particles, for example `{{version}} 버전은` and `{{version}} 버전으로`. The planner rejects direct number-bound particles, including after Markdown emphasis/code markers, so a patch-number change cannot silently break that grammar. +Korean generated spans use a fixed noun before pronunciation-dependent particles, for example `{{version}} 버전은` and `{{version}} 버전으로`. The planner rejects the listed common number-bound particle forms (`으로/로`, `은/는`, `이/가`, `을/를`, `과/와`, `나`, `며`, `라`, `랑`, `야`, `여`, `예요`), including after consecutive `*`, `_`, backtick and `~` markers. This is a narrow maintenance lint, not a Korean grammar or Markdown parser: inflected forms, HTML, links and quotation wrappers require editorial review. The registered templates use fixed nouns or pronunciation-independent forms; adding or changing templates still requires language review. diff --git a/test/current-version.test.ts b/test/current-version.test.ts index 5115319..f96b2d2 100644 --- a/test/current-version.test.ts +++ b/test/current-version.test.ts @@ -158,7 +158,23 @@ test('Korean templates use a fixed noun instead of number-dependent particles', const before = slot.line; slot.line = before.replace('{{version}} 버전은', span); const data = files(); - data.set(entry.file, data.get(entry.file)!.replace(before.replaceAll('{{version}}', version), slot.line.replaceAll('{{version}}', version))); + const original = data.get(entry.file)!; + const changed = original.replace(before.replaceAll('{{version}}', version), slot.line.replaceAll('{{version}}', version)); + assert.notEqual(changed, original, span); + data.set(entry.file, changed); assert.throws(() => planCurrentVersions(version, data, bad), /Korean generated versions need a fixed noun/, span); } }); + +test('Korean fixed nouns and pronunciation-independent forms stay valid', () => { + for (const span of ['{{version}} 버전은', '{{version}} 버전을', '{{version}} 버전으로', '버전({{version}})을', '{{version}}의', '{{version}}에서']) { + const good = structuredClone(inventory); + const entry = good.find(value => value.file === 'README.ko.md')!; + const slot = entry.templates.find(value => value.line.includes('{{version}} 버전은'))!; + const before = slot.line; + slot.line = before.replace('{{version}} 버전은', span); + const data = files(); + data.set(entry.file, data.get(entry.file)!.replace(before.replaceAll('{{version}}', version), slot.line.replaceAll('{{version}}', version))); + assert.deepEqual(planCurrentVersions(version, data, good), [], span); + } +}); From f57a808e97ecd118028e7459cb95ce9682b6f92f Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 30 Oct 2026 00:45:35 +0900 Subject: [PATCH 7/8] test: qualify storage fixtures and assert Windows refusal boundaries --- test/diagnostics.test.ts | 73 +++++++++++++++++++++++++--------- test/linux-observation.test.ts | 26 +++++++++++- test/skill-guidance.test.ts | 16 ++++++-- 3 files changed, 92 insertions(+), 23 deletions(-) diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts index 915a635..ed0b89e 100644 --- a/test/diagnostics.test.ts +++ b/test/diagnostics.test.ts @@ -9,6 +9,16 @@ import { syncBuiltinESMExports } from 'node:module'; import { DatabaseSync } from 'node:sqlite'; import { doctor, diagnoseCodex, diagnoseClaude, inspectSkills } from '../dist/diagnostics.js'; const cli = resolve('dist/cli.js'); +const windows = process.platform === 'win32'; +// The shared loader classifier refuses Windows before opening a state DB or +// interpreting its schema/config. This is not evidence of Windows readiness. +function assertWindowsStorageUnqualified(home: any) { + assert.equal(home.status, 'unsupported'); + assert.equal(home.code, 'codex_storage_platform_unqualified'); + assert.equal(home.storage.localLoaderPlatformSupported, false); + assert.equal(Object.hasOwn(home, 'sessionCount'), false); +} + // Answers `ssh -G` user metadata lookups without logging them as SSH dispatches. const sshConfigUser = "if(process.argv.includes('-G')){console.log('user fixture-user');process.exit(0);}"; function fixture(t: TestContext) { @@ -39,7 +49,8 @@ test('doctor succeeds independently of unavailable agents and truthfully declare assert.equal(code, 0); assert.equal(value.ok, true); assert.equal(value.ready, false); assert.equal(value.agents.claude.code, 'sessions_dir_missing'); assert.equal(value.agents.codex.tool.code, 'executable_unavailable'); - assert.equal(value.agents.codex.homes[0].code, 'state_db_missing'); + if (windows) assertWindowsStorageUnqualified(value.agents.codex.homes[0]); + else assert.equal(value.agents.codex.homes[0].code, 'state_db_missing'); for (const key of ['wake', 'wait', 'ack', 'consumptionConfirmation']) assert.equal(value.capabilities[key], false); assert.equal(value.capabilities.doctor, true); assert.equal(value.implementation, 'typescript'); assert.equal(value.diagnosticCompleted, true); @@ -53,8 +64,14 @@ test('per-home schema, missing, malformed, permission and unknown diagnostics st mkdirSync(malformed); writeFileSync(join(malformed, 'state_5.sqlite'), 'private-native-detail'); process.env.SESSION_PEER_CODEX_HOMES = JSON.stringify([missing, unsupported, malformed]); const result = await diagnoseCodex(undefined, process.execPath) as any; - assert.deepEqual(result.homes.map((h: any) => h.code), ['state_db_readable', 'state_db_missing', 'unsupported_threads_schema', 'state_db_unreadable']); - assert.equal(result.ready, false); assert.equal(result.homes[0].sessionCount, 0); + if (windows) { + assert.equal(result.homes.length, 4); + for (const home of result.homes) assertWindowsStorageUnqualified(home); + } else { + assert.deepEqual(result.homes.map((h: any) => h.code), ['state_db_readable', 'state_db_missing', 'unsupported_threads_schema', 'state_db_unreadable']); + assert.equal(result.homes[0].sessionCount, 0); + } + assert.equal(result.ready, false); assert.equal(JSON.stringify(result).includes('private-native-detail'), false); const stat = fs.statSync; t.mock.method(fs, 'statSync', ((path: any, ...args: any[]) => { @@ -62,7 +79,9 @@ test('per-home schema, missing, malformed, permission and unknown diagnostics st return (stat as any)(path, ...args); }) as typeof fs.statSync); syncBuiltinESMExports(); const denied = await diagnoseCodex(f.home, process.execPath) as any; - assert.equal(denied.homes[0].status, 'permission_denied'); assert.equal(JSON.stringify(denied).includes('secret'), false); + if (windows) assertWindowsStorageUnqualified(denied.homes[0]); + else assert.equal(denied.homes[0].status, 'permission_denied'); + assert.equal(JSON.stringify(denied).includes('secret'), false); }); test('explicit doctor home bypasses unrelated invalid inventory and never executes codex binary', async t => { @@ -70,7 +89,9 @@ test('explicit doctor home bypasses unrelated invalid inventory and never execut process.env.SESSION_PEER_CODEX_HOMES = 'bad-secret-config'; const before = readFileSync(file); const contents = readdirSync(f.home); const result = await diagnoseCodex(f.home, process.execPath) as any; - assert.equal(result.ready, true); assert.equal(result.tool.executed, false); assert.equal(result.writerVerified, false); + assert.equal(result.ready, !windows); + if (windows) assertWindowsStorageUnqualified(result.homes[0]); + assert.equal(result.tool.executed, false); assert.equal(result.writerVerified, false); assert.equal(result.sendAuthorized, false); assert.deepEqual(readFileSync(file), before); assert.deepEqual(readdirSync(f.home), contents); const automatic = await diagnoseCodex(undefined, process.execPath) as any; assert.equal(automatic.ready, false); assert.equal(automatic.errors[0].code, 'invalid_home_configuration'); @@ -109,7 +130,8 @@ test('permissions and unknown process inspection cannot authorize readiness', t test('stdio doctor has identical read-only result boundaries and rejects dispatch options', t => { const f = fixture(t); f.db(); const wire = f.invoke(['--stdio-request'], JSON.stringify({ schemaVersion: 1, args: ['doctor', '--json', '--agent', 'codex', '--codex-home', f.home, '--codex-bin', process.execPath] })); - assert.equal(wire.code, 0); assert.equal(wire.value.command, 'doctor'); assert.equal(wire.value.ready, true); + assert.equal(wire.code, 0); assert.equal(wire.value.command, 'doctor'); assert.equal(wire.value.ready, !windows); + if (windows) assertWindowsStorageUnqualified(wire.value.agents.codex.homes[0]); assert.deepEqual(Object.keys(wire.value.agents), ['codex']); for (const args of [['--to', 'codex:id'], ['--all'], ['--dry-run'], ['--message', 'SECRET'], ['--allow-inactive-codex-home']]) { const result = f.invoke(['doctor', '--json', ...args]); assert.equal(result.code, 2); assert.equal(result.value.status, 'refused'); @@ -190,15 +212,21 @@ test('doctor reports a relocated Codex sqlite_home as unsupported instead of rea writeFileSync(join(f.home, 'config.toml'), `${key} = "/relocated"\n`); const result = await diagnoseCodex(f.home, process.execPath) as any; assert.equal(result.homes[0].status, 'unsupported', key); - assert.equal(result.homes[0].code, 'unsupported_codex_sqlite_home', key); + if (windows) assertWindowsStorageUnqualified(result.homes[0]); + else assert.equal(result.homes[0].code, 'unsupported_codex_sqlite_home', key); assert.equal(result.ready, false); } writeFileSync(join(f.home, 'config.toml'), 'model = "sqlite_home = /relocated"\n# sqlite_home = "/commented"\n[profiles.p]\nmodel = "fixture"\n'); - assert.equal(((await diagnoseCodex(f.home, process.execPath)) as any).homes[0].code, 'state_db_readable'); + const harmless = await diagnoseCodex(f.home, process.execPath) as any; + if (windows) assertWindowsStorageUnqualified(harmless.homes[0]); + else assert.equal(harmless.homes[0].code, 'state_db_readable'); writeFileSync(join(f.home, 'config.toml'), 'sqlite_home = [\n'); const invalid = await diagnoseCodex(f.home, process.execPath) as any; - assert.equal(invalid.homes[0].status, 'unknown'); - assert.equal(invalid.homes[0].code, 'codex_config_unreadable'); + if (windows) assertWindowsStorageUnqualified(invalid.homes[0]); + else { + assert.equal(invalid.homes[0].status, 'unknown'); + assert.equal(invalid.homes[0].code, 'codex_config_unreadable'); + } }); test('doctor distinguishes nested sqlite_home keys from an escaped root override', async t => { @@ -211,8 +239,10 @@ test('doctor distinguishes nested sqlite_home keys from an escaped root override writeFileSync(file, text); const result = await diagnoseCodex(f.home, process.execPath) as any; const selected = result.homes.find((home: any) => home.codexHome === f.home); - assert.ok(selected); assert.equal(selected.status, 'available'); assert.equal(selected.code, 'state_db_readable'); - assert.equal(result.ready, true); assert.equal(result.tool.executed, false); + assert.ok(selected); + if (windows) assertWindowsStorageUnqualified(selected); + else { assert.equal(selected.status, 'available'); assert.equal(selected.code, 'state_db_readable'); } + assert.equal(result.ready, !windows); assert.equal(result.tool.executed, false); assert.equal(result.sendAuthorized, false); } // Root keys precede the table header; otherwise they would remain nested. @@ -222,7 +252,8 @@ test('doctor distinguishes nested sqlite_home keys from an escaped root override const result = await diagnoseCodex(f.home, process.execPath) as any; const selected = result.homes.find((home: any) => home.codexHome === f.home); assert.ok(selected); assert.equal(selected.status, 'unsupported'); - assert.equal(selected.code, 'unsupported_codex_sqlite_home'); + if (windows) assertWindowsStorageUnqualified(selected); + else assert.equal(selected.code, 'unsupported_codex_sqlite_home'); assert.equal(result.ready, false); assert.equal(result.tool.executed, false); assert.equal(result.sendAuthorized, false); } @@ -237,16 +268,22 @@ test('doctor rejects repeated exponent signs anywhere in Codex configuration', a for (const number of ['3e+20', '3e-20']) { writeFileSync(config, `numeric_setting = ${number}\n[profiles.p]\nweight = ${number}\n`); const result = await diagnoseCodex(f.home, process.execPath) as any; - assert.equal(result.homes[0].status, 'available', number); - assert.equal(result.homes[0].code, 'state_db_readable', number); - assert.equal(result.ready, true); assert.equal(result.tool.executed, false); + if (windows) assertWindowsStorageUnqualified(result.homes[0]); + else { + assert.equal(result.homes[0].status, 'available', number); + assert.equal(result.homes[0].code, 'state_db_readable', number); + } + assert.equal(result.ready, !windows); assert.equal(result.tool.executed, false); } for (const number of ['3e++20', '3e--20']) { for (const text of [`numeric_setting = ${number}\n`, `[profiles.p]\nweight = ${number}\n`]) { writeFileSync(config, text); const result = await diagnoseCodex(f.home, process.execPath) as any; - assert.equal(result.homes[0].status, 'unknown', text); - assert.equal(result.homes[0].code, 'codex_config_unreadable', text); + if (windows) assertWindowsStorageUnqualified(result.homes[0]); + else { + assert.equal(result.homes[0].status, 'unknown', text); + assert.equal(result.homes[0].code, 'codex_config_unreadable', text); + } assert.equal(result.ready, false); assert.equal(result.tool.executed, false); assert.equal(result.sendAuthorized, false); } diff --git a/test/linux-observation.test.ts b/test/linux-observation.test.ts index 597cc5c..647f394 100644 --- a/test/linux-observation.test.ts +++ b/test/linux-observation.test.ts @@ -532,7 +532,8 @@ async function cliFixture(t: TestContext, version = '0.162.1', warm = false) { const root = temp(t, 'codex-linux-cli-'), home = join(root, 'home'), bin = join(root, 'bin'); mkdirSync(home, { mode: 0o700 }); mkdirSync(bin, { mode: 0o700 }); mkdirSync(join(home, 'thread-writer-locks'), { mode: 0o700 }); const { DatabaseSync } = await import('node:sqlite'); - const state = new DatabaseSync(join(home, 'state_5.sqlite')); state.exec('CREATE TABLE threads(id TEXT)'); state.prepare('INSERT INTO threads VALUES(?)').run(thread); state.close(); + const state = new DatabaseSync(join(home, 'state_5.sqlite')); state.exec('CREATE TABLE threads(id TEXT, cwd TEXT)'); + state.prepare('INSERT INTO threads VALUES(?,?)').run(thread, root); state.close(); if (warm) { const history = new DatabaseSync(join(home, 'thread_history_1.sqlite')); history.exec(`CREATE TABLE thread_turns(thread_id TEXT NOT NULL,turn_id TEXT NOT NULL,status TEXT NOT NULL,PRIMARY KEY(thread_id,turn_id)); @@ -542,7 +543,15 @@ CREATE TABLE thread_history_projection_state(thread_id TEXT PRIMARY KEY,next_rol history.prepare('INSERT INTO thread_history_projection_state VALUES(?,?,?)').run(thread, 1, 1); history.close(); } const locks = createRequire(import.meta.url).resolve('fs-ext-extra-prebuilt'); - const held = spawn(process.execPath, [resolve('test/fixtures/ssh-codex-lock-holder.cjs'), join(home, 'thread-writer-locks', thread + '.lock'), locks]); + // A live writer fixture must retain the selected state inode, not just its + // lock: dispatch now verifies saved/physical cwd and real opened-store proof. + // Keep this stand-in local to the test; frozen cross-language fixtures stay + // unchanged. The parent owns this child and terminates only that handle. + const held = spawn(process.execPath, ['-e', `const fs=require('node:fs'); + fs.openSync(process.argv[1],'r'); + require(process.argv[3]).flockSync(fs.openSync(process.argv[2],'w',0o600),'exnb'); + process.stdout.write('ready');setInterval(()=>{},1000);`, + join(home, 'state_5.sqlite'), join(home, 'thread-writer-locks', thread + '.lock'), locks], { cwd: root }); held.stderr.resume(); await once(held.stdout, 'data'); t.after(async () => { if (held.exitCode === null && held.signalCode === null) { held.kill('SIGKILL'); await once(held, 'close'); } }); writeFileSync(join(bin, 'lsof.data'), `p${held.pid}\0ccodex-fixture\0u${process.getuid?.()}\0`, { mode: 0o600 }); @@ -567,12 +576,24 @@ CREATE TABLE thread_history_projection_state(thread_id TEXT PRIMARY KEY,next_rol args: ['send', '--to', 'codex:' + thread, '--codex-home', home, '--codex-bin', codex, '--message', 'fixture request', '--no-from', '--no-reply-to'] }; } +test('Linux CLI stand-in supplies saved cwd and real writer storage before delivery checks', posix, async t => { + const f = await cliFixture(t); + const dry = await f.invoke([...f.args, '--dry-run']); + assert.equal(dry.code, 0, dry.stdout); + assert.equal(dry.value.status, 'validated', dry.stdout); + assert.equal(dry.value.storage.status, 'classified', dry.stdout); + assert.equal(dry.value.submitted, false); + assert.deepEqual(f.queueCalls(), []); + assert.deepEqual(f.nativeCalls(), []); +}); + // Only versions without an exact row for this Linux host (all of them on x64). for (const version of ['0.160.1', '0.162.1'].filter(version => !selectNativeProfile({ platform: host, version }))) { test(`Linux host with exact Darwin-qualified ${version} refuses required delivery and downgrades best-effort before any app-server`, linuxNative, async t => { const f = await cliFixture(t, version); const required = await f.invoke([...f.args, '--wait-for', 'delivered', '--wait-timeout', '10']); assert.equal(required.code, 1, required.stdout); assert.equal(required.value.submitted, false); + assert.ok(required.value.handoff, required.stdout); assert.equal(required.value.handoff.wait.status, 'unsupported'); assert.equal(required.value.handoff.observation.status, 'unsupported'); assert.deepEqual(f.queueCalls(), []); const best = await f.invoke([...f.args, '--observe-delivery']); @@ -620,6 +641,7 @@ test('SSH to a Linux destination: complete result keeps pre-effect refusal; part }; const required = await invoke([...f.args, '--wait-for', 'delivered', '--wait-timeout', '15']); assert.equal(required.code, 1, required.stdout); assert.equal(required.value.submitted, false); + assert.ok(required.value.handoff, required.stdout); assert.equal(required.value.handoff.wait.status, 'unsupported'); assert.deepEqual(f.queueCalls(), []); // One preparation and one send request; the destination refused before effect. assert.deepEqual(f.log().map(request => request.handoff?.preparation === true), [true, false]); diff --git a/test/skill-guidance.test.ts b/test/skill-guidance.test.ts index 750103b..d61eaac 100644 --- a/test/skill-guidance.test.ts +++ b/test/skill-guidance.test.ts @@ -88,8 +88,18 @@ test('TS skill baseline list and guarded Codex dry-run examples never submit', t const doctorHelp = invoke(['doctor', '--help']); assert.equal(doctorHelp.status, 0); assert.match(doctorHelp.stdout, /^Usage: session-peer doctor /); const optedIn = invoke([...args, '--allow-inactive-codex-home']); - assert.equal(optedIn.status, 0, optedIn.stdout); const result = JSON.parse(optedIn.stdout); - assert.equal(result.submitted, false); assert.equal(result.status, 'validated'); - assert.equal(result.codexHome, realpathSync(home)); assert.equal(result.codexHomeResolution.reason, 'explicit_inactive_opt_in'); + const result = JSON.parse(optedIn.stdout); + if (process.platform === 'win32') { + assert.equal(optedIn.status, 1, optedIn.stdout); + assert.equal(result.status, 'refused'); + assert.equal(result.error, 'codex_storage_platform_unqualified'); + assert.equal(result.retryAllowed, false); + } else { + assert.equal(optedIn.status, 0, optedIn.stdout); + assert.equal(result.status, 'validated'); + assert.equal(result.codexHome, realpathSync(home)); + } + assert.equal(result.submitted, false); + assert.equal(result.codexHomeResolution.reason, 'explicit_inactive_opt_in'); assert.deepEqual(readFileSync(path), before); assert.deepEqual(readdirSync(home), ['state_5.sqlite']); }); From 76f556cca869da95ad4368af914bad6f0f43af3d Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 30 Oct 2026 00:50:00 +0900 Subject: [PATCH 8/8] test: preserve guarded shorthand results and diagnose pre-effect refusals --- test/handoff-delivery.test.ts | 2 +- test/shorthand-contract.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index bc58bdc..5a61010 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -236,7 +236,7 @@ test('qualified local opt-in waits for exact client ID injection, not completed const status=await f.invoke(['handoff','status','--correlation-id',h.correlationId]);assert.equal(status.code,0);assert.equal(status.value.handoff.observation.injectionObserved,true);assert.equal(f.calls().length,1); }); test('unknown after possible native write never falls back to legacy queue',qualified,async t=>{ - const f=await fixture(t,'loss');const r=await f.invoke([...f.args,'--wait-for','delivered']);assert.equal(r.code,1,r.stdout);assert.equal(r.value.status,'unknown');assert.equal(r.value.submitted,null);assert.equal(r.value.handoff.submission.status,'unknown');assert.equal(r.value.handoff.wait.status,'failed');assert.equal(f.calls().length,1);assert.equal(f.calls()[0].method,'thread/queue/add'); + const f=await fixture(t,'loss');const r=await f.invoke([...f.args,'--wait-for','delivered']);assert.equal(r.code,1,r.stdout);assert.equal(r.value.status,'unknown',r.stdout);assert.equal(r.value.submitted,null);assert.equal(r.value.handoff.submission.status,'unknown');assert.equal(r.value.handoff.wait.status,'failed');assert.equal(f.calls().length,1);assert.equal(f.calls()[0].method,'thread/queue/add'); }); test('positive native acceptance survives a subsequent ledger failure without another submission',qualified,async t=>{ const f=await fixture(t,'ledger-failure'); diff --git a/test/shorthand-contract.ts b/test/shorthand-contract.ts index 03ebf8a..dd4de3f 100644 --- a/test/shorthand-contract.ts +++ b/test/shorthand-contract.ts @@ -80,12 +80,18 @@ export function shorthandFixture(installed: string, bin: string, task: string) { for (const args of cases) { const direct = invoke(shell, false, args, 'stdin 한글\nsecond line'); const short = invoke(shell, true, args, 'stdin 한글\nsecond line'); - const expected = args.includes('--allow-inactive-codex-home') ? 0 : args.includes('--bad') || args.includes('--message') ? 2 : 0; + const expected = args.includes('--allow-inactive-codex-home') ? (win ? 1 : 0) : args.includes('--bad') || args.includes('--message') ? 2 : 0; let detail = ''; if (short.status !== direct.status || short.stdout !== direct.stdout || stderrOf(short) !== stderrOf(direct)) detail = 'alias differs from canonical'; else if (short.status !== expected) detail = `exit ${short.status}, expected ${expected}`; else if (args.includes('--allow-inactive-codex-home')) { - try { const result = JSON.parse(short.stdout); if (result.status !== 'validated' || result.submitted !== false) detail = 'not validated'; } + try { + const result = JSON.parse(short.stdout); + if (win) { + if (result.status !== 'refused' || result.error !== 'codex_storage_platform_unqualified' || + result.submitted !== false || result.retryAllowed !== false) detail = 'not a guarded Windows refusal'; + } else if (result.status !== 'validated' || result.submitted !== false) detail = 'not validated'; + } catch { detail = 'stdout is not JSON'; } } if (detail) failures.push(`${shell} ${JSON.stringify(args)}: ${detail}; direct=${direct.status} ${direct.stdout.trim().slice(0, 300)} ${direct.stderr.trim().slice(0, 300)}; alias=${short.status} ${short.stdout.trim().slice(0, 300)} ${short.stderr.trim().slice(0, 300)}`);