From 9ebddbc3c22888f4a329e80019a23719c795aa0a Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 3 Nov 2026 11:05:07 +0900 Subject: [PATCH 1/9] chore(release): prepare session-peer 0.6.0 metadata Align scoped current-version context, carry forward the four reviewed finite skill profiles, and retain the public 0.5.2 baseline and immutable skill pin. Feature integration and final artifact evidence remain separate preparation steps. Refs #175 --- PARITY.md | 2 +- README.ja.md | 16 ++--- README.ko.md | 16 ++--- README.md | 16 ++--- README.zh-CN.md | 16 ++--- docs/api.md | 14 ++-- docs/design/typescript-skill-compatibility.md | 29 ++++++++ docs/guide.ja.md | 30 ++++---- docs/guide.ko.md | 34 ++++----- docs/guide.md | 32 ++++----- docs/guide.zh-CN.md | 30 ++++---- docs/shorthand.md | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/release.mjs | 8 +-- src/protocol.ts | 2 +- src/skill-metadata.ts | 7 +- test/release.test.ts | 20 +++--- test/skill-guidance.test.ts | 3 +- test/skill-metadata.test.ts | 69 +++++++++++++++---- test/updates.test.ts | 62 ++++++++--------- 21 files changed, 244 insertions(+), 170 deletions(-) diff --git a/PARITY.md b/PARITY.md index 2f7f829..027ce8e 100644 --- a/PARITY.md +++ b/PARITY.md @@ -90,7 +90,7 @@ the installation pin; runtime publication is not companion-skill publication. ## Current scoped feature matrix -Current source/package version: **0.5.2**. +Current source/package version: **0.6.0**. Reviewed **2026-10-29 KST** against TS **0.5.1** source [`90481b148584926ea328ed956327f3050d9be9cb`](https://github.com/abruption/session-peer-ts/tree/90481b148584926ea328ed956327f3050d9be9cb) diff --git a/README.ja.md b/README.ja.md index 8cb3445..11a5f65 100644 --- a/README.ja.md +++ b/README.ja.md @@ -14,9 +14,9 @@ -**文書・ソースのバージョン: 0.5.2。** インストール前に、選択したリリースとレジストリのメタデータを確認してください。日付付きの準備・検証記録は [VALIDATION.md](VALIDATION.md)、リリース手順は [RELEASING.md](RELEASING.md) にあります。SSH の両端には同じ TypeScript クライアントのバージョンが必要で、0.3.6 と 0.5.2 は互いの SSH 事前確認を拒否します。 +**文書・ソースのバージョン: 0.6.0。** インストール前に、選択したリリースとレジストリのメタデータを確認してください。日付付きの準備・検証記録は [VALIDATION.md](VALIDATION.md)、リリース手順は [RELEASING.md](RELEASING.md) にあります。SSH の両端には同じ TypeScript クライアントのバージョンが必要で、0.3.6 と 0.6.0 は互いの 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.2 のソースは選択できない Linux arm64 の Codex 0.162.1 候補(固定された上流 `aarch64-unknown-linux-musl` 実行ファイル、スキーマ、注入テスト 1 回)を記録しますが、Linux 観測を有効化しません。Linux の native 観測は引き続き未対応です。 +**0.4.0 handoff の範囲:** 任意の native 観測は macOS arm64 の厳密な Codex 0.160.1/0.162.1 に限定し、明示的な `operator_confirmed` 受領確認には対応する POSIX ledger を使います。Linux・Windows の native 観測と自動 ACK 収集は未対応です。 0.6.0 のソースは選択できない Linux arm64 の Codex 0.162.1 候補(固定された上流 `aarch64-unknown-linux-musl` 実行ファイル、スキーマ、注入テスト 1 回)を記録しますが、Linux 観測を有効化しません。Linux の native 観測は引き続き未対応です。 **実行中の Claude Code・Codex セッションを見つけ、ローカルまたは SSH でメッセージを送信します。** Python 不要の Node.js クライアントです。パッケージ名とコマンド名は `session-peer` です。 @@ -61,15 +61,15 @@ SSH は既存の鍵・ホスト信頼設定と両端の**同じバージョン `Get-Command session-peer -All` で PATH を確認してください。利用する実装を選び、 別の管理ツールのファイルを `--force` で上書きしないでください。 -**0.5.2 のインストール:** npm の導入・更新例を実行する前に、レジストリで公開状況と選択したリリースを確認してください。利用できない場合は[レビュー済みソースビルド](docs/guide.ja.md#ソースからビルド)を使います。 +**0.6.0 のインストール:** npm の導入・更新例を実行する前に、レジストリで公開状況と選択したリリースを確認してください。利用できない場合は[レビュー済みソースビルド](docs/guide.ja.md#ソースからビルド)を使います。 ```sh -npm view session-peer@0.5.2 version dist.integrity -npm install --global --ignore-scripts session-peer@0.5.2 +npm view session-peer@0.6.0 version dist.integrity +npm install --global --ignore-scripts session-peer@0.6.0 session-peer --version ``` -期待する出力: `session-peer 0.5.2 (typescript)`。分離インストール・ソースビルド・ +期待する出力: `session-peer 0.6.0 (typescript)`。分離インストール・ソースビルド・ Windows・削除の手順は以下の詳細ガイドを参照してください。 任意の `sp` 短縮名は 0.3.0 以降に含まれ(0.2.1 以前にはありません)、自動では有効になりません。 @@ -78,12 +78,12 @@ Windows・削除の手順は以下の詳細ガイドを参照してください ### Update npm で導入したクライアントは npm で更新します。タグと対象バージョンを確認してから -正確なバージョンを指定してください。以下は古い npm 版を選択した 0.5.2 に更新する例です。 +正確なバージョンを指定してください。以下は古い npm 版を選択した 0.6.0 に更新する例です。 CLI は更新をインストールせず、`session-peer update --check` は確認のみ行います。 ```sh npm view session-peer dist-tags -npm install --global --ignore-scripts session-peer@0.5.2 +npm install --global --ignore-scripts session-peer@0.6.0 session-peer --version ``` diff --git a/README.ko.md b/README.ko.md index b22ba82..b0db84f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -23,9 +23,9 @@ Python을 설치하지 않아도 Node.js에서 실행되는 TypeScript 클라이 -**문서·소스 기준 버전: 0.5.2.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](VALIDATION.md), 릴리스 절차는 [RELEASING.md](RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6 버전과 0.5.2 버전은 서로의 SSH 사전 확인을 거부합니다. +**문서·소스 기준 버전: 0.6.0.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](VALIDATION.md), 릴리스 절차는 [RELEASING.md](RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6 버전과 0.6.0 버전은 서로의 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.2 소스는 선택할 수 없는 Linux arm64 Codex 0.162.1 후보(고정된 업스트림 `aarch64-unknown-linux-musl` 실행 파일, 스키마, 주입 테스트 1회)를 기록하며 Linux 관측을 활성화하지 않습니다. Linux native 관측은 계속 미지원입니다. +**0.4.0 handoff 범위:** 선택 기능인 native 관측은 macOS arm64의 정확한 Codex 0.160.1/0.162.1에 한정되며, 명시적 `operator_confirmed` 수령 확인에는 지원되는 POSIX ledger를 사용합니다. Linux·Windows의 native 관측과 자동 ACK 수집은 미지원입니다. 0.6.0 소스는 선택할 수 없는 Linux arm64 Codex 0.162.1 후보(고정된 업스트림 `aarch64-unknown-linux-musl` 실행 파일, 스키마, 주입 테스트 1회)를 기록하며 Linux 관측을 활성화하지 않습니다. Linux native 관측은 계속 미지원입니다. ## 시연 @@ -70,15 +70,15 @@ PowerShell에서는 `Get-Command session-peer -All`로 PATH(명령 검색 경로 실행 파일을 찾는지 확인하세요. 사용할 구현을 선택하고 다른 패키지 관리자가 설치한 파일을 `--force`로 덮어쓰지 마세요. 설치와 갱신은 사용자가 npm으로 직접 수행합니다. -**0.5.2 설치:** npm 설치·업데이트 예시를 실행하기 전에 레지스트리 제공 여부와 선택한 릴리스를 확인하세요. 해당 버전이 제공되지 않으면 [검토한 소스 빌드](docs/guide.ko.md#소스에서-빌드)를 사용하세요. +**0.6.0 설치:** npm 설치·업데이트 예시를 실행하기 전에 레지스트리 제공 여부와 선택한 릴리스를 확인하세요. 해당 버전이 제공되지 않으면 [검토한 소스 빌드](docs/guide.ko.md#소스에서-빌드)를 사용하세요. ```sh -npm view session-peer@0.5.2 version dist.integrity -npm install --global --ignore-scripts session-peer@0.5.2 +npm view session-peer@0.6.0 version dist.integrity +npm install --global --ignore-scripts session-peer@0.6.0 session-peer --version ``` -예상 결과는 `session-peer 0.5.2 (typescript)`입니다. 별도 경로에 설치하거나, +예상 결과는 `session-peer 0.6.0 (typescript)`입니다. 별도 경로에 설치하거나, 소스에서 빌드하거나, Windows에서 설치·제거하는 방법은 [사용자 가이드](docs/guide.ko.md#설치)를 참고하세요. 선택 사항인 `sp` 단축 명령은 0.3.0 이상에 포함되며(0.2.1 이하에는 없음) 자동으로 @@ -89,13 +89,13 @@ session-peer --version ### 업데이트 npm으로 설치한 클라이언트는 사용자가 직접 npm으로 업데이트합니다. 사용 가능한 배포 태그 -(dist-tag)와 대상 버전을 확인한 뒤 해당 버전을 지정해 설치하세요. 아래 예시는 이전 npm 설치본을 선택한 0.5.2 버전으로 +(dist-tag)와 대상 버전을 확인한 뒤 해당 버전을 지정해 설치하세요. 아래 예시는 이전 npm 설치본을 선택한 0.6.0 버전으로 업데이트합니다. `session-peer` CLI는 업데이트를 설치하지 않습니다. `session-peer update --check`는 새 버전이 있는지 확인할 뿐입니다. ```sh npm view session-peer dist-tags -npm install --global --ignore-scripts session-peer@0.5.2 +npm install --global --ignore-scripts session-peer@0.6.0 session-peer --version ``` diff --git a/README.md b/README.md index ddb7921..35ad81c 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ -**Documentation and source version: 0.5.2.** 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 0.5.2 refuse each other’s SSH preflight. +**Documentation and source version: 0.6.0.** 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 0.6.0 refuse each other’s SSH preflight. -**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 0.5.2 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. +**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 0.6.0 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. **Find and message running Claude Code and Codex sessions, locally or over SSH.** A Node.js client that runs without Python. Package and command: `session-peer`. @@ -61,15 +61,15 @@ Check `type -a session-peer` on macOS/Linux or `Get-Command session-peer -All` in PowerShell before installing. Choose the intended PATH entry; do not use `--force` to overwrite another manager’s files. -**Version 0.5.2 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). +**Version 0.6.0 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). ```sh -npm view session-peer@0.5.2 version dist.integrity -npm install --global --ignore-scripts session-peer@0.5.2 +npm view session-peer@0.6.0 version dist.integrity +npm install --global --ignore-scripts session-peer@0.6.0 session-peer --version ``` -Expected: `session-peer 0.5.2 (typescript)`. For isolated installation, source +Expected: `session-peer 0.6.0 (typescript)`. For isolated installation, source builds, Windows and removal, see the detailed guide below. Optional `sp` shorthand ships with 0.3.0 and later (not 0.2.1 or earlier) and is @@ -79,12 +79,12 @@ never enabled automatically. See [explicit activation and collisions](docs/short Use npm for an npm-managed installation. Check the available tags and review the target version, then install that exact version; this example updates an -older npm installation to the selected 0.5.2 release. The CLI does not install updates; +older npm installation to the selected 0.6.0 release. The CLI does not install updates; `session-peer update --check` only reports them. ```sh npm view session-peer dist-tags -npm install --global --ignore-scripts session-peer@0.5.2 +npm install --global --ignore-scripts session-peer@0.6.0 session-peer --version ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index b9d8a44..d1a9839 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -14,9 +14,9 @@ -**文档和源码版本:0.5.2。** 安装前,请审核所选发布版本和注册表元数据。按日期记录的准备与验证结果见 [VALIDATION.md](VALIDATION.md),发布流程见 [RELEASING.md](RELEASING.md)。SSH 两端必须使用相同的 TypeScript 客户端版本;0.3.6 与 0.5.2 会拒绝彼此的 SSH 预检。 +**文档和源码版本:0.6.0。** 安装前,请审核所选发布版本和注册表元数据。按日期记录的准备与验证结果见 [VALIDATION.md](VALIDATION.md),发布流程见 [RELEASING.md](RELEASING.md)。SSH 两端必须使用相同的 TypeScript 客户端版本;0.3.6 与 0.6.0 会拒绝彼此的 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.2 源码记录了不可选用的 Linux arm64 Codex 0.162.1 候选(固定的上游 `aarch64-unknown-linux-musl` 可执行文件、schema 及一次注入测试),但不启用 Linux 观测;Linux native 观测仍不支持。 +**0.4.0 handoff 范围:** 可选 native 观测仅限 macOS arm64 上精确 Codex 0.160.1/0.162.1 版本;明确的 `operator_confirmed` 收件确认使用受支持的 POSIX ledger。Linux、Windows 的 native 观测和自动 ACK 收集仍不支持。 0.6.0 源码记录了不可选用的 Linux arm64 Codex 0.162.1 候选(固定的上游 `aarch64-unknown-linux-musl` 可执行文件、schema 及一次注入测试),但不启用 Linux 观测;Linux native 观测仍不支持。 **发现正在运行的 Claude Code 和 Codex 会话,并在本机或通过 SSH 发送消息。** 这是不需要 Python 的 Node.js 客户端。包名与命令名均为 `session-peer`。 @@ -59,15 +59,15 @@ session-peer send --to CLAUDE_PID --message '请检查 API 契约并回复。' - `Get-Command session-peer -All` 检查 PATH。选择所需实现,不要用 `--force` 覆盖其他安装管理器的文件。 -**安装 0.5.2:** 运行 npm 安装或更新示例前,请确认注册表可用性并审核所选发布版本。如该版本不可用,请使用[已审核的源码构建](docs/guide.zh-CN.md#从源码构建)。 +**安装 0.6.0:** 运行 npm 安装或更新示例前,请确认注册表可用性并审核所选发布版本。如该版本不可用,请使用[已审核的源码构建](docs/guide.zh-CN.md#从源码构建)。 ```sh -npm view session-peer@0.5.2 version dist.integrity -npm install --global --ignore-scripts session-peer@0.5.2 +npm view session-peer@0.6.0 version dist.integrity +npm install --global --ignore-scripts session-peer@0.6.0 session-peer --version ``` -预期输出:`session-peer 0.5.2 (typescript)`。隔离安装、源码构建、Windows 和 +预期输出:`session-peer 0.6.0 (typescript)`。隔离安装、源码构建、Windows 和 卸载步骤见下方详细指南。 可选的 `sp` 简写包含在 0.3.0 及更高版本中(0.2.1 及更早版本没有),不会自动启用。 @@ -76,11 +76,11 @@ session-peer --version ### Update 使用 npm 更新由 npm 管理的安装。先检查标签并审阅目标版本,再安装指定版本。 -下面示例将旧 npm 安装更新至所选的 0.5.2。CLI 不会安装更新,`session-peer update --check` 只做检查。 +下面示例将旧 npm 安装更新至所选的 0.6.0。CLI 不会安装更新,`session-peer update --check` 只做检查。 ```sh npm view session-peer dist-tags -npm install --global --ignore-scripts session-peer@0.5.2 +npm install --global --ignore-scripts session-peer@0.6.0 session-peer --version ``` diff --git a/docs/api.md b/docs/api.md index 4ab40d3..14377e0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,18 +1,18 @@ # API reference -This reference describes source/package version **0.5.2**. 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. +This reference describes source/package version **0.6.0**. 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. [Back to README](../README.md) · [User guide](guide.md) The package exposes a small ESM API with TypeScript declarations. Install it -locally with `npm install --ignore-scripts session-peer@0.5.2` before importing it. +locally with `npm install --ignore-scripts session-peer@0.6.0` before importing it. Importing the package does not execute the CLI or submit a message. ```js import { VERSION, VERSION_LINE, reply, envelope } from 'session-peer'; -console.log(VERSION); // 0.5.2 -console.log(VERSION_LINE); // session-peer 0.5.2 (typescript) +console.log(VERSION); // 0.6.0 +console.log(VERSION_LINE); // session-peer 0.6.0 (typescript) const address = 'session-peer://v1/reply?agent=claude&session=12345&transport=local'; console.log(reply(address)); // { to: '12345' } console.log(envelope('Please review and reply.', true, address)); @@ -214,7 +214,7 @@ message/transcript scan manufactures ACK. Ordinary generated Reply-To stays v1. POSIX opt-in `send --host HOST` performs an exact-version preflight and a no-effect preparation before one destination-side send. Both ends must contain -the same reviewed 0.5.2 implementation. This workflow was introduced in +the same reviewed 0.6.0 implementation. This workflow was introduced in 0.4.0; a 0.3.x banner does not establish the capability. Initialize the destination's private ledger explicitly; no command auto-installs a handler or initializes missing history. The origin does not create a local receipt collector. Reconcile with `handoff status`/`wait` on the @@ -316,7 +316,7 @@ requested evidence goal. After any possible RPC write, no legacy fallback or second submission occurs. Selection is by the exact host platform/architecture and native version. -The 0.5.2 source records a Linux arm64 Codex 0.162.1 candidate (pinned musl +The 0.6.0 source records a Linux arm64 Codex 0.162.1 candidate (pinned musl executable, procfs storage binding, `/proc/locks` writer-owner check) but does not activate it. Linux and any other unlisted platform/version have no profile, so required delivery refuses before effect and best-effort uses the @@ -396,7 +396,7 @@ a completed milestone. Exact evidence and limits are in [VALIDATION](../VALIDATI ## 0.4.0 implementation scope -The 0.5.2 source preserves this supported handoff scope; its Linux and Windows +The 0.6.0 source preserves this supported handoff scope; its Linux and Windows groundwork does not enable additional platform or ACK support. The 0.4.0 implementation includes the qualified macOS arm64 observer and explicit diff --git a/docs/design/typescript-skill-compatibility.md b/docs/design/typescript-skill-compatibility.md index 2bda899..af9d6f5 100644 --- a/docs/design/typescript-skill-compatibility.md +++ b/docs/design/typescript-skill-compatibility.md @@ -2,6 +2,35 @@ This document retains the original TS source proposal and dated design review for [#118](https://github.com/abruption/session-peer-ts/issues/118), based on the companion [Draft #20 at `963c3d7`](https://github.com/abruption/session-peer-skill/blob/963c3d748a11b1164013ee26c9c0ef285c52b285/docs/typescript-compatibility.md). Companion [design acceptance at `699a325`](https://github.com/abruption/session-peer-skill/blob/699a3251830c30c62d0f812363faf4d0eb0c8e0c/docs/typescript-compatibility.md) records the full static review of the exact candidates below on 2026-10-09. This design-only acceptance does not authorize runtime merge/release, successor skill metadata/publication, installation or pin changes, content authenticity, or live delivery support. +## 0.6.0 finite row — 2026-11-03 KST release preparation + +The 0.6.0 source preparation adds one explicit runtime row accepting exactly the +four existing profiles: skill 0.1.0, 0.2.0, 0.2.1 and 0.3.0, with their existing +metadata values. Compiled default, explicit `undefined`, explicit 0.6.0 and outer +`inspectSkill` use this finite row. Matching metadata returns exactly +`compatible / skill_contract_compatible / metadata_only`; changed profiles and +unreviewed stable runtime identities such as 0.5.3, 0.6.1 and 0.7.0 remain +incompatible. Malformed identities, including 0.6.0-preview.1 and 00.6.0, remain +`unknown / skill_metadata_missing` with the `verification` property absent. +All historical matrix rows, exact profile values, grammar and bounded reader +behavior remain unchanged. + +The installation guidance retains independently published skill 0.2.1 source +`0f494cabd05901c03161dc91792dcf89f8e96631`, 13,996 raw UTF-8 bytes, SHA-256 +`8c3520f95e0d6807303be96bfa06df69887ddaf76be05f1905505d0a956820c3`. +This row neither publishes a new skill nor changes its installation pin, body, +minimum/full metadata or permissions. In particular it does not establish that +an older skill documents new runtime receipt operations. Optional operations +still require the selected runtime's help, platform and separately validated +route. Metadata compatibility is not content authentication, authorization, +recipient identity, delivery or ACK evidence. + +This section records source preparation only, not publication or completed +receipt/native qualification. The dated 0.5.2 checkpoint below retains that +historical validator's rejection of then-unreviewed 0.6.0; supplying 0.6.0 to the +new helper is not execution of the old published 0.5.2 validator. No historical +artifact or immutable complete-skill fixture is rewritten. + ## 0.5.2 finite row — 2026-10-30 KST release preparation The 0.5.2 source explicitly accepts exactly the four profiles accepted by public diff --git a/docs/guide.ja.md b/docs/guide.ja.md index 4629b6f..fe51172 100644 --- a/docs/guide.ja.md +++ b/docs/guide.ja.md @@ -10,11 +10,11 @@ [English](guide.md) | [한국어](guide.ko.md) | [日本語](guide.ja.md) | [简体中文](guide.zh-CN.md) -**文書・ソースのバージョン: 0.5.2。** インストール前に、選択したリリースとレジストリのメタデータを確認してください。日付付きの準備・検証記録は [VALIDATION.md](../VALIDATION.md)、リリース手順は [RELEASING.md](../RELEASING.md) にあります。SSH の両端には同じ TypeScript クライアントのバージョンが必要で、0.3.6 と 0.5.2 は互いの SSH 事前確認を拒否します。 +**文書・ソースのバージョン: 0.6.0。** インストール前に、選択したリリースとレジストリのメタデータを確認してください。日付付きの準備・検証記録は [VALIDATION.md](../VALIDATION.md)、リリース手順は [RELEASING.md](../RELEASING.md) にあります。SSH の両端には同じ TypeScript クライアントのバージョンが必要で、0.3.6 と 0.6.0 は互いの 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.2 のソースは選択できない Linux arm64 の Codex 0.162.1 候補(固定された上流 `aarch64-unknown-linux-musl` 実行ファイル、スキーマ、注入テスト 1 回)を記録しますが、Linux 観測を有効化しません。Linux の native 観測は引き続き未対応です。 +**0.4.0 handoff の範囲:** 任意の native 観測は macOS arm64 の厳密な Codex 0.160.1/0.162.1 に限定し、明示的な `operator_confirmed` 受領確認には対応する POSIX ledger を使います。Linux・Windows の native 観測と自動 ACK 収集は未対応です。 0.6.0 のソースは選択できない Linux arm64 の Codex 0.162.1 候補(固定された上流 `aarch64-unknown-linux-musl` 実行ファイル、スキーマ、注入テスト 1 回)を記録しますが、Linux 観測を有効化しません。Linux の native 観測は引き続き未対応です。 -0.5.2 の詳細ガイドです([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.6.0 の詳細ガイドです([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 の説明は旧版との比較です。 @@ -50,7 +50,7 @@ 実行中の **Claude Code と Codex セッション**に、ローカルまたは SSH 経由でメッセージを送る TypeScript クライアントです。Node.js で動作し、Python は不要です。 -**0.5.2 の利用ガイドです。**パッケージ名は `session-peer`、CLI コマンドは **`session-peer`**。Relay サーバーやホスティングサービスは提供しません。 +**0.6.0 の利用ガイドです。**パッケージ名は `session-peer`、CLI コマンドは **`session-peer`**。Relay サーバーやホスティングサービスは提供しません。 ## 機能と範囲 @@ -67,7 +67,7 @@ Relay 通信、MCP、wake/resume、Antigravity、自動更新は未実装です ビルド後の `node dist/cli.js list --json` は Claude/Codex をまとめて表示し、`list --agent codex --json` は既知の home を検索します。公開 npm **0.1.0** では引き続き agent と Codex 一覧の home を明示します。0.2.0 でも明示的なコマンド形式を利用できます。 -対象は既定の `~/.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 クライアントのバージョン(0.5.2)が必要です。 +対象は既定の `~/.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 クライアントのバージョン(0.6.0)が必要です。 ### 0.2.0 CLI の使いやすさ @@ -96,15 +96,15 @@ macOS / Linux / Windows native、Node **22.x の 22.13 以上、または 24.x** Node 22.x の 22.13 以上または 24.x を使用し、インストールする正確なバージョンの公開状況を確認してください。Python CLI も同じコマンド名を使うため、既存のインストールがあれば先に PATH を確認してください。 -**0.5.2 のインストール:** npm の導入・更新例を実行する前に、レジストリで公開状況と選択したリリースを確認してください。利用できない場合は[レビュー済みソースビルド](#ソースからビルド)を使います。 +**0.6.0 のインストール:** npm の導入・更新例を実行する前に、レジストリで公開状況と選択したリリースを確認してください。利用できない場合は[レビュー済みソースビルド](#ソースからビルド)を使います。 ```sh -npm view session-peer@0.5.2 version dist.integrity -npm install --global --ignore-scripts session-peer@0.5.2 +npm view session-peer@0.6.0 version dist.integrity +npm install --global --ignore-scripts session-peer@0.6.0 session-peer --version ``` -期待値は `session-peer 0.5.2 (typescript)`。 +期待値は `session-peer 0.6.0 (typescript)`。 1. `session-peer list --agent claude --json` で宛先を探し、正確な PID を選びます。 2. `session-peer send --to CLAUDE_PID --message 'Please reply after checking.' --dry-run --json` で提出せずに確認します。 @@ -123,7 +123,7 @@ npm run build node dist/cli.js --version npm pack --ignore-scripts # 任意のグローバルインストール前に PATH の既存コマンドを確認 -npm install --global --ignore-scripts ./session-peer-0.5.2.tgz +npm install --global --ignore-scripts ./session-peer-0.6.0.tgz session-peer --version ``` @@ -133,7 +133,7 @@ session-peer --version インストール前後に `type -a session-peer` と `command -v session-peer` を確認します。他の実装も同名コマンドを提供するため、PATH 上の一つを選ぶか `node /absolute/path/dist/cli.js` を使ってください。`--force` で他の管理ツールのファイルを上書きしないでください。Python パッケージ・スキル・サービスを自動変更しません。削除は `npm uninstall --global session-peer` を使い、PATH を再確認します。 -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-0.5.2.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` で削除します。 +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-0.6.0.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` で削除します。 ## 使い方 @@ -224,13 +224,13 @@ Python は開発時の比較基準です(v1.1.0、`8ca77cf31254c8287a2593fe484 ## npm リリース -公開 **0.3.2** は 2026-10-07 KST に検証されました([固定された公開記録](../VALIDATION.md#public-032--2026-10-07-kst))。以下のレジストリ照会で 0.5.2 の現在の公開状況と npm タグを確認してください。日付付きの準備記録は、その後の公開結果を証明しません。 +公開 **0.3.2** は 2026-10-07 KST に検証されました([固定された公開記録](../VALIDATION.md#public-032--2026-10-07-kst))。以下のレジストリ照会で 0.6.0 の現在の公開状況と npm タグを確認してください。日付付きの準備記録は、その後の公開結果を証明しません。 0.2.1 は 2026-09-29 KST、0.2.0 は 2026-09-28 KST に公開・検証されました。 [0.2.1 公開記録](../VALIDATION.md#public-021--2026-09-29-kst)を参照し(以降のリリースはそれぞれ日付付きの記録を追加します)、導入前に正確なバージョンと現在のタグを確認してください。 ```sh -npm view session-peer@0.5.2 version dist.integrity +npm view session-peer@0.6.0 version dist.integrity npm view session-peer dist-tags ``` @@ -272,7 +272,7 @@ session-peer update --check --channel preview --output-format text 実行中の CLI のパスからインストールの管理者を確実に特定できた場合にだけ示します。 対象は、自身の `session-peer` ランチャーがこのパッケージを指す npm グローバル prefix (既定、Homebrew、nvm、nvm-windows、fnm。例: -`npm install --global --ignore-scripts session-peer@0.5.2`)、マニフェストで +`npm install --global --ignore-scripts session-peer@0.6.0`)、マニフェストで `session-peer` を宣言している pnpm・Yarn・Bun のグローバルストア、Volta、npx キャッシュ です。プロジェクトへのインストール(`npm_project`、`pnpm_project`)、ソースチェック アウト(`source`)、それ以外(`unknown`)では `updateCommand: null` と `guidance` の文だけを @@ -367,4 +367,4 @@ native 観測は、macOS arm64 の厳密な Codex 0.160.1/0.162.1 と既存の [Observation](api.md#unreleased-local-delivery-observation--72) -Linux・Windows の native 注入観測は未対応です。 0.5.2 のソースは選択できない 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) の後続作業です。 +Linux・Windows の native 注入観測は未対応です。 0.6.0 のソースは選択できない 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) の後続作業です。 diff --git a/docs/guide.ko.md b/docs/guide.ko.md index ebde193..e7008b9 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.2.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](../VALIDATION.md), 릴리스 절차는 [RELEASING.md](../RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6 버전과 0.5.2 버전은 서로의 SSH 사전 확인을 거부합니다. +**문서·소스 기준 버전: 0.6.0.** 설치 전에 선택한 릴리스와 레지스트리 메타데이터를 확인하세요. 날짜별 준비·검증 기록은 [VALIDATION.md](../VALIDATION.md), 릴리스 절차는 [RELEASING.md](../RELEASING.md)에 있습니다. SSH 양쪽에는 같은 TypeScript 클라이언트 버전이 필요하며, 0.3.6 버전과 0.6.0 버전은 서로의 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.2 소스는 선택할 수 없는 Linux arm64 Codex 0.162.1 후보(고정된 업스트림 `aarch64-unknown-linux-musl` 실행 파일, 스키마, 주입 테스트 1회)를 기록하며 Linux 관측을 활성화하지 않습니다. Linux native 관측은 계속 미지원입니다. +**0.4.0 handoff 범위:** 선택 기능인 native 관측은 macOS arm64의 정확한 Codex 0.160.1/0.162.1에 한정되며, 명시적 `operator_confirmed` 수령 확인에는 지원되는 POSIX ledger를 사용합니다. Linux·Windows의 native 관측과 자동 ACK 수집은 미지원입니다. 0.6.0 소스는 선택할 수 없는 Linux arm64 Codex 0.162.1 후보(고정된 업스트림 `aarch64-unknown-linux-musl` 실행 파일, 스키마, 주입 테스트 1회)를 기록하며 Linux 관측을 활성화하지 않습니다. Linux native 관측은 계속 미지원입니다. -이 가이드는 0.5.2 버전을 다룹니다([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.6.0 버전을 다룹니다([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을 언급할 때는 이전 버전을 설명합니다. @@ -102,7 +102,7 @@ JSON에는 정제된 `codexHomeResolution`이 포함되며, 네이티브 응답 `queueId`도 포함됩니다. 둘 다 메시지를 읽거나 처리했음을 확인하지 않습니다. [홈 선택 계약](../PARITY.md#source-codex-home-selection--17--020)을 참고하세요. 공개 **0.1.0**은 여전히 실행 중인 홈을 명시해야 하며 비활성 세션 허용 옵션이 없습니다. -SSH를 사용할 때는 발신·수신 양쪽에 같은 TypeScript 클라이언트 버전(0.5.2)을 설치해야 +SSH를 사용할 때는 발신·수신 양쪽에 같은 TypeScript 클라이언트 버전(0.6.0)을 설치해야 합니다. @@ -148,20 +148,20 @@ Claude Code에는 받은 메시지함에 접근할 수 있는 실행 중 TUI(터 Node 22.x의 22.13 이상 또는 24.x를 사용하세요. Python CLI도 같은 `session-peer` 명령 이름을 쓰므로, 설치 전에 PATH(명령 검색 경로)에서 어떤 실행 파일을 찾는지 -확인하세요. 아래 명령은 선택한 0.5.2 버전을 npm으로 설치하는 예입니다. +확인하세요. 아래 명령은 선택한 0.6.0 버전을 npm으로 설치하는 예입니다. CLI 자체는 패키지를 설치하거나 업데이트하지 않습니다. -**0.5.2 설치:** npm 설치·업데이트 예시를 실행하기 전에 레지스트리 제공 여부와 선택한 릴리스를 확인하세요. 해당 버전이 제공되지 않으면 [검토한 소스 빌드](#소스에서-빌드)를 사용하세요. +**0.6.0 설치:** npm 설치·업데이트 예시를 실행하기 전에 레지스트리 제공 여부와 선택한 릴리스를 확인하세요. 해당 버전이 제공되지 않으면 [검토한 소스 빌드](#소스에서-빌드)를 사용하세요. ```sh -npm view session-peer@0.5.2 version dist.integrity -npm install --global --ignore-scripts session-peer@0.5.2 -session-peer --version # session-peer 0.5.2 (typescript) +npm view session-peer@0.6.0 version dist.integrity +npm install --global --ignore-scripts session-peer@0.6.0 +session-peer --version # session-peer 0.6.0 (typescript) session-peer list --agent claude --json session-peer send --to CLAUDE_PID --message 'Please review the API contract.' --dry-run --json ``` -예상 결과는 `session-peer 0.5.2 (typescript)`입니다. +예상 결과는 `session-peer 0.6.0 (typescript)`입니다. 1. 세션을 조회하고 `CLAUDE_PID`를 대상으로 선택한 정확한 PID로 바꾸세요. 2. `--dry-run`으로 검증합니다. 이 단계에서는 메시지를 제출하지 않습니다. @@ -189,11 +189,11 @@ npm run build node dist/cli.js --version npm pack --ignore-scripts # Optional global install: first check which session-peer your PATH selects. -npm install --global --ignore-scripts ./session-peer-0.5.2.tgz +npm install --global --ignore-scripts ./session-peer-0.6.0.tgz session-peer --version ``` -0.5.2에서 예상되는 결과는 `session-peer 0.5.2 (typescript)`입니다. 로컬 빌드 +0.6.0에서 예상되는 결과는 `session-peer 0.6.0 (typescript)`입니다. 로컬 빌드 산출물을 선택하려면 `./...tgz` 경로를 그대로 두세요. 이후 버전에 이 명령을 사용할 때는 먼저 체크아웃한 소스의 버전을 확인하세요. @@ -214,7 +214,7 @@ Python CLI를 덮어쓰지 않으려면 격리 prefix에 설치·제거할 수 npm ci --ignore-scripts npm run build npm pack --ignore-scripts -npm install --prefix "$env:TEMP\session-peer-ts-source" --ignore-scripts .\session-peer-0.5.2.tgz +npm install --prefix "$env:TEMP\session-peer-ts-source" --ignore-scripts .\session-peer-0.6.0.tgz & "$env:TEMP\session-peer-ts-source\node_modules\.bin\session-peer.cmd" --version # Later: npm uninstall --prefix "$env:TEMP\session-peer-ts-source" session-peer ``` @@ -457,7 +457,7 @@ Windows x64/Node 24에서 실제 TUI로 확인한 ACK는 별도의 일회성 증 ## npm 릴리스 -아래 레지스트리 조회로 0.5.2의 현재 제공 여부와 npm 태그를 확인하세요. 날짜가 기록된 준비 근거는 이후 발행 결과를 입증하지 않습니다. +아래 레지스트리 조회로 0.6.0의 현재 제공 여부와 npm 태그를 확인하세요. 날짜가 기록된 준비 근거는 이후 발행 결과를 입증하지 않습니다. 0.3.2는 2026-10-07 KST에, 0.3.1은 2026-10-04 KST에 공개·검증했습니다. [0.3.2 공개 기록](../VALIDATION.md#public-032--2026-10-07-kst)과 @@ -468,7 +468,7 @@ Windows x64/Node 24에서 실제 TUI로 확인한 ACK는 별도의 일회성 증 설치 전에는 정확한 버전과 현재 npm 태그를 확인하세요. ```sh -npm view session-peer@0.5.2 version dist.integrity +npm view session-peer@0.6.0 version dist.integrity npm view session-peer dist-tags ``` @@ -523,7 +523,7 @@ session-peer update --check --channel preview --output-format text `updateCommand`는 실행 중인 CLI 경로로 설치 주체를 확인할 수 있을 때 사용자에게 안내합니다. 해당하는 경우는 자체 `session-peer` 실행기가 이 패키지를 가리키는 npm 전역 prefix(기본, Homebrew, nvm, -nvm-windows, fnm. 예: `npm install --global --ignore-scripts session-peer@0.5.2`), +nvm-windows, fnm. 예: `npm install --global --ignore-scripts session-peer@0.6.0`), 매니페스트에 `session-peer`가 선언된 pnpm·Yarn·Bun 전역 저장소, Volta, npx 캐시입니다. 프로젝트 설치(`npm_project`, `pnpm_project`), 소스 체크아웃(`source`), 그 밖의 경우(`unknown`)에는 `updateCommand: null`과 `guidance` 문장만 돌려주므로, 관련 없는 현재 @@ -619,4 +619,4 @@ native 전송 관찰은 macOS arm64의 정확한 Codex 0.160.1/0.162.1과 이미 [Observation](api.md#unreleased-local-delivery-observation--72) -Linux·Windows의 native 주입 관측은 미지원입니다. 0.5.2 소스는 선택할 수 없는 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)에서 후속 작업으로 관리합니다. +Linux·Windows의 native 주입 관측은 미지원입니다. 0.6.0 소스는 선택할 수 없는 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)에서 후속 작업으로 관리합니다. diff --git a/docs/guide.md b/docs/guide.md index 3827e72..2d6af9f 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -10,11 +10,11 @@ See the [dated feature comparison and porting decisions](../PARITY.md#current-sc [English](guide.md) | [한국어](guide.ko.md) | [日本語](guide.ja.md) | [简体中文](guide.zh-CN.md) -**Documentation and source version: 0.5.2.** 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 0.5.2 refuse each other’s SSH preflight. +**Documentation and source version: 0.6.0.** 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 0.6.0 refuse each other’s SSH preflight. -**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 0.5.2 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. +**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 0.6.0 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. -This guide covers version 0.5.2 ([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. +This guide covers version 0.6.0 ([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. @@ -87,7 +87,7 @@ Side Session remains experimental. Relay/MCP are outside this comparison. **Find and message running Claude Code and Codex sessions, locally or over SSH.** This TypeScript client runs on Node.js without Python. -This guide describes **version 0.5.2**. The npm package and CLI command are both **`session-peer`**. This +This guide describes **version 0.6.0**. The npm package and CLI command are both **`session-peer`**. This project does not provide a Relay server or hosted service. ## Quick start @@ -95,12 +95,12 @@ project does not provide a Relay server or hosted service. Use Node **22.13+ within 22.x or 24.x**. Check which `session-peer` your PATH selects if the Python CLI is already installed; both packages use that command. -**Version 0.5.2 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). +**Version 0.6.0 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). ```sh -npm view session-peer@0.5.2 version dist.integrity -npm install --global --ignore-scripts session-peer@0.5.2 -session-peer --version # session-peer 0.5.2 (typescript) +npm view session-peer@0.6.0 version dist.integrity +npm install --global --ignore-scripts session-peer@0.6.0 +session-peer --version # session-peer 0.6.0 (typescript) session-peer list --agent claude --json session-peer send --to CLAUDE_PID --message 'Please review the API contract.' --dry-run --json ``` @@ -167,7 +167,7 @@ submits nothing. JSON adds sanitized `codexHomeResolution` and, when supplied by native queue output, `queueId`; neither confirms consumption. See the [selection contract](../PARITY.md#source-codex-home-selection--17--020). Published **0.1.0** still requires an explicit live home and has no inactive opt-in. -On SSH, both ends need the same TypeScript client version (0.5.2). +On SSH, both ends need the same TypeScript client version (0.6.0). ### Claude profiles on macOS @@ -197,11 +197,11 @@ npm run build node dist/cli.js --version npm pack --ignore-scripts # Optional global install: first check which session-peer your PATH selects. -npm install --global --ignore-scripts ./session-peer-0.5.2.tgz +npm install --global --ignore-scripts ./session-peer-0.6.0.tgz session-peer --version ``` -Expected for version 0.5.2: `session-peer 0.5.2 (typescript)`. Keep the `./...tgz` path to +Expected for version 0.6.0: `session-peer 0.6.0 (typescript)`. Keep the `./...tgz` path to select the locally built artifact. Check the checkout version before using these commands for a later release. @@ -221,7 +221,7 @@ On Windows PowerShell, inspect competing commands with npm ci --ignore-scripts npm run build npm pack --ignore-scripts -npm install --prefix "$env:TEMP\session-peer-ts-source" --ignore-scripts .\session-peer-0.5.2.tgz +npm install --prefix "$env:TEMP\session-peer-ts-source" --ignore-scripts .\session-peer-0.6.0.tgz & "$env:TEMP\session-peer-ts-source\node_modules\.bin\session-peer.cmd" --version # Later: npm uninstall --prefix "$env:TEMP\session-peer-ts-source" session-peer ``` @@ -486,7 +486,7 @@ separate approval. Licensed under [MIT](../LICENSE). ## Package release -Public **0.3.2** was verified on 2026-10-07 KST ([immutable checkpoint](../VALIDATION.md#public-032--2026-10-07-kst)). Check current 0.5.2 availability and npm tags with the registry queries below; dated preparation evidence does not establish a later publication outcome. +Public **0.3.2** was verified on 2026-10-07 KST ([immutable checkpoint](../VALIDATION.md#public-032--2026-10-07-kst)). Check current 0.6.0 availability and npm tags with the registry queries below; dated preparation evidence does not establish a later publication outcome. Version 0.2.1 was published and verified on 2026-09-29 KST and 0.2.0 on 2026-09-28 KST; see the [0.2.1 public release record](../VALIDATION.md#public-021--2026-09-29-kst). @@ -494,7 +494,7 @@ Later releases add their own dated records. Check the exact version and current tags before installing: ```sh -npm view session-peer@0.5.2 version dist.integrity +npm view session-peer@0.6.0 version dist.integrity npm view session-peer dist-tags ``` @@ -546,7 +546,7 @@ and reports `current`, `latest`, `channel` (`latest` by default, or `preview`), installation's owner is positively identified from the running CLI's path: an npm global prefix whose own `session-peer` launcher points at this package (default, Homebrew, nvm, nvm-windows and fnm prefixes), for example -`npm install --global --ignore-scripts session-peer@0.5.2`; a pnpm, Yarn or Bun +`npm install --global --ignore-scripts session-peer@0.6.0`; a pnpm, Yarn or Bun global store whose manifest declares `session-peer`; Volta; or the npx cache. Project installs (`npm_project`, `pnpm_project`), source checkouts (`source`) and anything else (`unknown`) get `updateCommand: null` and a `guidance` @@ -647,4 +647,4 @@ Native observation supports exact Codex 0.160.1/0.162.1 on macOS arm64 with an a [Observation](api.md#unreleased-local-delivery-observation--72) -Native delivery observation is unsupported on Linux and Windows. The 0.5.2 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). +Native delivery observation is unsupported on Linux and Windows. The 0.6.0 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). diff --git a/docs/guide.zh-CN.md b/docs/guide.zh-CN.md index bb99c92..f3d1806 100644 --- a/docs/guide.zh-CN.md +++ b/docs/guide.zh-CN.md @@ -10,11 +10,11 @@ [English](guide.md) | [한국어](guide.ko.md) | [日本語](guide.ja.md) | [简体中文](guide.zh-CN.md) -**文档和源码版本:0.5.2。** 安装前,请审核所选发布版本和注册表元数据。按日期记录的准备与验证结果见 [VALIDATION.md](../VALIDATION.md),发布流程见 [RELEASING.md](../RELEASING.md)。SSH 两端必须使用相同的 TypeScript 客户端版本;0.3.6 与 0.5.2 会拒绝彼此的 SSH 预检。 +**文档和源码版本:0.6.0。** 安装前,请审核所选发布版本和注册表元数据。按日期记录的准备与验证结果见 [VALIDATION.md](../VALIDATION.md),发布流程见 [RELEASING.md](../RELEASING.md)。SSH 两端必须使用相同的 TypeScript 客户端版本;0.3.6 与 0.6.0 会拒绝彼此的 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.2 源码记录了不可选用的 Linux arm64 Codex 0.162.1 候选(固定的上游 `aarch64-unknown-linux-musl` 可执行文件、schema 及一次注入测试),但不启用 Linux 观测;Linux native 观测仍不支持。 +**0.4.0 handoff 范围:** 可选 native 观测仅限 macOS arm64 上精确 Codex 0.160.1/0.162.1 版本;明确的 `operator_confirmed` 收件确认使用受支持的 POSIX ledger。Linux、Windows 的 native 观测和自动 ACK 收集仍不支持。 0.6.0 源码记录了不可选用的 Linux arm64 Codex 0.162.1 候选(固定的上游 `aarch64-unknown-linux-musl` 可执行文件、schema 及一次注入测试),但不启用 Linux 观测;Linux native 观测仍不支持。 -这是 0.5.2 详细指南([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.6.0 详细指南([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 的说明用于旧版本对比。 @@ -50,7 +50,7 @@ 向正在运行的 **Claude Code 和 Codex 会话**发送消息,支持本机和跨机器 SSH。这是运行于 Node.js 的 TypeScript 客户端,不需要 Python。 -**这是 0.5.2 使用指南。**包名为 `session-peer`,CLI 命令为 **`session-peer`**。本项目不提供 Relay 服务器或托管服务。 +**这是 0.6.0 使用指南。**包名为 `session-peer`,CLI 命令为 **`session-peer`**。本项目不提供 Relay 服务器或托管服务。 ## 功能与边界 @@ -67,7 +67,7 @@ 构建后,`node dist/cli.js list --json` 合并列出 Claude/Codex,`list --agent codex --json` 查找已知 home。公开 npm **0.1.0** 仍要求显式 agent,以及 Codex 列表所需的 home;0.2.0 也支持显式命令格式。 -范围仅限默认 `~/.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 客户端版本(0.5.2)。 +范围仅限默认 `~/.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 客户端版本(0.6.0)。 ### 0.2.0 CLI 易用性 @@ -95,15 +95,15 @@ macOS、Linux 或 Windows native;Node **22.x 中的 22.13 及以上,或 24.x 使用 Node 22.x 的 22.13 及以上或 24.x,并确认要安装的确切版本是否已发布。Python CLI 也使用同一个命令名;若已有安装,请先检查 PATH。 -**安装 0.5.2:** 运行 npm 安装或更新示例前,请确认注册表可用性并审核所选发布版本。如该版本不可用,请使用[已审核的源码构建](#从源码构建)。 +**安装 0.6.0:** 运行 npm 安装或更新示例前,请确认注册表可用性并审核所选发布版本。如该版本不可用,请使用[已审核的源码构建](#从源码构建)。 ```sh -npm view session-peer@0.5.2 version dist.integrity -npm install --global --ignore-scripts session-peer@0.5.2 +npm view session-peer@0.6.0 version dist.integrity +npm install --global --ignore-scripts session-peer@0.6.0 session-peer --version ``` -预期输出:`session-peer 0.5.2 (typescript)`。 +预期输出:`session-peer 0.6.0 (typescript)`。 1. 用 `session-peer list --agent claude --json` 查找目标并选择准确 PID。 2. 用 `session-peer send --to CLAUDE_PID --message 'Please reply after checking.' --dry-run --json` 验证,不提交。 @@ -122,7 +122,7 @@ npm run build node dist/cli.js --version npm pack --ignore-scripts # 可选:全局安装前先检查 PATH 选择的现有命令 -npm install --global --ignore-scripts ./session-peer-0.5.2.tgz +npm install --global --ignore-scripts ./session-peer-0.6.0.tgz session-peer --version ``` @@ -132,7 +132,7 @@ session-peer --version 安装前后用 `type -a session-peer` 和 `command -v session-peer` 检查实际执行项。其他实现也可能提供同名命令;请选择 PATH 上的一种,或显式运行 `node /absolute/path/dist/cli.js`。不要用 `--force` 覆盖其他管理器的文件。本包不会自动安装、删除或调整 Python 包、技能或服务。卸载使用 `npm uninstall --global session-peer`,随后再次检查 PATH。 -在 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-0.5.2.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` 卸载。 +在 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-0.6.0.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` 卸载。 ## 使用 @@ -223,14 +223,14 @@ Python 只用作开发时的比较基准(v1.1.0,`8ca77cf31254c8287a2593fe484 ## npm 版本 -公开 **0.3.2** 于 2026-10-07 KST 完成验证([不可变公开记录](../VALIDATION.md#public-032--2026-10-07-kst))。请用下方注册表查询确认 0.5.2 的当前可用性和 npm 标签;带日期的准备证据不能证明后续发布结果。 +公开 **0.3.2** 于 2026-10-07 KST 完成验证([不可变公开记录](../VALIDATION.md#public-032--2026-10-07-kst))。请用下方注册表查询确认 0.6.0 的当前可用性和 npm 标签;带日期的准备证据不能证明后续发布结果。 0.2.1 已于 2026-09-29 KST、0.2.0 已于 2026-09-28 KST 公开并验证。请参阅 [0.2.1 公开记录](../VALIDATION.md#public-021--2026-09-29-kst)(之后的版本各自追加带日期的记录), 安装前核对准确版本与当前标签。 ```sh -npm view session-peer@0.5.2 version dist.integrity +npm view session-peer@0.6.0 version dist.integrity npm view session-peer dist-tags ``` @@ -269,7 +269,7 @@ dist-tag(3 秒超时,不重试),并报告 `current`、`latest`、`channe `ahead`)、`managedBy`、`updateCommand` 和 `guidance`。只有根据当前 CLI 路径能确切识别 安装的管理方时才给出命令:其自身 `session-peer` 启动器指向本包的 npm 全局 prefix (默认、Homebrew、nvm、nvm-windows、fnm,例如 -`npm install --global --ignore-scripts session-peer@0.5.2`),清单中声明了 +`npm install --global --ignore-scripts session-peer@0.6.0`),清单中声明了 `session-peer` 的 pnpm、Yarn、Bun 全局存储,Volta,或 npx 缓存。项目安装 (`npm_project`、`pnpm_project`)、源码检出(`source`)以及其他情况(`unknown`)只返回 `updateCommand: null` 和一句 `guidance`,因此不会给出可能修改无关当前目录的命令。它只报告 @@ -351,4 +351,4 @@ native 观测仅支持 macOS arm64 上的精确 Codex 0.160.1/0.162.1 和已有 [Observation](api.md#unreleased-local-delivery-observation--72) -Linux、Windows 的 native 注入观察尚不支持。 0.5.2 源码记录了不可选用的 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) 的后续工作。 +Linux、Windows 的 native 注入观察尚不支持。 0.6.0 源码记录了不可选用的 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) 的后续工作。 diff --git a/docs/shorthand.md b/docs/shorthand.md index deca071..2e4b2c3 100644 --- a/docs/shorthand.md +++ b/docs/shorthand.md @@ -1,6 +1,6 @@ # Optional `sp` shorthand -This reference describes source/package version 0.5.2. 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. +This reference describes source/package version 0.6.0. 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. [README](../README.md) · [User guide](guide.md) diff --git a/package-lock.json b/package-lock.json index f03d59d..28e3506 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "session-peer", - "version": "0.5.2", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "session-peer", - "version": "0.5.2", + "version": "0.6.0", "license": "MIT", "os": [ "darwin", diff --git a/package.json b/package.json index 5b5048b..8200ce6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "session-peer", - "version": "0.5.2", + "version": "0.6.0", "private": false, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/scripts/release.mjs b/scripts/release.mjs index f117f15..d562488 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -8,9 +8,9 @@ import { fileURLToPath } from 'node:url'; export const repository = 'abruption/session-peer-ts'; export const registry = 'https://registry.npmjs.org'; -const stableVersion = '0.5.2'; -const previousStable = '0.5.1'; -const previousStableIntegrity = 'sha512-m+bXuJ0UpPYZQIwh9g9b11S2EqjquE1J0D2WYM3yOIGKk7s68AG05O1ARgKH5nK3QKidfHXgANmHAJQWJzkTrw=='; +const stableVersion = '0.6.0'; +const previousStable = '0.5.2'; +const previousStableIntegrity = 'sha512-4cgqyU7AaSVUnTZvsFeP5Ocyri9XNqUQCxV+WDZOhHK0aJ8c/CkeMiv7isVtZns3RYu58TyU0sAoz4oBbQLhAA=='; const verifiedPreview = '0.1.0-preview.1'; const verifiedPreviewIntegrity = 'sha512-h4SMvrQ/LWA9osd4EHIs9rSTqv1u+S3MXQAmn+yG/gZ9+7NwYMutq+Oa5K/0ggIq61Wfwdh11Cv+5YdEffiNMA=='; 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', @@ -30,7 +30,7 @@ export function validatePackage(pkg) { assert.deepEqual(pkg.exports, { '.': { types: './dist/index.d.ts', import: './dist/index.js' } }); assert.equal(pkg.private, false); assert.ok(pkg.version === stableVersion || /^\d+\.\d+\.\d+-preview\.\d+$/.test(pkg.version), - 'only reviewed preview versions and 0.5.2 stable are supported'); + 'only reviewed preview versions and 0.6.0 stable are supported'); assert.deepEqual(pkg.bin, { 'session-peer': 'dist/cli.js' }); assert.equal(pkg.repository.url, `git+https://github.com/${repository}.git`); assert.deepEqual(pkg.publishConfig, { registry: `${registry}/`, access: 'public', diff --git a/src/protocol.ts b/src/protocol.ts index ea9cc14..e2704dd 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -1,7 +1,7 @@ import { isIPv6 } from 'node:net'; import { Refusal } from './discovery.js'; import { uuid } from './writer.js'; -export const VERSION = '0.5.2'; +export const VERSION = '0.6.0'; export const VERSION_LINE = `session-peer ${VERSION} (typescript)`; // Return the OpenSSH destination: [user@]name, or an IPv6 literal with any // brackets removed (`ssh` does not strip `[...]` outside ssh:// URIs). Zone IDs diff --git a/src/skill-metadata.ts b/src/skill-metadata.ts index 5803675..9515284 100644 --- a/src/skill-metadata.ts +++ b/src/skill-metadata.ts @@ -11,8 +11,8 @@ const verdict = (compatible: boolean): SkillCheck => ({ status: compatible ? 'co code: compatible ? 'skill_contract_compatible' : 'skill_contract_mismatch', verification: 'metadata_only' }); export const SKILL_METADATA_LIMIT = 65536; const keys = ['version', 'runtime-implementation', 'runtime-min-version', 'runtime-full-version', 'runtime-capability-policy'] as const; -// Preserve each published runtime/profile result. The 0.5.2 release row -// carries forward only the four exact profiles accepted by public 0.5.1. +// Preserve each published runtime/profile result. The 0.6.0 preparation row +// carries forward only the four exact profiles accepted by public 0.5.2. // Metadata compatibility does not authenticate content or publish/install a skill. // Every later runtime/profile requires separate review; no version ranges. const matrix: Record = { @@ -21,7 +21,8 @@ const matrix: Record = { '0.4.0': ['0.1.0', '0.2.0', '0.2.1', '0.3.0'], '0.5.0': ['0.1.0', '0.2.0', '0.2.1', '0.3.0'], '0.5.1': ['0.1.0', '0.2.0', '0.2.1', '0.3.0'], - '0.5.2': ['0.1.0', '0.2.0', '0.2.1', '0.3.0'] + '0.5.2': ['0.1.0', '0.2.0', '0.2.1', '0.3.0'], + '0.6.0': ['0.1.0', '0.2.0', '0.2.1', '0.3.0'] }; const profiles: Record = { '0.1.0': ['0.1.0', 'typescript', '0.1.0', '0.1.0', 'probe-help'], diff --git a/test/release.test.ts b/test/release.test.ts index bf67d9d..62aeb9e 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -23,7 +23,7 @@ test('release dispatch binds stable and preview modes to exact versions, tags an { RELEASE_CONFIRMATION: '' }, { GITHUB_SHA: 'main; echo unsafe' }]) { assert.throws(() => validateDispatch(pkg, { ...env, ...patch })); } - for (const patch of [{ private: true }, { version: '1.0.0' }, { version: '0.5.1' }, { publishConfig: { ...pkg.publishConfig, tag: 'preview' } }, + for (const patch of [{ private: true }, { version: '1.0.0' }, { version: '0.5.2' }, { publishConfig: { ...pkg.publishConfig, tag: 'preview' } }, { repository: { url: 'https://example.com' } }, { scripts: { ...pkg.scripts, prepublishOnly: 'echo unsafe' } }]) { assert.throws(() => validatePackage({ ...pkg, ...patch })); } @@ -49,13 +49,13 @@ test('release gate rejects moved main, absent/failed/pending CI and missing envi assert.throws(() => validateGitHubGate(sha, main, runs, { name: 'npm', protection_rules: [{ type: 'required_reviewers', reviewers: [] }] })); }); -test('0.5.2 stable staging requires verified 0.5.1 and unchanged preview/tag baselines', () => { +test('0.6.0 stable staging requires verified 0.5.2 and unchanged preview/tag baselines', () => { const previewDist = { integrity: 'sha512-h4SMvrQ/LWA9osd4EHIs9rSTqv1u+S3MXQAmn+yG/gZ9+7NwYMutq+Oa5K/0ggIq61Wfwdh11Cv+5YdEffiNMA==', attestations: { url: 'https://registry.npmjs.org/-/npm/v1/attestations/session-peer@0.1.0-preview.1' } }; - const stableDist = { integrity: 'sha512-m+bXuJ0UpPYZQIwh9g9b11S2EqjquE1J0D2WYM3yOIGKk7s68AG05O1ARgKH5nK3QKidfHXgANmHAJQWJzkTrw==', - attestations: { url: 'https://registry.npmjs.org/-/npm/v1/attestations/session-peer@0.5.1' } }; - const current = { name: pkg.name, versions: { '0.5.1': { dist: stableDist }, '0.1.0-preview.1': { dist: previewDist } }, - 'dist-tags': { latest: '0.5.1', preview: '0.1.0-preview.1' } }; + const stableDist = { integrity: 'sha512-4cgqyU7AaSVUnTZvsFeP5Ocyri9XNqUQCxV+WDZOhHK0aJ8c/CkeMiv7isVtZns3RYu58TyU0sAoz4oBbQLhAA==', + attestations: { url: 'https://registry.npmjs.org/-/npm/v1/attestations/session-peer@0.5.2' } }; + const current = { name: pkg.name, versions: { '0.5.2': { dist: stableDist }, '0.1.0-preview.1': { dist: previewDist } }, + 'dist-tags': { latest: '0.5.2', preview: '0.1.0-preview.1' } }; validateRegistryState(pkg, 'stable-stage', current); assert.throws(() => validateRegistryState(pkg, 'stable-stage', null)); assert.throws(() => validateRegistryState(pkg, 'trusted-stage', current)); @@ -71,10 +71,10 @@ test('0.5.2 stable staging requires verified 0.5.1 and unchanged preview/tag bas versions: { ...current.versions, '0.1.0-preview.1': { dist: { ...previewDist, attestations: undefined } } } })); const preview = { ...pkg, version: '0.1.0-preview.2', publishConfig: { ...pkg.publishConfig, tag: 'preview' } }; assert.throws(() => validateRegistryState(preview, 'trusted-stage', { ...current, 'dist-tags': { latest: '0.1.0-preview.0' } }), /latest_points_to_prerelease/); - assert.throws(() => validateRegistryState(pkg, 'stable-stage', { ...current, versions: { ...current.versions, '0.5.1': { dist: { ...stableDist, integrity: 'tampered' } } } }), /verified_stable_changed/); - assert.throws(() => validateRegistryState(pkg, 'stable-stage', { ...current, versions: { ...current.versions, '0.5.1': { dist: { ...stableDist, attestations: undefined } } } })); + assert.throws(() => validateRegistryState(pkg, 'stable-stage', { ...current, versions: { ...current.versions, '0.5.2': { dist: { ...stableDist, integrity: 'tampered' } } } }), /verified_stable_changed/); + assert.throws(() => validateRegistryState(pkg, 'stable-stage', { ...current, versions: { ...current.versions, '0.5.2': { dist: { ...stableDist, attestations: undefined } } } })); validateRegistryState(preview, 'trusted-stage', { ...current, - 'dist-tags': { latest: '0.5.1', preview: '0.1.0-preview.1' } }); + 'dist-tags': { latest: '0.5.2', preview: '0.1.0-preview.1' } }); }); test('release artifact binds version, allowlist, commit, SHA-256 and registry integrity', () => { @@ -83,7 +83,7 @@ test('release artifact binds version, allowlist, commit, SHA-256 and registry in files: packageFiles, sha256: createHash('sha256').update(bytes).digest('hex'), integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}` }; validateArtifact(pkg, manifest, bytes, sha); - for (const patch of [{ filename: '../escape.tgz' }, { version: '1.0.0' }, { version: '0.5.1' }, { commit: 'b'.repeat(40) }, + for (const patch of [{ filename: '../escape.tgz' }, { version: '1.0.0' }, { version: '0.5.2' }, { commit: 'b'.repeat(40) }, { files: [...packageFiles, '.npmrc'] }, { sha256: '0'.repeat(64) }, { integrity: 'sha512-invalid' }]) { assert.throws(() => validateArtifact(pkg, { ...manifest, ...patch }, bytes, sha)); } diff --git a/test/skill-guidance.test.ts b/test/skill-guidance.test.ts index 6882103..2cb1d79 100644 --- a/test/skill-guidance.test.ts +++ b/test/skill-guidance.test.ts @@ -43,9 +43,10 @@ test('published skill 0.2.1 complete bytes match the recorded release and finite assert.deepEqual(validateSkillMetadata(text, '0.5.0'), expected); assert.deepEqual(validateSkillMetadata(text, '0.5.1'), expected); assert.deepEqual(validateSkillMetadata(text, '0.5.2'), expected); + assert.deepEqual(validateSkillMetadata(text, '0.6.0'), expected); const path = fileURLToPath(url); assert.deepEqual(inspectSkill(path), { ...expected, path }); - for (const runtime of ['0.3.2', '0.3.3', '0.3.4', '0.3.7', '0.4.1', '0.5.3', '0.6.0']) { + for (const runtime of ['0.3.2', '0.3.3', '0.3.4', '0.3.7', '0.4.1', '0.5.3', '0.6.1']) { assert.deepEqual(validateSkillMetadata(text, runtime), { status: 'incompatible', code: 'skill_contract_mismatch', verification: 'metadata_only' }); diff --git a/test/skill-metadata.test.ts b/test/skill-metadata.test.ts index 96c963a..893f418 100644 --- a/test/skill-metadata.test.ts +++ b/test/skill-metadata.test.ts @@ -29,7 +29,7 @@ test('finite runtime/profile matrix preserves published 0.3.2 and 0.3.3 profiles const legacy = fixtureText(), successor = fixtureText({ ...fields, version: '0.2.0', 'runtime-full-version': '0.3.2' }); assert.deepEqual(validateSkillMetadata(legacy, '0.3.2'), compatible); assert.deepEqual(validateSkillMetadata(successor, '0.3.2'), incompatible); - assert.equal(VERSION, '0.5.2'); + assert.equal(VERSION, '0.6.0'); for (const text of [legacy, successor]) { assert.deepEqual(validateSkillMetadata(text, '0.3.3'), compatible); assert.deepEqual(validateSkillMetadata(text), compatible); @@ -43,7 +43,7 @@ test('finite runtime/profile matrix preserves published 0.3.2 and 0.3.3 profiles test('0.3.4 patch row permits only the two exact published profiles', () => { const successor = { ...fields, version: '0.2.0', 'runtime-full-version': '0.3.2' }; - assert.equal(VERSION, '0.5.2'); + assert.equal(VERSION, '0.6.0'); for (const values of [fields, successor]) { assert.deepEqual(validateSkillMetadata(fixtureText(values), '0.3.4'), compatible); for (const key of Object.keys(values)) { @@ -63,7 +63,7 @@ test('0.3.6 release candidate carries forward only the finite 0.3.5 profiles', t const { file } = fixture(t); const successor = { ...fields, version: '0.2.1', 'runtime-full-version': '0.3.2' }; const text = fixtureText(successor); - assert.equal(VERSION, '0.5.2'); + assert.equal(VERSION, '0.6.0'); for (const values of [fields, { ...fields, version: '0.2.0', 'runtime-full-version': '0.3.2' }, successor]) { const profile = fixtureText(values); assert.deepEqual(validateSkillMetadata(profile, '0.3.5'), compatible); @@ -80,12 +80,12 @@ test('0.3.6 release candidate carries forward only the finite 0.3.5 profiles', t assert.deepEqual(validateSkillMetadata(text), compatible); assert.deepEqual(validateSkillMetadata(text, undefined), compatible); writeFileSync(file, text); - // The candidate doctor uses compiled 0.5.2; explicit historical rows remain unchanged. + // The candidate doctor uses compiled 0.6.0; explicit historical rows remain unchanged. assert.deepEqual(inspectSkill(file), { path: file, ...compatible }); for (const runtime of [null, {}, 0, '', '0.3.5-preview.1', '00.3.5', '0.3.6-preview.1', '00.3.6']) { assert.deepEqual(validateSkillMetadata(text, runtime), missing); } - for (const runtime of ['0.3.5', '0.3.6', '0.4.0', '0.5.0', '0.5.1', '0.5.2']) { + for (const runtime of ['0.3.5', '0.3.6', '0.4.0', '0.5.0', '0.5.1', '0.5.2', '0.6.0']) { for (const key of Object.keys(successor)) { assert.deepEqual(validateSkillMetadata(fixtureText({ ...successor, [key]: 'unsupported' }), runtime), incompatible, key); const omitted = { ...successor } as Record; delete omitted[key]; @@ -385,7 +385,7 @@ test('complete companion 0.2.1 candidate bytes match the exact reviewed fixture assert.equal(createHash('sha256').update(bytes).digest('hex'), '96d5a0d049f8a4d193703af3583b198a91079d44ae373de34e94a54e2296a4d6'); const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); - assert.equal(VERSION, '0.5.2'); + assert.equal(VERSION, '0.6.0'); assert.deepEqual(validateSkillMetadata(text), compatible); assert.deepEqual(validateSkillMetadata(text, undefined), compatible); assert.deepEqual(validateSkillMetadata(text, '0.3.5'), compatible); @@ -401,18 +401,19 @@ test('complete companion 0.2.1 candidate bytes match the exact reviewed fixture test('0.5.2 finite row preserves the four exact 0.4.0, 0.5.0 and 0.5.1 profiles', t => { const {file}=fixture(t); - assert.equal(VERSION,'0.5.2'); + assert.equal(VERSION, '0.6.0'); for(const values of [fields,{...fields,version:'0.2.0','runtime-full-version':'0.3.2'}, {...fields,version:'0.2.1','runtime-full-version':'0.3.2'},{...fields,version:'0.3.0','runtime-full-version':'0.4.0'}]){ const text=fixtureText(values); assert.deepEqual(validateSkillMetadata(text,'0.4.0'),compatible); assert.deepEqual(validateSkillMetadata(text,'0.5.0'),compatible); assert.deepEqual(validateSkillMetadata(text,'0.5.1'),compatible); assert.deepEqual(validateSkillMetadata(text,'0.5.2'),compatible); + assert.deepEqual(validateSkillMetadata(text,'0.6.0'),compatible); assert.deepEqual(validateSkillMetadata(text),compatible); assert.deepEqual(validateSkillMetadata(text,undefined),compatible); writeFileSync(file,text);assert.deepEqual(inspectSkill(file),{path:file,...compatible}); - for(const runtime of ['0.3.7','0.4.1','0.5.3','0.6.0'])assert.deepEqual(validateSkillMetadata(text,runtime),incompatible); - for(const runtime of [null,{},0,'','0.4.0-preview.1','00.4.0','0.5.0-preview.1','00.5.0','0.5.1-preview.1','00.5.1','0.5.2-preview.1','00.5.2'])assert.deepEqual(validateSkillMetadata(text,runtime),missing); + for(const runtime of ['0.3.7','0.4.1','0.5.3','0.6.1'])assert.deepEqual(validateSkillMetadata(text,runtime),incompatible); + for(const runtime of [null,{},0,'','0.4.0-preview.1','00.4.0','0.5.0-preview.1','00.5.0','0.5.1-preview.1','00.5.1','0.5.2-preview.1','00.5.2','0.6.0-preview.1','00.6.0'])assert.deepEqual(validateSkillMetadata(text,runtime),missing); for(const key of Object.keys(values)){ assert.deepEqual(validateSkillMetadata(fixtureText({...values,[key]:'unsupported'})),incompatible,key); const omitted={...values} as Record;delete omitted[key];assert.deepEqual(validateSkillMetadata(fixtureText(omitted)),missing,key); @@ -435,19 +436,61 @@ test('0.5.2 finite row preserves the four exact 0.4.0, 0.5.0 and 0.5.1 profiles' // Companion PR37 b8ff909 is an immutable review checkpoint, not an install pin. -test('complete 0.3.0 companion candidate bytes match exact review and the 0.4.0, 0.5.0, 0.5.1 and 0.5.2 guards', t=>{ +test('complete 0.3.0 companion candidate bytes match exact review and the 0.4.0, 0.5.0, 0.5.1, 0.5.2 and 0.6.0 guards', t=>{ const bytes=readFileSync(new URL('./fixtures/ts-skill-0.3.0-candidate.md',import.meta.url)); assert.equal(bytes.length,20162);assert.ok(bytes.length<=SKILL_METADATA_LIMIT); assert.equal(createHash('sha256').update(bytes).digest('hex'),'1ae3690cb5d03777bf448cf06496fed27e4a9329deb03741d6d956a3b13c5ed4'); - const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes);assert.equal(VERSION,'0.5.2'); + const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes);assert.equal(VERSION, '0.6.0'); assert.deepEqual(validateSkillMetadata(text),compatible); assert.deepEqual(validateSkillMetadata(text,undefined),compatible); assert.deepEqual(validateSkillMetadata(text,'0.4.0'),compatible); assert.deepEqual(validateSkillMetadata(text,'0.5.0'),compatible); assert.deepEqual(validateSkillMetadata(text,'0.5.1'),compatible); assert.deepEqual(validateSkillMetadata(text,'0.5.2'),compatible); + assert.deepEqual(validateSkillMetadata(text,'0.6.0'),compatible); const {file}=fixture(t);writeFileSync(file,bytes);assert.deepEqual(inspectSkill(file),{path:file,...compatible}); - for(const runtime of ['0.3.2','0.3.3','0.3.4','0.3.5','0.3.6','0.3.7','0.4.1','0.5.3','0.6.0'])assert.deepEqual(validateSkillMetadata(text,runtime),incompatible); - for(const runtime of [null,{},0,'','0.4.0-preview.1','00.4.0','0.5.0-preview.1','00.5.0','0.5.1-preview.1','00.5.1','0.5.2-preview.1','00.5.2'])assert.deepEqual(validateSkillMetadata(text,runtime),missing); + for(const runtime of ['0.3.2','0.3.3','0.3.4','0.3.5','0.3.6','0.3.7','0.4.1','0.5.3','0.6.1'])assert.deepEqual(validateSkillMetadata(text,runtime),incompatible); + for(const runtime of [null,{},0,'','0.4.0-preview.1','00.4.0','0.5.0-preview.1','00.5.0','0.5.1-preview.1','00.5.1','0.5.2-preview.1','00.5.2','0.6.0-preview.1','00.6.0'])assert.deepEqual(validateSkillMetadata(text,runtime),missing); // Only bounded metadata is interpreted; candidate body instructions stay inert. }); + + +test('0.6.0 compiled finite row retains exact profiles and every historical runtime boundary', t => { + const { file } = fixture(t); + assert.equal(VERSION, '0.6.0'); + const known = [ + fields, + { ...fields, version: '0.2.0', 'runtime-full-version': '0.3.2' }, + { ...fields, version: '0.2.1', 'runtime-full-version': '0.3.2' }, + { ...fields, version: '0.3.0', 'runtime-full-version': '0.4.0' }, + ]; + const rows: [string, string[]][] = [ + ['0.3.2', ['0.1.0']], ['0.3.3', ['0.1.0', '0.2.0']], ['0.3.4', ['0.1.0', '0.2.0']], + ['0.3.5', ['0.1.0', '0.2.0', '0.2.1']], ['0.3.6', ['0.1.0', '0.2.0', '0.2.1']], + ...['0.4.0', '0.5.0', '0.5.1', '0.5.2', '0.6.0'].map(runtime => + [runtime, ['0.1.0', '0.2.0', '0.2.1', '0.3.0']] as [string, string[]]), + ]; + for (const profile of known) { + const text = fixtureText(profile); + for (const [runtime, accepted] of rows) { + assert.deepEqual(validateSkillMetadata(text, runtime), accepted.includes(profile.version) ? compatible : incompatible, + `${runtime}/${profile.version}`); + } + assert.deepEqual(validateSkillMetadata(text), compatible); + assert.deepEqual(validateSkillMetadata(text, undefined), compatible); + writeFileSync(file, text); assert.deepEqual(inspectSkill(file), { path: file, ...compatible }); + for (const runtime of ['0.3.7', '0.4.1', '0.5.3', '0.6.1', '0.7.0', '1.0.0']) { + assert.deepEqual(validateSkillMetadata(text, runtime), incompatible); + } + for (const runtime of [null, {}, 0, '', '0.6.0-preview.1', '00.6.0']) { + assert.deepEqual(validateSkillMetadata(text, runtime), missing); + } + for (const key of Object.keys(profile)) { + assert.deepEqual(validateSkillMetadata(fixtureText({ ...profile, [key]: 'unsupported' })), incompatible, key); + const omitted = { ...profile } as Record; delete omitted[key]; + assert.deepEqual(validateSkillMetadata(fixtureText(omitted)), missing, key); + } + } + assert.deepEqual(validateSkillMetadata(fixtureText({ ...fields, version: '0.3.1', 'runtime-full-version': '0.4.0' })), incompatible); + assert.deepEqual(validateSkillMetadata(fixtureText({ ...fields, version: '0.3.0', 'runtime-full-version': '0.6.0' })), incompatible); +}); diff --git a/test/updates.test.ts b/test/updates.test.ts index 043ee5b..f3b8539 100644 --- a/test/updates.test.ts +++ b/test/updates.test.ts @@ -16,7 +16,7 @@ import { acquireLock, cacheDirectory, checkUpdate, compareVersions, manager, ref const cli = resolve('dist/cli.js'); const SENTINEL = 'REGISTRY-BODY-SENTINEL'; type Reply = { status?: number; body?: string; wait?: number }; -async function fixture(t: TestContext, reply: Reply = { body: JSON.stringify({ latest: '0.5.3', preview: '0.6.0-preview.1' }) }) { +async function fixture(t: TestContext, reply: Reply = { body: JSON.stringify({ latest: '0.6.1', preview: '0.7.0-preview.1' }) }) { const root = mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-updates-')); mkdirSync(join(root, '.claude/sessions'), { recursive: true }); const requests: string[] = []; @@ -158,7 +158,7 @@ test('update --check reports npm dist-tags, writes a private atomic cache and le assert.deepEqual(f.requests, ['/-/package/session-peer/dist-tags']); const v = r.value; assert.equal(v.command, 'update'); assert.equal(v.ok, true); assert.equal(v.package, 'session-peer'); - assert.equal(v.current, '0.5.2'); assert.equal(v.latest, '0.5.3'); assert.equal(v.channel, 'latest'); assert.equal(v.distTag, 'latest'); + assert.equal(v.current, '0.6.0'); assert.equal(v.latest, '0.6.1'); assert.equal(v.channel, 'latest'); assert.equal(v.distTag, 'latest'); assert.equal(v.source, 'npm_registry'); assert.equal(v.status, 'update_available'); assert.equal(v.outdated, true); assert.equal(v.updated, false); assert.equal(v.referenceVersion, '1.1.0'); assert.equal(v.cache, 'written'); // The test runs from a checkout; installed-path guidance is covered above and in package-smoke. @@ -169,31 +169,31 @@ test('update --check reports npm dist-tags, writes a private atomic cache and le assert.deepEqual([readFileSync(skill), readFileSync(python)], before); const cached = JSON.parse(readFileSync(f.file, 'utf8')); assert.deepEqual(Object.keys(cached).sort(), ['channel', 'checkedAt', 'latest', 'package', 'schemaVersion', 'source']); - assert.equal(cached.latest, '0.5.3'); + assert.equal(cached.latest, '0.6.1'); assert.deepEqual(readdirSync(f.cache), ['npm-update.json']); if (process.platform !== 'win32') { assert.equal(statSync(f.cache).mode & 0o777, 0o700); assert.equal(statSync(f.file).mode & 0o777, 0o600); } const text = await f.call(['update', '--check', '--output-format', 'text']); - assert.equal(text.code, 0); assert.match(text.stdout, /0\.5\.2 \(typescript\): 0\.5\.3 available on npm dist-tag latest/); + assert.equal(text.code, 0); assert.match(text.stdout, /0\.6\.0 \(typescript\): 0\.6\.1 available on npm dist-tag latest/); assert.match(text.stdout, /source checkout/); assert.equal(text.stderr, ''); - f.state.reply = { body: JSON.stringify({ latest: '0.5.2' }) }; + f.state.reply = { body: JSON.stringify({ latest: '0.6.0' }) }; const current = await f.call(['update', '--check', '--json']); assert.equal(current.value.status, 'up_to_date'); assert.equal(current.value.outdated, false); assert.equal(current.value.updateCommand, null); }); test('preview channel accepts prereleases, compares separately and does not replace the stable cache', async t => { const f = await fixture(t); - f.seed('0.5.2'); + f.seed('0.6.0'); const before = readFileSync(f.file, 'utf8'); const ahead = await f.call(['update', '--check', '--channel', 'preview', '--json']); - assert.equal(ahead.code, 0); assert.equal(ahead.value.latest, '0.6.0-preview.1'); assert.equal(ahead.value.channel, 'preview'); + assert.equal(ahead.code, 0); assert.equal(ahead.value.latest, '0.7.0-preview.1'); assert.equal(ahead.value.channel, 'preview'); assert.equal(ahead.value.status, 'update_available'); assert.equal(ahead.value.outdated, true); assert.equal(ahead.value.cache, 'not_applicable'); - f.state.reply = { body: JSON.stringify({ latest: '0.5.2', preview: '0.1.0-preview.1' }) }; + f.state.reply = { body: JSON.stringify({ latest: '0.6.0', preview: '0.1.0-preview.1' }) }; const older = await f.call(['update', '--check', '--channel=preview', '--json']); assert.equal(older.value.status, 'ahead'); assert.equal(older.value.outdated, false); assert.equal(older.value.updateCommand, null); assert.equal(readFileSync(f.file, 'utf8'), before); - f.state.reply = { body: JSON.stringify({ latest: '0.5.2' }) }; + f.state.reply = { body: JSON.stringify({ latest: '0.6.0' }) }; const missing = await f.call(['update', '--check', '--channel', 'preview', '--json']); assert.equal(missing.code, 1); assert.equal(missing.value.error, 'dist_tag_missing'); }); @@ -206,7 +206,7 @@ test('invalid, failed and slow registry responses refuse without printing bodies [{ body: JSON.stringify({ latest: 9, note: SENTINEL }) }, 'registry_response_invalid'], [{ body: `[${JSON.stringify(SENTINEL)}]` }, 'registry_response_invalid'], [{ body: `${SENTINEL} not json` }, 'registry_response_invalid'], - [{ body: JSON.stringify({ latest: '0.5.3', padding: SENTINEL.repeat(4000) }) }, 'registry_response_invalid'], + [{ body: JSON.stringify({ latest: '0.6.1', padding: SENTINEL.repeat(4000) }) }, 'registry_response_invalid'], [{ status: 500, body: SENTINEL }, 'registry_http_error'], [{ status: 302, body: SENTINEL }, 'registry_unreachable'], ]; @@ -218,7 +218,7 @@ test('invalid, failed and slow registry responses refuse without printing bodies const text = await f.call(['update', '--check', '--output-format=text']); assert.match(text.stdout, new RegExp(`Error: ${error}`)); assert.equal(text.stdout.includes(SENTINEL), false); } - f.state.reply = { body: JSON.stringify({ latest: '0.5.3' }), wait: 8000 }; + f.state.reply = { body: JSON.stringify({ latest: '0.6.1' }), wait: 8000 }; const started = Date.now(); const slow = await f.call(['update', '--check', '--json']); assert.equal(slow.value.error, 'registry_timeout'); assert.ok(Date.now() - started < 7000, 'bounded timeout'); @@ -265,7 +265,7 @@ test('update without --check refuses with ownership guidance and no network; opt test('cached notices are off by default, opt-in additive, stderr-only in text and opt-out wins', async t => { const f = await fixture(t); - f.seed('0.5.3'); + f.seed('0.6.1'); const plain = await f.call(['list', '--json']); assert.equal(plain.code, 0); assert.equal('clientUpdate' in plain.value, false); assert.equal(plain.stderr, ''); const on = { SESSION_PEER_UPDATE_NOTICE: '1' }; @@ -274,14 +274,14 @@ test('cached notices are off by default, opt-in additive, stderr-only in text an const { clientUpdate, ...rest } = json.value; assert.deepEqual(rest, plain.value); assert.deepEqual(Object.keys(clientUpdate).sort(), ['channel', 'checkedAt', 'command', 'current', 'guidance', 'latest', 'managedBy', 'schemaVersion', 'source', 'status']); - assert.equal(clientUpdate.status, 'available'); assert.equal(clientUpdate.current, '0.5.2'); assert.equal(clientUpdate.latest, '0.5.3'); + assert.equal(clientUpdate.status, 'available'); assert.equal(clientUpdate.current, '0.6.0'); assert.equal(clientUpdate.latest, '0.6.1'); assert.equal(clientUpdate.source, 'npm_registry_cache'); assert.match(clientUpdate.checkedAt, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/); const textOff = await f.call(['list', '--output-format', 'text']); const textOn = await f.call(['list', '--output-format', 'text'], { SESSION_PEER_UPDATE_NOTICE: 'yes' }); assert.equal(textOn.stdout, textOff.stdout); assert.equal(textOn.code, textOff.code); - assert.match(textOn.stderr, /^Update available: session-peer 0\.5\.2 -> 0\.5\.3 \(npm dist-tag latest\)\. This is a source checkout; update it with git and rebuild\.\n$/); + assert.match(textOn.stderr, /^Update available: session-peer 0\.6\.0 -> 0\.6\.1 \(npm dist-tag latest\)\. This is a source checkout; update it with git and rebuild\.\n$/); const doctor = await f.call(['doctor', '--json'], on); - assert.equal(doctor.value.clientUpdate.latest, '0.5.3'); assert.equal(doctor.value.capabilities.updateCheck, true); assert.equal(doctor.value.capabilities.selfUpdate, false); + assert.equal(doctor.value.clientUpdate.latest, '0.6.1'); assert.equal(doctor.value.capabilities.updateCheck, true); assert.equal(doctor.value.capabilities.selfUpdate, false); const refusal = await f.call(['send', '--to', 'codex:bad', '--json', 'hi'], on); assert.equal(refusal.code, 2); assert.equal('clientUpdate' in refusal.value, false); for (const [args, extra] of [[['list', '--json', '--no-update-notice'], on], [['list', '--json'], { ...on, SESSION_PEER_NO_UPDATE_NOTICE: 'TRUE' }], @@ -289,7 +289,7 @@ test('cached notices are off by default, opt-in additive, stderr-only in text an const r = await f.call(args, extra); assert.equal(r.code, 0); assert.equal(r.stderr, ''); assert.equal(r.value?.clientUpdate, undefined); } - f.seed('0.5.2'); + f.seed('0.6.0'); assert.equal('clientUpdate' in (await f.call(['list', '--json'], on)).value, false); // Python's own update.json in the same directory is a different release stream. writeFileSync(join(f.cache, 'update.json'), JSON.stringify({ schemaVersion: 1, latest: '9.9.9', checkedAt: Math.floor(Date.now() / 1000) })); @@ -305,26 +305,26 @@ test('missing, expired and invalid caches refresh once in the background without assert.deepEqual(first.value, baseline.value); assert.equal(first.code, baseline.code); assert.equal(first.stderr, ''); await f.settled(1); assert.equal(f.requests.length, 1); - assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).latest, '0.5.3'); + assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).latest, '0.6.1'); if (process.platform !== 'win32') assert.equal(statSync(f.file).mode & 0o777, 0o600); - assert.equal((await f.call(['list', '--json'], on)).value.clientUpdate.latest, '0.5.3'); + assert.equal((await f.call(['list', '--json'], on)).value.clientUpdate.latest, '0.6.1'); assert.equal(f.requests.length, 1, 'fresh cache does not refresh'); - f.seed('0.5.3', 25 * 3600_000); + f.seed('0.6.1', 25 * 3600_000); assert.equal('clientUpdate' in (await f.call(['list', '--json'], on)).value, false, 'expired cache is not shown'); await f.settled(2); assert.equal(f.requests.length, 2); for (const content of ['not json', JSON.stringify({ schemaVersion: 1, package: 'session-peer', source: 'npm_registry', channel: 'latest', latest: '0.3.0-rc.1', checkedAt: Date.now() }), - JSON.stringify({ schemaVersion: 1, package: 'session-peer', source: 'npm_registry', channel: 'latest', latest: '0.5.3', checkedAt: Date.now() + 3600_000 }), 'x'.repeat(5000)]) { + JSON.stringify({ schemaVersion: 1, package: 'session-peer', source: 'npm_registry', channel: 'latest', latest: '0.6.1', checkedAt: Date.now() + 3600_000 }), 'x'.repeat(5000)]) { const count: number = f.requests.length; writeFileSync(f.file, content); const r = await f.call(['list', '--json'], on); assert.equal(r.code, 0); assert.equal('clientUpdate' in r.value, false); await f.settled(count + 1); assert.equal(f.requests.length, count + 1); - assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).latest, '0.5.3'); + assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).latest, '0.6.1'); } }); test('concurrent invocations perform one bounded refresh; locks and failures back off', async t => { - const f = await fixture(t, { body: JSON.stringify({ latest: '0.5.3' }), wait: 1000 }); + const f = await fixture(t, { body: JSON.stringify({ latest: '0.6.1' }), wait: 1000 }); const on = { SESSION_PEER_UPDATE_NOTICE: '1' }; const results = await Promise.all(Array.from({ length: 8 }, () => f.call(['list', '--json'], on))); for (const r of results) { assert.equal(r.code, 0); assert.equal('clientUpdate' in r.value, false); } @@ -342,7 +342,7 @@ test('concurrent invocations perform one bounded refresh; locks and failures bac await f.settled(2); assert.equal(f.requests.length, 2); // Offline/failed refresh records a backoff without a version and never alters output. f.state.reply = { status: 503, body: SENTINEL }; - f.seed('0.5.3', 25 * 3600_000); + f.seed('0.6.1', 25 * 3600_000); const offline = await f.call(['list', '--json'], on); assert.equal(offline.code, 0); assert.equal(offline.stdout.includes(SENTINEL), false); await f.settled(3); assert.equal(f.requests.length, 3); @@ -361,7 +361,7 @@ test('remote wire requests never read, refresh or report client notices', async const on = { SESSION_PEER_UPDATE_NOTICE: '1' }; const wire = await f.call(['--stdio-request'], on, JSON.stringify({ schemaVersion: 1, args: ['list', '--json'] })); assert.equal(wire.code, 0); assert.equal('clientUpdate' in wire.value, false); - f.seed('0.5.3'); + f.seed('0.6.1'); const seeded = await f.call(['--stdio-request'], on, JSON.stringify({ schemaVersion: 1, args: ['doctor', '--json'] })); assert.equal('clientUpdate' in seeded.value, false); await delay(1000); @@ -372,7 +372,7 @@ test('remote wire requests never read, refresh or report client notices', async test('over SSH the local client adds its own notice; the receiver never reads or refreshes', { skip: process.platform === 'win32' && 'POSIX fake ssh' }, async t => { const f = await fixture(t); - f.seed('0.5.3'); + f.seed('0.6.1'); const bin = join(f.root, 'bin'), receiver = join(f.root, 'receiver-cache'); mkdirSync(bin); writeFileSync(join(bin, 'ssh'), `#!${process.execPath}\nconst {spawnSync}=require('node:child_process');const flag=process.argv.at(-1).endsWith('--version')?'--version':'--stdio-request';` + @@ -381,7 +381,7 @@ test('over SSH the local client adds its own notice; the receiver never reads or const on = { SESSION_PEER_UPDATE_NOTICE: '1', PATH: `${bin}${delimiter}${process.env.PATH}` }; const json = await f.call(['list', '--host', 'audit', '--json'], on); assert.equal(json.code, 0, json.stdout); assert.equal(json.value.host, 'audit'); - assert.equal(json.value.clientUpdate.latest, '0.5.3'); assert.equal(json.stderr, ''); + assert.equal(json.value.clientUpdate.latest, '0.6.1'); assert.equal(json.stderr, ''); const text = await f.call(['list', '--host', 'audit', '--output-format', 'text'], on); assert.equal(text.code, 0); assert.equal(text.stderr.match(/Update available/g)?.length, 1); const off = await f.call(['list', '--host', 'audit', '--json', '--no-update-notice'], on); @@ -431,9 +431,9 @@ test('explicit and background cache writes are serialized by the same lock and n const locked = await f.call(['update', '--check', '--json']); assert.equal(locked.code, 0); assert.equal(locked.value.latest, '0.3.9'); assert.equal(locked.value.cache, 'skipped_locked'); assert.equal(existsSync(f.file), false); - f.state.reply = { body: JSON.stringify({ latest: '0.5.3' }) }; + f.state.reply = { body: JSON.stringify({ latest: '0.6.1' }) }; await refreshCache({ ...f.env, SESSION_PEER_UPDATE_LOCK_TOKEN: background }); - assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).latest, '0.5.3'); assert.equal(existsSync(f.lock), false); + assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).latest, '0.6.1'); assert.equal(existsSync(f.lock), false); f.state.reply = { body: JSON.stringify({ latest: '0.3.9' }) }; const written = await f.call(['update', '--check', '--json']); assert.equal(written.value.cache, 'written'); assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).latest, '0.3.9'); @@ -441,7 +441,7 @@ test('explicit and background cache writes are serialized by the same lock and n writeFileSync(f.file, JSON.stringify({ schemaVersion: 1, package: 'session-peer', source: 'npm_registry', channel: 'latest', latest: '0.3.9', checkedAt: Date.now() + 60_000 })); const newer = readFileSync(f.file, 'utf8'); const late = acquireLock(env)!; - f.state.reply = { body: JSON.stringify({ latest: '0.5.3' }) }; + f.state.reply = { body: JSON.stringify({ latest: '0.6.1' }) }; await refreshCache({ ...f.env, SESSION_PEER_UPDATE_LOCK_TOKEN: late }); assert.equal(readFileSync(f.file, 'utf8'), newer); assert.equal(existsSync(f.lock), false); const skipped = await f.call(['update', '--check', '--json']); @@ -466,7 +466,7 @@ test('concurrent lock acquisition across processes yields one owner, and none fo }); test('lock I/O failures: a failed token write removes only its own new lock; an unverifiable lock maps to cache failed', async t => { - const f = await fixture(t, { body: JSON.stringify({ latest: '0.5.3' }) }); + const f = await fixture(t, { body: JSON.stringify({ latest: '0.6.1' }) }); const env = { ...f.env }; t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); }); const token = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @@ -488,7 +488,7 @@ test('lock I/O failures: a failed token write removes only its own new lock; an syncBuiltinESMExports(); const result = await checkUpdate('latest', env); t.mock.restoreAll(); syncBuiltinESMExports(); - assert.equal(result.cache, 'failed'); assert.equal(result.latest, '0.5.3'); assert.equal(existsSync(f.file), false); + assert.equal(result.cache, 'failed'); assert.equal(result.latest, '0.6.1'); assert.equal(existsSync(f.file), false); // Release failed too, so the lock is left for documented manual recovery. assert.ok(existsSync(f.lock)); }); From 749199fb13d1f37ffd2126302fbcae35a354eec2 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 3 Nov 2026 11:46:06 +0900 Subject: [PATCH 2/9] feat(receipt): add explicit local authenticated receipt bootstrap Add a manually operated private collector and receipt-only handler. Preserve original budgets through private IPC v2, generation-bound Codex v1, and a separate Claude delegated context without native identity or consumption claims. Fence one native submission, preserve known facts on unknown receipt outcomes, and keep authority out of argv, environment, public output and sender/collector persistence. Refs #132 --- PARITY.md | 8 + docs/api.md | 25 ++ docs/design/receipt-bootstrap.md | 13 + docs/guide.ja.md | 20 ++ docs/guide.ko.md | 20 ++ docs/guide.md | 20 ++ docs/guide.zh-CN.md | 20 ++ package.json | 2 +- scripts/release.mjs | 2 +- src/cli.ts | 44 ++- src/discovery.ts | 68 +--- src/handoff-operations.ts | 22 +- src/help.ts | 17 +- src/macos-acl.ts | 52 +++ src/output.ts | 6 +- src/receipt-collector.ts | 186 +++++++++++ src/receipt-command.ts | 175 ++++++++++ src/receipt-producer.ts | 74 +++++ src/receipt-protocol.ts | 303 +++++++++++++++++ src/receipt-send.ts | 242 ++++++++++++++ src/receipt-state.ts | 380 ++++++++++++++++++++++ src/receipt-store.ts | 193 +++++++++++ src/send.ts | 9 +- test/package-smoke.ts | 2 +- test/receipt-acl.test.ts | 55 ++++ test/receipt-cli.test.ts | 128 ++++++++ test/receipt-collector.test.ts | 99 ++++++ test/receipt-command.test.ts | 220 +++++++++++++ test/receipt-producer.test.ts | 153 +++++++++ test/receipt-protocol.test.ts | 211 ++++++++++++ test/receipt-send.test.ts | 318 ++++++++++++++++++ test/receipt-state.test.ts | 290 +++++++++++++++++ test/receipt-transport-boundaries.test.ts | 226 +++++++++++++ 33 files changed, 3534 insertions(+), 69 deletions(-) create mode 100644 src/macos-acl.ts create mode 100644 src/receipt-collector.ts create mode 100644 src/receipt-command.ts create mode 100644 src/receipt-producer.ts create mode 100644 src/receipt-protocol.ts create mode 100644 src/receipt-send.ts create mode 100644 src/receipt-state.ts create mode 100644 src/receipt-store.ts create mode 100644 test/receipt-acl.test.ts create mode 100644 test/receipt-cli.test.ts create mode 100644 test/receipt-collector.test.ts create mode 100644 test/receipt-command.test.ts create mode 100644 test/receipt-producer.test.ts create mode 100644 test/receipt-protocol.test.ts create mode 100644 test/receipt-send.test.ts create mode 100644 test/receipt-state.test.ts create mode 100644 test/receipt-transport-boundaries.test.ts diff --git a/PARITY.md b/PARITY.md index 2f7f829..fa7eb63 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1426,3 +1426,11 @@ the exact proposed skill 0.3.0/min0.1.0/full0.4.0/probe-help tuple after design/ review of the complete Draft #37 fixture. That fixture is not a published skill or installation pin; final skill bytes require renewed exact review. A new runtime version is never automatically compatible by numerical ordering. + +## Explicit local receipts — #132 source implementation + +The implementation candidate follows the owner-selected [local bootstrap design](https://github.com/abruption/session-peer-ts/blob/d922d6e20345f10b914ee9cf4da40476fd044f5d/docs/design/receipt-bootstrap.md): manual local collector, explicit private-input authority delegation and a separately invoked receipt-only handler. Existing generation-bound Codex v1 semantics remain intact; Claude delegated-context v2 keeps native generation unknown and proves only token possession. This is separate from manual `operator_confirmed` evidence. + +Managed format-2 state has one live collector owner and no migration/import/recovery path. Original user-payload digests remain privately encrypted; raw capability values are never persisted by sender/collector. Fast pre-outcome receipts cannot promote an unknown native submission, reopen an effect fence or rewrite terminal wait results. Receipt/status/wait operations have no native submission path. Codex token delivery uses qualified private-stdin RPC only; ordinary argv-based queue submission is never a token-bearing fallback. + +The [operator guide](docs/guide.md#explicit-local-receipts) and [API contract](docs/api.md#explicit-local-receipt-bootstrap-source-implementation) describe the opt-in scope. Existing platform/native qualification, same-version SSH rules and independent skill pins remain unchanged. Windows managed storage, reverse-SSH collectors, automatic services, wake, automatic model ACK, resend and exactly-once claims remain outside this implementation. Transcript-derived Claude observation #73 was explicitly closed NO-GO; that decision does not prohibit voluntary receipt submission. diff --git a/docs/api.md b/docs/api.md index 4ab40d3..431af58 100644 --- a/docs/api.md +++ b/docs/api.md @@ -407,3 +407,28 @@ voluntary receipt producer/authenticated collector bootstrap [#132](https://gith are future extensions. An automatic collector would still require an explicit receiver receipt; it must never infer ACK from messages or turn completion. Unsupported required goals refuse before effect; no post-attempt fallback or resend. + +## Explicit local receipt bootstrap (source implementation) + +The `receipt` owner commands and `send --receipt-collector SOCKET` select a local, manually operated receipt route. They do not start services, discover alternate collectors, wake a session, answer an approval or resend a native message. Read the [operator guide](guide.md#explicit-local-receipts) before supplying private receipt input. + +- `receipt init` creates a fresh managed format-2 store. Existing unmanaged format-1 history is not migrated or adopted. New legacy owner commands refuse a managed context; managed status and waits require the live collector. +- `receipt serve` is a foreground process with a single mutation and monotonic-clock owner. A second owner refuses. Unexpected state/key/endpoint replacement is not repaired. Cleanup may be unconfirmed; that does not authorize removal of an unrelated path. +- `receipt respond --receipt -` accepts only a bounded private receipt frame on stdin, through the explicitly selected private endpoint. A successful invocation records authenticated receipt evidence only; it cannot send a message. Never put capability data in argv, environment, URI, shell command text or public output. +- The generic CLI envelope stays `schemaVersion:1`. Codex's nested handoff/receipt stays v1 with the original non-null native generation. Claude uses a separate nested handoff/receipt v2 with `targetGeneration:null` and `receiptContext:{kind:"delegated_context",id:UUID}`. A v1 validator must reject that new scope rather than synthesizing a generation or downgrading it. +- `token_possession` means delegated receipt authority, not native/model identity, consumption, completion or responsibility acceptance. Existing `operator_confirmed` evidence remains a separate manual assurance. The intended recipient's native queue/history may retain delegated authority. +- Codex delegation uses only the qualified owned RPC route with private stdin. A token-bearing body is never passed through a native `--message` argument or a legacy queue fallback. Unsupported required ACK refuses before effect. Best-effort unsupported bootstrap can retain ordinary submission facts without minting an unused token. +- The original ACK operation is opened before the effect fence and uses the original total budget. A fast authenticated receipt can occupy one reserved candidate slot until positive native submission is recorded. Unknown/refused outcomes cannot be promoted by that receipt. ACK eligibility is classified through the collector's serialized transition; it never backdates an ACK or rewrites a terminal timeout. +- Each independent wait has its own immutable operation ID/deadline. Polling does not open another wait, extend authority or submit native input. A fresh collector cannot revive unused old-clock authority. Committed receipts and terminal waits are retained. + +IPC is separately versioned and limited to the closed receipt operations described in the [reviewed design](https://github.com/abruption/session-peer-ts/blob/d922d6e20345f10b914ee9cf4da40476fd044f5d/docs/design/receipt-bootstrap.md). Request/response frames are at most 16,384 bytes including LF; each raw private receipt is at most 4,096 bytes. The collector admits at most 16 live connections, with finite per-read/write and total exchange budgets. Unknown post-attempt responses never authorize automatic retry. These bounds do not promise a deadline for arbitrary kernel-blocked filesystem I/O or protect against a hostile same-user writer. + +Windows managed storage and reverse-SSH receipt collection remain unsupported. Existing native observation qualification remains unchanged. OS/IPC fixtures, synthetic native transports and actual live-agent receipts are separate evidence categories; this source description is not a release or live-model qualification claim. + +### Original-budget IPC 2 and retained-context limits + +Private IPC 2 `hello.clockReferenceMs` is an opaque collector monotonic reference. A client captures its remaining total milliseconds after that reply and echoes the fixed reference/span in `wait-open`; the actor anchors the original cutoff to the earlier reference. Network, preparation and mint latency cannot start a renewed wait budget. This does not depend on clocks sharing an origin or on UTC comparisons. Public CLI/receipt/handoff version namespaces are unchanged by the private IPC revision. + +Explicit revoke/quarantine rejects further receipt presentations, including duplicates, while owner status retains committed ACK evidence. TTL expiry alone does not erase the authenticated identical-receipt retrieval exception. A late flag refers to the first recorded wait; a later wait cannot renew it. Context limits apply jointly: the current conservative 64-KiB retained reservation can fill a 32-MiB store at about 511 small intents before the separate 10,000-record upper bound. There is no automatic retirement or deletion. A full-store refusal never authorizes dropping history or resending. + +Before a managed intent is established, bootstrap refusal can have no handoff IDs; the client does not fabricate an epoch/correlation/operation for unavailable storage. Once an operation is established, its original verified IDs/deadline and independently known native facts are preserved on later failures without inventing a durable terminal result. diff --git a/docs/design/receipt-bootstrap.md b/docs/design/receipt-bootstrap.md index bf9e51b..06b4bce 100644 --- a/docs/design/receipt-bootstrap.md +++ b/docs/design/receipt-bootstrap.md @@ -568,3 +568,16 @@ a prerequisite and cannot enable transcript-derived ACK in this design. No implementation, platform qualification, live receipt, delivery/ACK result, merge, release, installation or operational change is claimed by this proposal. + +## Implementation review refinements — 2026-11-03 + +The following reviewed implementation choices refine the earlier proposal; they do not change existing receipt/handoff v1 or enable a published release: + +- Private bootstrap IPC uses **`ipcVersion:2`**, not the historical proposal's IPC 1. Public CLI schema 1, generation-bound Codex receipt/handoff 1, delegated Claude receipt/handoff 2, managed-store 2 and producer challenge protocol 1 remain separate namespaces. +- `hello` returns a required non-secret `clockReferenceMs`, an opaque coordinate from the live collector's monotonic clock. After receiving that response, the client samples its original total remaining milliseconds exactly once. It does not interpret or compare the collector coordinate with its own clock. +- `wait-open` adds required `clockReferenceMs` and `remainingMs` to its closed wire body, retaining the original `timeoutSeconds` cap. The collector bounds the span by that cap and anchors `cutoff = reference + remaining - cleanup`. Because the reference precedes the client's remaining-budget measurement, later prepare/mint/IPC delay cannot renew the original deadline. No UTC authority or cross-process absolute-clock assumption is introduced. A future reference, missing pair, invalid integer or oversized span refuses. +- A terminal ACK/timeout that wins a stop race is read once using the original operation ID. It is not relabelled as a stopped wait or persistence failure. Actual stopped-by-operator results use exit 130; an immutable timeout uses exit 1. +- Explicit revocation/quarantine refuses further receipt presentations, including duplicate receipt retrieval; the committed ACK remains available through owner status. This preserves existing v1 behavior. TTL expiry alone retains the authenticated read-only identical-receipt exception while detailed history remains available. +- `late` remains anchored to the first recorded wait, including an insufficient-budget terminal wait. Later waits do not renew that originating cutoff or rewrite earlier terminal results. +- The intent count and journal bounds apply together. A conservative 64-KiB retained reservation per intent can exhaust the 32-MiB store at about 511 small intents, before the separate 10,000-intent upper bound. There is no automatic deletion/reset/retirement engine. A full store refuses new managed work; discarding history never proves safe resend. +- IPC read/write/total durations are upper bounds, not promised minimum service time. The client currently uses a stricter one-shot two-second collection budget capped by its remaining total; a post-attempt timeout is unknown, not permission to retry. diff --git a/docs/guide.ja.md b/docs/guide.ja.md index 4629b6f..999b4ed 100644 --- a/docs/guide.ja.md +++ b/docs/guide.ja.md @@ -88,6 +88,26 @@ Relay 通信、MCP、wake/resume、Antigravity、自動更新は未実装です 別の探索先でエラーが起きても読み取れたセッションは一覧に残し、探索が不完全であることを診断情報と失敗終了コードで示します。送信と送信者の識別には、完全で矛盾のない根拠が必要です。同じ実体の登録ディレクトリを指す別名は一つの行にまとめられますが、PID や inbox の情報が競合する場合は、ディレクトリの順番や PID の指定では解決しません。選択と handoff の連続性検査は元の登録情報の根拠を保持し、提出直前に再確認します。一覧と dry-run は inbox に接続しません。これらの検査は native の世代、メッセージの消費、受領確認 ACK を証明しません。 +### 明示的なローカル受領確認 + +receipt は通常の posted/queued という提出根拠とは別です。所有者が非公開状態を明示的に初期化し、フォアグラウンドの collector を手動で開始します。サービス登録や自動起動はしません。次の `serve` は所有者が停止するまで終了しません。 + +```sh +session-peer receipt init --state-dir "$HOME/.session-peer-receipts" --json +session-peer receipt serve --state-dir "$HOME/.session-peer-receipts" --json +``` + +別のターミナルで受領確認用の権限委任を明示的に要求します。受信側は非公開 stdin から receipt 専用 handler を別途呼び出します。下の二つ目のコマンド自体が receipt を取得するわけではありません。トークンをシェルのコマンド文字列、引数、環境変数、URI、公開出力、送信者や collector のファイルに含めず、非公開 stdin/FD で渡せる producer だけを使ってください。許可された受信者の native キューや履歴には残る場合があります。 + +```sh +session-peer send --to claude:NAME --message "Receipt fixture" --request-ack --receipt-collector "$HOME/.session-peer-receipts/s" --wait-for acknowledged --wait-timeout 30 --json +session-peer receipt respond --collector "$HOME/.session-peer-receipts/s" --receipt - --json +``` + +`token_possession` は委任されたトークンの所持を示すだけで、モデルの身元、メッセージの消費、作業完了を証明しません。Claude は native 世代が不明な別の v2 delegated receipt context を使います。Codex は元の世代に結び付いた v1 を維持し、非公開 stdin を使う検証済み owned RPC 経路だけを許可します。トークンを native の `--message` 引数に入れる代替経路はありません。既存の手動 `operator_confirmed` 根拠とも異なります。 + +collector は同じ端末の所有者専用経路です。保存容量の制限が重なり、小さい記録でも約 511 件で新しい受領確認要求を拒否する場合があります。自動削除や初期化で履歴を捨てることはありません。逆向き SSH collector と Windows managed storage は未対応です。明示的な各 wait は自分の期限を維持します。status/wait は非公開の状態分類を書き込む場合がありますが、native メッセージを再提出しません。不明な結果から自動再送は行いません。既存の native・プラットフォーム検証範囲は変わらず、fixture の成功は実エージェントの receipt 検証ではありません。 + ## 必要条件 macOS / Linux / Windows native、Node **22.x の 22.13 以上、または 24.x**。Node 26 は対象外です。ネイティブロック依存には対応する x64/arm64 バイナリが必要で、純 JavaScript パッケージではありません。Codex 送信には `codex` が必要で、macOS/Linux では `lsof`・`ps` も必要です。Windows はネイティブロックと Restart Manager で所有者を確認します。Claude にはアクセス可能な inbox を持つ稼働中 TUI が必要です。SSH は既存の鍵・ホスト信頼と、接続先の**同一バージョン**のクライアントを使います。 diff --git a/docs/guide.ko.md b/docs/guide.ko.md index ebde193..e89775c 100644 --- a/docs/guide.ko.md +++ b/docs/guide.ko.md @@ -132,6 +132,26 @@ Claude 이름은 Unicode 14.0.0의 기본 완전 대소문자 접기(full casefo 다른 탐색 경로에서 오류가 나더라도 읽은 세션은 목록에 남기고, 탐색이 불완전하다는 진단과 실패 종료 코드를 반환합니다. 전송과 발신자 식별에는 완전하고 충돌 없는 근거가 필요합니다. 같은 실제 등록 디렉터리를 가리키는 별칭은 한 행으로 합칠 수 있지만, PID나 받은 메시지함 정보가 충돌하면 디렉터리 순서나 PID 지정으로 대상을 결정하지 않습니다. 선택과 handoff 연속성 검사는 원래 등록 정보의 근거를 유지하고 제출 직전에 다시 확인합니다. 목록과 dry-run은 받은 메시지함에 연결하지 않습니다. 이 검사가 native 세대, 메시지 소비 또는 수령 확인 ACK를 증명하지는 않습니다. +### 명시적 로컬 수령 확인 + +수령 확인 receipt는 일반 posted/queued 전송 근거와 별개입니다. 소유자가 비공개 상태를 직접 초기화하고, 터미널에서 실행 상태를 유지하는 수집기를 직접 시작합니다. 서비스 등록이나 자동 시작은 하지 않습니다. 다음 명령의 `serve`는 사용자가 중지할 때까지 종료되지 않습니다. + +```sh +session-peer receipt init --state-dir "$HOME/.session-peer-receipts" --json +session-peer receipt serve --state-dir "$HOME/.session-peer-receipts" --json +``` + +다른 터미널에서 수령 확인 권한 위임을 명시적으로 요청합니다. 수신자는 비공개 표준 입력으로 receipt 전용 처리기를 별도로 호출해야 합니다. 아래 두 번째 명령이 스스로 receipt를 가져오지는 않습니다. 권한 토큰을 셸 명령문·인자·환경변수·URI·공개 출력·발신자 또는 수집기 파일에 넣지 않고 비공개 stdin/FD로 전달할 수 있는 도구만 사용하세요. 허용된 수신자의 native 큐·대화 기록에는 토큰이 남을 수 있습니다. + +```sh +session-peer send --to claude:NAME --message "Receipt fixture" --request-ack --receipt-collector "$HOME/.session-peer-receipts/s" --wait-for acknowledged --wait-timeout 30 --json +session-peer receipt respond --collector "$HOME/.session-peer-receipts/s" --receipt - --json +``` + +검증 결과의 `token_possession`은 위임받은 토큰의 소지만 증명하며, 모델 신원·메시지 소비·작업 완료를 증명하지 않습니다. Claude는 native 세대를 알 수 없는 별도의 v2 위임 receipt context를 사용합니다. Codex는 원래 세대에 결합한 v1을 유지하고, 비공개 stdin을 사용하는 검증된 owned RPC 경로만 허용합니다. 토큰을 native `--message` 인자로 넘기는 대체 경로는 없습니다. 기존 수동 `operator_confirmed` 근거와도 다릅니다. + +수집기는 같은 기기의 소유자 전용 경로만 사용합니다. 보존 용량 제한이 함께 적용되어 작은 기록 약 511개부터 새 수령 확인 요청이 거부될 수 있으며, 자동 정리·초기화로 이력을 버리지 않습니다. 역방향 SSH 수집기와 Windows managed 저장소는 미지원입니다. 각 명시적 대기는 자신의 마감 시각을 유지합니다. status/wait는 비공개 상태 분류를 기록할 수 있지만 native 메시지를 다시 제출하지 않습니다. 결과를 알 수 없다고 자동 재전송하지 않습니다. 기존 native·플랫폼 검증 범위는 그대로이며, fixture 성공이 실제 에이전트 receipt 검증을 뜻하지는 않습니다. + ## 요구사항 macOS, Linux 또는 Windows와 **Node 22.x의 22.13 이상 또는 24.x**가 필요합니다. diff --git a/docs/guide.md b/docs/guide.md index 3827e72..3297b76 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -175,6 +175,26 @@ Without a nonempty `CLAUDE_CONFIG_DIR` or `ANTHROPIC_CONFIG_DIR`, Claude discove Readable sessions remain listed when another source fails, with incomplete-inventory diagnostics and a failing list exit status. Sending and sender identification require complete, conflict-free evidence. Aliases of the same canonical registry can share one row; conflicting PID or inbox claims cannot be resolved by directory order or by choosing a PID. Selection and handoff continuity retain the original registry evidence and recheck it before submission. Listing and dry-run do not connect to an inbox. These checks do not establish a native generation, consumption or ACK. +### Explicit local receipts + +A receipt is separate from ordinary posted/queued evidence. The owner explicitly initializes private state and starts a foreground collector; it is never installed as a service or started automatically. Run the following commands in an operator terminal. `serve` stays in the foreground until stopped. + +```sh +session-peer receipt init --state-dir "$HOME/.session-peer-receipts" --json +session-peer receipt serve --state-dir "$HOME/.session-peer-receipts" --json +``` + +In another terminal, explicitly request receipt delegation. The recipient separately invokes the receipt-only handler with private stdin; the second command below does not obtain a receipt by itself. Use only producer tooling that can supply private stdin/FD data without putting the capability in shell command text, argv, environment, URI, public output or sender/collector files. The authorized recipient's native queue/history can retain it. + +```sh +session-peer send --to claude:NAME --message "Receipt fixture" --request-ack --receipt-collector "$HOME/.session-peer-receipts/s" --wait-for acknowledged --wait-timeout 30 --json +session-peer receipt respond --collector "$HOME/.session-peer-receipts/s" --receipt - --json +``` + +Receipt validation establishes delegated `token_possession`, not model identity, consumption or completion. Claude uses a separate v2 delegated receipt context with unknown native generation; Codex keeps generation-bound v1 and requires the qualified owned RPC route with private stdin. There is no token-bearing native `--message` fallback. Existing manual `operator_confirmed` evidence has a different assurance. + +The collector is local and owner-private. Combined retained-capacity limits can refuse new receipt intents at about 511 small records; no automatic cleanup/reset discards history. Reverse SSH collectors and Windows managed storage remain unsupported. Each explicit wait has its own immutable deadline; status/wait can update private classifications but never submit another native message. Unknown outcomes never authorize automatic resend. Existing native/platform qualification limits still apply; fixture results are not live-agent receipt qualification. + ## Requirements macOS, Linux or native Windows; Node **22.13+ within 22.x or 24.x**. Node 26 is not supported. diff --git a/docs/guide.zh-CN.md b/docs/guide.zh-CN.md index bb99c92..f907333 100644 --- a/docs/guide.zh-CN.md +++ b/docs/guide.zh-CN.md @@ -87,6 +87,26 @@ Unicode 14.0.0 默认 full casefold 精确匹配,不进行规范化或模糊 其他探索来源出错时,已读取的会话仍保留在列表中,同时返回探索不完整的诊断信息和失败退出码。发送和发件人识别要求信息完整且无冲突。指向同一实际登记目录的别名可以合并为一行;PID 或收件端点信息冲突时,不会按目录顺序或指定 PID 来决定目标。选择和 handoff 连续性检查保留原始登记信息的依据,并在提交前重新检查。列表和 dry-run 不连接收件端点。这些检查不证明 native 代次、消息消费或收讫确认 ACK。 +### 显式本地收讫确认 + +receipt 与通常的 posted/queued 提交依据分开。所有者显式初始化私有状态,并手动启动前台 collector;不会注册服务或自动启动。以下 `serve` 会持续运行,直到所有者将其停止。 + +```sh +session-peer receipt init --state-dir "$HOME/.session-peer-receipts" --json +session-peer receipt serve --state-dir "$HOME/.session-peer-receipts" --json +``` + +在另一个终端中显式请求委托 receipt 权限。接收方必须通过私有 stdin 单独调用 receipt 专用 handler。下面第二个命令不会自行获取 receipt。只能使用能够通过私有 stdin/FD 传输令牌的 producer,不得把令牌放入 shell 命令文本、参数、环境变量、URI、公开输出或发送方/collector 的文件中。获准接收方的 native 队列和历史记录可能保留令牌。 + +```sh +session-peer send --to claude:NAME --message "Receipt fixture" --request-ack --receipt-collector "$HOME/.session-peer-receipts/s" --wait-for acknowledged --wait-timeout 30 --json +session-peer receipt respond --collector "$HOME/.session-peer-receipts/s" --receipt - --json +``` + +`token_possession` 仅证明持有获委托的令牌,不证明模型身份、消息消费或任务完成。Claude 使用单独的 v2 delegated receipt context,native 代次仍未知。Codex 保持绑定原始代次的 v1,仅允许使用私有 stdin 的已验证 owned RPC 路径;不会退回到把令牌放入 native `--message` 参数的路径。它也不同于现有的手动 `operator_confirmed` 依据。 + +collector 仅限同一设备上的所有者私有路径。保留容量的限制共同生效,小记录约 511 条时就可能拒绝新的收讫确认请求;不会通过自动清理或重置丢弃历史记录。反向 SSH collector 和 Windows managed storage 仍不支持。每次显式 wait 保留自己的截止时间。status/wait 可以记录私有状态分类,但不重新提交 native 消息。未知结果不会触发自动重发。现有 native/平台验证范围保持不变;fixture 成功不代表已验证真实代理的 receipt。 + ## 环境要求 macOS、Linux 或 Windows native;Node **22.x 中的 22.13 及以上,或 24.x**。不支持 Node 26。原生锁依赖需要匹配的 x64/arm64 预编译二进制,本包不是纯 JavaScript 实现。Codex 发送需要 `codex`,macOS/Linux 还需要 `lsof`、`ps`;Windows 使用原生锁和 Restart Manager 验证所有者。Claude 需要运行中的 TUI 及可访问的原生 inbox。SSH 使用已有密钥和主机信任,远端必须安装**同版本**客户端。 diff --git a/package.json b/package.json index 5b5048b..4275e19 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "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/claude-profiles.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: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/claude-profiles.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/receipt-protocol.test.ts test/receipt-state.test.ts test/receipt-collector.test.ts test/receipt-command.test.ts test/receipt-send.test.ts test/receipt-transport-boundaries.test.ts test/receipt-acl.test.ts test/receipt-cli.test.ts test/receipt-producer.test.ts", "test:package": "npm run build && node --experimental-strip-types test/package-smoke.ts" }, "devDependencies": { diff --git a/scripts/release.mjs b/scripts/release.mjs index f117f15..166c3df 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -19,7 +19,7 @@ export const packageFiles = ['UNICODE-LICENSE.txt', 'dist/casefold.js', 'dist/ca 'dist/cli.js', 'dist/discovery.js', 'dist/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(); + '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', 'dist/macos-acl.js', 'dist/macos-acl.d.ts', 'dist/receipt-protocol.js', 'dist/receipt-protocol.d.ts', 'dist/receipt-store.js', 'dist/receipt-store.d.ts', 'dist/receipt-state.js', 'dist/receipt-state.d.ts', 'dist/receipt-collector.js', 'dist/receipt-collector.d.ts', 'dist/receipt-command.js', 'dist/receipt-command.d.ts', 'dist/receipt-producer.js', 'dist/receipt-producer.d.ts', 'dist/receipt-send.js', 'dist/receipt-send.d.ts', 'package.json'].sort(); const json = path => JSON.parse(readFileSync(path, 'utf8')); const digest = (bytes, algorithm, encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding); const npm = args => execFileSync('npm', args, { encoding: 'utf8', timeout: 120000 }); diff --git a/src/cli.ts b/src/cli.ts index 3756f57..e45943c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,11 +1,14 @@ #!/usr/bin/env node import { handoffCommand, handoffSend, prepareHandoff, privateMessage } from './handoff-operations.js'; +import { receiptCommand } from './receipt-command.js'; +import { managedReceiptSend } from './receipt-send.js'; import { canonicalId, closed, HANDOFF_LIMITS, HandoffBudget, parseStrictJson, parseWaitTimeout, safeInteger, systemClock, type Handoff } from './handoff.js'; import { doctor } from './diagnostics.js'; import { help } from './help.js'; import { renderOutput, type OutputFormat } from './output.js'; import { hostname } from 'node:os'; import { lstatSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; import { listing, Refusal } from './discovery.js'; import { executable, run, UnknownOutcome, type Done } from './process.js'; import { checkMessage, send, CodexUnknownOutcome } from './send.js'; @@ -19,7 +22,7 @@ import { configuredHost, detectedHost, isSelf, probeReturnRoute, unsafeReturnHos type Options = { command: 'list' | 'send' | 'doctor' | 'update'; values: Map; flags: Set; hosts: string[]; ssh: SshOptions; jump?: SshJump; address?: { uri: string; transport: 'local' | 'ssh'; implicit: boolean } }; const FLAGS = ['--json', '--all', '--dry-run', '--no-from', '--no-reply-to', '--no-update-notice', '--allow-inactive-codex-home', '--check-return-route', '--check', '--request-ack', '--observe-delivery']; -const VALUES = ['--agent', '--codex-home', '--codex-bin', '--output-format', '--to', '--message', '-m', '--host', '--remote-bin', '--remote-platform', '--ssh-control-path', '--reply-address', '--ssh-opt', '--ssh-jump', '--reply-to', '--return-route-host', '--channel', '--correlation-id', '--wait-for', '--wait-timeout', '--message-file']; +const VALUES = ['--agent', '--codex-home', '--codex-bin', '--output-format', '--to', '--message', '-m', '--host', '--remote-bin', '--remote-platform', '--ssh-control-path', '--reply-address', '--ssh-opt', '--ssh-jump', '--reply-to', '--return-route-host', '--channel', '--correlation-id', '--wait-for', '--wait-timeout', '--message-file', '--receipt-collector']; // Repeated in order; every other value option is single. const REPEATED = ['--host', '--ssh-opt']; // Requested destinations, so a command-wide refusal can be attributed to each. @@ -71,6 +74,14 @@ export function parse(args: string[]): Options { if (flags.has('--json') && values.get('--output-format') === 'text') throw new Refusal('conflicting_output_options'); if (!flags.has('--json') && !values.has('--output-format')) throw new Refusal('json_output_required'); const handoff = flags.has('--request-ack') || flags.has('--observe-delivery') || values.has('--correlation-id') || values.has('--wait-for') || values.has('--wait-timeout'); + if (values.has('--receipt-collector')) { + const selector = values.get('--receipt-collector')!; + if (command !== 'send' || !isAbsolute(selector) || selector.includes('\0') || hosts.length || + repeated.get('--ssh-opt')!.length || values.has('--remote-bin') || values.has('--remote-platform') || + values.has('--ssh-control-path') || values.has('--ssh-jump') || values.has('--correlation-id') || + flags.has('--observe-delivery') || values.get('--wait-for') === 'delivered' || flags.has('--allow-inactive-codex-home')) throw new Refusal('inapplicable_option'); + if (!flags.has('--dry-run') && !flags.has('--request-ack') && values.get('--wait-for') !== 'acknowledged') throw new Refusal('receipt_opt_in_required', 2); + } if (handoff) { if (command !== 'send') throw new Refusal('inapplicable_option'); if (values.has('--correlation-id') && !canonicalId(values.get('--correlation-id'))) throw new Refusal('invalid_correlation_id'); @@ -122,6 +133,7 @@ export function parse(args: string[]): Options { // Owned SSH descendant cleanup has only been qualified on POSIX origins. if (process.platform === 'win32' || values.get('--remote-platform') === 'win32') throw new Refusal('remote_handoff_unsupported', 1); } + if (values.has('--receipt-collector') && hosts.length) throw new Refusal('remote_receipt_unsupported', 1); if (command === 'send' && flags.has('--allow-inactive-codex-home')) { if (!values.get('--to')!.startsWith('codex:')) throw new Refusal('inapplicable_option'); if (!values.get('--codex-home')) throw new Refusal('inactive_opt_in_requires_explicit_home'); @@ -344,7 +356,26 @@ try { if (args.length === 1 && args[0] === REFRESH_ARG) await refreshCache(); else if (args.length === 1 && args[0] === '--version') console.log(VERSION_LINE); else if ((args.length === 1 && ['--help', '-h'].includes(args[0]!)) || - (args.length === 2 && ['list', 'send', 'doctor', 'update', 'handoff', 'ack'].includes(args[0]!) && ['--help', '-h'].includes(args[1]!))) console.log(help(args.length === 2 ? args[0] : undefined)); + (args.length === 2 && ['list', 'send', 'doctor', 'update', 'handoff', 'ack', 'receipt'].includes(args[0]!) && ['--help', '-h'].includes(args[1]!))) console.log(help(args.length === 2 ? args[0] : undefined)); + else if (args[0] === 'receipt') { + command = 'receipt'; + const operation = await receiptCommand(args, (limit, privateInput, timeoutMs) => input(limit, privateInput ?? true, timeoutMs)); + console.log(JSON.stringify(operation.raw ? operation.value : { schemaVersion: 1, host: hostname(), command, ok: operation.exitCode === 0, ...operation.value })); + process.exitCode = operation.exitCode; + if (operation.collector) { + const collector = operation.collector; let stopping = false; + const stop = (code: number) => { + if (stopping) return; stopping = true; + void collector.close().then(result => { + process.removeListener('SIGINT', interrupt); process.removeListener('SIGTERM', terminate); + process.exitCode = code; + if (result.cleanup !== 'confirmed') { console.error('receipt_cleanup_unconfirmed'); process.exit(code || 1); } + }, () => { console.error('receipt_cleanup_unconfirmed'); process.exit(code || 1); }); + }; + const interrupt = () => stop(130), terminate = () => stop(143); + process.once('SIGINT', interrupt); process.once('SIGTERM', terminate); + } + } else if (args[0] === 'handoff' || args[0] === 'ack') { command = args[0]; const operation = await handoffCommand(args, limit => input(limit, true)); @@ -392,6 +423,7 @@ try { } command = ['list', 'send', 'doctor', 'update'].includes(args[0] ?? '') ? args[0]! : 'unknown'; const options = parse(args); + if (wire && options.values.has('--receipt-collector')) throw new Refusal('remote_receipt_unsupported', 1); const handoffEnabled = options.flags.has('--request-ack') || options.flags.has('--observe-delivery') || options.values.has('--correlation-id') || options.values.has('--wait-for') || options.values.has('--wait-timeout'); if (wire && (handoffEnabled !== !!wireContext || handoffEnabled !== !!wireBudget)) throw new Refusal('remote_handoff_context_required', 1); if (wireContext && (command !== 'send' || process.platform === 'win32')) throw new Refusal('remote_handoff_unsupported', 1); @@ -471,6 +503,10 @@ try { const settings = { to: options.values.get('--to')!, home: options.values.get('--codex-home'), codexBin: options.values.get('--codex-bin'), message: message!, dryRun: options.flags.has('--dry-run'), allowInactive: options.flags.has('--allow-inactive-codex-home') }; const enabled = options.flags.has('--request-ack') || options.flags.has('--observe-delivery') || options.values.has('--correlation-id') || options.values.has('--wait-for') || options.values.has('--wait-timeout'); if (wireContext?.preparation) ({ value: result, exitCode } = await prepareHandoff(settings, undefined, options.values.get('--correlation-id'), handoffBudget)); + else if (options.values.has('--receipt-collector')) ({ value: result, exitCode } = await managedReceiptSend(settings, { + collector: options.values.get('--receipt-collector')!, requestAck: options.flags.has('--request-ack'), + waitFor: options.values.get('--wait-for') as 'acknowledged' | undefined, + seconds: parseWaitTimeout(options.values.get('--wait-timeout') ?? '30'), payload: rawMessage, budget: handoffBudget })); else if (enabled) ({ value: result, exitCode } = await handoffSend(settings, { remote: wireContext !== undefined, correlationId: options.values.get('--correlation-id'), requestAck: options.flags.has('--request-ack'), observeDelivery: options.flags.has('--observe-delivery'), waitFor: options.values.get('--wait-for') as 'delivered' | 'acknowledged' | undefined, seconds: parseWaitTimeout(options.values.get('--wait-timeout') ?? '30'), payload: rawMessage, budget: handoffBudget, resultOverhead: Buffer.byteLength(JSON.stringify(routing)) + 1024 })); else result = await send(settings); } @@ -502,8 +538,8 @@ try { } } catch (error) { // A command-wide refusal before dispatch applies to every requested host. - if (command === 'handoff' || command === 'ack') { - const failureCode = error instanceof Refusal ? error.code : 'handoff_operation_failed'; + if (command === 'handoff' || command === 'ack' || command === 'receipt') { + const failureCode = error instanceof Refusal ? error.code : command === 'receipt' ? 'receipt_operation_failed' : 'handoff_operation_failed'; console.log(JSON.stringify({ schemaVersion: 1, host: hostname(), command, ok: false, error: failureCode })); process.exitCode = error instanceof Refusal ? error.exitCode : 1; } else { diff --git a/src/discovery.ts b/src/discovery.ts index 6895692..12df2bd 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -2,7 +2,7 @@ // Windows Claude metadata uses native process inspection. import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readlinkSync, readSync, readdirSync, realpathSync, statSync, type Stats } from 'node:fs'; import { createHash } from 'node:crypto'; -import { spawnSync } from 'node:child_process'; +import { MACOS_ACL_LIMITS, inspectMacosAcls } from './macos-acl.js'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { windowsProcessStart } from './windows.js'; @@ -46,59 +46,10 @@ const claudeProofs = new WeakMap(); type InventoryProof = { path: string; direct: boolean; identity: string | null }; const claudeInventories = new WeakMap(); const claudeAclPaths = new WeakMap(); -export const CLAUDE_ACL_LIMITS = Object.freeze({ paths: 1 + 3 * CLAUDE_PROFILE_LIMIT + CLAUDE_ENTRY_LIMIT, - batchPaths: 128, argumentBytes: 32768, batchOutput: 262144, totalOutput: 8388608, batches: 128, timeoutMs: 5000 }); +export const CLAUDE_ACL_LIMITS = MACOS_ACL_LIMITS; const nodeIdentity = (s: Stats) => [s.dev, s.ino, s.uid, s.mode].join(':'); const privateNode = (s: Stats) => typeof process.getuid === 'function' && s.uid === process.getuid() && (s.mode & 0o022) === 0; const recordIdentity = (s: Stats) => [nodeIdentity(s), s.size, s.mtimeMs, s.ctimeMs, s.birthtimeMs].join(':'); -// Node exposes POSIX mode bits, not macOS extended ACLs. Conservatively reject -// ANY extended ACL on automatic registries, including owner-only ACLs. The -// trusted system metadata helper receives only absolute paths and a minimal C -// locale environment; no shell, PATH lookup, agent binary or filename parsing. -// -b escapes embedded newlines and -n avoids account-name lookups. ACL entries -// must also be rejected when an xattr '@' masks the '+' permission suffix. -function inspectClaudeAcls(input: readonly string[]): 'clear' | 'acl' | 'unknown' { - const paths = [...new Set(input)]; - if (!paths.length) return 'clear'; - if (process.platform !== 'darwin' || paths.length > CLAUDE_ACL_LIMITS.paths) return 'unknown'; - const batches: string[][] = []; let batch: string[] = [], bytes = 0; - for (const path of paths) { - const size = Buffer.byteLength(path, 'utf8') + 1; - if (!isAbsolute(path) || path.includes('\0') || size > CLAUDE_ACL_LIMITS.argumentBytes) return 'unknown'; - if (batch.length && (batch.length >= CLAUDE_ACL_LIMITS.batchPaths || bytes + size > CLAUDE_ACL_LIMITS.argumentBytes)) { - batches.push(batch); batch = []; bytes = 0; - } - batch.push(path); bytes += size; - } - if (batch.length) batches.push(batch); - if (batches.length > CLAUDE_ACL_LIMITS.batches) return 'unknown'; - const cutoff = performance.now() + CLAUDE_ACL_LIMITS.timeoutMs; let output = 0; - for (const entries of batches) { - const remaining = Math.floor(cutoff - performance.now()); - if (remaining < 1) return 'unknown'; - let done: ReturnType; - try { - done = spawnSync('/bin/ls', ['-ldebn', '--', ...entries], { encoding: 'utf8', - env: { PATH: '/usr/bin:/bin', LC_ALL: 'C' }, timeout: remaining, - maxBuffer: CLAUDE_ACL_LIMITS.batchOutput, killSignal: 'SIGKILL' }); - } catch { return 'unknown'; } - if (done.error || done.status !== 0 || done.signal || typeof done.stdout !== 'string' || - typeof done.stderr !== 'string' || done.stderr.length || performance.now() >= cutoff) return 'unknown'; - const size = Buffer.byteLength(done.stdout, 'utf8'); output += size; - if (size > CLAUDE_ACL_LIMITS.batchOutput || output > CLAUDE_ACL_LIMITS.totalOutput) return 'unknown'; - const lines = done.stdout.split('\n'); if (lines.at(-1) === '') lines.pop(); - let records = 0; - for (const line of lines) { - if (/^\s+\d+:/.test(line)) return 'acl'; - const mode = /^[-dlcbps][rwxstST-]{9}([+@]?)\s/.exec(line); - if (!mode) return 'unknown'; - if (mode[1] === '+') return 'acl'; - records++; - } - if (records !== entries.length) return 'unknown'; - } - return 'clear'; -} function safeDirectory(path: string, sameUser: boolean, direct = true): DirectoryProof { const node = direct ? lstatSync(path) : statSync(path); if (!node.isDirectory() || (sameUser && !privateNode(node))) throw new Error('unsafe_directory'); @@ -309,7 +260,7 @@ export function claudeInventory(): ClaudeInventory { recordIdentity(statSync(directory)) !== before) failure('sessions_directory_changed'); } catch (error) { failure('sessions_directory_unavailable', error); } } - const aclStatus = inspectClaudeAcls([...aclPaths]); + const aclStatus = inspectMacosAcls([...aclPaths]); if (aclStatus !== 'clear') failure(aclStatus === 'acl' ? 'claude_extended_acl' : 'claude_acl_unavailable'); for (const observed of scanEvidence) { let current: string | null; @@ -373,7 +324,7 @@ export function revalidateClaudeInventory(row: Row, other?: Row): void { // accepted from an earlier clear result. These remain observational checks, // not an atomic filesystem snapshot or a universal kernel-I/O deadline. metadataUnchanged(); - if (inspectClaudeAcls([...paths]) !== 'clear') throw new Refusal('claude_discovery_incomplete', 1); + if (inspectMacosAcls([...paths]) !== 'clear') throw new Refusal('claude_discovery_incomplete', 1); metadataUnchanged(); } export function claudeSelectionIdentity(row: Row): string { @@ -389,6 +340,17 @@ export function claudeSelectionIdentity(row: Row): string { return createHash('sha256').update(JSON.stringify(proof)).digest('hex'); } catch { throw new Refusal('target_changed', 1); } } +// Private opt-in receipt binding only. This is never a public row or native +// generation, and does not grant an action to a recipient or authenticate a model. +export type ClaudeReceiptProvenance = Readonly<{ registryDirectory: string; registryRecordPath: string; + registryIdentity: string; socketPath: string; routeIdentity: string }>; +export function claudeReceiptProvenance(row: Row): ClaudeReceiptProvenance { + const proof = claudeProofs.get(row); + if (!proof || process.platform === 'win32') throw new Refusal('receipt_bootstrap_unsupported', 1); + return { registryDirectory: proof.canonical, registryRecordPath: realpathSync(proof.record), + registryIdentity: createHash('sha256').update(proof.recordIdentity).digest('hex'), + socketPath: resolve(String(row.socket)), routeIdentity: claudeSelectionIdentity(row) }; +} export function claude(all: boolean): Row { const inventory = claudeInventory(); return { sessions: inventory.sessions.filter(row => all || row.reachable), diff --git a/src/handoff-operations.ts b/src/handoff-operations.ts index 157124f..775abf4 100644 --- a/src/handoff-operations.ts +++ b/src/handoff-operations.ts @@ -18,10 +18,26 @@ 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 }; export type OperationResult = { value: Record; exitCode: number }; +function unmanaged(path: string): string { + // A managed v2 context has one collector writer. New legacy CLI paths must + // refuse before even creating an old lock file; historical v1 stays intact. + if (process.platform !== 'win32') { + for (const marker of ['managed.json', 'collector.lock']) { + try { lstatSync(join(path, marker)); throw new Refusal('receipt_managed_context_required', 1); } + catch (error) { + if (!['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) { + if (error instanceof Refusal) throw error; + throw new Refusal('handoff_history_unavailable', 1); + } + } + } + } + return path; +} export function ledgerPath(): string { const path = process.env.SESSION_PEER_HANDOFF_HOME ?? join(homedir(), '.local', 'state', 'session-peer-ts', 'handoff'); if (!isAbsolute(path)) throw new Refusal('invalid_handoff_ledger', 2); - return resolve(path); + return unmanaged(resolve(path)); } export function privateMessage(path: string): string { const before = lstatSync(path); @@ -90,7 +106,7 @@ async function settleCutoffEdge(budget: HandoffBudget): Promise { } class BeforeEffectRefused extends Error {} export async function prepareHandoff(options: SendOptions, path = ledgerPath(), correlationId?: string, budget?: HandoffBudget): Promise { - const ledger = new HandoffLedger(path); let handoff: Handoff | undefined; + const ledger = new HandoffLedger(unmanaged(path)); let handoff: Handoff | undefined; const value = await send({ ...options, dryRun: true, hooks: { resolved(context) { budget?.beforeEffect(); handoff = ledger.prepare(binding(context, options.message), { correlationId }); @@ -98,7 +114,7 @@ export async function prepareHandoff(options: SendOptions, path = ledgerPath(), return { value: { ...value, handoff }, exitCode: 0 }; } export async function handoffSend(options: SendOptions, handoffOptions: HandoffOptions): Promise { - const ledger = new HandoffLedger(handoffOptions.ledgerPath ?? ledgerPath()); + const ledger = new HandoffLedger(unmanaged(handoffOptions.ledgerPath ?? ledgerPath())); const budget = handoffOptions.budget ?? new HandoffBudget(handoffOptions.seconds ?? 30); let handoff: Handoff | undefined, snapshot: Record | undefined; let scope: MetadataScope | undefined, queueScope: AppServerScope | undefined, deliverySupported = false, stopped = false, operationId: string | undefined; diff --git a/src/help.ts b/src/help.ts index c4ad03f..14710dc 100644 --- a/src/help.ts +++ b/src/help.ts @@ -17,6 +17,17 @@ Transport: known online peers use their MagicDNS name, known offline peers are refused. --no-update-notice Suppress the opt-in cached update notice -h, --help Show command help`; + if (command === 'receipt') return `Usage: session-peer receipt --json + init --state-dir DIR [--collector SOCKET] Explicitly create fresh private managed state + serve --state-dir DIR [--collector SOCKET] Start a foreground local collector; stop with Ctrl-C + respond --collector SOCKET --receipt - Read one private receipt from stdin; never prompt an agent + status|revoke --collector SOCKET --ledger-epoch UUID --correlation-id UUID + wait --collector SOCKET --ledger-epoch UUID --correlation-id UUID [--wait-timeout SECONDS] +Explicit POSIX local owner operations only; no automatic start, remote collector, migration or resend. +Only use separately permitted private stdin/FD tooling. Never put receipt capability data in argv, +environment, URI, shell command text, public output or sender/collector files. +Receipt evidence is delegated token possession, not model identity, consumption or completion. +Status/wait can retain private classifications; they never submit native messages.`; if (command === 'handoff' || command === 'ack') return `Usage: session-peer handoff init|prepare|status|wait|confirm --json init Initialize private POSIX sender ledger explicitly prepare --to TARGET --message-file FILE Reserve a fresh ID without submitting (FILE mode 0600) @@ -60,7 +71,8 @@ TARGET: Claude name/PID, claude:NAME/PID, codex:UUID, or session-peer reply URI. Omit the body or use - for UTF-8 stdin. Only one body source is permitted. Unicode names use exact Unicode 14.0.0 default casefold; collisions require PID. --correlation-id UUID Use an already-prepared private handoff ID (opt-in) - --request-ack Add handoff metadata; automated ACK currently unsupported + --request-ack Add handoff metadata; no implicit receipt bootstrap + --receipt-collector SOCKET Explicit local receipt bootstrap (requires --request-ack or ACK wait) --observe-delivery Request optional body-free observation (qualified routes only) --wait-for delivered|acknowledged Refuse before submission if required evidence is unsupported --wait-timeout SECONDS Total budget 1..60 (default 30, includes 5 s cleanup) @@ -85,8 +97,9 @@ Usage: session-peer [options] doctor Inspect read-only metadata and capabilities update --check Check npm for a newer version (no self-update) handoff / ack Opt-in private evidence operations (see handoff --help) + receipt Explicit local receipt collector and private handler operations --version Print exact runtime version --help, -h Show help; --help for details ${common} -No Relay server, MCP, wake, wait or automatic ACK support.`; +No Relay server, MCP or wake support. Receipts never imply automatic model ACK or permission to act.`; } diff --git a/src/macos-acl.ts b/src/macos-acl.ts new file mode 100644 index 0000000..83b408a --- /dev/null +++ b/src/macos-acl.ts @@ -0,0 +1,52 @@ +// Shared bounded macOS ACL metadata. No shell, arbitrary executable or file contents. +import {spawnSync} from 'node:child_process'; +import {isAbsolute} from 'node:path'; +export const MACOS_ACL_LIMITS = Object.freeze({paths:4481,batchPaths:128,argumentBytes:32768,batchOutput:262144,totalOutput:8388608,batches:128,timeoutMs:5000}); +// Node exposes POSIX mode bits, not macOS extended ACLs. Conservatively reject +// ANY extended ACL on automatic registries, including owner-only ACLs. The +// trusted system metadata helper receives only absolute paths and a minimal C +// locale environment; no shell, PATH lookup, agent binary or filename parsing. +// -b escapes embedded newlines and -n avoids account-name lookups. ACL entries +// must also be rejected when an xattr '@' masks the '+' permission suffix. +export function inspectMacosAcls(input: readonly string[]): 'clear' | 'acl' | 'unknown' { + const paths = [...new Set(input)]; + if (!paths.length) return 'clear'; + if (process.platform !== 'darwin' || paths.length > MACOS_ACL_LIMITS.paths) return 'unknown'; + const batches: string[][] = []; let batch: string[] = [], bytes = 0; + for (const path of paths) { + const size = Buffer.byteLength(path, 'utf8') + 1; + if (!isAbsolute(path) || path.includes('\0') || size > MACOS_ACL_LIMITS.argumentBytes) return 'unknown'; + if (batch.length && (batch.length >= MACOS_ACL_LIMITS.batchPaths || bytes + size > MACOS_ACL_LIMITS.argumentBytes)) { + batches.push(batch); batch = []; bytes = 0; + } + batch.push(path); bytes += size; + } + if (batch.length) batches.push(batch); + if (batches.length > MACOS_ACL_LIMITS.batches) return 'unknown'; + const cutoff = performance.now() + MACOS_ACL_LIMITS.timeoutMs; let output = 0; + for (const entries of batches) { + const remaining = Math.floor(cutoff - performance.now()); + if (remaining < 1) return 'unknown'; + let done: ReturnType; + try { + done = spawnSync('/bin/ls', ['-ldebn', '--', ...entries], { encoding: 'utf8', + env: { PATH: '/usr/bin:/bin', LC_ALL: 'C' }, timeout: remaining, + maxBuffer: MACOS_ACL_LIMITS.batchOutput, killSignal: 'SIGKILL' }); + } catch { return 'unknown'; } + if (done.error || done.status !== 0 || done.signal || typeof done.stdout !== 'string' || + typeof done.stderr !== 'string' || done.stderr.length || performance.now() >= cutoff) return 'unknown'; + const size = Buffer.byteLength(done.stdout, 'utf8'); output += size; + if (size > MACOS_ACL_LIMITS.batchOutput || output > MACOS_ACL_LIMITS.totalOutput) return 'unknown'; + const lines = done.stdout.split('\n'); if (lines.at(-1) === '') lines.pop(); + let records = 0; + for (const line of lines) { + if (/^\s+\d+:/.test(line)) return 'acl'; + const mode = /^[-dlcbps][rwxstST-]{9}([+@]?)\s/.exec(line); + if (!mode) return 'unknown'; + if (mode[1] === '+') return 'acl'; + records++; + } + if (records !== entries.length) return 'unknown'; + } + return 'clear'; +} diff --git a/src/output.ts b/src/output.ts index 089075d..78be8a7 100644 --- a/src/output.ts +++ b/src/output.ts @@ -41,7 +41,8 @@ export function renderOutput(value: Record | Record | Record; lines.push(`Handoff: ${safe(h.correlationId)} (${safe(h.state)})`); + if (h.schemaVersion === 2) lines.push('Receipt scope: delegated context; native generation and model identity are not established.'); for (const key of ['observation', 'ack', 'wait']) lines.push(`${key}: ${safe(JSON.stringify(h[key]))}`); } + if (value.receiptBootstrap && typeof value.receiptBootstrap === 'object') + lines.push(`Receipt bootstrap: ${safe(JSON.stringify(value.receiptBootstrap))}`); if (value.sshTransport && typeof value.sshTransport === 'object') { const transport = value.sshTransport as Record; lines.push(`SSH transport: ${safe(transport.status)} (${safe(transport.reason)}); verified remote facts retained.`); diff --git a/src/receipt-collector.ts b/src/receipt-collector.ts new file mode 100644 index 0000000..2163b6b --- /dev/null +++ b/src/receipt-collector.ts @@ -0,0 +1,186 @@ +// Explicit local receipt-only IPC. No native agent, shell, network or transcript calls. +import { randomUUID } from 'node:crypto'; +import { chmodSync, lstatSync, realpathSync, type BigIntStats } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { createConnection, createServer, type Server, type Socket } from 'node:net'; +import { Refusal } from './discovery.js'; +import { inspectMacosAcls } from './macos-acl.js'; +import { ReceiptState } from './receipt-state.js'; +import { RECEIPT_LIMITS, RECEIPT_PAIRS, parseReceiptRequest, parseReceiptResponse, + publicReceiptError, validateReceiptRequest, validateReceiptResponse, + type ReceiptOperation, type ReceiptRequest, type ReceiptRequestBody, type ReceiptResponse } from './receipt-protocol.js'; + +const fail = (code: string): never => { throw new Refusal(code, 1); }; +function privateParent(path: string): void { + if (process.platform === 'win32' || process.getuid?.() === undefined) fail('receipt_storage_unsupported'); + if (!isAbsolute(path) || resolve(path) !== path || Buffer.byteLength(path) > 100 || path.includes('\0')) fail('receipt_endpoint_untrusted'); + const parent = dirname(path), stat = lstatSync(parent); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid!() || (stat.mode & 0o777) !== 0o700 || + realpathSync(parent) !== parent) fail('receipt_endpoint_untrusted'); +} +function identity(stat: BigIntStats): string { + return [stat.dev,stat.ino,stat.uid,stat.mode,stat.ctimeNs,stat.birthtimeNs].join(':'); +} +export function receiptEndpoint(path: string): string { + try { + privateParent(path); + const stat = lstatSync(path, { bigint: true }); + if (!stat.isSocket() || stat.uid !== BigInt(process.getuid!()) || (stat.mode & 0o777n) !== 0o600n) fail('receipt_endpoint_untrusted'); + if (process.platform === 'darwin' && (inspectMacosAcls([dirname(path), path]) !== 'clear' || + identity(lstatSync(path, { bigint: true })) !== identity(stat))) fail('receipt_endpoint_untrusted'); + return identity(stat); + } catch (error) { + if (error instanceof Refusal) throw error; + if (['ENOENT','ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) return fail('receipt_collector_unavailable'); + return fail('receipt_endpoint_untrusted'); + } +} +const frame = (value: unknown) => Buffer.from(JSON.stringify(value) + '\n'); +export type Collector = { contextId: string; ledgerEpoch: string; socketPath: string; + close: () => Promise<{ cleanup: 'confirmed' | 'unconfirmed' }> }; +// Retain an unreferenced listener if its pathname was replaced. Node's normal +// server.close() unlinks that pathname, including a replacement regular file. +// Closing an untrusted pathname is therefore refused; process exit closes the +// orphaned FD. This is an explicitly unconfirmed cleanup, never deletion authority. +const orphanedListeners = new Set(); +export async function startReceiptCollector(stateDir: string, socketPath: string): Promise { + privateParent(socketPath); + if (dirname(socketPath) !== resolve(stateDir)) fail('receipt_endpoint_untrusted'); + const state = new ReceiptState(stateDir); state.open(); + try { + if (state.endpointSelector !== socketPath) fail('receipt_endpoint_untrusted'); + try { lstatSync(socketPath); fail('receipt_endpoint_exists'); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + } catch (error) { state.close(); throw error; } + let stopped = false, busy = false, original = ''; + const connections = new Set(); + const server = createServer({ allowHalfOpen: true }, socket => { + if (stopped || connections.size >= RECEIPT_LIMITS.connections) { socket.destroy(); return; } + connections.add(socket); + const started = performance.now(); let bytes = Buffer.alloc(0), handled = false; + const timeout = setTimeout(() => socket.destroy(), RECEIPT_LIMITS.readMs); + socket.on('error', () => {}); + socket.once('close', () => { clearTimeout(timeout); connections.delete(socket); }); + socket.on('data', chunk => { + if (handled) { socket.destroy(); return; } + if (performance.now() - started >= RECEIPT_LIMITS.exchangeMs || bytes.length + chunk.length > RECEIPT_LIMITS.frame) { socket.destroy(); return; } + bytes = Buffer.concat([bytes, chunk]); + }); + // EOF is part of the one-frame request: do not mutate state merely because + // an early chunk ends in LF and then accept delayed trailing pollution. + socket.once('end', () => { + if (handled || socket.destroyed) return; + handled = true; clearTimeout(timeout); + if (performance.now() - started >= RECEIPT_LIMITS.readMs) { socket.destroy(); return; } + let request: ReceiptRequest; + try { request = parseReceiptRequest(bytes); } catch { socket.destroy(); return; } + let response: ReceiptResponse; + try { + if (stopped || receiptEndpoint(socketPath) !== original) fail('receipt_endpoint_changed'); + if (request.collectorContextId !== null && request.collectorContextId !== state.collectorContextId) fail('receipt_context_mismatch'); + if (busy) fail('receipt_collector_busy'); + busy = true; + const body = dispatch(state, request); + response = validateReceiptResponse({ ipcVersion: 2, requestId: request.requestId, + collectorContextId: state.collectorContextId, ok: true, body }, request); + } catch (error) { + response = { ipcVersion: 2, requestId: request.requestId, collectorContextId: state.collectorContextId, + ok: false, error: publicReceiptError(error) }; + } finally { busy = false; } + const remaining = Math.floor(RECEIPT_LIMITS.exchangeMs - (performance.now() - started)); + if (remaining <= 0) { socket.destroy(); return; } + const encoded = frame(response); + if (encoded.length > RECEIPT_LIMITS.frame) { socket.destroy(); return; } + const output = setTimeout(() => socket.destroy(), Math.min(RECEIPT_LIMITS.writeMs, remaining)); + socket.once('close', () => clearTimeout(output)); + socket.end(encoded); + }); + }); + try { + await new Promise((ok, reject) => { + server.once('error', reject); server.listen(socketPath, () => { server.removeListener('error', reject); ok(); }); + }); + chmodSync(socketPath, 0o600); original = receiptEndpoint(socketPath); + } catch (error) { + state.close(); + if (server.listening) { server.unref(); orphanedListeners.add(server); } + throw error instanceof Refusal ? error : new Refusal('receipt_endpoint_untrusted', 1); + } + server.on('error', () => {}); + let closing: Promise<{ cleanup: 'confirmed' | 'unconfirmed' }> | undefined; + return { contextId: state.collectorContextId, ledgerEpoch: state.ledgerEpoch, socketPath, + close() { + if (closing) return closing; + closing = (async () => { + stopped = true; for (const socket of connections) socket.destroy(); + let safe = false; + try { safe = receiptEndpoint(socketPath) === original; } catch { /* Never unlink an untrusted replacement. */ } + let cleanup: 'confirmed' | 'unconfirmed' = 'unconfirmed'; + if (safe) { + await new Promise(ok => server.close(() => ok())); + try { lstatSync(socketPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') cleanup = 'confirmed'; } + } else { server.unref(); orphanedListeners.add(server); } + try { state.close(); } catch { cleanup = 'unconfirmed'; } + return { cleanup }; + })(); + return closing; + } + }; +} +function dispatch(state: ReceiptState, request: ReceiptRequest): unknown { + state.assertOpen(); + switch (request.op) { + case 'hello': return { clockReferenceMs: state.clockReference(), receiptSchemas: RECEIPT_PAIRS, managedStoreVersion: 2, + endpointOwner: { uid: process.getuid!(), pid: process.pid } }; + case 'prepare': return state.prepare(request.body); + case 'mint': return { capability: state.mint(request.body).capability }; + case 'fence': return { handoff: state.fence(request.body) }; + case 'submission': return { handoff: state.submission(request.body) }; + case 'status': return { handoff: state.status(request.body) }; + case 'wait-open': return { handoff: state.waitOpen(request.body) }; + case 'wait-status': return { handoff: state.waitStatus(request.body) }; + case 'wait-stop': return { handoff: state.waitStop(request.body) }; + case 'revoke': return { handoff: state.revoke(request.body) }; + case 'receipt': { + const received = state.receipt(request.body.receipt); + return { ...received, ledgerEpoch: received.handoff.ledgerEpoch, correlationId: received.handoff.correlationId }; + } + } +} +export async function receiptExchange(socketPath: string, context: string | null, + op: K, body: ReceiptRequestBody[K], timeoutMs: number = RECEIPT_LIMITS.exchangeMs): Promise { + const started = performance.now(); + if (!Number.isFinite(timeoutMs) || timeoutMs < 1) fail('deadline_before_effect'); + const duration = Math.min(Math.floor(timeoutMs), RECEIPT_LIMITS.exchangeMs); + const request = validateReceiptRequest({ ipcVersion: 2, requestId: randomUUID(), collectorContextId: context, op, body }); + const before = receiptEndpoint(socketPath), encoded = frame(request); + if (performance.now() - started >= duration) fail('deadline_before_effect'); + return new Promise((ok, reject) => { + let attempted = false, finished = false, input = Buffer.alloc(0); + const connection = createConnection(socketPath); + const finish = (error?: unknown, response?: ReceiptResponse) => { + if (finished) return; + finished = true; clearTimeout(timer); connection.destroy(); + if (error) reject(error); else ok(response!); + }; + const timer = setTimeout(() => finish(new Refusal(attempted ? 'receipt_transport_unknown' : 'receipt_transport_timeout', 1)), + Math.max(1, Math.min(RECEIPT_LIMITS.readMs, duration - (performance.now() - started)))); + connection.once('connect', () => { + try { + if (receiptEndpoint(socketPath) !== before) fail('receipt_endpoint_changed'); + if (performance.now() - started >= duration) fail('deadline_before_effect'); + attempted = true; connection.end(encoded); + } catch (error) { finish(error instanceof Refusal ? error : new Refusal('receipt_endpoint_untrusted', 1)); } + }); + connection.on('data', chunk => { + if (input.length + chunk.length > RECEIPT_LIMITS.frame) { finish(new Refusal('receipt_transport_unknown', 1)); return; } + input = Buffer.concat([input, chunk]); + }); + connection.once('end', () => { + try { finish(undefined, parseReceiptResponse(input, request)); } + catch { finish(new Refusal('receipt_transport_unknown', 1)); } + }); + connection.once('error', () => finish(new Refusal(attempted ? 'receipt_transport_unknown' : 'receipt_collector_unavailable', 1))); + connection.once('close', () => { if (!finished) finish(new Refusal(attempted ? 'receipt_transport_unknown' : 'receipt_collector_unavailable', 1)); }); + }); +} diff --git a/src/receipt-command.ts b/src/receipt-command.ts new file mode 100644 index 0000000..e0dca94 --- /dev/null +++ b/src/receipt-command.ts @@ -0,0 +1,175 @@ +// Explicit owner commands for the local receipt-only collector. This module +// never launches programs, submits native input or discovers another endpoint. +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { lstatSync, realpathSync } from 'node:fs'; +import { setTimeout as delay } from 'node:timers/promises'; +import { Refusal } from './discovery.js'; +import { HandoffBudget, canonicalId, parseWaitTimeout, safeId } from './handoff.js'; +import { ReceiptState } from './receipt-state.js'; +import { receiptExchange, startReceiptCollector, type Collector } from './receipt-collector.js'; +import { RECEIPT_LIMITS, parseProducerProbeRequest, publicReceiptError, validateManagedHandoff, validateManagedReceipt, + type ManagedHandoff, type ReceiptResponse } from './receipt-protocol.js'; +import { VERSION } from './protocol.js'; + +export type ReceiptCommandResult = { value: Record; exitCode: number; raw?: boolean; collector?: Collector }; +const operations = ['init','serve','respond','status','wait','revoke','probe'] as const; +type Operation = typeof operations[number]; +type Reader = (limit: number, privateInput?: boolean, timeoutMs?: number) => Promise; +function fail(code: string, exit = 2): never { throw new Refusal(code, exit); } +function options(args: string[]): { operation: Operation; values: Map } { + if (args[0] !== 'receipt' || !operations.includes(args[1] as Operation)) fail('unsupported_receipt_command'); + const operation = args[1] as Operation, values = new Map(); let json = false; + const names = ['--state-dir','--collector','--ledger-epoch','--correlation-id','--wait-timeout','--receipt']; + for (let i = 2; i < args.length; i++) { + const token = args[i]!, equal = token.indexOf('='), key = equal < 0 ? token : token.slice(0,equal); + if (key === '--json') { if (json || equal >= 0) fail('invalid_option'); json = true; continue; } + if (!names.includes(key)) fail('unsupported_option'); + if (values.has(key)) fail('invalid_option'); + const value = equal < 0 ? args[++i] : token.slice(equal+1); + if (value === undefined || (equal < 0 && value.startsWith('--'))) fail('invalid_option'); + values.set(key,value); + } + if (!json) fail('json_output_required'); + const allowed: Record = { + init: ['--state-dir','--collector'], serve: ['--state-dir','--collector'], + respond: ['--collector','--receipt'], status: ['--collector','--ledger-epoch','--correlation-id'], + wait: ['--collector','--ledger-epoch','--correlation-id','--wait-timeout'], + revoke: ['--collector','--ledger-epoch','--correlation-id'], probe: [] + }; + if ([...values.keys()].some(key => !allowed[operation].includes(key))) fail('inapplicable_option'); + return { operation, values }; +} +function absolute(value: string | undefined, code: string, maximum: number = 4096): string { + if (!safeId(value,maximum) || !isAbsolute(value) || resolve(value) !== value) fail(code); + return value; +} +function success(response: ReceiptResponse): Record { + if (!response.ok) throw new Refusal(response.error,1); + return response.body; +} +function unknown(error: unknown, handoff?: ManagedHandoff, exit = 1): ReceiptCommandResult | undefined { + if (!(error instanceof Refusal) || error.code !== 'receipt_transport_unknown') return undefined; + return { value: { ok: false, status: 'unknown', error: 'receipt_transport_unknown', retryAllowed: false, + ...(handoff ? { handoff } : {}) }, exitCode: exit }; +} +function waitResult(handoff: ManagedHandoff): ReceiptCommandResult { + const satisfied = handoff.wait.status === 'satisfied'; + return { value: { ok: satisfied, handoff }, exitCode: satisfied ? 0 : handoff.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator' ? 130 : 1 }; +} +async function privateInput(readInput: Reader): Promise { + let raw: string; + try { raw = await readInput(RECEIPT_LIMITS.privateFrame,true,RECEIPT_LIMITS.readMs); } + catch { throw new Refusal('invalid_receipt_protocol',1); } + if (typeof raw !== 'string') throw new Refusal('invalid_receipt_protocol',1); + if (Buffer.byteLength(raw,'utf8') > RECEIPT_LIMITS.privateFrame) throw new Refusal('receipt_frame_too_large',1); + return raw; +} +function platform(): void { + if (process.platform === 'win32' || process.getuid?.() === undefined) throw new Refusal('receipt_storage_unsupported',1); +} +// Initialization may create only its explicitly selected leaf. Reject an alias +// before creating files that could not later pass the collector endpoint guard. +function statePath(value: string | undefined): string { + const path = absolute(value,'invalid_receipt_state'); + try { + let present = true; + try { lstatSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') present = false; else throw error; } + const existing = present ? path : dirname(path); + if (realpathSync(existing) !== existing) throw new Error(); + } catch { throw new Refusal('receipt_storage_untrusted',1); } + return path; +} +async function wait(socket: string, epoch: string, id: string, seconds: number): Promise { + const budget = new HandoffBudget(seconds); let interrupted = false, stopAttempted = false; + let latest: ManagedHandoff | undefined, original: ManagedHandoff | undefined; + let context: string | undefined, operationId: string | undefined; + const selector = { ledgerEpoch: epoch, correlationId: id }; + const stop = () => { interrupted = true; }; + const accept = (value: unknown): ManagedHandoff => { + const next = validateManagedHandoff(value); + if (next.ledgerEpoch !== epoch || next.correlationId !== id || next.wait.for !== 'acknowledged' || !canonicalId(next.wait.operationId)) throw new Refusal('invalid_receipt_protocol',1); + if (original && (next.schemaVersion !== original.schemaVersion || next.targetGeneration !== original.targetGeneration || + next.wait.operationId !== operationId || next.wait.deadlineAtUtcMs !== original.wait.deadlineAtUtcMs || + (original.schemaVersion === 2 && (next.schemaVersion !== 2 || next.receiptContext.id !== original.receiptContext.id)))) throw new Refusal('receipt_binding_conflict',1); + if (!original) { original = structuredClone(next); operationId = next.wait.operationId; } + return next; + }; + const retained = (error: unknown): ReceiptCommandResult => ({ value: { ok: false, status: 'unknown', + error: publicReceiptError(error), retryAllowed: false, ...(latest ? { handoff: latest } : {}) }, exitCode: latest?.wait.status === 'stopped' && latest.wait.reason === 'stopped_by_operator' ? 130 : 1 }); + process.on('SIGINT',stop); process.on('SIGTERM',stop); + try { + const hello = await receiptExchange(socket,null,'hello',{},budget.remainingMs()); + const remainingMs = Math.floor(budget.remainingMs()), capabilities = success(hello); context = hello.collectorContextId; + if (!('clockReferenceMs' in capabilities) || remainingMs < 1) throw new Refusal('deadline_before_effect',1); + const opened = success(await receiptExchange(socket,context,'wait-open', + { ...selector, goal: 'acknowledged', timeoutSeconds: seconds, clockReferenceMs: capabilities.clockReferenceMs, remainingMs },budget.remainingMs())); + latest = accept(opened.handoff); + while (latest.wait.status === 'pending') { + if (budget.remainingMs() <= 0) return retained(new Refusal('receipt_transport_unknown',1)); + if (interrupted && !stopAttempted) { + stopAttempted = true; + latest = accept(success(await receiptExchange(socket,context,'wait-stop', + { ...selector, operationId: operationId! },budget.remainingMs())).handoff); + return waitResult(latest); + } + await delay(Math.min(100,Math.max(1,budget.remainingMs()))); + if (interrupted) continue; + latest = accept(success(await receiptExchange(socket,context,'wait-status', + { ...selector, operationId: operationId! },budget.remainingMs())).handoff); + } + return waitResult(latest); + } catch (error) { + // A terminal actor result may win the race against the explicit stop. Read + // that same operation once; do not retry stop, renew a wait or adopt an owner. + if (latest && stopAttempted && error instanceof Refusal && error.code === 'receipt_wait_terminal' && + context && operationId && budget.remainingMs() > 0) { + try { + const terminal = accept(success(await receiptExchange(socket,context,'wait-status', + { ...selector, operationId },budget.remainingMs())).handoff); + if (terminal.wait.status !== 'pending') { latest = terminal; return waitResult(terminal); } + } catch (readError) { return retained(readError); } + } + if (latest) return retained(error); + const result = unknown(error,undefined,1); if (result) return result; + if (interrupted) return { value: { ok: false, error: publicReceiptError(error), retryAllowed: false }, exitCode: 1 }; + throw error; + } finally { process.removeListener('SIGINT',stop); process.removeListener('SIGTERM',stop); } +} +export async function receiptCommand(args: string[], readInput: Reader): Promise { + const { operation, values } = options(args); + if (operation === 'probe') { + const request = parseProducerProbeRequest(await privateInput(readInput)); + return { value: { ...request, implementation: 'typescript', runtimeVersion: VERSION }, exitCode: 0, raw: true }; + } + platform(); + if (operation === 'init' || operation === 'serve') { + const state = statePath(values.get('--state-dir')); + const socket = absolute(values.get('--collector') ?? join(state,'s'),'receipt_endpoint_untrusted',100); + if (dirname(socket) !== state) throw new Refusal('receipt_endpoint_untrusted',1); + if (operation === 'init') return { value: { ...ReceiptState.initialize(state,socket), collector: socket }, exitCode: 0 }; + const collector = await startReceiptCollector(state,socket); + return { value: { status: 'ready', collector: socket, collectorContextId: collector.contextId, + ledgerEpoch: collector.ledgerEpoch }, exitCode: 0, collector }; + } + const socket = absolute(values.get('--collector'),'receipt_endpoint_untrusted',100); + if (operation === 'respond') { + if (values.get('--receipt') !== '-') fail('private_receipt_stdin_required'); + const raw = await privateInput(readInput), receipt = validateManagedReceipt(raw); + try { + const hello = await receiptExchange(socket,null,'hello',{}), capabilities = success(hello); + const scope = receipt.schemaVersion === 1 ? 'original_generation' : 'delegated_context'; + if (!capabilities.receiptSchemas.some((pair: any) => pair.receiptSchemaVersion === receipt.schemaVersion && pair.receiptScope === scope)) throw new Refusal('receipt_scope_unsupported',1); + const result = success(await receiptExchange(socket,hello.collectorContextId,'receipt',{ receipt: raw })); + return { value: result, exitCode: 0 }; + } catch (error) { const result = unknown(error); if (result) return result; throw error; } + } + const epoch = values.get('--ledger-epoch'), id = values.get('--correlation-id'); + if (!canonicalId(epoch)) fail('invalid_ledger_epoch'); + if (!canonicalId(id)) fail('invalid_correlation_id'); + if (operation === 'wait') return wait(socket,epoch,id,parseWaitTimeout(values.get('--wait-timeout') ?? '30')); + try { + const hello = await receiptExchange(socket,null,'hello',{}); success(hello); + const result = success(await receiptExchange(socket,hello.collectorContextId,operation,{ ledgerEpoch: epoch, correlationId: id })); + return { value: result, exitCode: 0 }; + } catch (error) { const result = unknown(error); if (result) return result; throw error; } +} diff --git a/src/receipt-producer.ts b/src/receipt-producer.ts new file mode 100644 index 0000000..812bf56 --- /dev/null +++ b/src/receipt-producer.ts @@ -0,0 +1,74 @@ +// Local artifact continuity and a private nonce challenge; this is not code authentication. +import { createHash, randomUUID } from 'node:crypto'; +import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readSync, realpathSync, type Stats } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Refusal } from './discovery.js'; +import { inspectMacosAcls } from './macos-acl.js'; +import { run } from './process.js'; +import { VERSION } from './protocol.js'; +import { parseProducerProbeResponse, type ProducerEvidence, type ReceiptPair } from './receipt-protocol.js'; +const fail = (): never => { throw new Refusal('receipt_producer_mismatch', 1); }; +const identity = (s: Stats) => [s.dev,s.ino,s.uid,s.mode,s.size,s.mtimeMs,s.ctimeMs,s.birthtimeMs].join(':'); +export async function validateReceiptProducer(pair: ReceiptPair, options: { timeoutMs?: number } = {}): Promise<{ evidence: ProducerEvidence; revalidate(): void }> { + const entry = fileURLToPath(new URL('./cli.js', import.meta.url)), directory = dirname(entry), node = realpathSync(process.execPath); + const pinned = new Map(), hash = createHash('sha256'); + const inspect = (path: string, directory = false) => { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || + ![0,process.getuid?.()].includes(stat.uid) || (stat.mode & 0o022) !== 0) fail(); + return stat; + }; + try { + pinned.set(directory, identity(inspect(directory,true))); + pinned.set(node, identity(inspect(node))); + const names: string[] = [], listing = opendirSync(directory); + try { + let count = 0; + for (;;) { + const item = listing.readSync(); if (!item) break; + if (++count > 128) fail(); + if (item.name.endsWith('.js')) names.push(item.name); + } + } finally { listing.closeSync(); } + names.sort(); + if (!names.length || !names.includes('cli.js')) fail(); + let total = 0; + for (const name of names) { + const path = join(directory,name), stat = inspect(path); + if (stat.size > 4 * 1024 * 1024 || total + stat.size > 16 * 1024 * 1024) fail(); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + if (identity(fstatSync(fd)) !== identity(stat)) fail(); + const buffer = Buffer.alloc(stat.size + 1); let length = 0; + while (length < buffer.length) { + const got = readSync(fd,buffer,length,buffer.length-length,null); + if (!got) break; length += got; + } + total += length; + if (length !== stat.size || total > 16 * 1024 * 1024 || identity(fstatSync(fd)) !== identity(stat) || identity(inspect(path)) !== identity(stat)) fail(); + const bytes = buffer.subarray(0,length); + hash.update(name).update('\0').update(bytes); pinned.set(path,identity(stat)); + } finally { closeSync(fd); } + } + const revalidate = () => { + try { + for (const [path,value] of pinned) if (identity(inspect(path,path === directory)) !== value) fail(); + if (process.platform === 'darwin' && inspectMacosAcls([...pinned.keys()]) !== 'clear') fail(); + for (const [path,value] of pinned) if (identity(inspect(path,path === directory)) !== value) fail(); + } catch { fail(); } + }; + revalidate(); + const challenge = { producerProtocolVersion: 1 as const, challengeId: randomUUID(), receiptSchemaVersion: pair.receiptSchemaVersion, receiptScope: pair.receiptScope }; + const timeout = Math.min(2000, Math.floor(options.timeoutMs ?? 2000)); + if (timeout < 1) fail(); + const result = await run(node,[entry,'receipt','probe','--json'],{input:JSON.stringify(challenge)+'\n',timeout,limit:4096,rawStdout:true, + env:{ PATH:'/usr/bin:/bin', LANG:'C', LC_ALL:'C' }}); + if (result.interrupted || result.code !== 0 || !result.spawned || result.stderr.length) fail(); + const response = parseProducerProbeResponse(result.stdoutBytes ?? Buffer.from(result.stdout),challenge); + if (response.runtimeVersion !== VERSION || response.implementation !== 'typescript') fail(); + revalidate(); + return { evidence:{protocolVersion:1,artifactSha256:hash.digest('hex'),challengeId:challenge.challengeId, + receiptSchemaVersion:pair.receiptSchemaVersion,receiptScope:pair.receiptScope}, revalidate }; + } catch { return fail(); } +} diff --git a/src/receipt-protocol.ts b/src/receipt-protocol.ts new file mode 100644 index 0000000..ccaf4f9 --- /dev/null +++ b/src/receipt-protocol.ts @@ -0,0 +1,303 @@ +// Pure local receipt protocol. No filesystem, transport, process or native input. +import { posix } from 'node:path'; +import { Refusal } from './discovery.js'; +import { canonicalId, closed, HANDOFF_LIMITS, parseStrictJson, safeId, safeInteger, + validateHandoff, validateReceipt, type Handoff, type Receipt } from './handoff.js'; + +export const RECEIPT_LIMITS = Object.freeze({ frame: 16384, privateFrame: 4096, depth: 16, nodes: 1024, + connections: 16, readMs: 2000, writeMs: 2000, exchangeMs: 5000, activeWaits: 128 }); +export type ReceiptScope = 'original_generation' | 'delegated_context'; +export type ReceiptPair = { agent: 'codex'; receiptSchemaVersion: 1; receiptScope: 'original_generation' } | + { agent: 'claude'; receiptSchemaVersion: 2; receiptScope: 'delegated_context' }; +export const RECEIPT_PAIRS: readonly ReceiptPair[] = Object.freeze([ + Object.freeze({ agent: 'codex', receiptSchemaVersion: 1, receiptScope: 'original_generation' }), + Object.freeze({ agent: 'claude', receiptSchemaVersion: 2, receiptScope: 'delegated_context' }) +]); +export type ReceiptBinding = { + agent: 'codex'; destination: string; target: string; home: string; writerIdentity: string; + generation: string; payloadDigest: string; +} | { + agent: 'claude'; destination: string; target: string; registryDirectory: string; registryRecordPath: string; + registryIdentity: string; socketPath: string; routeIdentity: string; generation: null; payloadDigest: string; +}; +export type ProducerEvidence = { protocolVersion: 1; artifactSha256: string; challengeId: string; + receiptSchemaVersion: 1 | 2; receiptScope: ReceiptScope }; +export type ReceiptV2 = { schemaVersion: 2; kind: 'receipt'; receiptScope: 'delegated_context'; + ledgerEpoch: string; correlationId: string; receiptContextId: string; receiptId: string; capability: string }; +export type ManagedReceipt = Receipt | ReceiptV2; +export type HandoffV2 = Omit & { + schemaVersion: 2; targetGeneration: null; receiptContext: { kind: 'delegated_context'; id: string }; +}; +export type ManagedHandoff = Handoff | HandoffV2; +export type ReceiptHandle = { ledgerEpoch: string; correlationId: string; receiptContextId: string | null }; +export type ReceiptRequestBody = { + hello: Record; + prepare: { binding: ReceiptBinding; receiptSchemaVersion: 1 | 2; receiptScope: ReceiptScope; producerEvidence: ProducerEvidence }; + mint: ReceiptHandle & { producerProtocolVersion: 1 }; + fence: ReceiptHandle & { binding: ReceiptBinding }; + submission: ReceiptHandle & { status: 'submitted' | 'refused' | 'unknown' }; + status: { ledgerEpoch: string; correlationId: string }; + 'wait-open': { ledgerEpoch: string; correlationId: string; goal: 'acknowledged'; timeoutSeconds: number; clockReferenceMs: number; remainingMs: number }; + 'wait-status': { ledgerEpoch: string; correlationId: string; operationId: string }; + 'wait-stop': { ledgerEpoch: string; correlationId: string; operationId: string }; + revoke: { ledgerEpoch: string; correlationId: string }; + receipt: { receipt: string }; +}; +export type ReceiptOperation = keyof ReceiptRequestBody; +export type ReceiptRequest = { [K in ReceiptOperation]: { + ipcVersion: 2; requestId: string; collectorContextId: K extends 'hello' ? null : string; op: K; body: ReceiptRequestBody[K] +} }[ReceiptOperation]; +export type ReceiptResponseBody = { + hello: { clockReferenceMs: number; receiptSchemas: ReceiptPair[]; managedStoreVersion: 2; endpointOwner: { uid: number; pid: number } }; + prepare: ReceiptHandle & { handoff: ManagedHandoff }; + mint: { capability: string }; + fence: { handoff: ManagedHandoff }; submission: { handoff: ManagedHandoff }; status: { handoff: ManagedHandoff }; + 'wait-open': { handoff: ManagedHandoff }; 'wait-status': { handoff: ManagedHandoff }; 'wait-stop': { handoff: ManagedHandoff }; + revoke: { handoff: ManagedHandoff }; + receipt: { status: 'awaiting_submission' | 'acknowledged'; ledgerEpoch: string; correlationId: string; + receiptId: string; handoff: ManagedHandoff }; +}; +export type ReceiptResponse = { + ipcVersion: 2; requestId: string; collectorContextId: string; +} & ({ ok: true; body: ReceiptResponseBody[ReceiptOperation] } | { ok: false; error: ReceiptErrorCode }); +export const RECEIPT_ERROR_CODES = Object.freeze([ + 'invalid_receipt_protocol', 'invalid_handoff_json', 'handoff_frame_too_large', 'invalid_receipt', 'invalid_handoff', 'receipt_ipc_version_unsupported', 'receipt_schema_unsupported', 'receipt_scope_unsupported', + 'receipt_storage_unsupported', 'receipt_storage_untrusted', 'receipt_history_unavailable', 'receipt_history_changed', + 'receipt_history_corrupt', 'receipt_state_exists', 'receipt_collector_busy', 'receipt_collector_closed', + 'receipt_collector_error', 'receipt_collector_unavailable', 'receipt_ledger_full', 'receipt_id_unknown', + 'receipt_epoch_mismatch', 'receipt_binding_conflict', 'receipt_scope_mismatch', 'receipt_authority_unavailable', + 'receipt_authority_expired', 'receipt_authentication_failed', 'receipt_already_committed', 'receipt_candidate_conflict', + 'receipt_already_attempted', 'receipt_outcome_not_authorized', 'receipt_wait_quota', 'receipt_wait_unknown', + 'receipt_wait_terminal', 'receipt_context_mismatch', 'receipt_producer_mismatch', 'receipt_history_quarantined', + 'receipt_bootstrap_unsupported', 'receipt_managed_context_required', 'receipt_endpoint_untrusted', + 'receipt_endpoint_changed', 'receipt_endpoint_exists', 'receipt_endpoint_busy', 'receipt_transport_unknown', + 'receipt_frame_too_large', 'receipt_transport_timeout', 'insufficient_budget', 'deadline_before_effect' +] as const); +export type ReceiptErrorCode = typeof RECEIPT_ERROR_CODES[number]; +export function isReceiptErrorCode(value: unknown): value is ReceiptErrorCode { + return typeof value === 'string' && (RECEIPT_ERROR_CODES as readonly string[]).includes(value); +} +export function publicReceiptError(error: unknown): ReceiptErrorCode { + return error instanceof Refusal && isReceiptErrorCode(error.code) ? error.code : 'receipt_collector_error'; +} +function invalid(code = 'invalid_receipt_protocol'): never { throw new Refusal(code, 1); } +const digest = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-f]{64}$/.test(v); +const nativeUuid = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(v); +const member = (v: unknown, choices: readonly string[]): v is string => typeof v === 'string' && choices.includes(v); +const capability = (v: unknown): v is string => typeof v === 'string' && /^[A-Za-z0-9_-]{43}$/.test(v) && + Buffer.from(v, 'base64url').length === 32 && Buffer.from(v, 'base64url').toString('base64url') === v; +const path = (v: unknown): v is string => safeId(v, 4096) && posix.isAbsolute(v) && v.startsWith('/') && posix.normalize(v) === v; +function keys(value: unknown, required: string[]): Record { + try { return closed(value, required); } catch { invalid(); } +} +function bounded(value: unknown, max: number = RECEIPT_LIMITS.frame): void { + let nodes = 0; + const seen = new Set(); + function visit(item: unknown, depth: number): void { + if (++nodes > RECEIPT_LIMITS.nodes || depth > RECEIPT_LIMITS.depth) invalid(); + if (item === null || typeof item === 'boolean') return; + if (typeof item === 'string') { + if (Buffer.byteLength(item, 'utf8') > max || /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? RECEIPT_LIMITS.nodes || !safeId(key, max)) invalid(); visit(child, depth + 1); + } + seen.delete(item); + } + visit(value, 0); + try { if (Buffer.byteLength(JSON.stringify(value), 'utf8') + 1 > max) invalid('receipt_frame_too_large'); } + catch (error) { if (error instanceof Refusal) throw error; invalid(); } +} +export function validateReceiptPair(agent: unknown, version: unknown, scope: unknown): ReceiptPair { + const pair = RECEIPT_PAIRS.find(item => item.agent === agent && item.receiptSchemaVersion === version && item.receiptScope === scope); + if (!pair) invalid('receipt_scope_unsupported'); + return { ...pair }; +} +export function validateBinding(value: unknown): ReceiptBinding { + const agent = value && typeof value === 'object' ? (value as Record).agent : undefined; + const row = keys(value, agent === 'codex' ? ['agent','destination','target','home','writerIdentity','generation','payloadDigest'] : + ['agent','destination','target','registryDirectory','registryRecordPath','registryIdentity','socketPath','routeIdentity','generation','payloadDigest']); + if (!safeId(row.destination, 512) || !digest(row.payloadDigest)) invalid(); + if (agent === 'codex') { + if (!nativeUuid(row.target) || !path(row.home) || !safeId(row.generation, 256) || !safeId(row.writerIdentity, 256)) invalid(); + } else if (agent === 'claude') { + if (typeof row.target !== 'string' || row.target.length > 10 || !/^[1-9][0-9]*$/.test(row.target) || Number(row.target) <= 1 || Number(row.target) > 2147483647 || + !path(row.registryDirectory) || !path(row.registryRecordPath) || !digest(row.registryIdentity) || + !safeId(row.socketPath, 4096) || !digest(row.routeIdentity) || row.generation !== null) invalid(); + } else invalid(); + return structuredClone(row) as ReceiptBinding; +} +export const validateReceiptBinding = validateBinding; +export function validateProducerEvidence(value: unknown): ProducerEvidence { + const row = keys(value, ['protocolVersion','artifactSha256','challengeId','receiptSchemaVersion','receiptScope']); + if (row.protocolVersion !== 1 || !digest(row.artifactSha256) || !canonicalId(row.challengeId)) invalid('receipt_producer_mismatch'); + validateReceiptPair(row.receiptSchemaVersion === 1 ? 'codex' : 'claude', row.receiptSchemaVersion, row.receiptScope); + return structuredClone(row) as ProducerEvidence; +} +export function validateReceiptV2(raw: string | Uint8Array): ReceiptV2 { + const row = keys(parseStrictJson(raw), ['schemaVersion','kind','receiptScope','ledgerEpoch','correlationId','receiptContextId','receiptId','capability']); + if (row.schemaVersion !== 2 || row.kind !== 'receipt' || row.receiptScope !== 'delegated_context' || + !canonicalId(row.ledgerEpoch) || !canonicalId(row.correlationId) || !canonicalId(row.receiptContextId) || + !canonicalId(row.receiptId) || !capability(row.capability)) invalid(); + return structuredClone(row) as ReceiptV2; +} +export function validateManagedReceipt(raw: string | Uint8Array): ManagedReceipt { + const value = parseStrictJson(raw) as Record; + if (value && value.schemaVersion === 1) return validateReceipt(raw); + if (value && value.schemaVersion === 2) return validateReceiptV2(raw); + invalid('receipt_schema_unsupported'); +} +export function validateHandoffV2(value: unknown): HandoffV2 { + const row = keys(value, ['schemaVersion','correlationId','ledgerEpoch','state','submission','observation','ack','wait', + 'targetGeneration','receiptContext','decisionOwner','retry','nextActions']); + const context = keys(row.receiptContext, ['kind','id']); + if (row.schemaVersion !== 2 || row.targetGeneration !== null || context.kind !== 'delegated_context' || !canonicalId(context.id)) invalid(); + const obs = keys(row.observation, ['status','injectionObserved']); + if (!member(obs.status, ['not_requested','unsupported']) || obs.injectionObserved !== false || row.state === 'delivered') invalid(); + const ack = row.ack as Record, wait = row.wait as Record; + if (!ack || !wait || (ack.status === 'acknowledged' && ack.assurance !== 'token_possession') || + (wait.for === 'delivered' && !member(wait.status, ['unsupported','failed'])) || + (Array.isArray(row.nextActions) && row.nextActions.includes('keep_waiting') && (wait.for !== 'acknowledged' || wait.status !== 'pending'))) invalid(); + // Reuse all unchanged v1 predicates without teaching its strict validator a + // permissive extension. The synthetic generation is local validation-only; + // it is never returned, persisted, sent or used as identity evidence. + const legacy = { ...row, schemaVersion: 1, targetGeneration: 'validation-only-not-native-generation' }; + delete (legacy as Record).receiptContext; + validateHandoff(legacy); + try { if (Buffer.byteLength(JSON.stringify(row), 'utf8') > HANDOFF_LIMITS.publicFrame) invalid('receipt_frame_too_large'); } + catch (error) { if (error instanceof Refusal) throw error; invalid(); } + return structuredClone(row) as HandoffV2; +} +export function validateManagedHandoff(value: unknown): ManagedHandoff { + if (value && typeof value === 'object' && (value as Record).schemaVersion === 2) return validateHandoffV2(value); + return validateHandoff(value); +} +export function parseManagedHandoff(raw: string | Uint8Array): ManagedHandoff { + return validateManagedHandoff(parseStrictJson(raw, { scope: 'handoff' })); +} +function handle(value: Record): void { + if (!canonicalId(value.ledgerEpoch) || !canonicalId(value.correlationId) || + (value.receiptContextId !== null && !canonicalId(value.receiptContextId))) invalid(); +} +const operations: readonly ReceiptOperation[] = ['hello','prepare','mint','fence','submission','status','wait-open','wait-status','wait-stop','revoke','receipt']; +export function validateReceiptRequest(value: unknown): ReceiptRequest { + const row = keys(value, ['ipcVersion','requestId','collectorContextId','op','body']); + if (row.ipcVersion !== 2) invalid('receipt_ipc_version_unsupported'); + if (!canonicalId(row.requestId) || !operations.includes(row.op as ReceiptOperation) || + (row.op === 'hello' ? row.collectorContextId !== null : !canonicalId(row.collectorContextId))) invalid(); + let body: Record; + switch (row.op) { + case 'hello': keys(row.body, []); break; + case 'prepare': { + body = keys(row.body, ['binding','receiptSchemaVersion','receiptScope','producerEvidence']); + const binding = validateBinding(body.binding), producer = validateProducerEvidence(body.producerEvidence); + validateReceiptPair(binding.agent, body.receiptSchemaVersion, body.receiptScope); + if (producer.receiptSchemaVersion !== body.receiptSchemaVersion || producer.receiptScope !== body.receiptScope) invalid('receipt_producer_mismatch'); + break; + } + case 'mint': body = keys(row.body, ['ledgerEpoch','correlationId','receiptContextId','producerProtocolVersion']); handle(body); if (body.producerProtocolVersion !== 1) invalid('receipt_producer_mismatch'); break; + case 'fence': body = keys(row.body, ['ledgerEpoch','correlationId','receiptContextId','binding']); handle(body); { + const binding = validateBinding(body.binding); if ((binding.agent === 'codex') !== (body.receiptContextId === null)) invalid(); + } break; + case 'submission': body = keys(row.body, ['ledgerEpoch','correlationId','receiptContextId','status']); handle(body); if (!member(body.status, ['submitted','refused','unknown'])) invalid(); break; + case 'status': case 'revoke': body = keys(row.body, ['ledgerEpoch','correlationId']); if (!canonicalId(body.ledgerEpoch) || !canonicalId(body.correlationId)) invalid(); break; + case 'wait-open': body = keys(row.body, ['ledgerEpoch','correlationId','goal','timeoutSeconds','clockReferenceMs','remainingMs']); if (!canonicalId(body.ledgerEpoch) || !canonicalId(body.correlationId) || body.goal !== 'acknowledged' || !safeInteger(body.timeoutSeconds) || body.timeoutSeconds < 1 || body.timeoutSeconds > 60 || !safeInteger(body.clockReferenceMs) || !safeInteger(body.remainingMs) || body.remainingMs < 1 || body.remainingMs > body.timeoutSeconds * 1000) invalid(); break; + case 'wait-status': case 'wait-stop': body = keys(row.body, ['ledgerEpoch','correlationId','operationId']); if (!canonicalId(body.ledgerEpoch) || !canonicalId(body.correlationId) || !canonicalId(body.operationId)) invalid(); break; + case 'receipt': body = keys(row.body, ['receipt']); if (typeof body.receipt !== 'string') invalid(); validateManagedReceipt(body.receipt); break; + default: invalid(); + } + bounded(row); return structuredClone(row) as ReceiptRequest; +} +function parseFrame(raw: string | Uint8Array, maximum: number = RECEIPT_LIMITS.frame): unknown { + const bytes = typeof raw === 'string' ? Buffer.from(raw, 'utf8') : raw; + if (bytes.byteLength > maximum) invalid('receipt_frame_too_large'); + if (!bytes.byteLength || bytes[bytes.byteLength - 1] !== 10 || bytes.subarray(0, -1).includes(10) || bytes.includes(13)) invalid(); + return parseStrictJson(typeof raw === 'string' ? raw : bytes, { maxBytes: maximum }); +} +export function parseReceiptRequest(raw: string | Uint8Array): ReceiptRequest { return validateReceiptRequest(parseFrame(raw)); } +function matchingHandoff(value: unknown, request: ReceiptRequest): ManagedHandoff { + const result = validateManagedHandoff(value), body = request.body as Record; + if ('ledgerEpoch' in body && (result.ledgerEpoch !== body.ledgerEpoch || result.correlationId !== body.correlationId)) invalid(); + // Handles carry an immutable scope discriminator: null is Codex v1; an + // original context UUID is Claude v2. IDs alone cannot authorize a cross-scope + // response, even when both variants are independently well-formed. + if (Object.hasOwn(body, 'receiptContextId')) { + if (body.receiptContextId === null) { if (result.schemaVersion !== 1) invalid(); } + else if (result.schemaVersion !== 2 || result.receiptContext.id !== body.receiptContextId) invalid(); + } + if (request.op === 'fence' && result.targetGeneration !== request.body.binding.generation) invalid(); + if ((request.op === 'wait-status' || request.op === 'wait-stop') && result.wait.operationId !== body.operationId) invalid(); + return result; +} +export function validateReceiptResponse(value: unknown, request: ReceiptRequest): ReceiptResponse { + const row = value && typeof value === 'object' ? value as Record : {}; + keys(row, row.ok === true ? ['ipcVersion','requestId','collectorContextId','ok','body'] : ['ipcVersion','requestId','collectorContextId','ok','error']); + if (row.ipcVersion !== 2 || row.requestId !== request.requestId || !canonicalId(row.collectorContextId)) invalid(); + if (request.collectorContextId !== null && row.collectorContextId !== request.collectorContextId) invalid('receipt_context_mismatch'); + if (row.ok === false) { if (!isReceiptErrorCode(row.error)) invalid(); bounded(row); return structuredClone(row) as ReceiptResponse; } + if (row.ok !== true) invalid(); + let body: Record; + switch (request.op) { + case 'hello': { + body = keys(row.body, ['receiptSchemas','managedStoreVersion','endpointOwner','clockReferenceMs']); + const owner = keys(body.endpointOwner, ['uid','pid']); + if (!safeInteger(body.clockReferenceMs) || body.managedStoreVersion !== 2 || !safeInteger(owner.uid) || !safeInteger(owner.pid) || owner.pid <= 1 || owner.pid > 2147483647 || + !Array.isArray(body.receiptSchemas) || body.receiptSchemas.length < 1 || body.receiptSchemas.length > 2) invalid(); + const scopes = new Set(); + for (const item of body.receiptSchemas) { + const pair = keys(item, ['agent','receiptSchemaVersion','receiptScope']); validateReceiptPair(pair.agent,pair.receiptSchemaVersion,pair.receiptScope); + if (scopes.has(String(pair.receiptScope))) invalid(); scopes.add(String(pair.receiptScope)); + } break; + } + case 'prepare': { + body = keys(row.body, ['ledgerEpoch','correlationId','receiptContextId','handoff']); handle(body); + const handoff = validateManagedHandoff(body.handoff), binding = request.body.binding; + if (handoff.ledgerEpoch !== body.ledgerEpoch || handoff.correlationId !== body.correlationId || handoff.targetGeneration !== binding.generation || + (binding.agent === 'codex' ? body.receiptContextId !== null || handoff.schemaVersion !== 1 : + handoff.schemaVersion !== 2 || handoff.receiptContext.id !== body.receiptContextId)) invalid(); + break; + } + case 'mint': body = keys(row.body, ['capability']); if (!capability(body.capability)) invalid(); break; + case 'receipt': { + body = keys(row.body, ['status','ledgerEpoch','correlationId','receiptId','handoff']); + const receipt = validateManagedReceipt(request.body.receipt), handoff = validateManagedHandoff(body.handoff); + if (!member(body.status, ['awaiting_submission','acknowledged']) || body.ledgerEpoch !== receipt.ledgerEpoch || + body.correlationId !== receipt.correlationId || body.receiptId !== receipt.receiptId || handoff.ledgerEpoch !== receipt.ledgerEpoch || + handoff.correlationId !== receipt.correlationId || (body.status === 'acknowledged') !== (handoff.ack.status === 'acknowledged') || + (receipt.schemaVersion === 1 ? handoff.schemaVersion !== 1 || handoff.targetGeneration !== receipt.targetGeneration : + handoff.schemaVersion !== 2 || handoff.receiptContext.id !== receipt.receiptContextId)) invalid(); + break; + } + default: body = keys(row.body, ['handoff']); matchingHandoff(body.handoff, request); break; + } + bounded(row); return structuredClone(row) as ReceiptResponse; +} +export function parseReceiptResponse(raw: string | Uint8Array, request: ReceiptRequest): ReceiptResponse { + return validateReceiptResponse(parseFrame(raw), request); +} +export type ProducerProbeRequest = { producerProtocolVersion: 1; challengeId: string; receiptSchemaVersion: 1 | 2; receiptScope: ReceiptScope }; +export type ProducerProbeResponse = ProducerProbeRequest & { implementation: 'typescript'; runtimeVersion: string }; +export function validateProducerProbeRequest(value: unknown): ProducerProbeRequest { + const row = keys(value, ['producerProtocolVersion','challengeId','receiptSchemaVersion','receiptScope']); + if (row.producerProtocolVersion !== 1 || !canonicalId(row.challengeId)) invalid('receipt_producer_mismatch'); + validateReceiptPair(row.receiptSchemaVersion === 1 ? 'codex' : 'claude', row.receiptSchemaVersion,row.receiptScope); + bounded(row, RECEIPT_LIMITS.privateFrame); return structuredClone(row) as ProducerProbeRequest; +} +export function validateProducerProbeResponse(value: unknown, request: ProducerProbeRequest): ProducerProbeResponse { + const row = keys(value, ['producerProtocolVersion','challengeId','receiptSchemaVersion','receiptScope','implementation','runtimeVersion']); + if (row.producerProtocolVersion !== request.producerProtocolVersion || row.challengeId !== request.challengeId || + row.receiptSchemaVersion !== request.receiptSchemaVersion || row.receiptScope !== request.receiptScope || row.implementation !== 'typescript' || + typeof row.runtimeVersion !== 'string' || !/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/.test(row.runtimeVersion) || row.runtimeVersion.length > 64) invalid('receipt_producer_mismatch'); + bounded(row, RECEIPT_LIMITS.privateFrame); return structuredClone(row) as ProducerProbeResponse; +} +export function parseProducerProbeRequest(raw: string | Uint8Array): ProducerProbeRequest { + return validateProducerProbeRequest(parseFrame(raw, RECEIPT_LIMITS.privateFrame)); +} +export function parseProducerProbeResponse(raw: string | Uint8Array, request: ProducerProbeRequest): ProducerProbeResponse { + return validateProducerProbeResponse(parseFrame(raw, RECEIPT_LIMITS.privateFrame), request); +} diff --git a/src/receipt-send.ts b/src/receipt-send.ts new file mode 100644 index 0000000..4f134ae --- /dev/null +++ b/src/receipt-send.ts @@ -0,0 +1,242 @@ +// Explicit local receipt delegation. No import-time IO, collector startup or replay. +import { createHash, randomUUID } from 'node:crypto'; +import { lstatSync, realpathSync, type Stats } from 'node:fs'; +import { hostname } from 'node:os'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import { nativeQueueCapability, nativeQueueOnce, AppServerFault, type AppServerScope } from './app-server.js'; +import { Refusal, type ClaudeReceiptProvenance } from './discovery.js'; +import { HandoffBudget } from './handoff.js'; +import { inspectMacosAcls } from './macos-acl.js'; +import { currentPlatform, selectNativeProfile } from './native-profiles.js'; +import { run, UnknownOutcome } from './process.js'; +import { receiptEndpoint, receiptExchange } from './receipt-collector.js'; +import { validateReceiptProducer } from './receipt-producer.js'; +import { RECEIPT_PAIRS, validateReceiptBinding, validateManagedHandoff, type ManagedHandoff, type ReceiptBinding, + type ReceiptHandle, type ReceiptOperation, type ReceiptPair, type ReceiptRequestBody, type ReceiptResponse } from './receipt-protocol.js'; +import { checkMessage, send, type SendContext, type SendOptions } from './send.js'; + +export type ReceiptSendConfig = { collector: string; requestAck?: boolean; waitFor?: 'acknowledged'; seconds?: number; payload?: string; budget?: HandoffBudget }; +// Explicit in-process fixtures only; these are never configured by CLI or environment. +export type ReceiptSendDependencies = { send?: typeof send; exchange?: typeof receiptExchange; producer?: typeof validateReceiptProducer; + endpoint?: typeof receiptEndpoint; nativeQueue?: typeof nativeQueueOnce; run?: typeof run }; +class Unsupported extends Refusal { constructor() { super('receipt_bootstrap_unsupported',1); } } +const digest = (value: string) => createHash('sha256').update(value).digest('hex'); +const identity = (s: Stats) => [s.dev,s.ino,s.uid,s.mode,s.size,s.mtimeMs,s.ctimeMs,s.birthtimeMs].join(':'); +function claudeAdmission(proof: ClaudeReceiptProvenance): () => void { + const paths = [proof.registryDirectory,proof.registryRecordPath]; + const inspect = () => { + const directory = lstatSync(paths[0]!), record = lstatSync(paths[1]!); + if (directory.isSymbolicLink() || !directory.isDirectory() || record.isSymbolicLink() || !record.isFile() || + directory.uid !== process.getuid?.() || record.uid !== process.getuid?.() || + ((directory.mode | record.mode) & 0o022) !== 0 || realpathSync(paths[0]!) !== paths[0] || + realpathSync(paths[1]!) !== paths[1] || dirname(paths[1]!) !== paths[0] || digest(identity(record)) !== proof.registryIdentity) throw new Refusal('receipt_registry_untrusted',1); + return [identity(directory),identity(record)].join('/'); + }; + try { + const pinned = inspect(); + const revalidate = () => { + if (inspect() !== pinned || (process.platform === 'darwin' && inspectMacosAcls(paths) !== 'clear') || inspect() !== pinned) throw new Refusal('receipt_registry_untrusted',1); + }; + revalidate(); return revalidate; + } catch { throw new Refusal('receipt_registry_untrusted',1); } +} +export async function managedReceiptSend(options: SendOptions, config: ReceiptSendConfig, deps: ReceiptSendDependencies = {}): Promise<{ value: Record; exitCode: number }> { + if ((config.requestAck !== undefined && typeof config.requestAck !== 'boolean') || + (config.waitFor !== undefined && config.waitFor !== 'acknowledged')) throw new Refusal('receipt_opt_in_required',2); + if (!options.dryRun && config.requestAck !== true && config.waitFor !== 'acknowledged') throw new Refusal('receipt_opt_in_required',2); + const sourceSend = deps.send ?? send; + if (options.dryRun) return {value:{...await sourceSend({...options,hooks:undefined}),receiptBootstrap:{status:'not_checked'}},exitCode:0}; + const budget = config.budget ?? new HandoffBudget(config.seconds ?? 30), exchange = deps.exchange ?? receiptExchange; + const endpoint = deps.endpoint ?? receiptEndpoint, probe = deps.run ?? run; + const required = config.waitFor === 'acknowledged'; + let contextId: string | null = null, endpointIdentity: string | undefined, original: ReceiptBinding | undefined; + let handoff: ManagedHandoff | undefined, handle: ReceiptHandle | undefined, operationId: string | undefined, operationDeadline: number | undefined; + let snapshot: Record | undefined, knownNative: Record | undefined; + let firstContext: SendContext | undefined; + let waitBudget: { clockReferenceMs: number; remainingMs: number } | undefined; + let prepared = false, fenced = false, stopped = false, stopSent = false; + let producer: Awaited> | undefined, revalidateTarget = () => {}; + let queueScope: AppServerScope | undefined, ownerArgs: string[] = []; + const timeout = () => { const remaining = Math.floor(budget.remainingMs()); if (remaining < 1) throw new Refusal('deadline_before_effect',1); return remaining; }; + const rpc = async (op: K, body: ReceiptRequestBody[K]) => { + if (endpointIdentity !== undefined && endpoint(config.collector) !== endpointIdentity) throw new Refusal('receipt_endpoint_changed',1); + const response: ReceiptResponse = await exchange(config.collector,contextId,op,body,timeout()); + if (contextId !== null && response.collectorContextId !== contextId) throw new Refusal('receipt_context_mismatch',1); + if (!response.ok) throw new Refusal(response.error,1); + if ('handoff' in response.body) { + const h = validateManagedHandoff(response.body.handoff); + if (handle && (h.ledgerEpoch !== handle.ledgerEpoch || h.correlationId !== handle.correlationId || + h.schemaVersion !== (handle.receiptContextId === null ? 1 : 2) || + (h.schemaVersion === 2 && h.receiptContext.id !== handle.receiptContextId) || h.targetGeneration !== original?.generation)) throw new Refusal('receipt_context_mismatch',1); + if (operationId && (op === 'wait-status' || op === 'wait-stop') && + (h.wait.operationId !== operationId || h.wait.deadlineAtUtcMs !== operationDeadline)) throw new Refusal('receipt_wait_unknown',1); + } + return response; + }; + const update = (response: ReceiptResponse) => { + if (!response.ok || !('handoff' in response.body)) throw new Refusal('invalid_receipt_protocol',1); + const incoming = validateManagedHandoff(response.body.handoff); + if (operationId && incoming.wait.operationId === operationId && incoming.wait.deadlineAtUtcMs !== operationDeadline) + throw new Refusal('receipt_wait_unknown',1); + // A general actor response may expose a concurrent owner's latest wait. + // Only an explicit original-operation query can replace our wait evidence. + handoff = !required ? {...incoming,wait:{for:'none',status:'not_requested'},nextActions:['reconcile']} : + operationId && incoming.wait.operationId !== operationId && handoff ? + {...incoming,wait:handoff.wait,nextActions:['reconcile']} : incoming; + }; + const selector = () => ({ledgerEpoch:handle!.ledgerEpoch,correlationId:handle!.correlationId}); + const ownWait = async () => { if (operationId) update(await rpc('wait-status',{...selector(),operationId})); }; + const stopWait = async () => { + stopSent = true; + try { update(await rpc('wait-stop',{...selector(),operationId:operationId!})); } + catch (error) { + if (budget.remainingMs() <= 0) throw error; + await ownWait(); // One read of the original operation; a terminal result wins. + if (handoff?.wait.status === 'pending') throw error; + } + }; + const authorize = async () => { + if (fenced) throw new Refusal('receipt_already_attempted',1); + if (stopped) throw new Refusal('stopped_by_operator',130); + budget.beforeEffect(); producer!.revalidate(); revalidateTarget(); + if (endpoint(config.collector) !== endpointIdentity) throw new Refusal('receipt_endpoint_changed',1); + budget.beforeEffect(); update(await rpc('fence',{...handle!,binding:original!})); fenced = true; + producer!.revalidate(); revalidateTarget(); budget.beforeEffect(); + }; + const verifyOwner = async (scope: AppServerScope) => { + if (stopped || budget.observationRemainingMs() < 1) return false; + const result = await probe(process.execPath,[fileURLToPath(new URL('./handoff-writer-probe.js',import.meta.url)),scope.codexHome,scope.threadId,...ownerArgs], + {timeout:Math.min(800,budget.observationRemainingMs()),limit:4096,env:{...process.env,NODE_OPTIONS:undefined,NODE_PATH:undefined}}); + if (stopped || result.interrupted || result.code !== 0) return false; + try { const row = JSON.parse(result.stdout); return row.schemaVersion === 1 && row.ok === true && row.generation === scope.generation && Object.keys(row).every(k => ['schemaVersion','ok','generation'].includes(k)); } catch { return false; } + }; + const stop = () => { stopped = true; }; + const finish = (value: Record, exitCode: number) => ({value:{...value,receiptBootstrap:{status:prepared ? 'supported' : 'unsupported'},...(handoff ? {handoff} : {})},exitCode}); + if (required) process.on('SIGINT',stop); + try { + const value = await sourceSend({...options,hooks:{privateReceiptContext:true, + async resolved(context: SendContext,native,provenance) { + firstContext = {...context}; snapshot = {...native}; budget.beforeEffect(); + const pair: ReceiptPair = RECEIPT_PAIRS.find(p => p.agent === context.agent)!; + if (!['linux','darwin'].includes(process.platform)) throw new Unsupported(); + if (context.agent === 'claude') { + if (!provenance || context.generation !== null) throw new Unsupported(); + if (provenance.routeIdentity !== context.writerIdentity) throw new Refusal('receipt_registry_untrusted',1); + revalidateTarget = claudeAdmission(provenance); + original = validateReceiptBinding({agent:'claude',destination:hostname(),target:context.target,...provenance,generation:null,payloadDigest:digest(config.payload ?? options.message)}); + } else { + if (!context.home || !context.writerIdentity || !context.generation || !context.binary) throw new Unsupported(); + original = validateReceiptBinding({agent:'codex',destination:hostname(),target:context.target,home:context.home,writerIdentity:context.writerIdentity,generation:context.generation,payloadDigest:digest(config.payload ?? options.message)}); + queueScope = {codexHome:context.home,threadId:context.target,generation:context.generation,ownerIdentity:context.writerIdentity}; + const result = await probe(context.binary,['--version'],{timeout:Math.min(1000,timeout()),limit:4096}); + const version = !result.interrupted && result.code === 0 && /^codex-cli \d+\.\d+\.\d+\s*$/.test(result.stdout) ? result.stdout.trim().slice(10) : ''; + const profile = selectNativeProfile({platform:currentPlatform(),version}); + if (!profile || !nativeQueueCapability({version,platform:currentPlatform(),scope:queueScope}).queueSupported) throw new Unsupported(); + ownerArgs = profile.ownerInspection === 'lsof_ps_procfs_locks' ? ['--procfs-owner'] : []; + if (!await verifyOwner(queueScope)) throw new Unsupported(); + } + endpointIdentity = endpoint(config.collector); + const hello = await rpc('hello',{}); const remainingMs = Math.floor(budget.remainingMs()); contextId = hello.collectorContextId; + if (!hello.ok || !('receiptSchemas' in hello.body)) throw new Refusal('invalid_receipt_protocol',1); + if (remainingMs < 1) throw new Refusal('deadline_before_effect',1); + waitBudget = {clockReferenceMs:hello.body.clockReferenceMs,remainingMs}; + if (hello.body.endpointOwner.uid !== process.getuid?.()) throw new Refusal('receipt_endpoint_untrusted',1); + if (!hello.body.receiptSchemas.some(p => p.agent === pair.agent && p.receiptSchemaVersion === pair.receiptSchemaVersion && p.receiptScope === pair.receiptScope)) throw new Unsupported(); + producer = await (deps.producer ?? validateReceiptProducer)(pair,{timeoutMs:timeout()}); + revalidateTarget(); budget.beforeEffect(); + // Account for the whole inert recipient envelope before allocating authority. + const placeholder = {schemaVersion:pair.receiptSchemaVersion,kind:'receipt',ledgerEpoch:randomUUID(),correlationId:randomUUID(), + ...(pair.agent === 'codex' ? {targetGeneration:context.generation} : {receiptScope:'delegated_context',receiptContextId:randomUUID()}),receiptId:randomUUID(),capability:'x'.repeat(43)}; + checkMessage(options.message+'\nCorrelation-Id: '+placeholder.correlationId+'\nReceipt-Only: '+JSON.stringify({collector:config.collector,receipt:placeholder}),context.agent === 'codex'); + // Once prepare is attempted, no ordinary fallback is allowed even on an ambiguous response. + prepared = true; + const response = await rpc('prepare',{binding:original,receiptSchemaVersion:pair.receiptSchemaVersion,receiptScope:pair.receiptScope,producerEvidence:producer.evidence}); + if (!response.ok || !('ledgerEpoch' in response.body) || !('receiptContextId' in response.body) || !('handoff' in response.body)) throw new Refusal('invalid_receipt_protocol',1); + handle = {ledgerEpoch:response.body.ledgerEpoch,correlationId:response.body.correlationId,receiptContextId:response.body.receiptContextId}; + const h = validateManagedHandoff(response.body.handoff); + if (h.schemaVersion !== pair.receiptSchemaVersion || h.targetGeneration !== context.generation || h.ledgerEpoch !== handle.ledgerEpoch || h.correlationId !== handle.correlationId || + (pair.agent === 'codex' ? handle.receiptContextId !== null : h.schemaVersion !== 2 || h.receiptContext.id !== handle.receiptContextId)) throw new Refusal('receipt_context_mismatch',1); + handoff = h; + const minted = await rpc('mint',{...handle,producerProtocolVersion:1}); + if (!minted.ok || !('capability' in minted.body)) throw new Refusal('invalid_receipt_protocol',1); + if (required) { + update(await rpc('wait-open',{...selector(),goal:'acknowledged',timeoutSeconds:budget.seconds,...waitBudget!})); + operationId = handoff!.wait.operationId; + operationDeadline = handoff!.wait.deadlineAtUtcMs; + if (!operationId || handoff!.wait.status !== 'pending') throw new Refusal(handoff!.wait.reason ?? 'receipt_bootstrap_unsupported',1); + } + const frame = {...placeholder,ledgerEpoch:handle.ledgerEpoch,correlationId:handle.correlationId, + ...(pair.agent === 'claude' ? {receiptContextId:handle.receiptContextId} : {}),capability:minted.body.capability}; + return options.message+'\nCorrelation-Id: '+handle.correlationId+'\nReceipt-Only: '+JSON.stringify({collector:config.collector,receipt:frame}); + },beforeEffect:authorize,codexRoute:()=> 'owned_app_server', + async submitCodex(_context,message,storage) { + if (!queueScope || !handle) throw new Unsupported(); + try { return await (deps.nativeQueue ?? nativeQueueOnce)({binary:storage.binary,scope:queueScope,message,clientUserMessageId:handle.correlationId, + deadlineMs:budget.cutoff,verifyOwner,beforeEffect:authorize,verifyStorage:()=>storage.revalidate(true),verifyStorageSync:()=>storage.finalCheck(true)}); } + catch(error) { if (error instanceof AppServerFault) { if (error.attempted) throw new UnknownOutcome(); throw new Refusal('receipt_bootstrap_unsupported',1); } throw error; } + },timeoutMs:()=> {budget.beforeEffect(); return Math.max(1,Math.floor(budget.observationRemainingMs()));} + }}); + knownNative = {...value}; + update(await rpc('submission',{...handle!,status:'submitted'})); + await ownWait(); + while (required && handoff!.wait.status === 'pending' && !stopped && budget.observationRemainingMs() > 0) { + await delay(Math.min(100,budget.observationRemainingMs())); if (stopped) break; await ownWait(); + } + if (required && handoff!.wait.status === 'pending') { + if (stopped && !stopSent) { await stopWait(); } + else if (!stopped) { await delay(Math.min(25,budget.remainingMs())); if (stopped && !stopSent) await stopWait(); else await ownWait(); } + } + if (required && handoff!.wait.status !== 'satisfied') { + const interrupted = handoff!.wait.status === 'stopped' && handoff!.wait.reason === 'stopped_by_operator'; + return finish({...value,ok:false,error:handoff!.wait.reason ?? 'timed_out_unknown',retryAllowed:false},interrupted ? 130 : 1); + } + return finish(value,0); + } catch(error) { + if (!prepared && !required && firstContext && error instanceof Refusal && ['receipt_bootstrap_unsupported','receipt_collector_unavailable'].includes(error.code)) { + try { + const value = await sourceSend({...options,hooks:{ + resolved(context) { + const fields = ['agent','target','home','generation','writerIdentity','binary'] as const; + if (fields.some(field => context[field] !== firstContext![field])) throw new Refusal('target_changed',1); + budget.beforeEffect(); + }, + beforeEffect() { budget.beforeEffect(); }, + timeoutMs() { budget.beforeEffect(); return Math.max(1,Math.floor(budget.observationRemainingMs())); } + }}); + return {value:{...value,receiptBootstrap:{status:'unsupported'},retryAllowed:false},exitCode:0}; + } catch (fallbackError) { error = fallbackError; } + } + if (!snapshot) throw error; + let warning = false; + const uncertain = error instanceof UnknownOutcome || (fenced && !(error instanceof Refusal)); + let stopError: unknown; + // Preserve an explicit stop before revoke/submission terminalizes this wait. + if (stopped && operationId && handoff?.wait.status === 'pending' && !stopSent) { + try { await stopWait(); } catch (failure) { stopError = failure; warning = true; } + } + if (knownNative) { + // A receipt/IPC failure cannot erase independently positive native acceptance. + if (handoff) handoff = {...handoff,state:handoff.ack.status === 'acknowledged' ? 'acknowledged' : 'submitted',submission:{status:'submitted'},nextActions:['reconcile']}; + if (required && stopSent && handoff && handoff.wait.status !== 'pending') { + if (handoff.wait.status === 'satisfied') return finish(knownNative,0); + const confirmedStop = handoff.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator'; + return finish({...knownNative,ok:false,error:handoff.wait.reason ?? 'timed_out_unknown',retryAllowed:false},confirmedStop ? 130 : 1); + } + return finish({...knownNative,...(required ? {ok:false,error:stopSent ? 'stop_unconfirmed' : 'receipt_persistence_failed'} : {}),handoffWarning:'persistence_failed',retryAllowed:false},required ? 1 : 0); + } + if (handle) { + try { + if (fenced) update(await rpc('submission',{...handle,status:uncertain ? 'unknown' : 'refused'})); + else update(await rpc('revoke',selector())); + await ownWait(); + } catch { warning = true; } + } + if (error instanceof Refusal && error.code === 'stopped_by_operator' && + !(handoff?.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator')) + error = stopError instanceof Refusal ? stopError : new Refusal('stop_unconfirmed',1); + return finish({...snapshot,ok:false,status:uncertain ? 'unknown' : 'refused',submitted:uncertain ? null : false,consumptionConfirmed:false, + error:error instanceof Refusal ? error.code : uncertain ? 'outcome_unknown' : 'receipt_operation_failed',retryAllowed:false,...(warning ? {handoffWarning:'persistence_failed'} : {})},handoff?.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator' ? 130 : error instanceof Refusal && error.exitCode !== 130 ? error.exitCode : 1); + } finally { if (required) process.removeListener('SIGINT',stop); } +} diff --git a/src/receipt-state.ts b/src/receipt-state.ts new file mode 100644 index 0000000..72c61fa --- /dev/null +++ b/src/receipt-state.ts @@ -0,0 +1,380 @@ +// Single-owner receipt actor. It only records the original caller's evidence; +// it never sends native input, reads transcripts or treats a token as identity. +import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; +import { canonicalId, closed, fail, HANDOFF_LIMITS, safeInteger, systemClock, type Clock, type Handoff } from './handoff.js'; +import { ReceiptStore, receiptDigest } from './receipt-store.js'; +import { validateBinding, validateProducerEvidence, validateReceiptPair, validateManagedHandoff, validateManagedReceipt, + type ReceiptBinding, type ProducerEvidence, type ManagedHandoff, type ReceiptScope } from './receipt-protocol.js'; + +type Handle = { ledgerEpoch: string; correlationId: string; receiptContextId: string | null }; +type Selector = Pick; +type Wait = { public: Handoff['wait']; clockId: string; cutoff: number }; +type Candidate = { receiptId: string; receiptSchemaVersion: 1 | 2; receiptScope: ReceiptScope; receiptContextId: string | null; + arrivalClockContextId: string; arrivalMonotonic: number; arrivalUtcMs: number }; +type RecordIntent = { binding: ReceiptBinding; producerEvidence: ProducerEvidence; receiptSchemaVersion: 1 | 2; + receiptScope: ReceiptScope; receiptContextId: string | null; originClockId: string; preparedMono: number; preparedUtc: number; + phase: 'prepared' | 'fenced' | 'finished'; handoff: ManagedHandoff; waits: Wait[]; reserve: number; + revoked: boolean; expired: boolean; capabilityHash?: string; receiptId?: string; pendingReceipt?: Candidate }; +type Ledger = { version: 2; epoch: string; keyId: string; quarantined: boolean; records: Record }; +export type ReceiptPreparation = { binding: ReceiptBinding; receiptSchemaVersion: 1 | 2; receiptScope: ReceiptScope; producerEvidence: ProducerEvidence }; +export type PreparedReceipt = Handle & { handoff: ManagedHandoff }; +export type MintedReceipt = Handle & { capability: string; receiptSchemaVersion: 1 | 2; receiptScope: ReceiptScope; targetGeneration: string | null }; +export type ReceiptAcceptance = { status: 'awaiting_submission' | 'acknowledged'; receiptId: string; handoff: ManagedHandoff }; +const reserveBytes = 65536; +const hex = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); +const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0; +const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); +function normalizeBinding(value: unknown): ReceiptBinding { + const row = validateBinding(value); + // Stable keys make comparison independent of sender JSON insertion order. + return Object.fromEntries(Object.entries(row).sort(([a], [b]) => a.localeCompare(b))) as ReceiptBinding; +} +function assertSelector(value: Selector): void { + closed(value, ['ledgerEpoch', 'correlationId']); + if (!canonicalId(value.ledgerEpoch) || !canonicalId(value.correlationId)) fail('receipt_id_unknown'); +} + +export class ReceiptState { + readonly collectorContextId = randomUUID(); + private readonly clock: Clock; + private store?: ReceiptStore; + private data?: Ledger; + private busy = false; + private stopped = false; + private lastMono?: number; + constructor(readonly path: string, options: { clock?: Clock } = {}) { this.clock = options.clock ?? systemClock; } + static initialize(path: string, endpointSelector: string) { + return ReceiptStore.initialize(path, endpointSelector); + } + get endpointSelector(): string { if (!this.store) fail('receipt_collector_closed'); return this.store.marker.endpointSelector; } + get ledgerEpoch(): string { if (!this.data) fail('receipt_collector_closed'); return this.data.epoch; } + assertOpen(): void { + if (!this.store || !this.data || this.stopped) fail('receipt_collector_closed'); + this.now(); this.store.assertUnchanged(); + } + open(): this { + if (this.store || this.stopped) fail('receipt_collector_closed'); + try { + this.store = new ReceiptStore(this.path); + const raw = this.store.read() as Ledger; + if (!raw.records || typeof raw.records !== 'object' || Array.isArray(raw.records)) fail('receipt_history_corrupt'); + for (const [id, item] of Object.entries(raw.records)) { + if (!canonicalId(id) || !item || typeof item !== 'object' || !item.binding) fail('receipt_history_corrupt'); + item.binding.payloadDigest = this.store.openDigest(item.binding.payloadDigest, raw.epoch, id); + } + this.validate(raw); this.data = raw; + // Restart never adopts a prior owner's live authority or monotonic waits. + this.transact(data => { + let changed = false; + for (const item of Object.values(data.records)) changed = this.classify(item) || changed; + return { value: undefined, write: changed }; + }); + return this; + } catch (error) { + this.store?.close(); this.store = undefined; this.data = undefined; this.stopped = true; + if ((error as NodeJS.ErrnoException).code === 'ENOENT') fail('receipt_history_unavailable'); + throw error; + } + } + close(): void { this.stopped = true; this.store?.close(); this.store = undefined; this.data = undefined; } + private now(): number { + const now = this.clock.monotonic(); + if (!finite(now) || (this.lastMono !== undefined && now < this.lastMono)) { + // Observed clock loss permanently closes this owner. Catching up later + // cannot revive a capability or a deadline from the lost clock context. + this.stopped = true; fail('receipt_authority_expired'); + } + this.lastMono = now; return now; + } + clockReference(): number { + this.assertOpen(); + return Math.floor(this.now()); + } + private utc(): number { const now = Math.floor(this.clock.utc()); if (!safeInteger(now)) fail('receipt_authority_unavailable'); return now; } + private transact(operation: (data: Ledger) => { value: T; write?: boolean }): T { + if (this.busy) fail('receipt_collector_busy'); + if (!this.store || !this.data || this.stopped) fail('receipt_collector_closed'); + this.busy = true; + try { + this.store.assertUnchanged(); + const copy = structuredClone(this.data), result = operation(copy); + if (result.write !== false) { + this.validate(copy); + const stored = structuredClone(copy); + for (const [id, item] of Object.entries(stored.records)) item.binding.payloadDigest = this.store.sealDigest(item.binding.payloadDigest, stored.epoch, id); + const text = JSON.stringify({ digest: receiptDigest(JSON.stringify(stored)), data: stored }); + const reserved = Object.values(stored.records).reduce((n, item) => n + Math.max(0, item.reserve - Buffer.byteLength(JSON.stringify(item))), 0); + if (Buffer.byteLength(text) + reserved > HANDOFF_LIMITS.journal) fail('receipt_ledger_full'); + try { this.store.write(stored); } catch (error) { this.stopped = true; throw error; } + this.data = copy; + } + return result.value; + } finally { this.busy = false; } + } + private validate(data: Ledger): void { + try { + closed(data, ['version', 'epoch', 'keyId', 'quarantined', 'records']); + if (data.version !== 2 || !canonicalId(data.epoch) || !hex(data.keyId) || typeof data.quarantined !== 'boolean' || + !data.records || typeof data.records !== 'object' || Array.isArray(data.records) || Object.keys(data.records).length > HANDOFF_LIMITS.intents) throw 0; + for (const [id, item] of Object.entries(data.records)) { + closed(item, ['binding', 'producerEvidence', 'receiptSchemaVersion', 'receiptScope', 'receiptContextId', 'originClockId', + 'preparedMono', 'preparedUtc', 'phase', 'handoff', 'waits', 'reserve', 'revoked', 'expired'], ['capabilityHash', 'receiptId', 'pendingReceipt']); + if (!canonicalId(id) || !canonicalId(item.originClockId) || !finite(item.preparedMono) || !safeInteger(item.preparedUtc) || + !['prepared', 'fenced', 'finished'].includes(item.phase) || typeof item.revoked !== 'boolean' || typeof item.expired !== 'boolean' || + !safeInteger(item.reserve) || item.reserve > reserveBytes || !Array.isArray(item.waits) || item.waits.length > HANDOFF_LIMITS.waits) throw 0; + item.binding = normalizeBinding(item.binding); validateProducerEvidence(item.producerEvidence); + validateReceiptPair(item.binding.agent, item.receiptSchemaVersion, item.receiptScope); + if (item.producerEvidence.receiptSchemaVersion !== item.receiptSchemaVersion || item.producerEvidence.receiptScope !== item.receiptScope || + (item.binding.agent === 'codex' ? item.receiptContextId !== null : !canonicalId(item.receiptContextId))) throw 0; + const h = validateManagedHandoff(item.handoff); + if (h.correlationId !== id || h.ledgerEpoch !== data.epoch || h.targetGeneration !== item.binding.generation || + h.schemaVersion !== item.receiptSchemaVersion || (h.schemaVersion === 2 && h.receiptContext.id !== item.receiptContextId)) throw 0; + if (item.capabilityHash !== undefined && !hex(item.capabilityHash)) throw 0; + if (item.receiptId !== undefined && (!canonicalId(item.receiptId) || h.ack.status !== 'acknowledged')) throw 0; + if (h.ack.status === 'acknowledged' && (!item.receiptId || !item.capabilityHash || h.submission.status !== 'submitted')) throw 0; + if (item.phase === 'prepared' && h.submission.status !== 'not_attempted') throw 0; + if (item.phase === 'fenced' && h.submission.status !== 'unknown') throw 0; + if (item.phase === 'finished' && !['submitted', 'refused', 'unknown'].includes(h.submission.status)) throw 0; + for (const wait of item.waits) { + closed(wait, ['public', 'clockId', 'cutoff']); + if (!canonicalId(wait.clockId) || !finite(wait.cutoff)) throw 0; + validateManagedHandoff({ ...h, wait: wait.public, nextActions: ['reconcile'] }); + } + if (item.pendingReceipt !== undefined) { + const p = item.pendingReceipt; + closed(p, ['receiptId', 'receiptSchemaVersion', 'receiptScope', 'receiptContextId', 'arrivalClockContextId', 'arrivalMonotonic', 'arrivalUtcMs']); + if (!canonicalId(p.receiptId) || p.receiptSchemaVersion !== item.receiptSchemaVersion || p.receiptScope !== item.receiptScope || + p.receiptContextId !== item.receiptContextId || !canonicalId(p.arrivalClockContextId) || !finite(p.arrivalMonotonic) || !safeInteger(p.arrivalUtcMs) || + !item.capabilityHash || item.phase === 'prepared' || (item.receiptId !== undefined && item.receiptId !== p.receiptId)) throw 0; + } + } + } catch { fail('receipt_history_corrupt'); } + } + private item(data: Ledger, selector: Selector): RecordIntent { + assertSelector(selector); + if (data.epoch !== selector.ledgerEpoch) fail('receipt_epoch_mismatch'); + if (data.quarantined) fail('receipt_history_quarantined'); + const item = data.records[selector.correlationId]; if (!item) fail('receipt_id_unknown'); + if (item.expired || this.utc() - item.preparedUtc >= HANDOFF_LIMITS.detailMs) fail('receipt_history_unavailable'); + return item; + } + private handle(data: Ledger, value: Handle): RecordIntent { + const item = this.item(data, { ledgerEpoch: value.ledgerEpoch, correlationId: value.correlationId }); + if (value.receiptContextId !== item.receiptContextId) fail('receipt_context_mismatch'); + return item; + } + private authority(item: RecordIntent): boolean { + const elapsed = this.now() - item.preparedMono; + return !!item.capabilityHash && !item.revoked && !item.expired && item.originClockId === this.collectorContextId && + elapsed >= 0 && elapsed < HANDOFF_LIMITS.capabilityMs; + } + private classify(item: RecordIntent): boolean { + const before = JSON.stringify(item), h = item.handoff, now = this.now(); + const live = this.authority(item); + if (h.ack.status === 'pending' && !live) h.ack = { status: 'unsupported' }; + for (const wait of item.waits) { + if (wait.public.status !== 'pending') continue; + if (!live || wait.clockId !== this.collectorContextId) { + wait.public.status = 'unsupported'; wait.public.reason = 'evidence_unsupported'; + } else if (now >= wait.cutoff) { + if (item.phase === 'prepared') { + wait.public.status = 'failed'; wait.public.reason = 'deadline_before_effect'; + item.phase = 'finished'; item.revoked = true; h.state = 'refused'; h.submission = { status: 'refused' }; h.ack = { status: 'unsupported' }; + } else { + wait.public.status = 'timed_out_unknown'; + if (h.ack.status !== 'acknowledged') h.state = 'timed_out_unknown'; + } + } + } + if (item.waits.length) h.wait = structuredClone(item.waits.at(-1)!.public); + if (h.ack.status !== 'acknowledged' && h.wait.status === 'timed_out_unknown' && ['submitted', 'unknown'].includes(h.submission.status)) h.state = 'timed_out_unknown'; + h.nextActions = ['reconcile']; + if (h.wait.status === 'pending') { + h.nextActions.push('stop_waiting'); + if (h.ack.status === 'pending' && ['submitted', 'unknown'].includes(h.submission.status)) h.nextActions.unshift('keep_waiting'); + } + return before !== JSON.stringify(item); + } + private snapshot(item: RecordIntent, wait?: Wait): ManagedHandoff { + const h = structuredClone(item.handoff); + if (wait) { + h.wait = structuredClone(wait.public); h.nextActions = ['reconcile']; + if (wait.public.status === 'pending') { + h.nextActions.push('stop_waiting'); + if (h.ack.status === 'pending' && ['submitted', 'unknown'].includes(h.submission.status)) h.nextActions.unshift('keep_waiting'); + } + } + return validateManagedHandoff(h); + } + prepare(input: ReceiptPreparation): PreparedReceipt { + closed(input, ['binding', 'receiptSchemaVersion', 'receiptScope', 'producerEvidence']); + const binding = normalizeBinding(input.binding), producer = validateProducerEvidence(input.producerEvidence); + validateReceiptPair(binding.agent, input.receiptSchemaVersion, input.receiptScope); + if (producer.receiptSchemaVersion !== input.receiptSchemaVersion || producer.receiptScope !== input.receiptScope) fail('receipt_producer_mismatch'); + return this.transact(data => { + if (data.quarantined) fail('receipt_history_quarantined'); + if (Object.keys(data.records).length >= HANDOFF_LIMITS.intents) fail('receipt_ledger_full'); + const id = randomUUID(), context = binding.agent === 'claude' ? randomUUID() : null; + const shared = { correlationId: id, ledgerEpoch: data.epoch, state: 'validated' as const, submission: { status: 'not_attempted' as const }, + observation: { status: 'not_requested' as const, injectionObserved: false }, ack: { status: 'unsupported' as const }, + wait: { for: 'none' as const, status: 'not_requested' as const }, targetGeneration: binding.generation, decisionOwner: 'sender_operator' as const, + retry: { allowed: false as const, reason: 'receiver_dedup_unavailable' as const }, nextActions: [] }; + const handoff = validateManagedHandoff(binding.agent === 'codex' ? { schemaVersion: 1, ...shared } : + { schemaVersion: 2, ...shared, receiptContext: { kind: 'delegated_context', id: context } }); + data.records[id] = { binding, producerEvidence: producer, receiptSchemaVersion: input.receiptSchemaVersion, receiptScope: input.receiptScope, + receiptContextId: context, originClockId: this.collectorContextId, preparedMono: this.now(), preparedUtc: this.utc(), phase: 'prepared', + handoff, waits: [], reserve: reserveBytes, revoked: false, expired: false }; + return { value: { ledgerEpoch: data.epoch, correlationId: id, receiptContextId: context, handoff: this.snapshot(data.records[id]!) } }; + }); + } + mint(input: Handle & { producerProtocolVersion: number }): MintedReceipt { + closed(input, ['ledgerEpoch', 'correlationId', 'receiptContextId', 'producerProtocolVersion']); + return this.transact(data => { + const item = this.handle(data, input); + const elapsed = this.now() - item.preparedMono; + if (item.phase !== 'prepared' || item.capabilityHash || item.originClockId !== this.collectorContextId || item.revoked || + elapsed < 0 || elapsed >= HANDOFF_LIMITS.capabilityMs) fail('receipt_authority_unavailable'); + if (input.producerProtocolVersion !== item.producerEvidence.protocolVersion) fail('receipt_producer_mismatch'); + const capability = randomBytes(32).toString('base64url'); item.capabilityHash = receiptDigest(capability); item.handoff.ack = { status: 'pending' }; + return { value: { ledgerEpoch: data.epoch, correlationId: input.correlationId, receiptContextId: item.receiptContextId, + receiptSchemaVersion: item.receiptSchemaVersion, receiptScope: item.receiptScope, targetGeneration: item.binding.generation, capability } }; + }); + } + fence(input: Handle & { binding: ReceiptBinding }): ManagedHandoff { + closed(input, ['ledgerEpoch', 'correlationId', 'receiptContextId', 'binding']); + const binding = normalizeBinding(input.binding); + return this.transact(data => { + const item = this.handle(data, input); this.classify(item); + if (!same(binding, item.binding)) fail('receipt_binding_conflict'); + if (item.phase !== 'prepared') fail('receipt_already_attempted'); + if (!this.authority(item)) fail('receipt_authority_unavailable'); + item.phase = 'fenced'; item.handoff.state = 'unknown'; item.handoff.submission = { status: 'unknown' }; this.classify(item); + return { value: this.snapshot(item) }; + }); + } + submission(input: Handle & { status: 'submitted' | 'refused' | 'unknown' }): ManagedHandoff { + closed(input, ['ledgerEpoch', 'correlationId', 'receiptContextId', 'status']); + if (!['submitted', 'refused', 'unknown'].includes(input.status)) fail('receipt_outcome_not_authorized'); + return this.transact(data => { + const item = this.handle(data, input); + if (item.phase !== 'fenced' || item.originClockId !== this.collectorContextId) fail('receipt_outcome_not_authorized'); + this.classify(item); item.phase = 'finished'; item.handoff.submission = { status: input.status }; item.handoff.state = input.status; + if (input.status !== 'submitted') { + item.handoff.ack = { status: 'unsupported' }; + for (const wait of item.waits) if (wait.public.status === 'pending') { + wait.public.status = input.status === 'refused' ? 'failed' : 'unsupported'; wait.public.reason = input.status === 'refused' ? 'evidence_failed' : 'evidence_unsupported'; + } + } else if (item.pendingReceipt && this.authority(item)) this.commitAck(item, item.pendingReceipt.receiptId); + this.classify(item); return { value: this.snapshot(item) }; + }); + } + status(input: Selector): ManagedHandoff { + assertSelector(input); + return this.transact(data => { const item = this.item(data, input), changed = this.classify(item); return { value: this.snapshot(item), write: changed }; }); + } + waitOpen(input: Selector & { goal: 'acknowledged'; timeoutSeconds: number; clockReferenceMs?: number; remainingMs?: number }): ManagedHandoff { + closed(input, ['ledgerEpoch', 'correlationId', 'goal', 'timeoutSeconds'], ['clockReferenceMs', 'remainingMs']); + if (input.goal !== 'acknowledged' || !Number.isInteger(input.timeoutSeconds) || input.timeoutSeconds < 1 || input.timeoutSeconds > 60) fail('receipt_wait_unknown'); + return this.transact(data => { + const item = this.item(data, { ledgerEpoch: input.ledgerEpoch, correlationId: input.correlationId }); this.classify(item); + if (item.waits.length >= HANDOFF_LIMITS.waits) fail('receipt_wait_quota'); + const now = this.now(); + const active = Object.values(data.records).reduce((n, r) => n + r.waits.filter(w => w.public.status === 'pending' && w.clockId === this.collectorContextId && now < w.cutoff).length, 0); + const paired = input.clockReferenceMs !== undefined || input.remainingMs !== undefined; + if (paired && (!safeInteger(input.clockReferenceMs) || input.clockReferenceMs > now || + !safeInteger(input.remainingMs) || input.remainingMs < 1 || input.remainingMs > input.timeoutSeconds * 1000)) fail('receipt_wait_unknown'); + const span = paired ? input.remainingMs! : input.timeoutSeconds * 1000; + const cutoff = Math.max(0, (paired ? input.clockReferenceMs! : now) + span - HANDOFF_LIMITS.cleanupMs); + const wait: Wait = { public: { for: 'acknowledged', status: 'pending', operationId: randomUUID(), + deadlineAtUtcMs: Math.max(0, Math.floor(this.utc() + Math.max(0, cutoff - now))) }, clockId: this.collectorContextId, cutoff }; + if (item.handoff.ack.status === 'acknowledged') wait.public.status = 'satisfied'; + else if (input.timeoutSeconds * 1000 <= HANDOFF_LIMITS.cleanupMs) { wait.public.status = 'failed'; wait.public.reason = 'insufficient_budget'; } + else if (!this.authority(item) || (item.phase === 'finished' && item.handoff.submission.status !== 'submitted')) { + wait.public.status = 'unsupported'; wait.public.reason = 'evidence_unsupported'; + } + else if (now >= cutoff) { + wait.public.status = item.phase === 'prepared' ? 'failed' : 'timed_out_unknown'; + if (item.phase === 'prepared') wait.public.reason = 'deadline_before_effect'; + } + if (wait.public.status === 'pending' && active >= 128) fail('receipt_wait_quota'); + item.waits.push(wait); + if (item.phase === 'prepared' && wait.public.status !== 'pending') { + item.phase = 'finished'; item.revoked = true; item.handoff.state = 'refused'; item.handoff.submission = { status: 'refused' }; item.handoff.ack = { status: 'unsupported' }; + } + this.classify(item); return { value: this.snapshot(item) }; + }); + } + private selectWait(item: RecordIntent, id: string): Wait { + if (!canonicalId(id)) fail('receipt_wait_unknown'); + const wait = item.waits.find(w => w.public.operationId === id); if (!wait) fail('receipt_wait_unknown'); return wait; + } + waitStatus(input: Selector & { operationId: string }): ManagedHandoff { + closed(input, ['ledgerEpoch', 'correlationId', 'operationId']); + return this.transact(data => { + const item = this.item(data, { ledgerEpoch: input.ledgerEpoch, correlationId: input.correlationId }); + const wait = this.selectWait(item, input.operationId), changed = this.classify(item); + return { value: this.snapshot(item, wait), write: changed }; + }); + } + waitStop(input: Selector & { operationId: string }): ManagedHandoff { + closed(input, ['ledgerEpoch', 'correlationId', 'operationId']); + return this.transact(data => { + const item = this.item(data, { ledgerEpoch: input.ledgerEpoch, correlationId: input.correlationId }); this.classify(item); + const wait = this.selectWait(item, input.operationId); + if (wait.public.status !== 'pending') fail('receipt_wait_terminal'); + wait.public.status = 'stopped'; wait.public.reason = 'stopped_by_operator'; this.classify(item); + return { value: this.snapshot(item, wait) }; + }); + } + revoke(input: Selector): ManagedHandoff { + assertSelector(input); + return this.transact(data => { + const item = this.item(data, input); item.revoked = true; + if (item.phase === 'prepared') { + item.phase = 'finished'; item.handoff.state = 'refused'; item.handoff.submission = { status: 'refused' }; + for (const wait of item.waits) if (wait.public.status === 'pending') { wait.public.status = 'failed'; wait.public.reason = 'evidence_failed'; } + } + this.classify(item); return { value: this.snapshot(item) }; + }); + } + private commitAck(item: RecordIntent, receiptId: string): void { + const now = this.now(), origin = item.waits[0]; + if (item.handoff.submission.status !== 'submitted' || !this.authority(item)) fail('receipt_authority_expired'); + this.classify(item); + const late = !!origin && (origin.public.status === 'timed_out_unknown' || now >= origin.cutoff); + item.handoff.state = 'acknowledged'; item.handoff.ack = { status: 'acknowledged', assurance: 'token_possession', receivedAtUtcMs: this.utc(), late }; + item.receiptId = receiptId; + for (const wait of item.waits) if (wait.public.status === 'pending') wait.public.status = now < wait.cutoff ? 'satisfied' : 'timed_out_unknown'; + this.classify(item); + } + receipt(raw: string): ReceiptAcceptance { + const proof = validateManagedReceipt(raw); + return this.transact(data => { + // Always compare a fixed-size hash, including unknown IDs; errors never + // reflect the secret-bearing receipt or raw transport/parser details. + const item = data.records[proof.correlationId]; + const actual = Buffer.from(receiptDigest(proof.capability), 'hex'), expected = Buffer.from(item?.capabilityHash ?? '0'.repeat(64), 'hex'); + const authenticated = timingSafeEqual(actual, expected); + if (!authenticated || !item || !item.capabilityHash || item.revoked || item.expired || data.quarantined || data.epoch !== proof.ledgerEpoch || + this.utc() - item.preparedUtc >= HANDOFF_LIMITS.detailMs || item.receiptSchemaVersion !== proof.schemaVersion || + (proof.schemaVersion === 1 ? item.binding.generation !== proof.targetGeneration : item.receiptContextId !== proof.receiptContextId)) fail('receipt_authentication_failed'); + if (item.receiptId) { + if (item.receiptId !== proof.receiptId) fail('receipt_already_committed'); + return { value: { status: 'acknowledged', receiptId: proof.receiptId, handoff: this.snapshot(item) }, write: false }; + } + if (!this.authority(item)) fail('receipt_authority_expired'); + if (item.phase === 'prepared') fail('receipt_authority_unavailable'); + if (item.pendingReceipt && item.pendingReceipt.receiptId !== proof.receiptId) fail('receipt_candidate_conflict'); + if (item.handoff.submission.status === 'submitted') { + this.commitAck(item, proof.receiptId); + return { value: { status: 'acknowledged', receiptId: proof.receiptId, handoff: this.snapshot(item) } }; + } + if (item.phase !== 'fenced') fail('receipt_authority_unavailable'); + if (item.pendingReceipt) return { value: { status: 'awaiting_submission', receiptId: proof.receiptId, handoff: this.snapshot(item) }, write: false }; + item.pendingReceipt = { receiptId: proof.receiptId, receiptSchemaVersion: item.receiptSchemaVersion, receiptScope: item.receiptScope, + receiptContextId: item.receiptContextId, arrivalClockContextId: this.collectorContextId, arrivalMonotonic: this.now(), arrivalUtcMs: this.utc() }; + this.classify(item); + return { value: { status: 'awaiting_submission', receiptId: proof.receiptId, handoff: this.snapshot(item) } }; + }); + } +} diff --git a/src/receipt-store.ts b/src/receipt-store.ts new file mode 100644 index 0000000..9bc3e73 --- /dev/null +++ b/src/receipt-store.ts @@ -0,0 +1,193 @@ +// Owner-private managed receipt storage. This module never launches a process, +// opens an agent inbox, interprets a supplied target path or repairs old history. +import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from 'node:crypto'; +import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, renameSync, writeSync, type BigIntStats } from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { canonicalId, closed, fail, HANDOFF_LIMITS, parseStrictJson, safeId } from './handoff.js'; +import { inspectMacosAcls } from './macos-acl.js'; + +export const receiptDigest = (value: string | Uint8Array): string => createHash('sha256').update(value).digest('hex'); +type Marker = { version: 2; ledgerEpoch: string; endpointSelector: string }; +const flags = constants.O_NOFOLLOW | constants.O_NONBLOCK; +const identity = (s: BigIntStats) => [s.dev, s.ino, s.uid, s.mode, s.nlink, s.size, s.mtimeNs, s.ctimeNs].join(':'); +const directoryIdentity = (s: BigIntStats) => [s.dev, s.ino, s.uid, s.mode].join(':'); +// ACL changes update ctime. Cache only an inspected unchanged descriptor/path +// snapshot, and bound this process-local metadata cache independently of history. +const aclEvidence = new Map(); +function privateStat(path: string, directory: boolean): BigIntStats { + const uid = process.getuid?.(); + if (uid === undefined || process.platform === 'win32') fail('receipt_storage_unsupported'); + const s = lstatSync(path, { bigint: true }); + if (s.isSymbolicLink() || (directory ? !s.isDirectory() : !s.isFile()) || s.uid !== BigInt(uid) || + (s.mode & 0o777n) !== (directory ? 0o700n : 0o600n) || (!directory && s.nlink !== 1n)) fail('receipt_storage_untrusted'); + if (process.platform === 'darwin' && aclEvidence.get(path) !== identity(s)) { + if (inspectMacosAcls([path]) !== 'clear' || identity(lstatSync(path, { bigint: true })) !== identity(s)) fail('receipt_storage_untrusted'); + if (aclEvidence.size >= 256) aclEvidence.clear(); + aclEvidence.set(path, identity(s)); + } + return s; +} +function writeAll(fd: number, data: Uint8Array): void { + let offset = 0; + while (offset < data.byteLength) { + const count = writeSync(fd, data, offset, data.byteLength - offset); + if (count <= 0) fail('receipt_history_unavailable'); + offset += count; + } + fsyncSync(fd); +} +function syncDirectory(path: string): void { + const fd = openSync(path, constants.O_RDONLY | flags); + try { fsyncSync(fd); } finally { closeSync(fd); } +} +function readPrivate(path: string, limit: number): { bytes: Buffer; evidence: string } { + const before = privateStat(path, false); + if (before.size > BigInt(limit)) fail('receipt_history_corrupt'); + const fd = openSync(path, constants.O_RDONLY | flags); + try { + const opened = fstatSync(fd, { bigint: true }); + if (identity(opened) !== identity(before)) fail('receipt_storage_untrusted'); + const buffer = Buffer.alloc(Math.min(Number(opened.size) + 1, limit + 1)); + let length = 0; + while (length < buffer.length) { + const count = readSync(fd, buffer, length, buffer.length - length, length); + if (!count) break; + length += count; + } + if (length !== Number(opened.size) || identity(fstatSync(fd, { bigint: true })) !== identity(opened) || + identity(privateStat(path, false)) !== identity(opened)) fail('receipt_storage_untrusted'); + return { bytes: buffer.subarray(0, length), evidence: identity(opened) }; + } finally { closeSync(fd); } +} +function createPrivate(path: string, value: Uint8Array): void { + const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | flags, 0o600); + try { writeAll(fd, value); } finally { closeSync(fd); } +} +const locks = () => createRequire(import.meta.url)('fs-ext-extra-prebuilt') as { flockSync(fd: number, mode: string): void }; + +// A lifetime kernel lock, with every later read/write tied to its original inode. +// A missing/replaced key, marker, lock or state is not an invitation to recreate it. +export class ReceiptStore { + readonly path: string; + readonly marker: Marker; + private fd: number | undefined; + private readonly lockEvidence: string; + private readonly directoryEvidence: string; + private readonly markerEvidence: string; + private readonly keyEvidence: string; + private readonly key: Buffer; + private stateEvidence = ''; + private poisoned = false; + constructor(path: string) { + if (!isAbsolute(path)) fail('receipt_storage_untrusted'); + this.path = resolve(path); + this.directoryEvidence = directoryIdentity(privateStat(this.path, true)); + const lockPath = join(this.path, 'collector.lock'), before = privateStat(lockPath, false); + const fd = openSync(lockPath, constants.O_RDWR | flags); + try { + if (identity(fstatSync(fd, { bigint: true })) !== identity(before)) fail('receipt_storage_untrusted'); + try { locks().flockSync(fd, 'exnb'); } catch { fail('receipt_collector_busy'); } + this.fd = fd; this.lockEvidence = identity(before); + const marker = readPrivate(join(this.path, 'managed.json'), 16384); + const m = closed(parseStrictJson(marker.bytes, { maxBytes: 16384 }), ['version', 'ledgerEpoch', 'endpointSelector']); + if (m.version !== 2 || !canonicalId(m.ledgerEpoch) || !safeId(m.endpointSelector, 4096) || !isAbsolute(m.endpointSelector)) fail('receipt_history_corrupt'); + this.marker = Object.freeze(m) as Marker; this.markerEvidence = marker.evidence; + const key = readPrivate(join(this.path, 'ledger.key'), 32); + if (key.bytes.length !== 32) fail('receipt_history_corrupt'); + this.key = Buffer.from(key.bytes); this.keyEvidence = key.evidence; + this.assertOwner(); + } catch (error) { + if (this.fd !== undefined) { try { locks().flockSync(fd, 'un'); } finally { this.fd = undefined; closeSync(fd); } } + else closeSync(fd); + throw error; + } + } + static initialize(path: string, endpointSelector: string): { ledgerEpoch: string; storage: 'private_posix'; managedVersion: 2 } { + if (!isAbsolute(path) || !safeId(endpointSelector, 4096) || !isAbsolute(endpointSelector)) fail('receipt_storage_untrusted'); + if (process.platform === 'win32' || process.getuid?.() === undefined) fail('receipt_storage_unsupported'); + try { mkdirSync(path, { mode: 0o700 }); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; } + privateStat(path, true); + // Even an old unmanaged ledger is left untouched. Partial initialization is + // retained as unavailable evidence, never automatically repaired or removed. + const entries = opendirSync(path); + try { if (entries.readSync() !== null) fail('receipt_state_exists'); } finally { entries.closeSync(); } + const lockPath = join(path, 'collector.lock'); + let fd: number; + try { fd = openSync(lockPath, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | flags, 0o600); } + catch { fail('receipt_state_exists'); } + try { + locks().flockSync(fd, 'exnb'); fsyncSync(fd); + const key = randomBytes(32), epoch = randomUUID(); + createPrivate(join(path, 'ledger.key'), key); + const data = { version: 2, epoch, keyId: receiptDigest(key), quarantined: false, records: {} }; + createPrivate(join(path, 'ledger.json'), Buffer.from(JSON.stringify({ digest: receiptDigest(JSON.stringify(data)), data }))); + createPrivate(join(path, 'managed.json'), Buffer.from(JSON.stringify({ version: 2, ledgerEpoch: epoch, endpointSelector }))); + syncDirectory(path); + return { ledgerEpoch: epoch, storage: 'private_posix', managedVersion: 2 }; + } finally { try { locks().flockSync(fd, 'un'); } finally { closeSync(fd); } } + } + assertOwner(): void { + if (this.fd === undefined || this.poisoned) fail('receipt_collector_closed'); + if (directoryIdentity(privateStat(this.path, true)) !== this.directoryEvidence || + identity(privateStat(join(this.path, 'collector.lock'), false)) !== this.lockEvidence || + identity(fstatSync(this.fd, { bigint: true })) !== this.lockEvidence || + identity(privateStat(join(this.path, 'managed.json'), false)) !== this.markerEvidence || + identity(privateStat(join(this.path, 'ledger.key'), false)) !== this.keyEvidence) fail('receipt_storage_untrusted'); + } + read(): unknown { + this.assertOwner(); + const content = readPrivate(join(this.path, 'ledger.json'), HANDOFF_LIMITS.journal); + try { + const wrapper = closed(parseStrictJson(content.bytes, { scope: 'outer', maxBytes: HANDOFF_LIMITS.journal }), ['digest', 'data']); + if (typeof wrapper.digest !== 'string' || wrapper.digest !== receiptDigest(JSON.stringify(wrapper.data))) fail('receipt_history_corrupt'); + const data = closed(wrapper.data, ['version', 'epoch', 'keyId', 'quarantined', 'records']); + if (data.version !== 2 || data.epoch !== this.marker.ledgerEpoch || data.keyId !== receiptDigest(this.key)) fail('receipt_history_corrupt'); + this.stateEvidence = content.evidence; + return data; + } catch { fail('receipt_history_corrupt'); } + } + assertUnchanged(): void { + this.assertOwner(); + if (!this.stateEvidence || identity(privateStat(join(this.path, 'ledger.json'), false)) !== this.stateEvidence) fail('receipt_history_changed'); + } + sealDigest(value: string, epoch: string, id: string): string { + if (!/^[a-f0-9]{64}$/.test(value)) fail('receipt_history_corrupt'); + const nonce = randomBytes(12), cipher = createCipheriv('aes-256-gcm', this.key, nonce); + cipher.setAAD(Buffer.from(JSON.stringify(['receipt-binding-v2', epoch, id]))); + const bytes = Buffer.concat([cipher.update(Buffer.from(value, 'hex')), cipher.final()]); + return `sealed-v2:${nonce.toString('hex')}:${bytes.toString('hex')}:${cipher.getAuthTag().toString('hex')}`; + } + openDigest(value: unknown, epoch: string, id: string): string { + try { + if (typeof value !== 'string' || !/^sealed-v2:[a-f0-9]{24}:[a-f0-9]{64}:[a-f0-9]{32}$/.test(value)) fail('receipt_history_corrupt'); + const [, nonce, bytes, tag] = (value as string).split(':'); + const cipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(nonce!, 'hex')); + cipher.setAAD(Buffer.from(JSON.stringify(['receipt-binding-v2', epoch, id]))); cipher.setAuthTag(Buffer.from(tag!, 'hex')); + return Buffer.concat([cipher.update(Buffer.from(bytes!, 'hex')), cipher.final()]).toString('hex'); + } catch { fail('receipt_history_corrupt'); } + } + write(data: unknown): void { + this.assertUnchanged(); + const text = Buffer.from(JSON.stringify({ digest: receiptDigest(JSON.stringify(data)), data })); + if (text.byteLength > HANDOFF_LIMITS.journal) fail('receipt_ledger_full'); + const temporary = join(this.path, `.receipt-ledger-${randomUUID()}.tmp`); + try { + createPrivate(temporary, text); + this.assertUnchanged(); + renameSync(temporary, join(this.path, 'ledger.json')); + syncDirectory(this.path); + this.stateEvidence = identity(privateStat(join(this.path, 'ledger.json'), false)); + this.assertOwner(); + } catch (error) { + // A sync failure can follow rename; do not claim rollback or allow another + // command to extend authority from uncertain durable state in this owner. + this.poisoned = true; throw error; + } + } + close(): void { + const fd = this.fd; this.fd = undefined; this.key.fill(0); + if (fd !== undefined) { try { locks().flockSync(fd, 'un'); } finally { closeSync(fd); } } + } +} diff --git a/src/send.ts b/src/send.ts index 84afa71..f2d9862 100644 --- a/src/send.ts +++ b/src/send.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { casefold } from './casefold.js'; import { createConnection } from 'node:net'; import { lstatSync } from 'node:fs'; -import { claude, claudeSelectionIdentity, revalidateClaudeInventory, Refusal } from './discovery.js'; +import { claude, claudeSelectionIdentity, claudeReceiptProvenance, revalidateClaudeInventory, Refusal, type ClaudeReceiptProvenance } from './discovery.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'; @@ -15,7 +15,9 @@ export type CodexStorageGuard = { readonly binary: string; readonly cwd: string; export type SendHooks = { // 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; + resolved: (context: SendContext, snapshot: Record, provenance?: ClaudeReceiptProvenance) => Promise | string | void; + // Requests private source metadata only; it is not producer/collector approval. + privateReceiptContext?: true; beforeEffect?: () => Promise | void; timeoutMs?: () => number; codexRoute?: () => 'ordinary' | 'owned_app_server'; @@ -210,7 +212,8 @@ export async function send(options: SendOptions): Promise { diff --git a/test/package-smoke.ts b/test/package-smoke.ts index 6195c2f..1e23980 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/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()); + 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', 'dist/macos-acl.js', 'dist/macos-acl.d.ts', 'dist/receipt-protocol.js', 'dist/receipt-protocol.d.ts', 'dist/receipt-store.js', 'dist/receipt-store.d.ts', 'dist/receipt-state.js', 'dist/receipt-state.d.ts', 'dist/receipt-collector.js', 'dist/receipt-collector.d.ts', 'dist/receipt-command.js', 'dist/receipt-command.d.ts', 'dist/receipt-producer.js', 'dist/receipt-producer.d.ts', 'dist/receipt-send.js', 'dist/receipt-send.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/receipt-acl.test.ts b/test/receipt-acl.test.ts new file mode 100644 index 0000000..5da0679 --- /dev/null +++ b/test/receipt-acl.test.ts @@ -0,0 +1,55 @@ +// macOS task-owned ACL fixtures; no actual profile, agent or credential access. +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir, userInfo } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { ReceiptState } from '../dist/receipt-state.js'; +import { receiptEndpoint, startReceiptCollector } from '../dist/receipt-collector.js'; + +const mac = { skip: process.platform !== 'darwin' }; +function fixture(t: TestContext) { + const root = fs.realpathSync(fs.mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-acl-'))); + const closers: (()=>void|Promise)[]=[]; + fs.chmodSync(root, 0o700); t.after(async () => {for(const close of closers.reverse())await close();fs.rmSync(root, { recursive: true, force: true });}); + return {root,cleanup:(close:()=>void|Promise)=>closers.push(close)}; +} +function acl(path: string, remove = false) { + const result = spawnSync('/bin/chmod', remove ? ['-N', path] : ['+a', `user:${userInfo().username} allow write`, path], + { encoding: 'utf8', timeout: 3000, maxBuffer: 4096 }); + assert.equal(result.status, 0, 'fixture ACL setup/cleanup must succeed'); +} +test('managed storage rejects inherited or named ACLs despite unchanged UID and POSIX permissions', mac, t => { + const {root} = fixture(t), state = join(root, 'state'); fs.mkdirSync(state, { mode: 0o700 }); + const before = fs.statSync(state); acl(state); + try { + const after = fs.statSync(state); assert.equal(before.uid, after.uid); assert.equal(before.mode, after.mode); + assert.throws(() => ReceiptState.initialize(state, join(state, 's')), { code: 'receipt_storage_untrusted' }); + assert.deepEqual(fs.readdirSync(state), [], 'unsafe initialization cannot create a key or authority'); + } finally { acl(state, true); } +}); +test('an ACL change invalidates a previously inspected private-key snapshot without rewriting history', mac, t => { + const f = fixture(t), statePath = join(f.root, 'state'); ReceiptState.initialize(statePath, join(statePath, 's')); + const state = new ReceiptState(statePath).open(), key = join(statePath, 'ledger.key'); + f.cleanup(()=>state.close()); + const before = fs.statSync(key, { bigint: true }), history = fs.readFileSync(join(statePath, 'ledger.json')); + acl(key); + try { + const after = fs.statSync(key, { bigint: true }); assert.equal(before.uid, after.uid); assert.equal(before.mode, after.mode); + assert.notEqual(before.ctimeNs, after.ctimeNs, 'ACL cache invalidation relies on observed ctime'); + assert.throws(() => state.assertOpen(), { code: 'receipt_storage_untrusted' }); + assert.deepEqual(fs.readFileSync(join(statePath, 'ledger.json')), history); + } finally { acl(key, true); state.close(); } +}); +test('a collector endpoint with an extended ACL is never admitted as a private route', mac, async t => { + const f = fixture(t), state = join(f.root, 'state'), socket = join(state, 's'); + ReceiptState.initialize(state, socket); const owner = await startReceiptCollector(state, socket); + f.cleanup(async()=>{assert.equal((await owner.close()).cleanup,'confirmed');}); + acl(state); + try { assert.throws(() => receiptEndpoint(socket), { code: 'receipt_endpoint_untrusted' }); } + finally { + acl(state, true); + const closed = await owner.close(); assert.equal(closed.cleanup, 'confirmed'); + } +}); diff --git a/test/receipt-cli.test.ts b/test/receipt-cli.test.ts new file mode 100644 index 0000000..6be3c70 --- /dev/null +++ b/test/receipt-cli.test.ts @@ -0,0 +1,128 @@ +// End-to-end CLI/FD/UDS fixtures. The inbox is a test socket, never a native agent. +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createServer } from 'node:net'; +import { once } from 'node:events'; +import { spawn } from 'node:child_process'; +import { ReceiptState } from '../dist/receipt-state.js'; +import { startReceiptCollector, receiptExchange } from '../dist/receipt-collector.js'; +import { renderOutput } from '../dist/output.js'; + +const posix = { skip: process.platform === 'win32' }, cli = resolve('dist/cli.js'); +function fixture(t: TestContext) { + const root = fs.realpathSync(fs.mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-rc-'))); fs.chmodSync(root, 0o700); + const config = join(root, 'claude'), sessions = join(config, 'sessions'); fs.mkdirSync(sessions, { recursive: true, mode: 0o700 }); + const env = { ...process.env, HOME: root, USERPROFILE: root, CLAUDE_CONFIG_DIR: config, ANTHROPIC_CONFIG_DIR: '', + CODEX_HOME: '', CODEX_THREAD_ID: '', CODEX_SESSION_ID: '', SESSION_PEER_CODEX_HOMES: '[]', + SESSION_PEER_HANDOFF_HOME: join(root, 'unused-legacy'), SESSION_PEER_TAILSCALE: 'off', SESSION_PEER_NO_UPDATE_NOTICE: '1', NODE_OPTIONS: '' }; + const closers: (() => void | Promise)[] = []; + t.after(async () => { for (const close of closers.reverse()) await close(); fs.rmSync(root, { recursive: true, force: true }); }); + function invoke(args: string[], input?: string) { + const child = spawn(process.execPath, [cli, ...args], { env, cwd: root, stdio: ['pipe','pipe','pipe'] }); + let stdout = '', stderr = '', size = 0; + const timer = setTimeout(() => child.kill('SIGKILL'), 40000); + child.stdout.on('data', chunk => { size += chunk.length; if (size > 1048576) child.kill('SIGKILL'); else stdout += chunk; }); + child.stderr.on('data', chunk => { if (stderr.length < 4096) stderr += String(chunk).slice(0,4096-stderr.length); }); + child.stdin.on('error', () => {}); child.stdin.end(input); + const done = once(child, 'close').then(([code,signal]) => { + clearTimeout(timer); assert.equal(signal, null, stderr); return { code, stdout, stderr, value: JSON.parse(stdout) }; + }); + return { child, done }; + } + return { root, sessions, env, invoke, cleanup: (close: () => void | Promise) => closers.push(close) }; +} +const opts = (t: string) => ['send','--to',t,'--message','original receipt fixture','--no-from','--no-reply-to','--json']; +test('real CLI sends one private delegation and accepts a handler receipt before native submission returns', posix, async t => { + const f = fixture(t), state = join(f.root,'state'), collectorPath = join(state,'s'), inbox = join(f.root,'i'); + ReceiptState.initialize(state, collectorPath); const collector = await startReceiptCollector(state,collectorPath); + let count = 0, delegated: any, handler: any; + const server = createServer({allowHalfOpen:true}, socket => { + count++; let bytes = ''; + socket.on('data', chunk => { bytes += chunk; }); + socket.on('end', () => { + void (async () => { + const packet = JSON.parse(bytes), text = packet.message.content as string; + const line = text.split('\n').find(value => value.startsWith('Receipt-Only: ')); + assert.ok(line, 'the opt-in sender must supply inert private delegation data'); + delegated = JSON.parse(line.slice('Receipt-Only: '.length)); + const raw = JSON.stringify(delegated.receipt); + const called = f.invoke(['receipt','respond','--collector',collectorPath,'--receipt','-','--json'], raw); + assert.equal(called.child.spawnargs.some(arg => arg.includes(delegated.receipt.capability)), false); + handler = await called.done; + assert.equal(handler.code,0,handler.stderr); + assert.equal(handler.value.status,'awaiting_submission'); + assert.equal(handler.stdout.includes(delegated.receipt.capability),false); + socket.end(); + })().catch(() => { socket.destroy(); t.diagnostic('fixture receipt handler failed without reflecting private input'); }); + }); + }); + server.listen(inbox); await once(server,'listening'); + f.cleanup(async () => { await new Promise(ok=>server.close(()=>ok())); assert.equal((await collector.close()).cleanup,'confirmed'); }); + fs.writeFileSync(join(f.sessions,`${process.pid}.json`),JSON.stringify({pid:process.pid,name:'receipt-fixture',messagingSocketPath:inbox}),{mode:0o600}); + const result = await f.invoke([...opts(String(process.pid)),'--receipt-collector',collectorPath,'--request-ack','--wait-for','acknowledged','--wait-timeout','30']).done; + assert.equal(result.code,0,result.stderr + result.stdout); assert.equal(count,1); + assert.equal(result.value.status,'posted'); assert.equal(result.value.submitted,true); assert.equal(result.value.consumptionConfirmed,false); + assert.equal(result.value.handoff.schemaVersion,2); assert.equal(result.value.handoff.targetGeneration,null); + assert.equal(result.value.handoff.ack.status,'acknowledged'); assert.equal(result.value.handoff.ack.assurance,'token_possession'); + assert.equal(result.value.handoff.wait.status,'satisfied'); assert.equal(result.stdout.includes(delegated.receipt.capability),false); + assert.equal(fs.existsSync(join(f.root,'unused-legacy')),false); + const again = await f.invoke(['receipt','respond','--collector',collectorPath,'--receipt','-','--json'],JSON.stringify(delegated.receipt)).done; + assert.equal(again.code,0); assert.equal(again.value.handoff.ack.receivedAtUtcMs,result.value.handoff.ack.receivedAtUtcMs); + assert.equal(count,1,'receipt retrieval cannot create another inbox input'); +}); +test('managed dry-run and required unavailable bootstrap never connect to an inbox or create legacy state', posix, async t => { + const f = fixture(t), inbox = join(f.root,'i'), missing = join(f.root,'missing'); let connections = 0; + const server = createServer(socket=>{connections++;socket.destroy();});server.listen(inbox);await once(server,'listening'); + f.cleanup(()=>new Promise(ok=>server.close(()=>ok()))); + fs.writeFileSync(join(f.sessions,`${process.pid}.json`),JSON.stringify({pid:process.pid,name:'fixture',messagingSocketPath:inbox}),{mode:0o600}); + const dry = await f.invoke([...opts(String(process.pid)),'--receipt-collector',missing,'--dry-run']).done; + assert.equal(dry.code,0,dry.stdout);assert.equal(dry.value.submitted,false); + const blocked = await f.invoke([...opts(String(process.pid)),'--receipt-collector',missing,'--wait-for','acknowledged']).done; + assert.notEqual(blocked.code,0);assert.notEqual(blocked.value.submitted,true); + assert.equal(connections,0);assert.equal(fs.existsSync(join(f.root,'unused-legacy')),false); +}); +test('legacy CLI refuses managed contexts before creating an old lock or changing history', posix, async t => { + const f = fixture(t), state = join(f.root,'state');ReceiptState.initialize(state,join(state,'s')); + f.env.SESSION_PEER_HANDOFF_HOME=state; + const before=fs.readFileSync(join(state,'ledger.json')); + for(const args of [['handoff','init','--json'],['handoff','status','--correlation-id','11111111-1111-4111-8111-111111111111','--json']]){ + const result=await f.invoke(args).done;assert.notEqual(result.code,0);assert.equal(result.value.error,'receipt_managed_context_required'); + } + assert.deepEqual(fs.readFileSync(join(state,'ledger.json')),before);assert.equal(fs.existsSync(join(state,'ledger.lock')),false); +}); +test('manual foreground collector starts explicitly, handles private IPC and cleans only its own listener on SIGINT',posix,async t=>{ + const f=fixture(t),state=join(f.root,'serve'),socket=join(state,'s'); + const initialized=await f.invoke(['receipt','init','--state-dir',state,'--json']).done;assert.equal(initialized.code,0,initialized.stdout); + const child=spawn(process.execPath,[cli,'receipt','serve','--state-dir',state,'--json'],{env:f.env,cwd:f.root,stdio:['ignore','pipe','pipe']}); + let stdout='',stderr='';let ready=false; + const closed=once(child,'close'); + f.cleanup(async()=>{if(child.exitCode===null&&child.signalCode===null){child.kill('SIGTERM');await closed;}}); + const result=await new Promise((ok,fail)=>{ + const timer=setTimeout(()=>fail(new Error('fixture collector readiness timeout')),10000); + child.stderr.on('data',part=>{if(stderr.length<4096)stderr+=String(part).slice(0,4096-stderr.length);}); + child.stdout.on('data',part=>{stdout+=part;if(!ready&&stdout.includes('\n')){ready=true;clearTimeout(timer);try{ok(JSON.parse(stdout.split('\n')[0]!));}catch{fail(new Error('invalid fixture readiness'));}}}); + void closed.then(()=>{if(!ready){clearTimeout(timer);fail(new Error('fixture collector exited before readiness'));}}); + }); + assert.equal(result.status,'ready');assert.equal(result.collector,socket); + const hello=await receiptExchange(socket,null,'hello',{});assert.equal(hello.ok,true); + if(hello.ok&&'endpointOwner'in hello.body)assert.equal(hello.body.endpointOwner.pid,child.pid); + child.kill('SIGINT');const[code,signal]=await closed;assert.equal(signal,null);assert.equal(code,130,stderr); + assert.equal(fs.existsSync(socket),false);assert.equal(fs.existsSync(join(state,'ledger.json')),true); + assert.equal(stdout.trim().split('\n').length,1,'foreground lifetime does not emit another success result'); +}); +test('private CLI input timeout is bounded without reflecting unfinished receipt data',async t=>{ + const f=fixture(t),child=spawn(process.execPath,[cli,'receipt','probe','--json'],{env:f.env,cwd:f.root,stdio:['pipe','pipe','pipe']}); + let output='';child.stdout.on('data',part=>{output+=part;});child.stderr.resume();child.stdin.on('error',()=>{}); + child.stdin.write('{"unfinished_private_input":"PRIVATE_STDIN_SENTINEL"'); + const timer=setTimeout(()=>child.kill('SIGKILL'),8000);const[code,signal]=await once(child,'close');clearTimeout(timer); + assert.equal(signal,null);assert.notEqual(code,0);assert.equal(output.includes('PRIVATE_STDIN_SENTINEL'),false); + assert.equal(JSON.parse(output).ok,false); +}); +test('delegated text output does not call receipt evidence native identity or model consumption',()=>{ + const text=renderOutput({command:'send',ok:true,status:'posted',submitted:true,handoff:{schemaVersion:2,correlationId:'fixture',state:'acknowledged',ack:{status:'acknowledged',assurance:'token_possession'}}},'text'); + assert.match(text,/model consumption and completion are not confirmed/);assert.match(text,/native generation and model identity are not established/); + assert.doesNotMatch(text,/consumption\/ACK is not confirmed/); +}); diff --git a/test/receipt-collector.test.ts b/test/receipt-collector.test.ts new file mode 100644 index 0000000..3c66c4a --- /dev/null +++ b/test/receipt-collector.test.ts @@ -0,0 +1,99 @@ +// Real owner-private fixture sockets only; no agent, transcript, model or SSH. +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID, createHash } from 'node:crypto'; +import { createConnection } from 'node:net'; +import { once } from 'node:events'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { ReceiptState } from '../dist/receipt-state.js'; +import { receiptExchange, startReceiptCollector } from '../dist/receipt-collector.js'; +import type { ReceiptRequestBody, ReceiptOperation, ReceiptResponse } from '../dist/receipt-protocol.js'; + +const posix = { skip: process.platform === 'win32' }; +const binding = () => ({ agent: 'claude' as const, destination: 'fixture-local', target: '123', generation: null, + registryDirectory: '/fixture/sessions', registryRecordPath: '/fixture/sessions/123.json', + registryIdentity: 'a'.repeat(64), socketPath: '/fixture/inbox.sock', routeIdentity: 'b'.repeat(64), + payloadDigest: createHash('sha256').update('original fixture').digest('hex') }); +const producer = () => ({ protocolVersion: 1 as const, artifactSha256: 'c'.repeat(64), challengeId: randomUUID(), + receiptSchemaVersion: 2 as const, receiptScope: 'delegated_context' as const }); +function body(result: ReceiptResponse): Record { + assert.equal(result.ok, true, result.ok ? undefined : result.error); return (result as any).body; +} +async function fixture(t: TestContext, expectedCleanup = 'confirmed') { + const root = fs.realpathSync(fs.mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-ipc-'))); fs.chmodSync(root, 0o700); + const state = join(root, 'state'), socket = join(state, 's'); + ReceiptState.initialize(state, socket); + const collector = await startReceiptCollector(state, socket); + t.after(async () => { const stopped = await collector.close(); assert.equal(stopped.cleanup, expectedCleanup); fs.rmSync(root, { recursive: true, force: true }); }); + const rpc = (op: K, value: ReceiptRequestBody[K]) => receiptExchange(socket, op === 'hello' ? null : collector.contextId, op, value); + return { root, state, socket, collector, rpc }; +} +test('private one-shot IPC binds contexts and accepts only an explicit correlated receipt', posix, async t => { + const f = await fixture(t), hello = body(await f.rpc('hello', {})); + assert.equal(hello.managedStoreVersion, 2); assert.equal(hello.receiptSchemas.length, 2); + const before = fs.readFileSync(join(f.state, 'ledger.json')); + await f.rpc('hello', {}); assert.deepEqual(fs.readFileSync(join(f.state, 'ledger.json')), before); + const prepared = body(await f.rpc('prepare', { binding: binding(), receiptSchemaVersion: 2, receiptScope: 'delegated_context', producerEvidence: producer() })); + const handle = { ledgerEpoch: prepared.ledgerEpoch, correlationId: prepared.correlationId, receiptContextId: prepared.receiptContextId }; + const minted = body(await f.rpc('mint', { ...handle, producerProtocolVersion: 1 })); + const wait = body(await f.rpc('wait-open', { ledgerEpoch: handle.ledgerEpoch, correlationId: handle.correlationId, goal: 'acknowledged', timeoutSeconds: 30, clockReferenceMs: hello.clockReferenceMs, remainingMs: 30000 })); + assert.equal(wait.handoff.wait.status, 'pending'); + await f.rpc('fence', { ...handle, binding: binding() }); + const receipt = JSON.stringify({ schemaVersion: 2, kind: 'receipt', receiptScope: 'delegated_context', ...handle, receiptId: randomUUID(), capability: minted.capability }); + const early = body(await f.rpc('receipt', { receipt })); assert.equal(early.status, 'awaiting_submission'); + const submitted = body(await f.rpc('submission', { ...handle, status: 'submitted' })); + assert.equal(submitted.handoff.ack.status, 'acknowledged'); assert.equal(submitted.handoff.targetGeneration, null); + const duplicate = body(await f.rpc('receipt', { receipt })); assert.equal(duplicate.status, 'acknowledged'); + assert.equal(JSON.stringify(duplicate).includes(minted.capability), false); + assert.equal(fs.readFileSync(join(f.state, 'ledger.json'), 'utf8').includes(minted.capability), false); + assert.equal(fs.readFileSync(join(f.state, 'ledger.json'), 'utf8').includes(binding().payloadDigest), false); +}); +test('delayed trailing pollution and malformed frames never authorize a transition', posix, async t => { + const f = await fixture(t), before = fs.readFileSync(join(f.state, 'ledger.json')); + const client = createConnection(f.socket); client.on('error', () => {}); await once(client, 'connect'); + client.write(JSON.stringify({ ipcVersion: 2, requestId: randomUUID(), collectorContextId: f.collector.contextId, + op: 'prepare', body: { binding: binding(), receiptSchemaVersion: 2, receiptScope: 'delegated_context', producerEvidence: producer() } }) + '\n'); + await new Promise(ok => setImmediate(ok)); + assert.deepEqual(fs.readFileSync(join(f.state, 'ledger.json')), before, 'LF without EOF must not commit a request'); + client.end('not-another-frame'); await once(client, 'close'); + assert.deepEqual(fs.readFileSync(join(f.state, 'ledger.json')), before); +}); +test('unknown contexts refuse without mutating history or discovering a replacement collector', posix, async t => { + const f = await fixture(t), before = fs.readFileSync(join(f.state, 'ledger.json')); + await assert.rejects(receiptExchange(f.socket, randomUUID(), 'revoke', { ledgerEpoch: f.collector.ledgerEpoch, correlationId: randomUUID() })); + assert.deepEqual(fs.readFileSync(join(f.state, 'ledger.json')), before); +}); +test('unsafe endpoint permissions refuse before a request can reach the collector', posix, async t => { + const f = await fixture(t, 'unconfirmed'), before = fs.readFileSync(join(f.state, 'ledger.json')); + fs.chmodSync(f.socket, 0o666); + try { await assert.rejects(f.rpc('hello', {})); } + finally { fs.chmodSync(f.socket, 0o600); } + assert.deepEqual(fs.readFileSync(join(f.state, 'ledger.json')), before); + // A mode change invalidates the original listener evidence even after restore. + // The cleanup guard includes ctime and conservatively refuses it; use an + // isolated child for replacement/unknown-cleanup tests instead of weakening it. +}); +test('a second collector cannot steal the original store or endpoint', posix, async t => { + const f = await fixture(t); + await assert.rejects(startReceiptCollector(f.state, f.socket)); + assert.equal(body(await f.rpc('hello', {})).endpointOwner.pid, process.pid); +}); +test('unconfirmed listener cleanup preserves an unrelated replacement file', posix, t => { + const root = fs.realpathSync(fs.mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-close-'))); fs.chmodSync(root, 0o700); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const module = pathToFileURL(resolve('dist/receipt-collector.js')).href, state = pathToFileURL(resolve('dist/receipt-state.js')).href; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import fs from 'node:fs'; import {ReceiptState} from ${JSON.stringify(state)}; + import {startReceiptCollector} from ${JSON.stringify(module)}; + const dir=${JSON.stringify(root)}, path=dir+'/s'; ReceiptState.initialize(dir,path); + const collector=await startReceiptCollector(dir,path); + fs.unlinkSync(path);fs.writeFileSync(path,'unrelated replacement',{mode:0o600}); + const result=await collector.close();console.log(JSON.stringify(result));process.exit(0); + `], { encoding: 'utf8', timeout: 10000 }); + assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout), { cleanup: 'unconfirmed' }); + assert.equal(fs.readFileSync(join(root, 's'), 'utf8'), 'unrelated replacement'); +}); diff --git a/test/receipt-command.test.ts b/test/receipt-command.test.ts new file mode 100644 index 0000000..e0ad42a --- /dev/null +++ b/test/receipt-command.test.ts @@ -0,0 +1,220 @@ +// Explicit owner command fixtures only. No real agent, native queue, transcript, +// model, SSH, global installation or automatic service is involved. +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import net, { createServer, type Socket } from 'node:net'; +import { once } from 'node:events'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID, createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { syncBuiltinESMExports } from 'node:module'; +import { receiptCommand } from '../dist/receipt-command.js'; +import { receiptExchange, type Collector } from '../dist/receipt-collector.js'; +import { RECEIPT_PAIRS } from '../dist/receipt-protocol.js'; +import { VERSION } from '../dist/protocol.js'; + +const posix = { skip: process.platform === 'win32' }; +const noInput = async () => { throw new Error('this command must not read stdin'); }; +const wire = (value: unknown) => JSON.stringify(value)+'\n'; +const digest = createHash('sha256').update('COMMAND_PRIVATE_PAYLOAD').digest('hex'); +const binding = () => ({ agent: 'claude' as const, destination: 'fixture-local', target: '123', generation: null, + registryDirectory: '/fixture/sessions', registryRecordPath: '/fixture/sessions/123.json', registryIdentity: 'a'.repeat(64), + socketPath: '/fixture/inbox.sock', routeIdentity: 'b'.repeat(64), payloadDigest: digest }); +function publicBody(value: any): any { assert.equal(value.ok,true,value.error); return value.body; } +async function fixture(t: TestContext) { + const root = fs.realpathSync(fs.mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(),'codex-command-'))); fs.chmodSync(root,0o700); + const state=join(root,'state'), socket=join(state,'s'); let collector: Collector | undefined; + t.after(async()=>{ if(collector)assert.equal((await collector.close()).cleanup,'confirmed');fs.rmSync(root,{recursive:true,force:true}); }); + const initialized=await receiptCommand(['receipt','init','--state-dir',state,'--json'],noInput); + assert.equal(initialized.exitCode,0);assert.equal(initialized.value.collector,socket); + const served=await receiptCommand(['receipt','serve','--state-dir',state,'--json'],noInput); + collector=served.collector;assert.ok(collector);assert.equal(served.value.status,'ready'); + const controller=collector; + const rpc=async(op:any,body:any)=>receiptExchange(socket,op==='hello'?null:controller.contextId,op,body); + const args=(op:string,epoch:string,id:string)=>['receipt',op,'--collector',socket,'--ledger-epoch',epoch,'--correlation-id',id,'--json']; + async function pending() { + const prepared=publicBody(await rpc('prepare',{binding:binding(),receiptSchemaVersion:2,receiptScope:'delegated_context',producerEvidence:{ + protocolVersion:1,artifactSha256:'c'.repeat(64),challengeId:randomUUID(),receiptSchemaVersion:2,receiptScope:'delegated_context'}})); + const handle={ledgerEpoch:prepared.ledgerEpoch,correlationId:prepared.correlationId,receiptContextId:prepared.receiptContextId}; + const minted=publicBody(await rpc('mint',{...handle,producerProtocolVersion:1})); + await rpc('fence',{...handle,binding:binding()});await rpc('submission',{...handle,status:'submitted'}); + const receipt=wire({schemaVersion:2,kind:'receipt',receiptScope:'delegated_context',...handle,receiptId:randomUUID(),capability:minted.capability}); + return {handle,receipt,capability:minted.capability}; + } + return {root,state,socket,collector:controller,rpc,args,pending,history:()=>fs.readFileSync(join(state,'ledger.json'))}; +} +function observeWaitOpen(t: TestContext): Promise { + const original=net.createServer;let resolveOpen!:()=>void; + const opened=new Promise(ok=>{resolveOpen=ok;}); + t.mock.method(net,'createServer',((...args:any[])=>{ + const listener=args.pop(); + return (original as any)(...args,(socket:Socket)=>{ + listener(socket);const input:Buffer[]=[];socket.on('data',part=>input.push(part)); + // Installed after the real EOF handler: state is committed before this + // observation. It does not alter clocks, admission, transport or state. + socket.once('end',()=>{const value=JSON.parse(Buffer.concat(input).toString('utf8'));if(value.op==='wait-open')queueMicrotask(resolveOpen);}); + }); + }) as typeof net.createServer);syncBuiltinESMExports(); + t.after(()=>{t.mock.restoreAll();syncBuiltinESMExports();});return opened; +} + +test('producer probe is private-input-only, raw, exact and does not claim route qualification',async()=>{ + const challenge={producerProtocolVersion:1,challengeId:randomUUID(),receiptSchemaVersion:2,receiptScope:'delegated_context'}; + let reads=0; + const result=await receiptCommand(['receipt','probe','--json'],async(limit,privateMode,timeoutMs)=>{ + reads++;assert.equal(limit,4096);assert.equal(privateMode,true);assert.equal(timeoutMs,2000);return wire(challenge); + }); + assert.equal(reads,1);assert.equal(result.raw,true);assert.equal(result.exitCode,0); + assert.deepEqual(result.value,{...challenge,implementation:'typescript',runtimeVersion:VERSION}); + await assert.rejects(receiptCommand(['receipt','probe','--json'],async()=>wire({...challenge,capability:'PRIVATE_PROBE_SENTINEL'})),error=>!String(error).includes('PRIVATE_PROBE_SENTINEL')); +}); + +test('closed command options reject authority in argv and never fall back to stdin or offline state',async()=>{ + let reads=0;const read=async()=>{reads++;return '';}; + const cases=[['receipt','probe'],['receipt','probe','--json','--json'],['receipt','probe','--collector','/fixture/s','--json'], + ['receipt','recover','--json'],['receipt','respond','--collector','/fixture/s','--receipt','PRIVATE_ARGV_TOKEN','--json'], + ['receipt','status','--state-dir','/fixture/private-state','--json'],['receipt','respond','--collector','/fixture/s','--receipt','-','--host','remote','--json'], + ['receipt','init','--state-dir','relative','--json'],['receipt','wait','--collector','/fixture/s','--ledger-epoch',randomUUID(),'--correlation-id',randomUUID(),'--wait-timeout','1.5','--json']]; + for(const args of cases)await assert.rejects(receiptCommand(args,read),error=>!String(error).includes('PRIVATE_ARGV_TOKEN')); + assert.equal(reads,0); +}); + +test('initialization is explicit and never adopts an old store or follows an alias',posix,async t=>{ + const root=fs.realpathSync(fs.mkdtempSync(join(process.env.TASK_TEMP??tmpdir(),'codex-command-old-')));fs.chmodSync(root,0o700); + t.after(()=>fs.rmSync(root,{recursive:true,force:true})); + const old=join(root,'old');fs.mkdirSync(old,{mode:0o700});fs.writeFileSync(join(old,'ledger.json'),'old unmanaged fixture',{mode:0o600}); + await assert.rejects(receiptCommand(['receipt','init','--state-dir',old,'--json'],noInput)); + assert.deepEqual(fs.readdirSync(old),['ledger.json']);assert.equal(fs.readFileSync(join(old,'ledger.json'),'utf8'),'old unmanaged fixture'); + const alias=join(root,'alias');fs.symlinkSync(old,alias,'dir'); + await assert.rejects(receiptCommand(['receipt','init','--state-dir',alias,'--json'],noInput)); + assert.deepEqual(fs.readdirSync(old),['ledger.json']); + const absent=join(root,'absent');await assert.rejects(receiptCommand(['receipt','serve','--state-dir',absent,'--json'],noInput)); + assert.equal(fs.existsSync(absent),false,'serve must not implicitly initialize storage'); +}); + +test('explicit respond records one correlated receipt without exposing raw authority or original payload',posix,async t=>{ + const f=await fixture(t), intent=await f.pending();let reads=0; + const result=await receiptCommand(['receipt','respond','--collector',f.socket,'--receipt','-','--json'],async(limit,privateMode,timeoutMs)=>{ + reads++;assert.equal(limit,4096);assert.equal(privateMode,true);assert.equal(timeoutMs,2000);return intent.receipt; + }); + assert.equal(result.exitCode,0);assert.equal(result.value.status,'acknowledged');assert.equal((result.value.handoff as any).targetGeneration,null); + const text=JSON.stringify(result.value);for(const secret of [intent.capability,digest,'COMMAND_PRIVATE_PAYLOAD','/fixture/sessions'])assert.equal(text.includes(secret),false); + const before=f.history(); + const duplicate=await receiptCommand(['receipt','respond','--collector',f.socket,'--receipt','-','--json'],async()=>intent.receipt); + assert.equal(duplicate.value.status,'acknowledged');assert.deepEqual(f.history(),before);assert.equal(reads,1); + const status=await receiptCommand(f.args('status',intent.handle.ledgerEpoch,intent.handle.correlationId),noInput); + assert.equal((status.value.handoff as any).ack.status,'acknowledged');assert.equal(status.exitCode,0); +}); + +test('revocation and insufficient wait budget are explicit owner operations with no native submission',posix,async t=>{ + const f=await fixture(t), intent=await f.pending(); + const short=await receiptCommand([...f.args('wait',intent.handle.ledgerEpoch,intent.handle.correlationId),'--wait-timeout','1'],noInput); + assert.equal(short.exitCode,1);assert.equal((short.value.handoff as any).wait.reason,'insufficient_budget'); + const revoked=await receiptCommand(f.args('revoke',intent.handle.ledgerEpoch,intent.handle.correlationId),noInput); + assert.equal(revoked.exitCode,0);assert.notEqual((revoked.value.handoff as any).ack.status,'acknowledged'); + await assert.rejects(receiptCommand(['receipt','respond','--collector',f.socket,'--receipt','-','--json'],async()=>intent.receipt),error=>!String(error).includes(intent.capability)); +}); + +test('wait observes only its original operation and collector context until a voluntary receipt arrives',posix,async t=>{ + const opened=observeWaitOpen(t), f=await fixture(t), intent=await f.pending(); + const active=receiptCommand(f.args('wait',intent.handle.ledgerEpoch,intent.handle.correlationId),noInput); + await opened; + const original=JSON.parse(f.history().toString('utf8')).data.records[intent.handle.correlationId].waits[0].public; + await f.rpc('receipt',{receipt:intent.receipt}); + const result=await active,handoff=result.value.handoff as any; + assert.equal(result.exitCode,0);assert.equal(handoff.wait.status,'satisfied');assert.equal(handoff.wait.operationId,original.operationId); + assert.equal(handoff.wait.deadlineAtUtcMs,original.deadlineAtUtcMs); + const waits=JSON.parse(f.history().toString('utf8')).data.records[intent.handle.correlationId].waits; + assert.equal(waits.length,1,'polls must not open renewed operations'); +}); + +test('SIGINT stops the same pending wait once and exits 130 without cancelling native work',posix,async t=>{ + const opened=observeWaitOpen(t), f=await fixture(t), intent=await f.pending(); + const script=join(f.root,'wait.mjs'),module=pathToFileURL(resolve('dist/receipt-command.js')).href; + fs.writeFileSync(script,`import {receiptCommand} from ${JSON.stringify(module)};\nconst result=await receiptCommand(${JSON.stringify(f.args('wait',intent.handle.ledgerEpoch,intent.handle.correlationId))},async()=>{throw Error('unexpected stdin')});\nprocess.stdout.write(JSON.stringify(result)+'\\n',()=>{process.exitCode=result.exitCode;});\n`,{mode:0o600}); + const child=spawn(process.execPath,[script],{stdio:['ignore','pipe','pipe']});let output='',stderr=''; + child.stdout.on('data',x=>output+=x);child.stderr.on('data',x=>stderr+=x); + const closed=once(child,'close');const hard=setTimeout(()=>child.kill('SIGKILL'),10000); + t.after(async()=>{clearTimeout(hard);if(child.exitCode===null&&child.signalCode===null){child.kill('SIGKILL');await once(child,'close');}}); + await opened;child.kill('SIGINT');const [code,signal]=await closed;clearTimeout(hard); + assert.equal(signal,null,stderr);assert.equal(code,130,stderr); + const result=JSON.parse(output);assert.equal(result.exitCode,130);assert.equal(result.value.handoff.wait.status,'stopped'); + const waits=JSON.parse(f.history().toString('utf8')).data.records[intent.handle.correlationId].waits; + assert.equal(waits.length,1);assert.equal(waits[0].public.operationId,result.value.handoff.wait.operationId); + assert.equal(waits[0].public.reason,'stopped_by_operator');assert.equal(result.value.handoff.submission.status,'submitted'); +}); + +test('a lost receipt response stays unknown without retry or secret reflection',posix,async t=>{ + const root=fs.realpathSync(fs.mkdtempSync(join(process.env.TASK_TEMP??tmpdir(),'codex-command-loss-')));fs.chmodSync(root,0o700); + const socket=join(root,'s'),context=randomUUID(),connections=new Set();let calls=0; + const server=createServer({allowHalfOpen:true},client=>{ + connections.add(client);client.on('error',()=>{});client.once('close',()=>connections.delete(client));const bytes:Buffer[]=[]; + client.on('data',part=>bytes.push(part));client.once('end',()=>{ + const request=JSON.parse(Buffer.concat(bytes).toString('utf8'));calls++; + if(request.op==='hello')client.end(wire({ipcVersion:2,requestId:request.requestId,collectorContextId:context,ok:true, + body:{receiptSchemas:RECEIPT_PAIRS,managedStoreVersion:2,clockReferenceMs:0,endpointOwner:{uid:process.getuid!(),pid:process.pid}}})); + else client.destroy(); + }); + });server.listen(socket);await once(server,'listening');fs.chmodSync(socket,0o600); + t.after(async()=>{for(const client of connections)client.destroy();await new Promise(ok=>server.close(()=>ok()));fs.rmSync(root,{recursive:true,force:true});}); + const capability=Buffer.alloc(32,9).toString('base64url'),receipt=wire({schemaVersion:2,kind:'receipt',receiptScope:'delegated_context', + ledgerEpoch:randomUUID(),correlationId:randomUUID(),receiptContextId:randomUUID(),receiptId:randomUUID(),capability}); + const result=await receiptCommand(['receipt','respond','--collector',socket,'--receipt','-','--json'],async()=>receipt); + assert.equal(result.exitCode,1);assert.equal(result.value.status,'unknown');assert.equal(result.value.retryAllowed,false); + assert.equal(Object.hasOwn(result.value,'submitted'),false);assert.equal(JSON.stringify(result).includes(capability),false);assert.equal(calls,2,'one hello and one receipt, never retry'); +}); + + +test('later owner failure retains the verified original wait instead of fabricating a terminal result',posix,async t=>{ + const opened=observeWaitOpen(t),f=await fixture(t),intent=await f.pending(); + const active=receiptCommand(f.args('wait',intent.handle.ledgerEpoch,intent.handle.correlationId),noInput); + await opened; + const original=JSON.parse(f.history().toString('utf8')).data.records[intent.handle.correlationId].waits[0].public; + fs.chmodSync(join(f.state,'ledger.json'),0o644); + const result=await active,handoff=result.value.handoff as any; + assert.equal(result.exitCode,1);assert.equal(result.value.ok,false);assert.equal(result.value.status,'unknown'); + assert.equal(result.value.error,'receipt_storage_untrusted');assert.equal(result.value.retryAllowed,false); + assert.equal(handoff.wait.status,'pending');assert.equal(handoff.wait.operationId,original.operationId); + assert.equal(handoff.wait.deadlineAtUtcMs,original.deadlineAtUtcMs);assert.equal(handoff.submission.status,'submitted'); + assert.equal(Object.hasOwn(result.value,'submitted'),false);assert.equal(JSON.stringify(result.value).includes(intent.capability),false); +}); + +test('Windows managed commands refuse before private input or owner-state access',async t=>{ + const original=Object.getOwnPropertyDescriptor(process,'platform')!; + Object.defineProperty(process,'platform',{value:'win32'}); + t.after(()=>Object.defineProperty(process,'platform',original)); + let reads=0;const reader=async()=>{reads++;throw new Error('private input must not be requested');}; + const id='11111111-1111-4111-8111-111111111111'; + for(const args of [ + ['receipt','init','--state-dir','/fixture/managed','--json'], + ['receipt','serve','--state-dir','/fixture/managed','--json'], + ['receipt','respond','--collector','/fixture/s','--receipt','-','--json'], + ...['status','wait','revoke'].map(op=>['receipt',op,'--collector','/fixture/s','--ledger-epoch',id,'--correlation-id',id,'--json']) + ])await assert.rejects(receiptCommand(args,reader),{code:'receipt_storage_unsupported'}); + assert.equal(reads,0); +}); + +for(const failedTerminalRead of [false,true])test(`unconfirmed command stop ${failedTerminalRead?'terminal query':'reply'} preserves pending evidence and exits one`,posix,async t=>{ + const f=await fixture(t),intent=await f.pending(),socket=join(f.root,'proxy'); + const original=publicBody(await f.rpc('status',{ledgerEpoch:intent.handle.ledgerEpoch,correlationId:intent.handle.correlationId})).handoff; + const pending={...original,wait:{for:'acknowledged',status:'pending',operationId:randomUUID(),deadlineAtUtcMs:Date.now()+25000},nextActions:['keep_waiting','reconcile','stop_waiting']}; + const calls:string[]=[];const server=createServer({allowHalfOpen:true},client=>{ + const parts:Buffer[]=[];client.on('data',part=>parts.push(part));client.on('error',()=>{});client.once('end',()=>{ + const request=JSON.parse(Buffer.concat(parts).toString('utf8'));calls.push(request.op); + const response={ipcVersion:2,requestId:request.requestId,collectorContextId:f.collector.contextId,ok:true}; + if(request.op==='hello')client.end(wire({...response,body:{receiptSchemas:RECEIPT_PAIRS,managedStoreVersion:2,clockReferenceMs:0,endpointOwner:{uid:process.getuid!(),pid:process.pid}}})); + else if(request.op==='wait-open'){client.end(wire({...response,body:{handoff:pending}}));process.emit('SIGINT');} + else if(request.op==='wait-stop'&&failedTerminalRead)client.end(wire({...response,ok:false,error:'receipt_wait_terminal'})); + else client.destroy(); + }); + });server.listen(socket);await once(server,'listening');fs.chmodSync(socket,0o600); + t.after(()=>new Promise(ok=>server.close(()=>ok()))); + const args=f.args('wait',intent.handle.ledgerEpoch,intent.handle.correlationId).map(value=>value===f.socket?socket:value); + const result=await receiptCommand(args,noInput); + assert.equal(result.exitCode,1);assert.equal((result.value.handoff as any).wait.status,'pending');assert.equal((result.value.handoff as any).wait.operationId,pending.wait.operationId); + assert.equal(calls.filter(op=>op==='wait-stop').length,1);assert.equal(calls.filter(op=>op==='wait-open').length,1); + assert.equal(calls.filter(op=>op==='wait-status').length,failedTerminalRead?1:0); +}); diff --git a/test/receipt-producer.test.ts b/test/receipt-producer.test.ts new file mode 100644 index 0000000..01deba1 --- /dev/null +++ b/test/receipt-producer.test.ts @@ -0,0 +1,153 @@ +// Every mutation and builtin mock belongs to a private installation/Node child. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const project = fileURLToPath(new URL('../', import.meta.url)); +// Explicit, bounded files: never recursively copy dist or a dependency tree. +const bundle = ['app-server','casefold','cli','codex-storage','diagnostics','discovery','handoff', + 'handoff-ledger','handoff-observer-probe','handoff-operations','handoff-writer-probe','help','index', + 'linux-native-probe','linux-proc','macos-acl','metadata-observation','native-profiles','output','process', + 'protocol','receipt-collector','receipt-command','receipt-producer','receipt-protocol','receipt-send', + 'receipt-state','receipt-store','replies','send','skill-metadata','ssh','ssh-response','updates','windows','writer']; + +const worker = String.raw` +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import cp from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +const [directory, scenario] = process.argv.slice(1); +const pair = {agent:'claude',receiptSchemaVersion:2,receiptScope:'delegated_context'}; +const originalSpawn = cp.spawn; +let probes = 0, challenge, response = ''; +cp.spawn = function(binary, args, options) { + probes++; + assert.equal(scenario, 'genuine', 'unsafe artifact must refuse before any probe spawn'); + assert.equal(binary, fs.realpathSync(process.execPath)); + assert.deepEqual(args, [join(directory,'cli.js'),'receipt','probe','--json']); + assert.equal(options.shell, false); + assert.deepEqual(options.env, {PATH:'/usr/bin:/bin',LANG:'C',LC_ALL:'C'}); + const child = originalSpawn(binary,args,options); + const end = child.stdin.end; + child.stdin.end = function(input, ...rest) { + assert.equal(Buffer.byteLength(input) < 4096, true); + challenge = JSON.parse(input); + assert.deepEqual(Object.keys(challenge).sort(), ['challengeId','producerProtocolVersion','receiptSchemaVersion','receiptScope']); + return end.call(this,input,...rest); + }; + child.stdout.on('data', bytes => { response += bytes; assert.ok(Buffer.byteLength(response) <= 4096); }); + return child; +}; +if (scenario === 'entries') { + // Only the non-JS entries push the installation over the budget. + const existing = fs.readdirSync(directory).length; + assert.ok(existing < 128); + for (let i=existing; i<129; i++) fs.writeFileSync(join(directory,'extra-'+i+'.txt'),''); + assert.equal(fs.readdirSync(directory).length,129); +} +if (scenario === 'symlink') fs.symlinkSync(join(directory,'protocol.js'),join(directory,'untrusted.js')); +if (scenario === 'writable') fs.chmodSync(join(directory,'protocol.js'),0o622); +let targetFd, grown = false, requested = 0, allocated = 0, unbounded = false; +const expected = scenario === 'growth-small' ? 32 : 4*1024*1024; +if (scenario.startsWith('growth-')) { + const path = join(directory,'aaa-growth.js'); + fs.writeFileSync(path,''); fs.truncateSync(path,expected); + const open = fs.openSync, stat = fs.fstatSync, read = fs.readSync, close = fs.closeSync, readFile = fs.readFileSync; + fs.openSync = function(pathname,...args) { + // truncateSync may itself open this same fixture path; retain the original + // inspected descriptor rather than accidentally tracking that nested FD. + const fd = open.call(this,pathname,...args); if (pathname === path && targetFd === undefined) targetFd = fd; return fd; + }; + fs.fstatSync = function(fd,...args) { + const snapshot = stat.call(this,fd,...args); + if (fd === targetFd && !grown) { + // Grow AFTER the first descriptor identity check's snapshot. Sparse, no large fixture allocation. + grown = true; fs.truncateSync(path,64*1024*1024); + } + return snapshot; + }; + fs.readSync = function(fd,buffer,offset,length,...args) { + if (fd === targetFd) { requested += length; assert.ok(requested <= expected+1,'descriptor read exceeds original size plus one'); } + return read.call(this,fd,buffer,offset,length,...args); + }; + fs.readFileSync = function(pathname,...args) { + if (pathname === targetFd || pathname === path) { unbounded = true; throw new Error('unbounded descriptor read'); } + return readFile.call(this,pathname,...args); + }; + fs.closeSync = function(fd) { const result = close.call(this,fd); if (fd === targetFd) targetFd = undefined; return result; }; + for (const method of ['alloc','allocUnsafe','allocUnsafeSlow']) { + const allocate = Buffer[method]; + Buffer[method] = function(size,...args) { + if (targetFd !== undefined) { allocated += size; assert.ok(allocated <= expected+1,'allocation exceeds original size plus one'); } + return allocate.call(this,size,...args); + }; + } +} +syncBuiltinESMExports(); +const {validateReceiptProducer} = await import(pathToFileURL(join(directory,'receipt-producer.js')).href); +if (scenario !== 'genuine') { + await assert.rejects(validateReceiptProducer(pair), {code:'receipt_producer_mismatch'}); + assert.equal(probes,0); + if (scenario.startsWith('growth-')) { + assert.equal(grown,true,'fixture must reach the descriptor race'); + assert.equal(unbounded,false,'must not delegate a growing descriptor to readFileSync'); + assert.ok(requested > 0 && requested <= expected+1); + assert.ok(allocated > 0 && allocated <= expected+1); + } +} else { + const {VERSION} = await import(pathToFileURL(join(directory,'protocol.js')).href); + const result = await validateReceiptProducer(pair); + assert.equal(probes,1); + const answer = JSON.parse(response); + assert.deepEqual(answer,{...challenge,implementation:'typescript',runtimeVersion:VERSION}); + assert.equal(result.evidence.challengeId,challenge.challengeId); + assert.match(result.evidence.artifactSha256,/^[0-9a-f]{64}$/); + assert.deepEqual(Object.keys(result.evidence).sort(),['artifactSha256','challengeId','protocolVersion','receiptSchemaVersion','receiptScope']); + assert.equal(result.evidence.receiptSchemaVersion,2); + assert.equal(result.evidence.receiptScope,'delegated_context'); + result.revalidate(); + // Continuity is useful after the challenge too; capability authority is absent throughout. + fs.appendFileSync(join(directory,'protocol.js'),'\n'); + assert.throws(()=>result.revalidate(),{code:'receipt_producer_mismatch'}); +} +`; + +function exercise(scenario: string): void { + const root = fs.realpathSync(fs.mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(),'codex-producer-'))); + fs.chmodSync(root,0o700); + try { + const directory = join(root,'installation'); fs.mkdirSync(directory,{mode:0o700}); + fs.writeFileSync(join(root,'package.json'),'{"type":"module"}\n',{mode:0o600}); + for (const name of bundle) { + const source = join(project,'dist',name+'.js'); + const stat = fs.lstatSync(source); + assert.ok(stat.isFile() && !stat.isSymbolicLink() && stat.size <= 4*1024*1024); + fs.copyFileSync(source,join(directory,name+'.js')); + fs.chmodSync(join(directory,name+'.js'),0o600); + } + // Real CLI imports smol-toml; link only this existing dependency installation. + fs.symlinkSync(fs.realpathSync(join(project,'node_modules')),join(root,'node_modules'),'junction'); + const child = spawnSync(process.execPath,['--input-type=module','--eval',worker,directory,scenario],{ + encoding:'utf8',timeout:15000,maxBuffer:64*1024, + env:{PATH:'/usr/bin:/bin',LANG:'C',LC_ALL:'C',SESSION_PEER_TAILSCALE:'off'} + }); + assert.ifError(child.error); + assert.equal(child.status,0,child.stderr || child.stdout); + } finally { fs.rmSync(root,{recursive:true,force:true}); } +} + +const posixOnly = {skip:process.platform === 'win32'}; +test('producer counts every installation entry before executing a probe',posixOnly,()=>exercise('entries')); +test('producer bounds descriptor growth reads and allocations at small and 4MiB limits',posixOnly,()=>{ + exercise('growth-small'); exercise('growth-limit'); +}); +test('producer rejects symlink and other-writable artifacts before executing a probe',posixOnly,()=>{ + exercise('symlink'); exercise('writable'); +}); +test('same-installation bounded CLI probe proves VERSION and nonce without capability authority',posixOnly,()=>exercise('genuine')); diff --git a/test/receipt-protocol.test.ts b/test/receipt-protocol.test.ts new file mode 100644 index 0000000..7fb14d9 --- /dev/null +++ b/test/receipt-protocol.test.ts @@ -0,0 +1,211 @@ +// Pure contract fixtures: no socket, filesystem, native process, model or ACK. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateHandoff, validateReceipt } from '../dist/handoff.js'; +import { RECEIPT_LIMITS, parseReceiptRequest, parseReceiptResponse, validateBinding, validateReceiptPair, + validateProducerEvidence, validateManagedReceipt, validateReceiptV2, validateHandoffV2, + validateManagedHandoff, parseManagedHandoff, validateReceiptRequest, validateReceiptResponse, + validateProducerProbeRequest, validateProducerProbeResponse, parseProducerProbeRequest, + parseProducerProbeResponse, publicReceiptError, type ReceiptBinding } from '../dist/receipt-protocol.js'; + +const epoch = '11111111-1111-4111-8111-111111111111'; +const id = '22222222-2222-4222-8222-222222222222'; +const receiptId = '33333333-3333-4333-8333-333333333333'; +const context = '44444444-4444-4444-8444-444444444444'; +const requestId = '55555555-5555-4555-8555-555555555555'; +const collector = '66666666-6666-4666-8666-666666666666'; +const token = Buffer.alloc(32, 7).toString('base64url'); +const hash = 'a'.repeat(64); +const wire = (value: unknown) => JSON.stringify(value) + '\n'; +const refused = (f: () => unknown) => assert.throws(f); +function binding(agent: 'claude' | 'codex' = 'claude'): ReceiptBinding { + return agent === 'claude' ? { agent, destination: 'local', target: '123', registryDirectory: '/fixture/profile/home/sessions', + registryRecordPath: '/fixture/profile/home/sessions/123.json', registryIdentity: hash, socketPath: '/fixture/inbox.sock', + routeIdentity: hash, generation: null, payloadDigest: hash } : { agent, destination: 'local', target: '01900000-0000-7000-8000-000000000001', + home: '/fixture/codex', writerIdentity: hash, generation: hash, payloadDigest: hash }; +} +function receipt(version: 1 | 2 = 2): Record { + return version === 2 ? { schemaVersion: 2, kind: 'receipt', receiptScope: 'delegated_context', ledgerEpoch: epoch, + correlationId: id, receiptContextId: context, receiptId, capability: token } : { schemaVersion: 1, kind: 'receipt', + ledgerEpoch: epoch, correlationId: id, targetGeneration: hash, receiptId, capability: token }; +} +function handoff(version: 1 | 2 = 2): Record { + return { schemaVersion: version, correlationId: id, ledgerEpoch: epoch, state: 'acknowledged', + submission: { status: 'submitted' }, observation: { status: 'not_requested', injectionObserved: false }, + ack: { status: 'acknowledged', assurance: 'token_possession', receivedAtUtcMs: 1, late: false }, + wait: { for: 'none', status: 'not_requested' }, targetGeneration: version === 1 ? hash : null, + ...(version === 2 ? { receiptContext: { kind: 'delegated_context', id: context } } : {}), + decisionOwner: 'sender_operator', retry: { allowed: false, reason: 'receiver_dedup_unavailable' }, nextActions: ['reconcile'] }; +} +function producer(version: 1 | 2 = 2) { + return { protocolVersion: 1, artifactSha256: hash, challengeId: context, receiptSchemaVersion: version, + receiptScope: version === 1 ? 'original_generation' : 'delegated_context' }; +} +function request(op = 'hello', body: unknown = {}): any { + return { ipcVersion: 2, requestId, collectorContextId: op === 'hello' ? null : collector, op, body }; +} +function response(body: unknown): any { return { ipcVersion: 2, requestId, collectorContextId: collector, ok: true, body }; } + +test('separate Claude v2 receipt/handoff accepts null native generation without weakening v1', () => { + const raw = wire(receipt()), publicResult = handoff(); + assert.equal(validateReceiptV2(raw).receiptContextId, context); + assert.equal(validateManagedReceipt(raw).schemaVersion, 2); + assert.equal(validateHandoffV2(publicResult).targetGeneration, null); + assert.equal(parseManagedHandoff(wire(publicResult)).schemaVersion, 2); + refused(() => validateReceipt(raw)); refused(() => validateHandoff(publicResult)); + const old = handoff(1); + assert.deepEqual(validateManagedHandoff(old), validateHandoff(old)); + assert.deepEqual(validateManagedReceipt(wire(receipt(1))), validateReceipt(wire(receipt(1)))); + old.targetGeneration = null; refused(() => validateManagedHandoff(old)); +}); + +test('Claude context cannot forge native generation, injection, completion, operator confirmation or receipt-to-submission promotion', () => { + const cases: ((v: Record) => void)[] = [ + v => { v.targetGeneration = context; }, v => { v.receiptContext.id = 'not-a-context'; }, + v => { v.receiptContext.extra = 'secret'; }, v => { v.state = 'delivered'; }, + v => { v.observation = { status: 'observed', injectionObserved: true, clientUserMessageId: id }; }, + v => { v.observation.turn = { id, status: 'completed' }; }, v => { v.ack.assurance = 'operator_confirmed'; }, + v => { v.submission.status = 'unknown'; }, v => { v.state = 'unknown'; }, v => { v.consumptionConfirmed = true; }, + v => { v.receiptContext.kind = 'original_generation'; }, v => { v.ack.receivedAtUtcMs = 0.5; } + ]; + for (const mutate of cases) { const sample = handoff(); mutate(sample); refused(() => validateHandoffV2(sample)); } + const pending = handoff(); pending.state = 'submitted'; pending.ack = { status: 'pending' }; + pending.wait = { for: 'acknowledged', status: 'pending', operationId: receiptId, deadlineAtUtcMs: 100 }; + pending.nextActions = ['keep_waiting','stop_waiting']; assert.equal(validateHandoffV2(pending).ack.status, 'pending'); + pending.wait.status = 'timed_out_unknown'; refused(() => validateHandoffV2(pending)); + pending.nextActions = ['reconcile']; assert.equal(validateHandoffV2(pending).wait.status, 'timed_out_unknown'); + pending.wait.for = 'delivered'; pending.wait.status = 'pending'; refused(() => validateHandoffV2(pending)); +}); + +test('private tagged binding preserves original payload and rejects cross-agent scope or replacement generation', () => { + assert.deepEqual(validateBinding(binding()), binding()); assert.deepEqual(validateBinding(binding('codex')), binding('codex')); + const cases = [ { ...binding(), generation: context }, { ...binding('codex'), generation: null }, + { ...binding(), payloadDigest: 'A'.repeat(64) }, { ...binding(), payloadDigest: undefined }, + { ...binding(), registryRecordPath: '../sessions/123.json' }, { ...binding(), rawPayload: 'private body' }, + { ...binding(), home: '/wrong-agent' }, { ...binding(), target: '01' }, { ...binding(), target: '2147483648' }, + { ...binding(), registryDirectory: '/fixture/../escape' }, { ...binding(), destination: 'x'.repeat(513) } ]; + for (const sample of cases) refused(() => validateBinding(sample)); + assert.deepEqual(validateReceiptPair('codex', 1, 'original_generation'), {agent:'codex',receiptSchemaVersion:1,receiptScope:'original_generation'}); + for (const [agent,version,scope] of [['claude',1,'original_generation'],['codex',2,'delegated_context'],['claude',3,'delegated_context']]) refused(() => validateReceiptPair(agent,version,scope)); +}); + +test('private receipt grammar and limits retain canonical capability, UTF-8, duplicate and integer defenses', () => { + const raw = wire(receipt()); + for (const bad of [raw.replace('"schemaVersion":2','"schemaVersion":2e0'), raw.replace('"kind":"receipt"','"kind":"receipt","kind":"receipt"'), + raw.replace('"kind":"receipt"','"kind":"receipt","k\\u0069nd":"receipt"'), raw + ' '.repeat(4096), + wire({ ...receipt(), capability: token+'=' }), wire({ ...receipt(), capability: 'x' }), + wire({ ...receipt(), targetGeneration: hash }), wire({ ...receipt(), receiptScope: 'original_generation' }), + wire({ ...receipt(), schemaVersion: 3 }), wire({ ...receipt(), receiptContextId: context.toUpperCase() + 'x' })]) refused(() => validateManagedReceipt(bad)); + refused(() => validateManagedReceipt(Uint8Array.from([123,255,125]))); + const old = receipt(1); old.receiptContextId = context; refused(() => validateManagedReceipt(wire(old))); +}); + +test('IPC exposes only finite closed operations and binds scope/producer/handle fields', () => { + const bodies: Record = { + hello: {}, prepare: { binding: binding(), receiptSchemaVersion: 2, receiptScope: 'delegated_context', producerEvidence: producer() }, + mint: { ledgerEpoch: epoch, correlationId: id, receiptContextId: context, producerProtocolVersion: 1 }, + fence: { ledgerEpoch: epoch, correlationId: id, receiptContextId: context, binding: binding() }, + submission: { ledgerEpoch: epoch, correlationId: id, receiptContextId: context, status: 'submitted' }, + status: { ledgerEpoch: epoch, correlationId: id }, 'wait-open': { ledgerEpoch: epoch, correlationId: id, goal: 'acknowledged', timeoutSeconds: 6, clockReferenceMs: 0, remainingMs: 6000 }, + 'wait-status': { ledgerEpoch: epoch, correlationId: id, operationId: receiptId }, 'wait-stop': { ledgerEpoch: epoch, correlationId: id, operationId: receiptId }, + revoke: { ledgerEpoch: epoch, correlationId: id }, receipt: { receipt: JSON.stringify(receipt(), null, 2) + '\n' } + }; + for (const [op, body] of Object.entries(bodies)) assert.equal(parseReceiptRequest(wire(request(op,body))).op, op); + for (const op of ['exec','send','confirm','repair','recover','retry','replace','import','stop']) refused(() => validateReceiptRequest(request(op))); + refused(() => validateReceiptRequest(request('prepare', { ...(bodies.prepare as object), receiptSchemaVersion: 1 }))); + refused(() => validateReceiptRequest(request('prepare', { ...(bodies.prepare as object), producerEvidence: producer(1) }))); + refused(() => validateReceiptRequest(request('fence', { ...(bodies.fence as object), receiptContextId: null }))); + refused(() => validateReceiptRequest({ ...request(), collectorContextId: collector })); + refused(() => validateReceiptRequest({ ...request('status',bodies.status), collectorContextId: null })); + refused(() => validateReceiptRequest(request('wait-open', { ...(bodies['wait-open'] as object), timeoutSeconds: 1.5 }))); + refused(() => validateReceiptRequest({ ...request(), unknown: token })); +}); + +test('IPC frame boundary requires one LF-terminated value and enforces raw bytes before parsing', () => { + const req = request(), line = JSON.stringify(req); + const atLimit = line + ' '.repeat(RECEIPT_LIMITS.frame - Buffer.byteLength(line) - 1) + '\n'; + assert.equal(Buffer.byteLength(atLimit), RECEIPT_LIMITS.frame); assert.equal(parseReceiptRequest(atLimit).op, 'hello'); + for (const raw of [line, line+'\r\n', wire(req)+'\n', wire(req)+wire(req), atLimit+' ', '{\n"ipcVersion":2}\n', + line.replace('"ipcVersion":2','"ipcVersion":2e0')+'\n', line.replace('"op":"hello"','"op":"hello","op":"hello"')+'\n']) refused(() => parseReceiptRequest(raw)); + refused(() => parseReceiptRequest(Uint8Array.from([255,10]))); + const deep = { ...req, body: { a: JSON.parse('['.repeat(17)+'0'+']'.repeat(17)) } }; refused(() => parseReceiptRequest(wire(deep))); + const oversizedStructure = { ...req, body: Object.fromEntries(Array.from({length:1025},(_,i)=>['k'+i,0])) }; refused(() => parseReceiptRequest(wire(oversizedStructure))); +}); + +test('response rejects mismatched request/context, private fields and unsupported error reflection', () => { + const req = validateReceiptRequest(request('status', { ledgerEpoch: epoch, correlationId: id })); + const good = response({ handoff: handoff() }); assert.equal(parseReceiptResponse(wire(good),req).ok, true); + const wrong = handoff(); wrong.correlationId = context; + for (const sample of [ { ...good, requestId: receiptId }, { ...good, collectorContextId: epoch }, + response({ handoff: wrong }), response({ handoff: handoff(), capability: token }), + { ...good, ok: true, error: 'receipt_id_unknown' }, { ...good, ok: false, error: 'receipt_id_unknown' } ]) refused(() => validateReceiptResponse(sample,req)); + const error = { ipcVersion:2,requestId,collectorContextId:collector,ok:false,error:'receipt_id_unknown' }; + assert.equal(parseReceiptResponse(wire(error),req).ok, false); + refused(() => validateReceiptResponse({ ...error, error:'private_body_sentinel' },req)); + assert.equal(publicReceiptError(new Error(token)), 'receipt_collector_error'); +}); + +test('mint authority is accepted only in its private response and receipt response cannot invent ACK', () => { + const req = validateReceiptRequest(request('mint', {ledgerEpoch:epoch,correlationId:id,receiptContextId:context,producerProtocolVersion:1})); + assert.equal(validateReceiptResponse(response({capability:token}),req).ok,true); + const receiptRequest = validateReceiptRequest(request('receipt',{receipt:wire(receipt())})); + const ack = response({status:'acknowledged',ledgerEpoch:epoch,correlationId:id,receiptId,handoff:handoff()}); + assert.equal(validateReceiptResponse(ack,receiptRequest).ok,true); + refused(() => validateReceiptResponse(response({...ack.body,status:'awaiting_submission'}),receiptRequest)); + refused(() => validateReceiptResponse(response({...ack.body,receiptId:context}),receiptRequest)); + const pending = handoff(); pending.state='unknown'; pending.submission.status='unknown';pending.ack={status:'pending'}; + const staged = response({status:'awaiting_submission',ledgerEpoch:epoch,correlationId:id,receiptId,handoff:pending}); + assert.equal(validateReceiptResponse(staged,receiptRequest).ok,true); +}); + +test('producer challenge validates exact typed pair, nonce and stable version without performing execution', () => { + assert.equal(validateProducerEvidence(producer()).artifactSha256,hash); + const challenge = { producerProtocolVersion:1,challengeId:context,receiptSchemaVersion:2,receiptScope:'delegated_context' }; + const req = validateProducerProbeRequest(challenge), answer = {...challenge,implementation:'typescript',runtimeVersion:'0.6.0'}; + assert.equal(parseProducerProbeRequest(wire(challenge)).challengeId,context); + assert.equal(parseProducerProbeResponse(wire(answer),req).runtimeVersion,'0.6.0'); + for (const patch of [{challengeId:receiptId},{receiptSchemaVersion:1},{receiptScope:'original_generation'}, + {implementation:'python'},{runtimeVersion:'0.6.0-extra'},{runtimeVersion:'00.6.0'},{capability:token}]) refused(()=>validateProducerProbeResponse({...answer,...patch},req)); + refused(()=>validateProducerEvidence({...producer(),artifactSha256:'not-a-digest'})); + refused(()=>validateProducerProbeRequest({...challenge,command:'native-send'})); +}); + + +test('fence and submission response handles preserve the original schema, context and fence generation', () => { + const claudeFence = validateReceiptRequest(request('fence',{ledgerEpoch:epoch,correlationId:id,receiptContextId:context,binding:binding()})); + assert.equal(validateReceiptResponse(response({handoff:handoff()}),claudeFence).ok,true); + refused(()=>validateReceiptResponse(response({handoff:handoff(1)}),claudeFence)); + const changedContext = handoff();changedContext.receiptContext.id=receiptId; + refused(()=>validateReceiptResponse(response({handoff:changedContext}),claudeFence)); + const claudeSubmission = validateReceiptRequest(request('submission',{ledgerEpoch:epoch,correlationId:id,receiptContextId:context,status:'submitted'})); + assert.equal(validateReceiptResponse(response({handoff:handoff()}),claudeSubmission).ok,true); + refused(()=>validateReceiptResponse(response({handoff:handoff(1)}),claudeSubmission)); + refused(()=>validateReceiptResponse(response({handoff:changedContext}),claudeSubmission)); + const codexFence = validateReceiptRequest(request('fence',{ledgerEpoch:epoch,correlationId:id,receiptContextId:null,binding:binding('codex')})); + assert.equal(validateReceiptResponse(response({handoff:handoff(1)}),codexFence).ok,true); + refused(()=>validateReceiptResponse(response({handoff:handoff()}),codexFence)); + const wrongGeneration=handoff(1);wrongGeneration.targetGeneration=context; + refused(()=>validateReceiptResponse(response({handoff:wrongGeneration}),codexFence)); + const codexSubmission = validateReceiptRequest(request('submission',{ledgerEpoch:epoch,correlationId:id,receiptContextId:null,status:'submitted'})); + assert.equal(validateReceiptResponse(response({handoff:handoff(1)}),codexSubmission).ok,true); + refused(()=>validateReceiptResponse(response({handoff:handoff()}),codexSubmission)); + // Status/wait selectors do not contain a context; do not manufacture one. + const selector=validateReceiptRequest(request('status',{ledgerEpoch:epoch,correlationId:id})); + assert.equal(validateReceiptResponse(response({handoff:handoff()}),selector).ok,true); + assert.equal(validateReceiptResponse(response({handoff:handoff(1)}),selector).ok,true); +}); + +test('private IPC2 requires the closed reference pair and rejects prototype IPC1 before allocation', () => { + const body = {ledgerEpoch:epoch,correlationId:id,goal:'acknowledged',timeoutSeconds:6,clockReferenceMs:0,remainingMs:5900}; + assert.equal(validateReceiptRequest(request('wait-open',body)).ipcVersion,2); + for (const ipcVersion of [1,3]) refused(() => validateReceiptRequest({...request('prepare',{}),ipcVersion})); + for (const bad of [{...body,remainingMs:6001},{...body,remainingMs:0},{...body,remainingMs:1.5}, + {...body,clockReferenceMs:-1},{...body,clockReferenceMs:undefined},{...body,remainingMs:undefined}]) { + refused(() => validateReceiptRequest(request('wait-open',bad))); + } + const hello = {receiptSchemas:[{agent:'claude',receiptSchemaVersion:2,receiptScope:'delegated_context'}],managedStoreVersion:2,endpointOwner:{uid:0,pid:123},clockReferenceMs:0}; + const req=validateReceiptRequest(request()); + assert.equal(validateReceiptResponse(response(hello),req).ok,true); + for(const bad of [{...hello,clockReferenceMs:undefined},{...hello,clockReferenceMs:-1},{...hello,extra:1}]) + refused(()=>validateReceiptResponse(response(bad),req)); +}); diff --git a/test/receipt-send.test.ts b/test/receipt-send.test.ts new file mode 100644 index 0000000..8112880 --- /dev/null +++ b/test/receipt-send.test.ts @@ -0,0 +1,318 @@ +// In-process actor and native-effect fixtures only. No live registry, inbox or agent. +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createHash, randomUUID } from 'node:crypto'; +import { managedReceiptSend, type ReceiptSendDependencies } from '../dist/receipt-send.js'; +import { ReceiptState } from '../dist/receipt-state.js'; +import { HandoffBudget } from '../dist/handoff.js'; +import { Refusal } from '../dist/discovery.js'; +import { UnknownOutcome } from '../dist/process.js'; +import type { ReceiptOperation, ReceiptRequestBody, ReceiptResponse } from '../dist/receipt-protocol.js'; +import type { SendOptions } from '../dist/send.js'; +const qualified = {skip:process.platform === 'win32'}; +const hash = (text: string) => createHash('sha256').update(text).digest('hex'); +const tempRoot = process.env.TASK_TEMP ?? tmpdir(); +function fixture(t: TestContext, clock?: {monotonic():number;utc():number}) { + const root = fs.mkdtempSync(join(tempRoot,'codex-send-')); fs.chmodSync(root,0o700); + const registry = join(root,'sessions'); fs.mkdirSync(registry,{mode:0o700}); + const record = join(registry,'123.json'); fs.writeFileSync(record,'{}',{mode:0o600}); + const s = fs.lstatSync(record), identity = [s.dev,s.ino,s.uid,s.mode,s.size,s.mtimeMs,s.ctimeMs,s.birthtimeMs].join(':'); + const socket = join(root,'collector.sock'), stateDir = join(root,'state'); + ReceiptState.initialize(stateDir,socket); const actor = new ReceiptState(stateDir,{clock}).open(); + const provenance = {registryDirectory:registry,registryRecordPath:record,registryIdentity:hash(identity),socketPath:join(root,'inbox.sock'),routeIdentity:hash('route')}; + const order: string[] = [], bodies: string[] = []; let effects = 0, rawCapability = '', behavior: (message: string) => void = () => {}; + let fault: ((op: ReceiptOperation, response: ReceiptResponse) => ReceiptResponse) | undefined; + const deps: ReceiptSendDependencies = { + endpoint:()=> 'fixed-private-endpoint', + producer:async pair => {order.push('producer'); return {evidence:{protocolVersion:1,artifactSha256:hash('installed'),challengeId:randomUUID(),receiptSchemaVersion:pair.receiptSchemaVersion,receiptScope:pair.receiptScope},revalidate(){order.push('revalidate-producer');}};}, + async exchange(_socket,context,op,body) { + order.push(op); if (op !== 'hello') assert.equal(context,actor.collectorContextId); + let result: unknown; + switch(op) { + case 'hello':result={receiptSchemas:[{agent:'claude',receiptSchemaVersion:2,receiptScope:'delegated_context'},{agent:'codex',receiptSchemaVersion:1,receiptScope:'original_generation'}],managedStoreVersion:2,clockReferenceMs:actor.clockReference(),endpointOwner:{uid:process.getuid!(),pid:process.pid}};break; + case 'prepare':result=actor.prepare(body as ReceiptRequestBody['prepare']);break; + case 'mint':result=actor.mint(body as ReceiptRequestBody['mint']);rawCapability=(result as {capability:string}).capability;result={capability:rawCapability};break; + case 'fence':result={handoff:actor.fence(body as ReceiptRequestBody['fence'])};break; + case 'submission':result={handoff:actor.submission(body as ReceiptRequestBody['submission'])};break; + case 'wait-open':result={handoff:actor.waitOpen(body as ReceiptRequestBody['wait-open'])};break; + case 'wait-status':result={handoff:actor.waitStatus(body as ReceiptRequestBody['wait-status'])};break; + case 'wait-stop':result={handoff:actor.waitStop(body as ReceiptRequestBody['wait-stop'])};break; + case 'revoke':result={handoff:actor.revoke(body as ReceiptRequestBody['revoke'])};break; + default:throw new Error('unexpected RPC'); + } + const response = {ipcVersion:2 as const,requestId:randomUUID(),collectorContextId:actor.collectorContextId,ok:true as const,body:result} as ReceiptResponse; + return fault ? fault(op,response) : response; + }, + async send(options: SendOptions) { + const snapshot = {ok:true,submitted:!options.dryRun,dryRun:Boolean(options.dryRun),status:options.dryRun?'validated':'posted',consumptionConfirmed:false}; + let body = options.message; + if (options.hooks) { + assert.ok(options.hooks.privateReceiptContext === true || options.hooks.privateReceiptContext === undefined); + const wrapped = await options.hooks.resolved({agent:'claude',target:'123',generation:null,writerIdentity:provenance.routeIdentity},snapshot,provenance); + if (wrapped !== undefined) body=wrapped; + } + if (options.dryRun) return snapshot; + await options.hooks?.beforeEffect?.(); order.push('native-effect');effects++;bodies.push(body);behavior(body); + return snapshot; + } + }; + t.after(()=>{actor.close();fs.rmSync(root,{recursive:true,force:true});}); + return {deps,order,bodies,actor,provenance,record,socket,root, get effects(){return effects;},get capability(){return rawCapability;}, + behavior(value: (message:string)=>void){behavior=value;},fault(value:typeof fault){fault=value;}, + candidate(message:string){const line=message.split('\n').find(line=>line.startsWith('Receipt-Only: '));assert.ok(line); const frame=JSON.parse(line.slice(14));assert.equal(frame.collector,socket);return actor.receipt(JSON.stringify(frame.receipt));}} +} +const options = {to:'claude:123',message:'From: fixture\noriginal task'}; +test('dry-run creates no bootstrap IPC, authority, producer or private wrapper',qualified,async t=>{ + const f=fixture(t),result=await managedReceiptSend({...options,dryRun:true},{collector:f.socket,requestAck:true},f.deps); + assert.equal(f.effects,0);assert.deepEqual(f.order,[]);assert.deepEqual(result.value.receiptBootstrap,{status:'not_checked'}); +}); +test('prepare/mint/wait/fence precede one effect and fast candidate requires positive submission',qualified,async t=>{ + const f=fixture(t);let candidateStatus='';f.behavior(body=>{candidateStatus=f.candidate(body).status;}); + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10,payload:'original task'},f.deps); + assert.equal(candidateStatus,'awaiting_submission');assert.equal(result.exitCode,0);assert.equal(f.effects,1); + const h=result.value.handoff as {ack:{status:string};targetGeneration:null;schemaVersion:number;wait:{status:string}}; + assert.equal(h.schemaVersion,2);assert.equal(h.targetGeneration,null);assert.equal(h.ack.status,'acknowledged');assert.equal(h.wait.status,'satisfied'); + const sequence=f.order.filter(x=>['hello','producer','prepare','mint','wait-open','fence','native-effect','submission'].includes(x)); + assert.deepEqual(sequence,['hello','producer','prepare','mint','wait-open','fence','native-effect','submission']); + assert.ok(f.capability);assert.equal(JSON.stringify(result).includes(f.capability),false);assert.match(f.bodies[0]!,/Receipt-Only: \{"collector"/); +}); +test('unknown native outcome never gains ACK from a fast candidate and never resends',qualified,async t=>{ + const f=fixture(t);f.behavior(body=>{assert.equal(f.candidate(body).status,'awaiting_submission');throw new UnknownOutcome();}); + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + assert.equal(f.effects,1);assert.equal(result.exitCode,1);assert.equal(result.value.submitted,null); + const h=result.value.handoff as {submission:{status:string};ack:{status:string}};assert.equal(h.submission.status,'unknown');assert.equal(h.ack.status,'unsupported'); + assert.equal(JSON.stringify(result).includes(f.capability),false); +}); +test('best effort unavailable collector selects exactly one ordinary unwrapped effect before prepare',qualified,async t=>{ + const f=fixture(t);f.deps.endpoint=()=>{throw new Refusal('receipt_collector_unavailable',1);}; + const result=await managedReceiptSend(options,{collector:f.socket,requestAck:true},f.deps); + assert.equal(result.exitCode,0);assert.equal(f.effects,1);assert.equal(f.bodies[0],options.message);assert.equal(f.order.includes('prepare'),false);assert.equal(f.capability,''); + assert.deepEqual(result.value.receiptBootstrap,{status:'unsupported'}); +}); +test('required ACK rejects unsupported producer before effect or authority',qualified,async t=>{ + const f=fixture(t);f.deps.producer=async()=>{throw new Refusal('receipt_producer_mismatch',1);}; + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged'},f.deps); + assert.equal(result.exitCode,1);assert.equal(f.effects,0);assert.equal(f.order.includes('prepare'),false);assert.equal(f.capability,''); +}); +test('positive native fact survives receipt persistence failure without a fabricated terminal wait',qualified,async t=>{ + const f=fixture(t);f.fault((op,response)=>{if(op==='submission')throw new Refusal('receipt_transport_unknown',1);return response;}); + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + assert.equal(f.effects,1);assert.equal(result.value.submitted,true);assert.equal(result.value.handoffWarning,'persistence_failed');assert.equal(result.exitCode,1); + const h=result.value.handoff as {submission:{status:string};wait:{status:string}};assert.equal(h.submission.status,'submitted');assert.equal(h.wait.status,'pending'); +}); +test('every status response is pinned to original delegated context',qualified,async t=>{ + const f=fixture(t);f.fault((op,response)=>{ + if(op==='wait-status'&&response.ok&&'handoff'in response.body){const h=response.body.handoff;assert.equal(h.schemaVersion,2);if(h.schemaVersion===2)return {...response,body:{handoff:{...h,receiptContext:{kind:'delegated_context',id:randomUUID()}}}};} + return response; + }); + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + assert.equal(result.exitCode,1);assert.equal(f.effects,1);assert.equal(result.value.submitted,true);assert.equal(result.value.handoffWarning,'persistence_failed'); +}); +test('the original wait deadline cannot change in a later owner response',qualified,async t=>{ + const f=fixture(t);let originalDeadline:number|undefined; + f.fault((op,response)=>{ + if(op==='wait-open'&&response.ok&&'handoff'in response.body)originalDeadline=response.body.handoff.wait.deadlineAtUtcMs; + if(op==='wait-status'&&response.ok&&'handoff'in response.body){const h=response.body.handoff; + return {...response,body:{handoff:{...h,wait:{...h.wait,deadlineAtUtcMs:h.wait.deadlineAtUtcMs!+1000}}}}; + }return response; + }); + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + assert.equal(result.exitCode,1);assert.equal(result.value.submitted,true);assert.equal(f.effects,1); + assert.equal((result.value.handoff as {wait:{deadlineAtUtcMs:number}}).wait.deadlineAtUtcMs,originalDeadline); + assert.equal(result.value.handoffWarning,'persistence_failed'); +}); +test('SIGINT stops only original pending wait once without native cancellation or resend',qualified,async t=>{ + const f=fixture(t);f.behavior(()=>{process.emit('SIGINT');}); + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + assert.equal(result.exitCode,130);assert.equal(f.effects,1);assert.equal(f.order.filter(op=>op==='wait-stop').length,1); + assert.equal((result.value.handoff as {wait:{status:string}}).wait.status,'stopped'); +}); +test('a terminal timeout observed with SIGINT remains timeout exit 1 rather than a new stopped outcome',qualified,async t=>{ + const f=fixture(t);f.fault((op,response)=>{ + if(op==='wait-status'&&response.ok&&'handoff'in response.body){ + const h=response.body.handoff;process.emit('SIGINT'); + return {...response,body:{handoff:{...h,state:'timed_out_unknown',wait:{...h.wait,status:'timed_out_unknown'},nextActions:['reconcile']}}}; + }return response; + }); + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + assert.equal(result.exitCode,1);assert.equal(f.effects,1); + assert.equal((result.value.handoff as {wait:{status:string}}).wait.status,'timed_out_unknown'); + assert.equal(f.order.includes('wait-stop'),false); +}); +test('five second original total budget refuses before bootstrap and native effect',qualified,async t=>{ + const f=fixture(t),result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',budget:new HandoffBudget(5)},f.deps); + assert.equal(result.exitCode,1);assert.equal(f.effects,0);assert.equal(result.value.error,'insufficient_budget');assert.equal(f.order.includes('prepare'),false); +}); +test('unsafe selected registry refuses required ACK even if ordinary discovery is compatible',qualified,async t=>{ + const f=fixture(t);fs.chmodSync(f.record,0o622); + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged'},f.deps); + assert.equal(result.exitCode,1);assert.equal(f.effects,0);assert.equal(f.order.includes('prepare'),false); +}); +test('unexpected post-fence error stays unknown and does not claim no native effect',qualified,async t=>{ + const f=fixture(t);f.behavior(body=>{assert.equal(f.candidate(body).status,'awaiting_submission');throw new Error('private transport cleanup failed');}); + const result=await managedReceiptSend(options,{collector:f.socket,requestAck:true},f.deps); + assert.equal(f.effects,1);assert.equal(result.value.submitted,null);assert.equal(result.value.status,'unknown');assert.equal(result.exitCode,1); + const h=result.value.handoff as {submission:{status:string};ack:{status:string}};assert.equal(h.submission.status,'unknown');assert.equal(h.ack.status,'unsupported'); + assert.equal(JSON.stringify(result).includes(f.capability),false); +}); +test('concurrent latest wait cannot replace original pending operation after failed own query',qualified,async t=>{ + const f=fixture(t);let originalId: string|undefined,otherId: string|undefined; + f.fault((op,response)=>{ + if(op==='wait-open'&&response.ok&&'handoff'in response.body)originalId=response.body.handoff.wait.operationId; + if(op==='submission'&&response.ok&&'handoff'in response.body){ + const h=response.body.handoff; + const other=f.actor.waitOpen({ledgerEpoch:h.ledgerEpoch,correlationId:h.correlationId,goal:'acknowledged',timeoutSeconds:10}); + otherId=other.wait.operationId;assert.notEqual(otherId,originalId); + return {...response,body:{handoff:other}}; + } + if(op==='wait-status')throw new Refusal('receipt_transport_unknown',1); + return response; + }); + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + const h=result.value.handoff as {submission:{status:string};wait:{operationId:string;status:string}}; + assert.equal(result.value.submitted,true);assert.equal(result.value.handoffWarning,'persistence_failed');assert.equal(h.wait.operationId,originalId);assert.notEqual(h.wait.operationId,otherId);assert.equal(h.wait.status,'pending');assert.equal(h.submission.status,'submitted');assert.equal(f.effects,1); +}); +test('qualified Codex receipt uses owned private queue and keeps capability out of probe argv', {skip:process.platform!=='darwin'||process.arch!=='arm64'},async t=>{ + const f=fixture(t),calls: {binary:string;args:string[]}[]=[],nativeBodies: string[]=[]; + const thread='01900000-0000-7000-8000-000000000001',generation=hash('original codex writer');let queueCalls=0; + f.deps.run=async(binary,args)=>{calls.push({binary,args});return {code:0,stdout:args[0]==='--version'?'codex-cli 0.162.1\n':JSON.stringify({schemaVersion:1,ok:true,generation}),stderr:'',spawned:true,interrupted:false};}; + f.deps.nativeQueue=async input=>{ + queueCalls++;assert.equal(await input.verifyOwner(input.scope),true);await input.verifyStorage?.();await input.beforeEffect();input.verifyStorageSync?.(); + nativeBodies.push(input.message);assert.equal(f.candidate(input.message).status,'awaiting_submission'); + return {queueId:'private-fixture-queue',clientUserMessageId:input.clientUserMessageId}; + }; + f.deps.send=async input=>{ + assert.ok(input.hooks);assert.equal(input.hooks.codexRoute?.(),'owned_app_server'); + const context={agent:'codex' as const,target:thread,home:f.root,generation,writerIdentity:generation,binary:'/fixture/qualified-codex'}; + const snapshot={ok:true,submitted:true,status:'queued',consumptionConfirmed:false}; + const message=await input.hooks.resolved(context,snapshot);assert.equal(typeof message,'string'); + assert.ok(input.hooks.submitCodex);await input.hooks.submitCodex(context,message!,{binary:context.binary,cwd:f.root,async revalidate(owned){assert.equal(owned,true);},finalCheck(owned){assert.equal(owned,true);}}); + return snapshot; + }; + const result=await managedReceiptSend({to:'codex:'+thread,message:'original codex task'},{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + assert.equal(result.exitCode,0);assert.equal(queueCalls,1);assert.equal(nativeBodies.length,1);assert.ok(nativeBodies[0]!.includes(f.capability)); + assert.equal(JSON.stringify(calls).includes(f.capability),false);assert.equal(JSON.stringify(result).includes(f.capability),false);assert.ok(calls.every(call=>!call.args.some(arg=>arg.startsWith('--message')))); + assert.equal((result.value.handoff as {schemaVersion:number;targetGeneration:string;ack:{status:string}}).schemaVersion,1); + assert.equal((result.value.handoff as {targetGeneration:string}).targetGeneration,generation); +}); +test('preflight fallback cannot reroute a selected name to a successor target',qualified,async t=>{ + const f=fixture(t),send=f.deps.send!;let calls=0; + f.deps.endpoint=()=>{throw new Refusal('receipt_collector_unavailable',1);}; + f.deps.send=async input=>{ + calls++; + if(input.hooks&&!input.hooks.privateReceiptContext){ + await input.hooks.resolved({agent:'claude',target:'124',generation:null,writerIdentity:hash('successor')},{ok:true,submitted:true,status:'posted'}); + throw new Error('fallback unexpectedly accepted replacement'); + } + return send(input); + }; + const result=await managedReceiptSend({...options,to:'claude:named-session'},{collector:f.socket,requestAck:true},f.deps); + assert.equal(result.exitCode,1);assert.equal(result.value.error,'target_changed');assert.equal(calls,2);assert.equal(f.effects,0);assert.equal(f.capability,''); +}); +test('fallback refuses after the original budget expires instead of renewing ordinary timeout',qualified,async t=>{ + const f=fixture(t);let mono=0; + const budget=new HandoffBudget(10,{monotonic:()=>mono,utc:()=>100000}); + f.deps.endpoint=()=>{mono=6000;throw new Refusal('receipt_collector_unavailable',1);}; + const result=await managedReceiptSend(options,{collector:f.socket,requestAck:true,budget},f.deps); + assert.equal(result.exitCode,1);assert.equal(result.value.error,'deadline_before_effect');assert.equal(f.effects,0);assert.equal(f.order.includes('prepare'),false); +}); +test('best effort has no owner wait and never exposes a concurrent owner operation',qualified,async t=>{ + const f=fixture(t);let otherId:string|undefined; + f.fault((op,response)=>{ + if(op==='submission'&&response.ok&&'handoff'in response.body){const h=response.body.handoff; + const other=f.actor.waitOpen({ledgerEpoch:h.ledgerEpoch,correlationId:h.correlationId,goal:'acknowledged',timeoutSeconds:10});otherId=other.wait.operationId; + return {...response,body:{handoff:other}}; + }return response; + }); + const result=await managedReceiptSend(options,{collector:f.socket,requestAck:true},f.deps); + assert.equal(result.exitCode,0);assert.equal(f.effects,1);assert.ok(otherId); + const h=result.value.handoff as {wait:unknown;nextActions:string[]};assert.deepEqual(h.wait,{for:'none',status:'not_requested'});assert.deepEqual(h.nextActions,['reconcile']);assert.equal(JSON.stringify(result).includes(otherId!),false); +}); +test('unsafe selected registry is a trust refusal for best effort and cannot trigger ordinary fallback',qualified,async t=>{ + const f=fixture(t);fs.chmodSync(f.record,0o622); + const result=await managedReceiptSend(options,{collector:f.socket,requestAck:true},f.deps); + assert.equal(result.exitCode,1);assert.equal(result.value.error,'receipt_registry_untrusted');assert.equal(f.effects,0);assert.equal(f.capability,'');assert.equal(f.order.includes('prepare'),false); +}); +test('producer artifact mismatch is a trust refusal for best effort and never falls back',qualified,async t=>{ + const f=fixture(t);f.deps.producer=async()=>{throw new Refusal('receipt_producer_mismatch',1);}; + const result=await managedReceiptSend(options,{collector:f.socket,requestAck:true},f.deps); + assert.equal(result.exitCode,1);assert.equal(result.value.error,'receipt_producer_mismatch');assert.equal(f.effects,0);assert.equal(f.capability,''); +}); + +for(const winner of ['ack','timeout'] as const) test(`terminal ${winner} wins pending-to-SIGINT stop race with original wait and native facts`,qualified,async t=>{ + let mono=1000,utc=100000;const clock={monotonic:()=>mono,utc:()=>utc}; + const f=fixture(t,clock),exchange=f.deps.exchange!;let operationId:string|undefined,stopOperation:string|undefined; + f.behavior(()=>process.emit('SIGINT')); + f.deps.exchange=async(socket,context,op,body,timeout)=>{ + if(op==='wait-stop'){ + stopOperation=(body as ReceiptRequestBody['wait-stop']).operationId; + if(winner==='ack')f.candidate(f.bodies[0]!);else {mono+=5000;utc+=5000;} + } + const result=await exchange(socket,context,op,body,timeout); + if(op==='wait-open'&&result.ok&&'handoff'in result.body)operationId=result.body.handoff.wait.operationId; + return result; + }; + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',budget:new HandoffBudget(10,clock)},f.deps); + assert.equal(result.exitCode,winner==='ack'?0:1);assert.equal(f.effects,1);assert.equal(result.value.submitted,true);assert.equal(result.value.status,'posted'); + assert.equal(result.value.handoffWarning,undefined);assert.equal(stopOperation,operationId); + const h=result.value.handoff as {wait:{status:string;operationId:string};submission:{status:string}}; + assert.equal(h.wait.operationId,operationId);assert.equal(h.wait.status,winner==='ack'?'satisfied':'timed_out_unknown');assert.equal(h.submission.status,'submitted'); + assert.equal(f.order.filter(op=>op==='wait-stop').length,1);assert.equal(f.order.filter(op=>op==='wait-status').length,2); +}); +test('six second sender retains fractional observation after prepare and mint IPC delay',qualified,async t=>{ + let mono=1000,utc=100000;const clock={monotonic:()=>mono,utc:()=>utc}; + const f=fixture(t,clock),exchange=f.deps.exchange!;let openedDeadline:number|undefined,span:number|undefined; + f.deps.exchange=async(socket,context,op,body,timeout)=>{ + if(op==='hello'){const result=await exchange(socket,context,op,body,timeout);mono+=100;utc+=100;return result;} + if(op==='prepare'||op==='mint'||op==='wait-open'){mono+=100;utc+=100;} + if(op==='wait-open'){span=(body as ReceiptRequestBody['wait-open']).remainingMs;assert.equal((body as ReceiptRequestBody['wait-open']).timeoutSeconds,6);} + const result=await exchange(socket,context,op,body,timeout); + if(op==='wait-open'&&result.ok&&'handoff'in result.body)openedDeadline=result.body.handoff.wait.deadlineAtUtcMs; + return result; + }; + f.behavior(body=>f.candidate(body)); + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',budget:new HandoffBudget(6,clock)},f.deps); + assert.equal(result.exitCode,0);assert.equal(f.effects,1);assert.equal(span,5900);assert.equal(openedDeadline,100900); +}); + +test('catch cleanup confirms a lost stop reply before recording unknown native outcome',qualified,async t=>{ + const f=fixture(t),exchange=f.deps.exchange!;f.behavior(()=>{process.emit('SIGINT');throw new UnknownOutcome();}); + f.deps.exchange=async(...args)=>{const response=await exchange(...args);if(args[2]==='wait-stop')throw new Refusal('receipt_transport_unknown',1);return response;}; + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + assert.equal(result.exitCode,130);assert.equal(result.value.submitted,null);assert.equal(result.value.status,'unknown');assert.equal(result.value.handoffWarning,undefined); + const h=result.value.handoff as {wait:{status:string};ack:{status:string}};assert.equal(h.wait.status,'stopped');assert.equal(h.ack.status,'unsupported'); + assert.equal(f.order.filter(op=>op==='wait-stop').length,1);assert.ok(f.order.indexOf('wait-stop'){ + const f=fixture(t);f.fault((op,response)=>{if(op==='wait-open')process.emit('SIGINT');return response;}); + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + assert.equal(result.exitCode,130);assert.equal(f.effects,0);assert.equal(result.value.submitted,false); + assert.equal((result.value.handoff as {wait:{status:string}}).wait.status,'stopped'); + assert.ok(f.order.indexOf('wait-stop')op==='wait-stop').length,1); +}); +test('unconfirmed pre-effect stop keeps original pending facts and exits one',qualified,async t=>{ + const f=fixture(t),exchange=f.deps.exchange!; + f.deps.exchange=async(...args)=>{ + if(args[2]==='wait-stop'||args[2]==='wait-status'||args[2]==='revoke')throw new Refusal('receipt_transport_unknown',1); + const result=await exchange(...args);if(args[2]==='wait-open')process.emit('SIGINT');return result; + }; + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + assert.equal(result.exitCode,1);assert.equal(f.effects,0);assert.equal(result.value.error,'receipt_transport_unknown'); + assert.equal((result.value.handoff as {wait:{status:string}}).wait.status,'pending'); +}); + +for(const loseStatus of [false,true])test(`positive native acceptance survives lost stop reply ${loseStatus?'without':'with'} terminal confirmation`,qualified,async t=>{ + const f=fixture(t),exchange=f.deps.exchange!;let reads=0;f.behavior(()=>process.emit('SIGINT')); + f.deps.exchange=async(...args)=>{ + if(args[2]==='wait-status'&&++reads===2&&loseStatus)throw new Refusal('receipt_transport_unknown',1); + const result=await exchange(...args);if(args[2]==='wait-stop')throw new Refusal('receipt_transport_unknown',1);return result; + }; + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + assert.equal(result.exitCode,loseStatus?1:130);assert.equal(result.value.submitted,true);assert.equal(result.value.status,'posted');assert.equal(f.effects,1);assert.equal(reads,2); + assert.equal((result.value.handoff as {wait:{status:string}}).wait.status,loseStatus?'pending':'stopped'); + if(loseStatus)assert.equal(result.value.error,'stop_unconfirmed'); + assert.equal(f.order.filter(op=>op==='wait-stop').length,1);assert.equal(f.order.filter(op=>op==='wait-open').length,1); +}); diff --git a/test/receipt-state.test.ts b/test/receipt-state.test.ts new file mode 100644 index 0000000..c73937b --- /dev/null +++ b/test/receipt-state.test.ts @@ -0,0 +1,290 @@ +// Private actor/storage fixtures only; no agent input, process launch or inbox. +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createHash, randomUUID } from 'node:crypto'; +import { syncBuiltinESMExports } from 'node:module'; +import { ReceiptState, type PreparedReceipt, type MintedReceipt } from '../dist/receipt-state.js'; +import { validateHandoff, HANDOFF_LIMITS } from '../dist/handoff.js'; +import type { ReceiptBinding, ProducerEvidence } from '../dist/receipt-protocol.js'; + +const qualified = { skip: process.platform === 'win32' }; +const hash = (text: string) => createHash('sha256').update(text).digest('hex'); +const refusal = (code: string) => (error: { code?: string }) => error.code === code; +function fixture(t: TestContext) { + const root = fs.mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-receipt-state-')); + const path = join(root, 'managed'), socket = join(root, 'collector.sock'); + let mono = 1000, utc = 100000; + const clock = { monotonic: () => mono, utc: () => utc }; + const initialized = ReceiptState.initialize(path, socket), actors: ReceiptState[] = []; + let actor = new ReceiptState(path, { clock }).open(); actors.push(actor); + t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); for (const a of actors) a.close(); fs.rmSync(root, { recursive: true, force: true }); }); + function binding(agent: 'claude' | 'codex' = 'claude'): ReceiptBinding { + const shared = { destination: 'local', payloadDigest: hash('PRIVATE ORIGINAL BODY') }; + return agent === 'claude' ? { ...shared, agent, target: '123', registryDirectory: join(root, 'sessions'), + registryRecordPath: join(root, 'sessions/123.json'), registryIdentity: hash('registry'), socketPath: join(root, 'inbox.sock'), + routeIdentity: hash('route'), generation: null } : { ...shared, agent, target: '01900000-0000-7000-8000-000000000001', + home: join(root, 'codex'), writerIdentity: 'original-generation', generation: 'original-generation' }; + } + function prepare(agent: 'claude' | 'codex' = 'claude') { + const original = binding(agent), version = agent === 'claude' ? 2 : 1, scope = agent === 'claude' ? 'delegated_context' : 'original_generation'; + const producer: ProducerEvidence = { protocolVersion: 1, artifactSha256: hash('installed reviewed handler'), challengeId: randomUUID(), receiptSchemaVersion: version, receiptScope: scope }; + const prepared = actor.prepare({ binding: original, producerEvidence: producer, receiptSchemaVersion: version, receiptScope: scope }); + const handle = { ledgerEpoch: prepared.ledgerEpoch, correlationId: prepared.correlationId, receiptContextId: prepared.receiptContextId }; + const minted = actor.mint({ ...handle, producerProtocolVersion: 1 }); + return { original, prepared, handle, minted, selector: { ledgerEpoch: prepared.ledgerEpoch, correlationId: prepared.correlationId } }; + } + function receipt(minted: MintedReceipt, receiptId = randomUUID()) { + const base = { kind: 'receipt', ledgerEpoch: minted.ledgerEpoch, correlationId: minted.correlationId, receiptId, capability: minted.capability }; + return JSON.stringify(minted.receiptSchemaVersion === 1 ? { ...base, schemaVersion: 1, targetGeneration: minted.targetGeneration } : + { ...base, schemaVersion: 2, receiptScope: 'delegated_context', receiptContextId: minted.receiptContextId }); + } + return { root, path, socket, initialized, clock, binding, prepare, receipt, bytes: () => fs.readFileSync(join(path, 'ledger.json'), 'utf8'), + get actor() { return actor; }, advance(ms: number) { mono += ms; utc += ms; }, rollback(ms: number) { mono -= ms; }, + reopen() { actor.close(); actor = new ReceiptState(path, { clock }).open(); actors.push(actor); return actor; } }; +} + +test('managed initialization is explicit, private and incompatible with legacy storage', qualified, t => { + const f = fixture(t); + assert.equal(f.actor.ledgerEpoch, f.initialized.ledgerEpoch); assert.equal(f.actor.endpointSelector, f.socket); + assert.deepEqual(fs.readdirSync(f.path).sort(), ['collector.lock', 'ledger.json', 'ledger.key', 'managed.json']); + for (const entry of fs.readdirSync(f.path)) assert.equal(fs.statSync(join(f.path, entry)).mode & 0o777, 0o600); + assert.equal(fs.statSync(f.path).mode & 0o777, 0o700); assert.equal(fs.statSync(join(f.path, 'ledger.key')).size, 32); + assert.equal(JSON.parse(f.bytes()).data.version, 2); + const before = f.bytes(); assert.throws(() => ReceiptState.initialize(f.path, f.socket), refusal('receipt_state_exists')); assert.equal(f.bytes(), before); + const legacy = join(f.root, 'legacy'); fs.mkdirSync(legacy, { mode: 0o700 }); fs.writeFileSync(join(legacy, 'ledger.json'), '{"version":1}', { mode: 0o600 }); + assert.throws(() => ReceiptState.initialize(legacy, f.socket), refusal('receipt_state_exists')); + assert.deepEqual(fs.readdirSync(legacy), ['ledger.json']); +}); + +test('one lifetime owner holds the kernel lock; replacement owners cannot mint or fence old intent', qualified, t => { + const f = fixture(t), p = f.prepare(); + assert.throws(() => new ReceiptState(f.path, { clock: f.clock }).open(), refusal('receipt_collector_busy')); + const oldContext = f.actor.collectorContextId; f.reopen(); assert.notEqual(f.actor.collectorContextId, oldContext); + assert.equal(f.actor.status(p.selector).ack.status, 'unsupported'); + assert.throws(() => f.actor.fence({ ...p.handle, binding: p.original }), refusal('receipt_authority_unavailable')); + assert.throws(() => f.actor.mint({ ...p.handle, producerProtocolVersion: 1 }), refusal('receipt_authority_unavailable')); +}); + +test('Claude delegated receipt never fabricates native generation; digest and capability are not persisted raw', qualified, t => { + const f = fixture(t), p = f.prepare(); + assert.equal(p.prepared.handoff.schemaVersion, 2); assert.equal(p.prepared.handoff.targetGeneration, null); + assert.throws(() => validateHandoff(p.prepared.handoff)); + f.actor.fence({ ...p.handle, binding: p.original }); f.actor.submission({ ...p.handle, status: 'submitted' }); + const result = f.actor.receipt(f.receipt(p.minted)); + assert.equal(result.status, 'acknowledged'); assert.equal(result.handoff.ack.assurance, 'token_possession'); + assert.equal(result.handoff.targetGeneration, null); assert.equal(result.handoff.observation.injectionObserved, false); + for (const value of [p.minted.capability, p.original.payloadDigest, 'PRIVATE ORIGINAL BODY']) { + assert.equal(f.bytes().includes(value), false); assert.equal(JSON.stringify(result.handoff).includes(value), false); + } + assert.equal(f.bytes().includes('sealed-v2:'), true); +}); + +test('Codex uses exact original-generation v1, never the collector or Claude context', qualified, t => { + const f = fixture(t), p = f.prepare('codex'); assert.equal(p.handle.receiptContextId, null); + f.actor.fence({ ...p.handle, binding: p.original }); f.actor.submission({ ...p.handle, status: 'submitted' }); + const result = f.actor.receipt(f.receipt(p.minted)); + assert.equal(result.handoff.schemaVersion, 1); assert.equal(result.handoff.targetGeneration, 'original-generation'); validateHandoff(result.handoff); + const wrong = { ...JSON.parse(f.receipt(p.minted)), targetGeneration: 'successor-generation' }; + assert.throws(() => f.actor.receipt(JSON.stringify(wrong)), refusal('receipt_authentication_failed')); +}); + +test('fast receipt is one bounded authenticated candidate and duplicates cannot rewrite its bytes', qualified, t => { + const f = fixture(t), p = f.prepare(), wire = f.receipt(p.minted); + assert.throws(() => f.actor.receipt(wire), refusal('receipt_authority_unavailable')); + f.actor.fence({ ...p.handle, binding: p.original }); + const pending = f.actor.receipt(wire); assert.equal(pending.status, 'awaiting_submission'); + assert.equal(pending.handoff.submission.status, 'unknown'); assert.equal(pending.handoff.ack.status, 'pending'); + const before = f.bytes(); assert.deepEqual(f.actor.receipt(wire), pending); assert.equal(f.bytes(), before); + assert.throws(() => f.actor.receipt(f.receipt(p.minted)), refusal('receipt_candidate_conflict')); assert.equal(f.bytes(), before); + const committed = f.actor.submission({ ...p.handle, status: 'submitted' }); assert.equal(committed.ack.status, 'acknowledged'); +}); + +test('wrong token, original epoch or delegated context never writes history or reflects secret input', qualified, t => { + const f = fixture(t), p = f.prepare(); f.actor.fence({ ...p.handle, binding: p.original }); + const valid = JSON.parse(f.receipt(p.minted)), before = f.bytes(); + for (const change of [{ capability: Buffer.alloc(32, 9).toString('base64url') }, { ledgerEpoch: randomUUID() }, { receiptContextId: randomUUID() }]) { + let failure: unknown; + try { f.actor.receipt(JSON.stringify({ ...valid, ...change })); } catch (error) { failure = error; } + assert.ok(failure); assert.equal(String(failure).includes(p.minted.capability), false); assert.equal(f.bytes(), before); + } +}); + +for (const outcome of ['unknown', 'refused'] as const) test(`authenticated candidate cannot promote ${outcome} native outcome`, qualified, t => { + const f = fixture(t), p = f.prepare(); f.actor.fence({ ...p.handle, binding: p.original }); + const wire = f.receipt(p.minted); f.actor.receipt(wire); + const result = f.actor.submission({ ...p.handle, status: outcome }); + assert.equal(result.submission.status, outcome); assert.notEqual(result.ack.status, 'acknowledged'); + const waiting = f.actor.waitOpen({ ...p.selector, goal: 'acknowledged', timeoutSeconds: 30 }); + assert.equal(waiting.wait.status, 'unsupported'); assert.equal(waiting.nextActions.includes('keep_waiting'), false); + assert.throws(() => f.actor.submission({ ...p.handle, status: 'submitted' }), refusal('receipt_outcome_not_authorized')); + assert.throws(() => f.actor.receipt(wire), refusal('receipt_authority_unavailable')); + assert.throws(() => f.actor.fence({ ...p.handle, binding: p.original }), refusal('receipt_already_attempted')); +}); + +test('eligibility commit after original cutoff is late; a prior arrival never backdates ACK or timeout', qualified, t => { + const f = fixture(t), p = f.prepare(); + const original = f.actor.waitOpen({ ...p.selector, goal: 'acknowledged', timeoutSeconds: 10 }); + assert.equal(original.wait.status, 'pending'); assert.equal(original.nextActions.includes('keep_waiting'), false); + f.actor.fence({ ...p.handle, binding: p.original }); f.advance(4000); f.actor.receipt(f.receipt(p.minted)); + f.advance(1000); const cutoff = f.actor.waitStatus({ ...p.selector, operationId: original.wait.operationId! }); + assert.equal(cutoff.wait.status, 'timed_out_unknown'); + const accepted = f.actor.submission({ ...p.handle, status: 'submitted' }); + assert.equal(accepted.ack.status, 'acknowledged'); assert.equal(accepted.ack.late, true); assert.equal(accepted.ack.receivedAtUtcMs, 105000); + assert.deepEqual(f.actor.waitStatus({ ...p.selector, operationId: original.wait.operationId! }).wait, cutoff.wait); +}); + +test('separate wait operations retain their own deadlines and stopped results', qualified, t => { + const f = fixture(t), p = f.prepare(); + const first = f.actor.waitOpen({ ...p.selector, goal: 'acknowledged', timeoutSeconds: 10 }); + f.actor.fence({ ...p.handle, binding: p.original }); f.actor.submission({ ...p.handle, status: 'submitted' }); + f.advance(1000); const stopped = f.actor.waitStop({ ...p.selector, operationId: first.wait.operationId! }); + const second = f.actor.waitOpen({ ...p.selector, goal: 'acknowledged', timeoutSeconds: 20 }); + f.actor.receipt(f.receipt(p.minted)); + assert.deepEqual(f.actor.waitStatus({ ...p.selector, operationId: first.wait.operationId! }).wait, stopped.wait); + assert.equal(f.actor.waitStatus({ ...p.selector, operationId: second.wait.operationId! }).wait.status, 'satisfied'); + assert.throws(() => f.actor.waitStop({ ...p.selector, operationId: first.wait.operationId! }), refusal('receipt_wait_terminal')); +}); + +test('insufficient or expired pre-effect waits cannot authorize a native effect', qualified, t => { + const f = fixture(t), short = f.prepare(); + const failed = f.actor.waitOpen({ ...short.selector, goal: 'acknowledged', timeoutSeconds: 5 }); + assert.equal(failed.wait.reason, 'insufficient_budget'); assert.equal(failed.submission.status, 'refused'); + assert.throws(() => f.actor.fence({ ...short.handle, binding: short.original }), refusal('receipt_already_attempted')); + const late = f.prepare(); f.actor.waitOpen({ ...late.selector, goal: 'acknowledged', timeoutSeconds: 6 }); f.advance(1000); + assert.throws(() => f.actor.fence({ ...late.handle, binding: late.original }), refusal('receipt_already_attempted')); +}); + +test('all cleanup-insufficient budgets remain terminal without corrupting young-process history', qualified, t => { + const f = fixture(t); + for (const seconds of [1,2,3,4,5]) { + const p = f.prepare(); + const refused = f.actor.waitOpen({ ...p.selector, goal:'acknowledged', timeoutSeconds:seconds }); + assert.equal(refused.wait.status,'failed'); assert.equal(refused.wait.reason,'insufficient_budget'); + assert.equal(refused.submission.status,'refused'); + assert.throws(()=>f.actor.fence({...p.handle,binding:p.original}),refusal('receipt_already_attempted')); + const sent=f.prepare(); f.actor.fence({...sent.handle,binding:sent.original}); f.actor.submission({...sent.handle,status:'submitted'}); + const waited=f.actor.waitOpen({...sent.selector,goal:'acknowledged',timeoutSeconds:seconds}); + assert.equal(waited.wait.status,'failed'); assert.equal(waited.wait.reason,'insufficient_budget'); + assert.equal(waited.submission.status,'submitted'); + } +}); + +test('TTL loss and monotonic rollback refuse new receipts; committed duplicates remain authenticated read-only retrieval', qualified, t => { + const f = fixture(t), p = f.prepare(); f.actor.fence({ ...p.handle, binding: p.original }); f.actor.submission({ ...p.handle, status: 'submitted' }); + const wire = f.receipt(p.minted); f.actor.receipt(wire); const committed = f.bytes(); f.advance(HANDOFF_LIMITS.capabilityMs); + assert.equal(f.actor.receipt(wire).handoff.ack.status, 'acknowledged'); assert.equal(f.bytes(), committed); + const expiring = f.prepare(); f.actor.fence({ ...expiring.handle, binding: expiring.original }); f.actor.submission({ ...expiring.handle, status: 'submitted' }); f.advance(HANDOFF_LIMITS.capabilityMs); + assert.throws(() => f.actor.receipt(f.receipt(expiring.minted)), refusal('receipt_authority_expired')); + const rollback = f.prepare(); f.rollback(1); + assert.throws(() => f.actor.fence({ ...rollback.handle, binding: rollback.original }), refusal('receipt_authority_expired')); + f.advance(1); + assert.throws(() => f.actor.fence({ ...rollback.handle, binding: rollback.original }), refusal('receipt_collector_closed')); + f.reopen(); assert.equal(f.actor.status(rollback.selector).ack.status, 'unsupported'); + assert.throws(() => f.actor.fence({ ...rollback.handle, binding: rollback.original }), refusal('receipt_authority_unavailable')); + assert.equal(f.actor.receipt(wire).handoff.ack.status, 'acknowledged', 'committed evidence survives the new owner'); +}); + +test('restart cannot finish an old attempt or promote its pending receipt, but committed ACK survives', qualified, t => { + const f = fixture(t), done = f.prepare(); f.actor.fence({ ...done.handle, binding: done.original }); f.actor.submission({ ...done.handle, status: 'submitted' }); + const doneWire = f.receipt(done.minted); const accepted = f.actor.receipt(doneWire); + const pending = f.prepare(); f.actor.fence({ ...pending.handle, binding: pending.original }); const pendingWire = f.receipt(pending.minted); f.actor.receipt(pendingWire); + f.reopen(); assert.deepEqual(f.actor.receipt(doneWire).handoff, accepted.handoff); + assert.equal(f.actor.status(pending.selector).ack.status, 'unsupported'); + assert.throws(() => f.actor.submission({ ...pending.handle, status: 'submitted' }), refusal('receipt_outcome_not_authorized')); + assert.throws(() => f.actor.receipt(pendingWire), refusal('receipt_authority_expired')); +}); + +test('binding mismatch and revocation preserve the durable one-attempt fence', qualified, t => { + const f = fixture(t), p = f.prepare(); const before = f.bytes(); + assert.throws(() => f.actor.fence({ ...p.handle, binding: { ...p.original, payloadDigest: hash('different body') } }), refusal('receipt_binding_conflict')); + assert.equal(f.bytes(), before); f.actor.fence({ ...p.handle, binding: p.original }); f.actor.revoke(p.selector); + assert.equal(f.actor.status(p.selector).submission.status, 'unknown'); + assert.throws(() => f.actor.fence({ ...p.handle, binding: p.original }), refusal('receipt_already_attempted')); +}); + +test('retained wait quota cannot be bypassed by stopping old waits', qualified, t => { + const f = fixture(t), p = f.prepare(); f.actor.fence({ ...p.handle, binding: p.original }); f.actor.submission({ ...p.handle, status: 'submitted' }); + for (let i = 0; i < HANDOFF_LIMITS.waits; i++) { + const wait = f.actor.waitOpen({ ...p.selector, goal: 'acknowledged', timeoutSeconds: 30 }); f.actor.waitStop({ ...p.selector, operationId: wait.wait.operationId! }); + } + const before = f.bytes(); assert.throws(() => f.actor.waitOpen({ ...p.selector, goal: 'acknowledged', timeoutSeconds: 30 }), refusal('receipt_wait_quota')); assert.equal(f.bytes(), before); +}); + +test('external state/key replacement is refused instead of overwritten or repaired', qualified, t => { + const f = fixture(t), p = f.prepare(); const file = join(f.path, 'ledger.json'); + fs.appendFileSync(file, '{'); const changed = f.bytes(); assert.throws(() => f.actor.status(p.selector), refusal('receipt_history_changed')); assert.equal(f.bytes(), changed); + f.actor.close(); assert.throws(() => new ReceiptState(f.path, { clock: f.clock }).open(), refusal('receipt_history_corrupt')); + assert.equal(f.bytes(), changed); +}); + +test('missing or replaced private key cannot initialize a replacement history', qualified, t => { + const f = fixture(t); f.prepare(); const before = f.bytes(); f.actor.close(); + fs.writeFileSync(join(f.path, 'ledger.key'), Buffer.alloc(32, 7)); + assert.throws(() => new ReceiptState(f.path, { clock: f.clock }).open(), refusal('receipt_history_corrupt')); + assert.throws(() => ReceiptState.initialize(f.path, f.socket), refusal('receipt_state_exists')); assert.equal(f.bytes(), before); +}); + +test('encrypted payload digests are bound to original epoch and intent even if a checksum is recomputed', qualified, t => { + const f = fixture(t), first = f.prepare(), second = f.prepare(); f.actor.close(); + const wrapper = JSON.parse(f.bytes()); + wrapper.data.records[second.handle.correlationId].binding.payloadDigest = wrapper.data.records[first.handle.correlationId].binding.payloadDigest; + wrapper.digest = hash(JSON.stringify(wrapper.data)); fs.writeFileSync(join(f.path, 'ledger.json'), JSON.stringify(wrapper)); + assert.throws(() => new ReceiptState(f.path, { clock: f.clock }).open(), refusal('receipt_history_corrupt')); +}); + +test('sync failure after durable fence rename poisons admission and restart still cannot replay', qualified, t => { + const f = fixture(t), p = f.prepare(); + const rename = fs.renameSync, fsync = fs.fsyncSync; let renamed = false; + t.mock.method(fs, 'renameSync', (...args: Parameters) => { const result = rename(...args); renamed = true; return result; }); + t.mock.method(fs, 'fsyncSync', (fd: number) => { if (renamed) throw Object.assign(new Error('fixture EIO'), { code: 'EIO' }); return fsync(fd); }); syncBuiltinESMExports(); + assert.throws(() => f.actor.fence({ ...p.handle, binding: p.original }), (e: { code?: string }) => e.code === 'EIO'); + assert.throws(() => f.actor.status(p.selector), refusal('receipt_collector_closed')); + t.mock.restoreAll(); syncBuiltinESMExports(); f.reopen(); + assert.equal(f.actor.status(p.selector).submission.status, 'unknown'); + assert.throws(() => f.actor.fence({ ...p.handle, binding: p.original }), refusal('receipt_already_attempted')); +}); + +test('opaque hello reference charges later IPC delay without renewing submitted observation', qualified, t => { + const f=fixture(t),p=f.prepare(),clockReferenceMs=f.actor.clockReference(); + f.actor.fence({...p.handle,binding:p.original});f.actor.submission({...p.handle,status:'submitted'}); + // Client measured 5900ms after receiving a reference made 100ms earlier. + f.advance(600); + const h=f.actor.waitOpen({...p.selector,goal:'acknowledged',timeoutSeconds:6,clockReferenceMs,remainingMs:5900}); + assert.equal(h.wait.status,'pending');assert.equal(h.wait.deadlineAtUtcMs,100900); + f.advance(299);assert.equal(f.actor.waitStatus({...p.selector,operationId:h.wait.operationId!}).wait.status,'pending'); + f.advance(1);assert.equal(f.actor.waitStatus({...p.selector,operationId:h.wait.operationId!}).wait.status,'timed_out_unknown'); +}); +test('opaque reference charges mint and prepare latency and expires before the original effect', qualified, t => { + const f=fixture(t),clockReferenceMs=f.actor.clockReference();f.advance(600); + const p=f.prepare();f.advance(400); + const h=f.actor.waitOpen({...p.selector,goal:'acknowledged',timeoutSeconds:6,clockReferenceMs,remainingMs:5900}); + assert.equal(h.wait.status,'failed');assert.equal(h.wait.reason,'deadline_before_effect');assert.equal(h.submission.status,'refused'); + assert.throws(()=>f.actor.fence({...p.handle,binding:p.original})); + const terminal=f.actor.waitStatus({...p.selector,operationId:h.wait.operationId!});assert.deepEqual(terminal.wait,h.wait); +}); +test('reference pair validation rejects future, partial and over-span without recording an operation', qualified, t => { + const f=fixture(t),p=f.prepare(),clockReferenceMs=f.actor.clockReference(),before=f.bytes(); + const base={...p.selector,goal:'acknowledged' as const,timeoutSeconds:6}; + for(const pair of [{clockReferenceMs:clockReferenceMs+1,remainingMs:6000},{clockReferenceMs}, + {remainingMs:6000},{clockReferenceMs,remainingMs:6001}]) + assert.throws(()=>f.actor.waitOpen({...base,...pair}),refusal('receipt_wait_unknown')); + assert.equal(f.bytes(),before); +}); + +for(const submitted of [false,true]) test(`delayed hello with 4800ms remaining preserves original six second ${submitted?'submitted':'prepared'} classification`,qualified,t=>{ + const f=fixture(t),clockReferenceMs=f.actor.clockReference(),p=f.prepare(); + if(submitted){f.actor.fence({...p.handle,binding:p.original});f.actor.submission({...p.handle,status:'submitted'});} + f.advance(1200); + const h=f.actor.waitOpen({...p.selector,goal:'acknowledged',timeoutSeconds:6,clockReferenceMs,remainingMs:4800}); + assert.equal(h.wait.status,submitted?'timed_out_unknown':'failed'); + assert.equal(h.wait.reason,submitted?undefined:'deadline_before_effect'); +}); +test('expired reference creates its terminal operation even at active wait quota',qualified,t=>{ + const f=fixture(t),p=f.prepare(),owners=[f.prepare(),f.prepare()]; + for(const owner of owners)for(let i=0;i<64;i++)f.actor.waitOpen({...owner.selector,goal:'acknowledged',timeoutSeconds:60}); + const h=f.actor.waitOpen({...p.selector,goal:'acknowledged',timeoutSeconds:6,clockReferenceMs:f.actor.clockReference(),remainingMs:4800}); + assert.equal(h.wait.status,'failed');assert.equal(h.wait.reason,'deadline_before_effect');assert.ok(h.wait.operationId); +}); diff --git a/test/receipt-transport-boundaries.test.ts b/test/receipt-transport-boundaries.test.ts new file mode 100644 index 0000000..dfa3b20 --- /dev/null +++ b/test/receipt-transport-boundaries.test.ts @@ -0,0 +1,226 @@ +// Adversarial owner-private transport fixtures only. No agent, model, transcript, +// SSH, real profile or arbitrary-PID cleanup. Replaced listeners run in children. +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import net, { createConnection, createServer, type Socket } from 'node:net'; +import { once } from 'node:events'; +import { randomUUID, createHash } from 'node:crypto'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { syncBuiltinESMExports } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { ReceiptState } from '../dist/receipt-state.js'; +import { receiptExchange, startReceiptCollector } from '../dist/receipt-collector.js'; +import { RECEIPT_LIMITS, RECEIPT_PAIRS } from '../dist/receipt-protocol.js'; + +const posix = { skip: process.platform === 'win32' }; +const tick = () => new Promise(ok => setImmediate(ok)); +const wire = (value: unknown) => JSON.stringify(value) + '\n'; +function prepare(context: string) { + return { ipcVersion: 2, requestId: randomUUID(), collectorContextId: context, op: 'prepare', body: { + binding: { agent: 'claude', destination: 'fixture-local', target: '123', generation: null, + registryDirectory: '/fixture/transport/sessions', registryRecordPath: '/fixture/transport/sessions/123.json', + registryIdentity: 'a'.repeat(64), socketPath: '/fixture/transport/inbox.sock', routeIdentity: 'b'.repeat(64), + payloadDigest: createHash('sha256').update('PRIVATE_BOUNDARY_BODY').digest('hex') }, + receiptSchemaVersion: 2, receiptScope: 'delegated_context', producerEvidence: { + protocolVersion: 1, artifactSha256: 'c'.repeat(64), challengeId: randomUUID(), + receiptSchemaVersion: 2, receiptScope: 'delegated_context' } + } }; +} +async function fixture(t: TestContext) { + const root = fs.realpathSync(fs.mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-boundary-'))); fs.chmodSync(root, 0o700); + const state = join(root, 'state'), path = join(state, 's'); ReceiptState.initialize(state, path); + const collector = await startReceiptCollector(state, path); + t.after(async () => { assert.equal((await collector.close()).cleanup, 'confirmed'); fs.rmSync(root, { recursive: true, force: true }); }); + return { root, state, path, collector, history: () => fs.readFileSync(join(state, 'ledger.json')) }; +} +function connected(path: string): Promise { + return new Promise((ok, reject) => { + const socket = createConnection(path); socket.on('error', () => {}); + socket.once('connect', () => ok(socket)); socket.once('error', reject); + }); +} +// The hard bound is a test failure, never a successful forced EOF. +function collected(socket: Socket, timeoutMs = 7000): Promise { + return new Promise((ok, reject) => { + const chunks: Buffer[] = []; let length = 0; + const timer = setTimeout(() => { socket.destroy(); reject(new Error('fixture transport did not close within its bound')); }, timeoutMs); + socket.on('data', (data: Buffer) => { + length += data.length; + if (length > RECEIPT_LIMITS.frame * 2) { clearTimeout(timer); socket.destroy(); reject(new Error('fixture received excess transport output')); return; } + chunks.push(data); + }); + socket.once('close', () => { clearTimeout(timer); ok(Buffer.concat(chunks)); }); + }); +} +async function raw(path: string, value: string | Uint8Array): Promise { + const socket = await connected(path), result = collected(socket); socket.end(value); return result; +} +async function fakeOwner(t: TestContext, reply: (socket: Socket, request: any) => void) { + const root = fs.realpathSync(fs.mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-fake-owner-'))); fs.chmodSync(root, 0o700); + const path = join(root, 's'), connections = new Set(); let calls = 0; + const server = createServer({ allowHalfOpen: true }, socket => { + connections.add(socket); socket.on('error', () => {}); socket.once('close', () => connections.delete(socket)); + const input: Buffer[] = []; socket.on('data', chunk => input.push(chunk)); + socket.once('end', () => { calls++; reply(socket, JSON.parse(Buffer.concat(input).toString('utf8'))); }); + }); + server.listen(path); await once(server, 'listening'); fs.chmodSync(path, 0o600); + t.after(async () => { + for (const socket of connections) socket.destroy(); + await new Promise(ok => server.close(() => ok())); fs.rmSync(root, { recursive: true, force: true }); + }); + return { root, path, calls: () => calls }; +} +function helloResponse(request: { requestId: string }, context = randomUUID()) { + return { ipcVersion: 2, requestId: request.requestId, collectorContextId: context, ok: true, + body: { receiptSchemas: RECEIPT_PAIRS, managedStoreVersion: 2, clockReferenceMs: 0, endpointOwner: { uid: process.getuid!(), pid: process.pid } } }; +} + +// No timer or admission guard is mocked: the first sixteen real sockets occupy +// the service while the seventeenth attempts a mutating, otherwise valid frame. +test('connection admission refuses the seventeenth request without reserving an intent', posix, async t => { + const f = await fixture(t), before = f.history(); + const occupied = await Promise.all(Array.from({ length: RECEIPT_LIMITS.connections }, () => connected(f.path))); + try { + for (const socket of occupied) socket.write('{'); + await tick(); await tick(); + const reply = await raw(f.path, wire(prepare(f.collector.contextId))); + assert.equal(reply.length, 0); assert.deepEqual(f.history(), before); + } finally { + const closed = occupied.map(socket => new Promise(ok => { if (socket.closed) ok(); else socket.once('close', () => ok()); })); + for (const socket of occupied) socket.destroy(); await Promise.all(closed); await tick(); await tick(); + } + const healthy = await receiptExchange(f.path, null, 'hello', {}); + assert.equal(healthy.ok, true, 'admission release must not leak active-connection slots'); + assert.deepEqual(f.history(), before); +}); + +test('oversized and invalid UTF-8 request frames close without actor effects or reflection', posix, async t => { + const f = await fixture(t), before = f.history(), secret = 'PRIVATE_FRAME_SENTINEL'; + const valid = wire(prepare(f.collector.contextId)); + const oversized = valid + secret + 'x'.repeat(RECEIPT_LIMITS.frame); + for (const bytes of [Buffer.from(oversized), Buffer.from([123,255,125,10])]) { + assert.equal((await raw(f.path, bytes)).length, 0); assert.deepEqual(f.history(), before); + } + assert.equal((await receiptExchange(f.path, null, 'hello', {})).ok, true); + const atBoundary = JSON.stringify(prepare(f.collector.contextId)); + const padded = atBoundary + ' '.repeat(RECEIPT_LIMITS.frame - Buffer.byteLength(atBoundary) - 1) + '\n'; + assert.equal(Buffer.byteLength(padded), RECEIPT_LIMITS.frame); + const accepted = JSON.parse((await raw(f.path, padded)).toString('utf8')); + assert.equal(accepted.ok, true); assert.equal(accepted.body.handoff.state, 'validated'); + assert.notDeepEqual(f.history(), before, 'the exact limit is admitted; only overflow is refused'); +}); + +test('a complete JSON line without EOF expires at the read budget without mutation', { ...posix, timeout: 10000 }, async t => { + const f = await fixture(t), before = f.history(), socket = await connected(f.path); + const outcome = collected(socket), started = performance.now(); + socket.write(wire(prepare(f.collector.contextId))); // Deliberately never half-close. + const reply = await outcome; + assert.equal(reply.length, 0); assert.deepEqual(f.history(), before); + assert.ok(performance.now() - started >= RECEIPT_LIMITS.readMs - 100, 'fixture must exercise the read deadline rather than immediate malformed input'); +}); + +test('observed first request data cannot commit before delayed trailing pollution arrives', posix, async t => { + const original = net.createServer; let observe!: () => void; + const observed = new Promise(ok => { observe = ok; }); let first = false; + t.mock.method(net, 'createServer', ((...args: any[]) => { + const listener = args.pop(); + return (original as any)(...args, (socket: Socket) => { + listener(socket); + // Registered after the real handler, so this observes its completed data + // callback. This spy changes no framing, clock, buffer or admission rule. + socket.on('data', () => { if (!first) { first = true; queueMicrotask(observe); } }); + }); + }) as typeof net.createServer); syncBuiltinESMExports(); + t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); }); + const f = await fixture(t), before = f.history(), socket = await connected(f.path), result = collected(socket); + socket.write(wire(prepare(f.collector.contextId))); await observed; + assert.deepEqual(f.history(), before, 'the real server processed the first LF chunk and still must not dispatch'); + socket.end('PRIVATE_TRAILING_POLLUTION'); + assert.equal((await result).length, 0); assert.deepEqual(f.history(), before); +}); + +test('wrong collector context returns a fixed error without reflecting binding or changing history', posix, async t => { + const f = await fixture(t), before = f.history(), request = prepare(randomUUID()); + const result = JSON.parse((await raw(f.path, wire(request))).toString('utf8')); + assert.equal(result.ok, false); assert.equal(result.error, 'receipt_context_mismatch'); + assert.deepEqual(Object.keys(result).sort(), ['ipcVersion','requestId','collectorContextId','ok','error'].sort()); + assert.equal(JSON.stringify(result).includes(request.body.binding.payloadDigest), false); + assert.equal(JSON.stringify(result).includes('PRIVATE_BOUNDARY_BODY'), false); + assert.deepEqual(f.history(), before); +}); + +test('oversized owner response is post-attempt unknown and never retried or reflected', posix, async t => { + const owner = await fakeOwner(t, (socket, request) => { + socket.end(wire(helloResponse(request)) + 'PRIVATE_RESPONSE_SENTINEL' + 'x'.repeat(RECEIPT_LIMITS.frame)); + }); + await assert.rejects(receiptExchange(owner.path, null, 'hello', {}), (error: any) => { + assert.equal(error.code, 'receipt_transport_unknown'); assert.equal(String(error).includes('PRIVATE_RESPONSE_SENTINEL'), false); return true; + }); + assert.equal(owner.calls(), 1); +}); + +test('the client waits for owner EOF and rejects delayed response pollution', posix, async t => { + const original = net.createConnection; let observe!: () => void; + const observed = new Promise(ok => { observe = ok; }); let first = false; + t.mock.method(net, 'createConnection', ((...args: any[]) => { + const socket = (original as any)(...args) as Socket; + socket.on('data', () => { if (!first) { first = true; queueMicrotask(observe); } }); return socket; + }) as typeof net.createConnection); syncBuiltinESMExports(); + t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); }); + let release!: () => void; const trailing = new Promise(ok => { release = ok; }); + const owner = await fakeOwner(t, (socket, request) => { + socket.write(wire(helloResponse(request))); + void trailing.then(() => { if (!socket.destroyed) socket.end('PRIVATE_RESPONSE_POLLUTION'); }); + }); + let settled = false; + const result = receiptExchange(owner.path, null, 'hello', {}).then(value => ({ value }), error => ({ error })).finally(() => { settled = true; }); + try { + await observed; await tick(); + assert.equal(settled, false, 'a valid response prefix is not a completed one-frame response'); + } finally { release(); } + const done = await result; + assert.ok('error' in done); assert.equal((done as any).error.code, 'receipt_transport_unknown'); assert.equal(owner.calls(), 1); +}); + +test('truncated owner response remains unknown after the private request was submitted', posix, async t => { + const owner = await fakeOwner(t, socket => socket.end('{"ipcVersion":2')); + await assert.rejects(receiptExchange(owner.path, null, 'hello', {}), (error: any) => error.code === 'receipt_transport_unknown'); + assert.equal(owner.calls(), 1); +}); + +// A child confines orphaned listener handles. It exits without server.close(), +// which would unlink an unrelated replacement pathname in Node's implementation. +test('endpoint replacement before dispatch preserves the foreign file and refuses actor effects', posix, t => { + const root = fs.realpathSync(fs.mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-boundary-child-'))); fs.chmodSync(root, 0o700); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const collectorModule = pathToFileURL(resolve('dist/receipt-collector.js')).href; + const stateModule = pathToFileURL(resolve('dist/receipt-state.js')).href; + const script = join(root, 'child.mjs'), state = join(root, 'state'), socket = join(state, 's'); + fs.writeFileSync(script, ` + import fs from 'node:fs'; import {createConnection} from 'node:net'; import {once} from 'node:events'; + import {ReceiptState} from ${JSON.stringify(stateModule)}; + import {startReceiptCollector} from ${JSON.stringify(collectorModule)}; + const state=${JSON.stringify(state)}, path=${JSON.stringify(socket)}; + ReceiptState.initialize(state,path); const collector=await startReceiptCollector(state,path); + const before=fs.readFileSync(state+'/ledger.json'); + const client=createConnection(path); client.on('error',()=>{}); await once(client,'connect'); + const chunks=[];client.on('data',data=>chunks.push(data)); + const closed=once(client,'close'); + fs.unlinkSync(path);fs.writeFileSync(path,'foreign fixture replacement',{mode:0o600}); + const identity=fs.lstatSync(path,{bigint:true}).ino; + const request=${JSON.stringify(prepare('CONTEXT_PLACEHOLDER'))}; request.collectorContextId=collector.contextId; + client.end(JSON.stringify(request)+'\\n'); await closed; + const response=JSON.parse(Buffer.concat(chunks).toString('utf8')); + const cleanup=await collector.close(); + const result={cleanup:cleanup.cleanup,error:response.error,sameHistory:before.equals(fs.readFileSync(state+'/ledger.json')), + preserved:fs.readFileSync(path,'utf8')==='foreign fixture replacement'&&fs.lstatSync(path,{bigint:true}).ino===identity}; + process.stdout.write(JSON.stringify(result)+'\\n',()=>process.exit(0)); + `, { mode: 0o600 }); + const result = spawnSync(process.execPath, [script], { encoding: 'utf8', timeout: 10000 }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { cleanup: 'unconfirmed', error: 'receipt_endpoint_untrusted', sameHistory: true, preserved: true }); + assert.equal(fs.readFileSync(socket, 'utf8'), 'foreign fixture replacement'); +}); From 9fe471660a2c5d31db13c0cbd6ee69dce863f27b Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 3 Nov 2026 12:03:33 +0900 Subject: [PATCH 3/9] test(updates): derive newer advisory fixture from the release version --- test/integration-multihost-updates.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/test/integration-multihost-updates.test.ts b/test/integration-multihost-updates.test.ts index 6ce801c..06a06ee 100644 --- a/test/integration-multihost-updates.test.ts +++ b/test/integration-multihost-updates.test.ts @@ -14,6 +14,9 @@ import { fileURLToPath } from 'node:url'; import { VERSION } from '../dist/protocol.js'; const cli = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); +// The fixture must stay newer than the compiled release, including preview builds. +const parts = VERSION.split('-')[0]!.split('.').map(Number); +const fixtureLatest = `${parts[0]}.${parts[1]}.${parts[2]! + 1}`; const posix = { skip: process.platform === 'win32' && 'POSIX fake ssh' }; const hosts = ['alpha', 'beta', 'gamma']; async function fixture(t: TestContext) { @@ -22,7 +25,7 @@ async function fixture(t: TestContext) { const requests: string[] = []; const server = createServer((request, response) => { requests.push(request.url ?? ''); - response.writeHead(200, { 'content-type': 'application/json' }); response.end(JSON.stringify({ latest: '0.5.3' })); + response.writeHead(200, { 'content-type': 'application/json' }); response.end(JSON.stringify({ latest: fixtureLatest })); }); server.listen(0, '127.0.0.1'); await once(server, 'listening'); t.after(async () => { @@ -59,7 +62,7 @@ process.stdout.write(out);process.exit(r.status);`, { mode: 0o700 }); }; const seed = (age = 0) => { mkdirSync(cache, { recursive: true, mode: 0o700 }); - writeFileSync(file, JSON.stringify({ schemaVersion: 1, package: 'session-peer', source: 'npm_registry', channel: 'latest', latest: '0.5.3', checkedAt: Date.now() - age }), { mode: 0o600 }); + writeFileSync(file, JSON.stringify({ schemaVersion: 1, package: 'session-peer', source: 'npm_registry', channel: 'latest', latest: fixtureLatest, checkedAt: Date.now() - age }), { mode: 0o600 }); }; const settled = async (count: number) => { for (let i = 0; i < 200 && !(requests.length >= count && existsSync(file) && !existsSync(lock)); i++) await delay(50); @@ -87,7 +90,7 @@ test('repeated --host keeps the request-order array exactly; the notice is clien // One destination stays flat and gets the additive client field. const single = await f.call(['list', '--agent', 'claude', '--host', 'alpha', '--json']); const flat = JSON.parse(single.stdout); - assert.equal(Array.isArray(flat), false); assert.equal(flat.host, 'alpha'); assert.equal(flat.clientUpdate.latest, '0.5.3'); + assert.equal(Array.isArray(flat), false); assert.equal(flat.host, 'alpha'); assert.equal(flat.clientUpdate.latest, fixtureLatest); await delay(500); assert.deepEqual(f.requests, [], 'fresh cache: no refresh'); assert.equal(existsSync(f.receiver), false); }); @@ -99,7 +102,7 @@ test('each invocation schedules at most one refresh regardless of host count; re for (const item of JSON.parse(first.stdout)) assert.equal('clientUpdate' in item, false); await f.settled(1); assert.equal(f.requests.length, 1, 'one refresh for three hosts'); - assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).latest, '0.5.3'); + assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).latest, fixtureLatest); f.seed(25 * 3600_000); await f.call(f.args(['--output-format', 'text'])); await f.settled(2); @@ -119,7 +122,7 @@ test('failure policy: per-host results (including failed ones) may carry the adv const on = await f.call(['list', '--agent', 'claude', '--host', 'alpha', '--json'], down); const offValue = JSON.parse(off.stdout), { clientUpdate, ...onValue } = JSON.parse(on.stdout); assert.equal(offValue.ok, false); assert.equal(offValue.error, 'ssh_unreachable'); - assert.equal(on.code, off.code); assert.deepEqual(onValue, offValue); assert.equal(clientUpdate.latest, '0.5.3'); + assert.equal(on.code, off.code); assert.deepEqual(onValue, offValue); assert.equal(clientUpdate.latest, fixtureLatest); // Mixed array: element shape and exit code unchanged; text gets one stderr line for the invocation. const mixedOff = await f.call(f.args(['--json', '--no-update-notice']), down), mixedOn = await f.call(f.args(['--json']), down); assert.equal(mixedOn.code, mixedOff.code); assert.deepEqual(JSON.parse(mixedOn.stdout), JSON.parse(mixedOff.stdout)); @@ -153,7 +156,7 @@ test('clientUpdate is client-local: a remote-supplied value is dropped', posix, assert.equal(localRun.code, 0); const { clientUpdate: own, ...local } = JSON.parse(localRun.stdout); accepted(local, 'alpha'); - assert.equal(own.source, 'npm_registry_cache'); assert.equal(own.latest, '0.5.3'); assert.notEqual(own.command, 'fixture-only'); + assert.equal(own.source, 'npm_registry_cache'); assert.equal(own.latest, fixtureLatest); assert.notEqual(own.command, 'fixture-only'); const text = await f.call(f.args(['--output-format', 'text', '--no-update-notice']), inject); assert.equal(text.code, 0); assert.doesNotMatch(text.stdout, /fixture-only|Error:/); assert.equal(text.stderr, ''); assert.deepEqual(text.stdout.match(/^Host: .+$/gm), hosts.map(item => `Host: ${item}`)); From f36015bba74c45a63612a19bc1ce48db743221c7 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 3 Nov 2026 12:04:12 +0900 Subject: [PATCH 4/9] fix(receipt): recheck interruption at the native input boundary --- src/receipt-send.ts | 7 ++++--- src/send.ts | 10 +++++++--- test/receipt-cli.test.ts | 33 ++++++++++++++++++++++++++++++--- test/receipt-producer.test.ts | 8 +++++++- test/receipt-send.test.ts | 9 ++++++++- 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/receipt-send.ts b/src/receipt-send.ts index 4f134ae..ae8f4d5 100644 --- a/src/receipt-send.ts +++ b/src/receipt-send.ts @@ -103,6 +103,7 @@ export async function managedReceiptSend(options: SendOptions, config: ReceiptSe budget.beforeEffect(); producer!.revalidate(); revalidateTarget(); if (endpoint(config.collector) !== endpointIdentity) throw new Refusal('receipt_endpoint_changed',1); budget.beforeEffect(); update(await rpc('fence',{...handle!,binding:original!})); fenced = true; + if (stopped) throw new Refusal('stopped_by_operator',130); producer!.revalidate(); revalidateTarget(); budget.beforeEffect(); }; const verifyOwner = async (scope: AppServerScope) => { @@ -170,13 +171,13 @@ export async function managedReceiptSend(options: SendOptions, config: ReceiptSe const frame = {...placeholder,ledgerEpoch:handle.ledgerEpoch,correlationId:handle.correlationId, ...(pair.agent === 'claude' ? {receiptContextId:handle.receiptContextId} : {}),capability:minted.body.capability}; return options.message+'\nCorrelation-Id: '+handle.correlationId+'\nReceipt-Only: '+JSON.stringify({collector:config.collector,receipt:frame}); - },beforeEffect:authorize,codexRoute:()=> 'owned_app_server', + },beforeEffect:authorize,beforeWrite:()=>{if(stopped)throw new Refusal('stopped_by_operator',130);budget.beforeEffect();},codexRoute:()=> 'owned_app_server', async submitCodex(_context,message,storage) { if (!queueScope || !handle) throw new Unsupported(); try { return await (deps.nativeQueue ?? nativeQueueOnce)({binary:storage.binary,scope:queueScope,message,clientUserMessageId:handle.correlationId, - deadlineMs:budget.cutoff,verifyOwner,beforeEffect:authorize,verifyStorage:()=>storage.revalidate(true),verifyStorageSync:()=>storage.finalCheck(true)}); } + deadlineMs:budget.cutoff,verifyOwner,beforeEffect:authorize,verifyStorage:()=>storage.revalidate(true),verifyStorageSync:()=>{if(stopped)throw new Refusal('stopped_by_operator',130);budget.beforeEffect();storage.finalCheck(true);}}); } catch(error) { if (error instanceof AppServerFault) { if (error.attempted) throw new UnknownOutcome(); throw new Refusal('receipt_bootstrap_unsupported',1); } throw error; } - },timeoutMs:()=> {budget.beforeEffect(); return Math.max(1,Math.floor(budget.observationRemainingMs()));} + },timeoutMs:()=> {if(stopped)throw new Refusal('stopped_by_operator',130);budget.beforeEffect(); return Math.max(1,Math.floor(budget.observationRemainingMs()));} }}); knownNative = {...value}; update(await rpc('submission',{...handle!,status:'submitted'})); diff --git a/src/send.ts b/src/send.ts index f2d9862..7700428 100644 --- a/src/send.ts +++ b/src/send.ts @@ -19,6 +19,7 @@ export type SendHooks = { // Requests private source metadata only; it is not producer/collector approval. privateReceiptContext?: true; beforeEffect?: () => Promise | void; + beforeWrite?: () => void; timeoutMs?: () => number; codexRoute?: () => 'ordinary' | 'owned_app_server'; submitCodex?: (context: SendContext, message: string, storage: CodexStorageGuard) => Promise<{ queueId?: string; clientUserMessageId: string }>; @@ -36,7 +37,7 @@ export function checkMessage(text: string, codex = false): void { if (!text.trim() || text.includes('\0') || [...text].length > 1_000_000 || (codex && Buffer.byteLength(text, 'utf8') > 32768)) throw new Refusal('invalid_message', 2); } -export async function postSocket(path: string, text: string, timeout?: number): Promise { +export async function postSocket(path: string, text: string, timeout?: number, beforeWrite?: () => void): Promise { return new Promise((resolve, reject) => { let attempted = false, finished = false; const startedAt = performance.now(); @@ -45,11 +46,14 @@ export async function postSocket(path: string, text: string, timeout?: number): const finish = (error?: Error) => { if (finished) return; finished = true; clearTimeout(timer); socket.destroy(); - if (error) reject(attempted ? new UnknownOutcome() : new Refusal('inbox_unreachable', 1)); + if (error) reject(attempted ? new UnknownOutcome() : error instanceof Refusal ? error : new Refusal('inbox_unreachable', 1)); else resolve(); }; socket.on('error', error => finish(error)); socket.once('connect', () => { + if (finished) return; + try { beforeWrite?.(); } + catch (error) { finish(error instanceof Error ? error : new Error('before_write_refused')); return; } attempted = true; socket.end(JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n', () => { // Write completion is submission only, never consumption. Match Python's drain window. @@ -231,6 +235,6 @@ export async function send(options: SendOptions): Promise void | Promise)[] = []; t.after(async () => { for (const close of closers.reverse()) await close(); fs.rmSync(root, { recursive: true, force: true }); }); function invoke(args: string[], input?: string) { - const child = spawn(process.execPath, [cli, ...args], { env, cwd: root, stdio: ['pipe','pipe','pipe'] }); + const child = spawn(node, [entry, ...args], { env, cwd: root, stdio: ['pipe','pipe','pipe'] }); let stdout = '', stderr = '', size = 0; const timer = setTimeout(() => child.kill('SIGKILL'), 40000); child.stdout.on('data', chunk => { size += chunk.length; if (size > 1048576) child.kill('SIGKILL'); else stdout += chunk; }); @@ -36,7 +56,7 @@ function fixture(t: TestContext) { } const opts = (t: string) => ['send','--to',t,'--message','original receipt fixture','--no-from','--no-reply-to','--json']; test('real CLI sends one private delegation and accepts a handler receipt before native submission returns', posix, async t => { - const f = fixture(t), state = join(f.root,'state'), collectorPath = join(state,'s'), inbox = join(f.root,'i'); + const f = fixture(t,true), state = join(f.root,'state'), collectorPath = join(state,'s'), inbox = join(f.root,'i'); ReceiptState.initialize(state, collectorPath); const collector = await startReceiptCollector(state,collectorPath); let count = 0, delegated: any, handler: any; const server = createServer({allowHalfOpen:true}, socket => { @@ -121,6 +141,13 @@ test('private CLI input timeout is bounded without reflecting unfinished receipt assert.equal(signal,null);assert.notEqual(code,0);assert.equal(output.includes('PRIVATE_STDIN_SENTINEL'),false); assert.equal(JSON.parse(output).ok,false); }); +test('the actual socket write boundary honors a late guard without writing recipient input',posix,async t=>{ + const f=fixture(t),path=join(f.root,'i');let bytes=0,guards=0; + const server=createServer(socket=>{socket.on('data',part=>{bytes+=part.length;});socket.on('end',()=>socket.end());}); + server.listen(path);await once(server,'listening');f.cleanup(()=>new Promise(ok=>server.close(()=>ok()))); + await assert.rejects(postSocket(path,'PRIVATE_INPUT_NOT_WRITTEN',1000,()=>{guards++;throw new Refusal('stopped_by_operator',130);}),{code:'stopped_by_operator'}); + await new Promise(ok=>setImmediate(ok));assert.equal(guards,1);assert.equal(bytes,0); +}); test('delegated text output does not call receipt evidence native identity or model consumption',()=>{ const text=renderOutput({command:'send',ok:true,status:'posted',submitted:true,handoff:{schemaVersion:2,correlationId:'fixture',state:'acknowledged',ack:{status:'acknowledged',assurance:'token_possession'}}},'text'); assert.match(text,/model consumption and completion are not confirmed/);assert.match(text,/native generation and model identity are not established/); diff --git a/test/receipt-producer.test.ts b/test/receipt-producer.test.ts index 01deba1..10705a4 100644 --- a/test/receipt-producer.test.ts +++ b/test/receipt-producer.test.ts @@ -122,6 +122,12 @@ function exercise(scenario: string): void { const root = fs.realpathSync(fs.mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(),'codex-producer-'))); fs.chmodSync(root,0o700); try { + // setup-node tool-cache ownership/mode can differ from a supported private + // installation. Normalize only our fresh fixture binary, never the host. + let node=process.execPath;const native=fs.statSync(fs.realpathSync(node)); + if(![0,process.getuid?.()].includes(native.uid)||(native.mode&0o022)!==0){ + node=join(root,'node');fs.copyFileSync(process.execPath,node);fs.chmodSync(node,0o700); + } const directory = join(root,'installation'); fs.mkdirSync(directory,{mode:0o700}); fs.writeFileSync(join(root,'package.json'),'{"type":"module"}\n',{mode:0o600}); for (const name of bundle) { @@ -133,7 +139,7 @@ function exercise(scenario: string): void { } // Real CLI imports smol-toml; link only this existing dependency installation. fs.symlinkSync(fs.realpathSync(join(project,'node_modules')),join(root,'node_modules'),'junction'); - const child = spawnSync(process.execPath,['--input-type=module','--eval',worker,directory,scenario],{ + const child = spawnSync(node,['--input-type=module','--eval',worker,directory,scenario],{ encoding:'utf8',timeout:15000,maxBuffer:64*1024, env:{PATH:'/usr/bin:/bin',LANG:'C',LC_ALL:'C',SESSION_PEER_TAILSCALE:'off'} }); diff --git a/test/receipt-send.test.ts b/test/receipt-send.test.ts index 8112880..c349a1c 100644 --- a/test/receipt-send.test.ts +++ b/test/receipt-send.test.ts @@ -16,7 +16,7 @@ const qualified = {skip:process.platform === 'win32'}; const hash = (text: string) => createHash('sha256').update(text).digest('hex'); const tempRoot = process.env.TASK_TEMP ?? tmpdir(); function fixture(t: TestContext, clock?: {monotonic():number;utc():number}) { - const root = fs.mkdtempSync(join(tempRoot,'codex-send-')); fs.chmodSync(root,0o700); + const root = fs.realpathSync(fs.mkdtempSync(join(tempRoot,'codex-send-'))); fs.chmodSync(root,0o700); const registry = join(root,'sessions'); fs.mkdirSync(registry,{mode:0o700}); const record = join(registry,'123.json'); fs.writeFileSync(record,'{}',{mode:0o600}); const s = fs.lstatSync(record), identity = [s.dev,s.ino,s.uid,s.mode,s.size,s.mtimeMs,s.ctimeMs,s.birthtimeMs].join(':'); @@ -130,6 +130,13 @@ test('SIGINT stops only original pending wait once without native cancellation o assert.equal(result.exitCode,130);assert.equal(f.effects,1);assert.equal(f.order.filter(op=>op==='wait-stop').length,1); assert.equal((result.value.handoff as {wait:{status:string}}).wait.status,'stopped'); }); +test('SIGINT observed while the fence reply is pending refuses before native input',qualified,async t=>{ + const f=fixture(t);f.fault((op,response)=>{if(op==='fence')process.emit('SIGINT');return response;}); + const result=await managedReceiptSend(options,{collector:f.socket,waitFor:'acknowledged',seconds:10},f.deps); + assert.equal(f.effects,0);assert.equal(result.value.submitted,false);assert.equal(result.exitCode,130); + assert.equal((result.value.handoff as {wait:{status:string}}).wait.status,'stopped'); + assert.equal(f.order.filter(op=>op==='wait-stop').length,1); +}); test('a terminal timeout observed with SIGINT remains timeout exit 1 rather than a new stopped outcome',qualified,async t=>{ const f=fixture(t);f.fault((op,response)=>{ if(op==='wait-status'&&response.ok&&'handoff'in response.body){ From 8bf5f2429be03a50fa6835ff21d6b8e2789f821c Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 3 Nov 2026 12:04:13 +0900 Subject: [PATCH 5/9] docs(release): record 0.6.0 preparation scope and publication gates --- RELEASING.md | 42 ++++++++++++++++++++++++++++++++++++++++++ VALIDATION.md | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/RELEASING.md b/RELEASING.md index 1312605..705c4d5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -9,6 +9,48 @@ the historical 0.3.2 settings-read exception is recorded below. The workflow uses OIDC and no npm token. The protected GitHub environment requires a reviewer and permits deployments from `main` only. +## 0.6.0 preparation — 2026-11-03 KST + +This is release preparation, not a publication record or authorization to +publish. Issue [#175](https://github.com/abruption/session-peer-ts/issues/175) +tracks the candidate. The merged Orca Claude profile work +([#165](https://github.com/abruption/session-peer-ts/issues/165), +[#173](https://github.com/abruption/session-peer-ts/pull/173)) and explicit local +receipt implementation [#176](https://github.com/abruption/session-peer-ts/pull/176) +for [#132](https://github.com/abruption/session-peer-ts/issues/132) are separate +review checkpoints. Final integrated-source checks, archive identity and the +exact merged-main release gate must be recorded before source approval. + +The receipt collector is an explicitly started local foreground process. Codex +uses the generation-bound v1 receipt contract only on its qualified private +owned-queue path; Claude uses a separate v2 delegated receipt context and does +not claim a native generation. A valid receipt authenticates the delegated +receipt submission within that context; it does not prove model identity, +message consumption, injection or task completion. It does not turn native +submission or a queued message into an automatic ACK. Unknown effects must not +be retried, resent or rerouted. Native observation, platform and SSH +qualification are not expanded by these local receipt contracts. + +[#73](https://github.com/abruption/session-peer-ts/issues/73) is closed as +NO-GO for transcript/body-based probing. That research is not a runtime feature +or a source of receipt authority. The existing published companion skill and +its immutable source pin stay unchanged; a finite doctor compatibility row is +not evidence that the skill covers the new receipt workflow. + +Before any publication, use a new exact-source approval for +`session-peer@0.6.0`, `stable-stage` and eventual latest promotion to 0.6.0. +Require current-head macOS/Ubuntu Node 22/24, Windows Node 22/24, repository +policy, CodeQL and release-gate checks plus reviews of the final changes. +Verify the reproducible archive, packaged Markdown/source identity and isolated +package lifecycle against that same source. Fresh npm ownership, pending-stage, +stage-only Trusted Publisher, package publish-MFA and protected npm environment +checks remain separate gates; historical exceptions and approvals do not carry +forward. Protected-environment and final npm stage approvals remain separate. +Do not dispatch, approve a stage, create `v0.6.0` or publish a GitHub Release +until their required approvals are obtained. Both SSH endpoints must use the +same exact runtime version. No global or fleet installation is part of this +preparation. + ## Public 0.5.2 checkpoint — 2026-10-30 KST [GitHub v0.5.2](https://github.com/abruption/session-peer-ts/releases/tag/v0.5.2) diff --git a/VALIDATION.md b/VALIDATION.md index 63677a6..8f9be05 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -1,4 +1,41 @@ -# Validation record — updated 2026-10-30 KST +# Validation record — updated 2026-11-03 KST + +## 0.6.0 preparation scope — 2026-11-03 KST + +The candidate tracks [#175](https://github.com/abruption/session-peer-ts/issues/175) +and combines merged Orca Claude profile discovery +([#165](https://github.com/abruption/session-peer-ts/issues/165), +[#173](https://github.com/abruption/session-peer-ts/pull/173)) with +[#176](https://github.com/abruption/session-peer-ts/pull/176)'s staged local +receipt implementation for [#132](https://github.com/abruption/session-peer-ts/issues/132). +The final integrated source, current-head test/CI/review results and exact +archive hash are pending their own validation record. Earlier feature or +metadata-only checkpoints must not be presented as final release evidence. + +The new collector is explicitly started and stopped by its operator; it does +not start a service, wake a session or resend messages. Codex retains the v1 +non-null generation contract on the qualified owned native queue route. +Claude's v2 delegated receipt context deliberately does not prove a native +generation. Authenticated receipt submission proves neither model identity nor +message consumption, injection or completion. Sender/collector output does not +publish the private capability; the intended recipient's native input/history +may retain it. Unknown effects keep the no-retry/no-resend boundary. + +The implementation's local CLI, private IPC, storage and synthetic transport +checks do not establish new live native/model/SSH receipt or ACK qualification. +Existing platform refusals and native observation profiles remain bounded; +Linux/Windows observation and Windows managed receipt storage are not enabled. +[#73](https://github.com/abruption/session-peer-ts/issues/73)'s transcript/body +probe is closed NO-GO and is not included as a runtime feature. Existing +companion skill publication and its immutable source/hash pin remain separate +and unchanged; metadata compatibility is not new workflow coverage. + +This preparation makes no public 0.6.0 availability, fresh npm authentication or +publisher/MFA-policy audit, stage approval, installation or operational claim. +Exact merged-source approval, protected-environment approval, final npm stage +approval, publication verification and any authorized tag/release remain future +release gates. Historical publication records below retain their original +versions, dates and evidence limits. From 77300ed3349f54fb5178c62e6f9c9911d1e76349 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 3 Nov 2026 12:07:00 +0900 Subject: [PATCH 6/9] docs(validation): record integrated 0.6.0 local checks --- VALIDATION.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/VALIDATION.md b/VALIDATION.md index 8f9be05..3df41fc 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -8,9 +8,15 @@ and combines merged Orca Claude profile discovery [#173](https://github.com/abruption/session-peer-ts/pull/173)) with [#176](https://github.com/abruption/session-peer-ts/pull/176)'s staged local receipt implementation for [#132](https://github.com/abruption/session-peer-ts/issues/132). -The final integrated source, current-head test/CI/review results and exact -archive hash are pending their own validation record. Earlier feature or -metadata-only checkpoints must not be presented as final release evidence. +The integrated source `ef1ba36b7d9b4f4ac7917527fdb8d2db009d5def` passed +660 tests: 648 pass, 0 fail, 11 skip and 1 existing TODO on Node 24/macOS arm64, +using the pinned Python reference `8ca77cf31254c8287a2593fe484bb30a7dba2727`. +Build/types, scoped versions and repository links/structure passed. The exact +95-file package passed reproducible packing, isolated install, native binding, +shorthand lifecycle and uninstall. Final PR-head CI/reviews and exact merged-main +release evidence remain separate gates; the candidate archive identity is +recorded in the release PR and its source-bound manifest, not inferred from an +earlier feature or metadata-only artifact. The new collector is explicitly started and stopped by its operator; it does not start a service, wake a session or resend messages. Codex retains the v1 From f02a6a758a2dca6d4875d12b0f65ac76e27d3b07 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 3 Nov 2026 12:10:19 +0900 Subject: [PATCH 7/9] test(ci): bound file concurrency for private owner probes --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4275e19..f692a2f 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "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": "npm run build && npm run test:types && node --import ./test/fixtures/isolate-env.mjs --experimental-strip-types --test --test-concurrency=2 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/claude-profiles.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/receipt-protocol.test.ts test/receipt-state.test.ts test/receipt-collector.test.ts test/receipt-command.test.ts test/receipt-send.test.ts test/receipt-transport-boundaries.test.ts test/receipt-acl.test.ts test/receipt-cli.test.ts test/receipt-producer.test.ts", "test:package": "npm run build && node --experimental-strip-types test/package-smoke.ts" From adbd95ad2bf00cc11072d6b005a84a4e311d173f Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 3 Nov 2026 13:04:53 +0900 Subject: [PATCH 8/9] fix(receipt): preserve pre-effect refusal and terminal timeout reasons --- src/receipt-send.ts | 25 ++++++++++++++++++++++--- test/receipt-send.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/receipt-send.ts b/src/receipt-send.ts index ae8f4d5..2c8529c 100644 --- a/src/receipt-send.ts +++ b/src/receipt-send.ts @@ -174,9 +174,26 @@ export async function managedReceiptSend(options: SendOptions, config: ReceiptSe },beforeEffect:authorize,beforeWrite:()=>{if(stopped)throw new Refusal('stopped_by_operator',130);budget.beforeEffect();},codexRoute:()=> 'owned_app_server', async submitCodex(_context,message,storage) { if (!queueScope || !handle) throw new Unsupported(); + // The native adapter deliberately wraps callback failures. Retain only + // our own pre-effect Refusal so wrapping cannot erase stop/deadline facts. + let guardedRefusal: Refusal | undefined; + const beforeEffect = async () => { + try { await authorize(); } catch (error) { if (error instanceof Refusal) guardedRefusal=error; throw error; } + }; + const verifyStorageSync = () => { + try { if(stopped)throw new Refusal('stopped_by_operator',130);budget.beforeEffect(); } + catch (error) { if(error instanceof Refusal)guardedRefusal=error;throw error; } + storage.finalCheck(true); + }; try { return await (deps.nativeQueue ?? nativeQueueOnce)({binary:storage.binary,scope:queueScope,message,clientUserMessageId:handle.correlationId, - deadlineMs:budget.cutoff,verifyOwner,beforeEffect:authorize,verifyStorage:()=>storage.revalidate(true),verifyStorageSync:()=>{if(stopped)throw new Refusal('stopped_by_operator',130);budget.beforeEffect();storage.finalCheck(true);}}); } - catch(error) { if (error instanceof AppServerFault) { if (error.attempted) throw new UnknownOutcome(); throw new Refusal('receipt_bootstrap_unsupported',1); } throw error; } + deadlineMs:budget.cutoff,verifyOwner,beforeEffect,verifyStorage:()=>storage.revalidate(true),verifyStorageSync}); } + catch(error) { if (error instanceof AppServerFault) { + if (error.attempted) throw new UnknownOutcome(); + if (guardedRefusal) throw guardedRefusal; + if (stopped) throw new Refusal('stopped_by_operator',130); + throw new Refusal('receipt_bootstrap_unsupported',1); + } throw error; } + },timeoutMs:()=> {if(stopped)throw new Refusal('stopped_by_operator',130);budget.beforeEffect(); return Math.max(1,Math.floor(budget.observationRemainingMs()));} }}); knownNative = {...value}; @@ -236,7 +253,9 @@ export async function managedReceiptSend(options: SendOptions, config: ReceiptSe } if (error instanceof Refusal && error.code === 'stopped_by_operator' && !(handoff?.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator')) - error = stopError instanceof Refusal ? stopError : new Refusal('stop_unconfirmed',1); + error = handoff?.wait.status === 'timed_out_unknown' ? new Refusal('timed_out_unknown',1) : + handoff?.wait.status === 'failed' && handoff.wait.reason ? new Refusal(handoff.wait.reason,1) : + stopError instanceof Refusal ? stopError : new Refusal('stop_unconfirmed',1); return finish({...snapshot,ok:false,status:uncertain ? 'unknown' : 'refused',submitted:uncertain ? null : false,consumptionConfirmed:false, error:error instanceof Refusal ? error.code : uncertain ? 'outcome_unknown' : 'receipt_operation_failed',retryAllowed:false,...(warning ? {handoffWarning:'persistence_failed'} : {})},handoff?.wait.status === 'stopped' && handoff.wait.reason === 'stopped_by_operator' ? 130 : error instanceof Refusal && error.exitCode !== 130 ? error.exitCode : 1); } finally { if (required) process.removeListener('SIGINT',stop); } diff --git a/test/receipt-send.test.ts b/test/receipt-send.test.ts index c349a1c..2a83661 100644 --- a/test/receipt-send.test.ts +++ b/test/receipt-send.test.ts @@ -10,6 +10,7 @@ import { ReceiptState } from '../dist/receipt-state.js'; import { HandoffBudget } from '../dist/handoff.js'; import { Refusal } from '../dist/discovery.js'; import { UnknownOutcome } from '../dist/process.js'; +import { AppServerFault } from '../dist/app-server.js'; import type { ReceiptOperation, ReceiptRequestBody, ReceiptResponse } from '../dist/receipt-protocol.js'; import type { SendOptions } from '../dist/send.js'; const qualified = {skip:process.platform === 'win32'}; @@ -323,3 +324,38 @@ for(const loseStatus of [false,true])test(`positive native acceptance survives l if(loseStatus)assert.equal(result.value.error,'stop_unconfirmed'); assert.equal(f.order.filter(op=>op==='wait-stop').length,1);assert.equal(f.order.filter(op=>op==='wait-open').length,1); }); + +for (const scenario of ['sync-stop','authorize-stop','owner-stop','sync-deadline','timeout-stop-race','attempted-stop'] as const) { + test('Codex native adapter preserves pre-effect reason across wrapping: '+scenario,{skip:process.platform!=='darwin'||process.arch!=='arm64'},async t=>{ + let mono=0;const clock={monotonic:()=>mono,utc:()=>100000}; + const f=fixture(t,clock),thread='01900000-0000-7000-8000-000000000001',generation=hash('original codex writer');let writes=0; + const budget=new HandoffBudget(10,clock); + f.deps.run=async(_binary,args)=>({code:0,stdout:args[0]==='--version'?'codex-cli 0.162.1\n':JSON.stringify({schemaVersion:1,ok:true,generation}),stderr:'',spawned:true,interrupted:false}); + f.deps.nativeQueue=async input=>{ + if(scenario==='authorize-stop')process.emit('SIGINT'); + try{await input.beforeEffect();}catch{throw new AppServerFault('effect_guard_failed',false);} + if(scenario==='sync-deadline')mono=6000; + else process.emit('SIGINT'); + if(scenario==='timeout-stop-race')mono=6000; + if(scenario==='owner-stop')throw new AppServerFault('owner_changed',false); + if(scenario==='attempted-stop'){writes++;throw new AppServerFault('connection_closed',true);} + try{input.verifyStorageSync?.();}catch{throw new AppServerFault('storage_binding_unverified',false);} + writes++;throw new Error('pre-write guard unexpectedly admitted input'); + }; + f.deps.send=async input=>{ + const context={agent:'codex' as const,target:thread,home:f.root,generation,writerIdentity:generation,binary:'/fixture/qualified-codex'}; + const snapshot={ok:true,submitted:true,status:'queued',consumptionConfirmed:false}; + const message=await input.hooks!.resolved(context,snapshot); + await input.hooks!.submitCodex!(context,message!,{binary:context.binary,cwd:f.root,async revalidate(){},finalCheck(){}}); + return snapshot; + }; + const result=await managedReceiptSend({to:'codex:'+thread,message:'original codex task'},{collector:f.socket,waitFor:'acknowledged',budget},f.deps); + assert.equal(writes,scenario==='attempted-stop'?1:0);assert.equal(result.value.submitted,scenario==='attempted-stop'?null:false); + const h=result.value.handoff as {wait:{status:string;reason?:string}}; + if(scenario==='sync-deadline'){assert.equal(result.value.error,'deadline_before_effect');assert.equal(result.exitCode,1);} + else if(scenario==='timeout-stop-race'){assert.equal(h.wait.status,'timed_out_unknown');assert.equal(result.value.error,'timed_out_unknown');assert.equal(result.exitCode,1);} + else if(scenario==='attempted-stop'){assert.equal(result.value.status,'unknown');assert.equal(result.value.error,'outcome_unknown');assert.equal(h.wait.status,'stopped');assert.equal(result.exitCode,130);} + else{assert.equal(result.value.error,'stopped_by_operator');assert.equal(h.wait.status,'stopped');assert.equal(result.exitCode,130);} + assert.equal(JSON.stringify(result).includes(f.capability),false); + }); +} From 0410d8f7fa88ef049b91540720430dcebad4c8c1 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 3 Nov 2026 13:20:42 +0900 Subject: [PATCH 9/9] test(ci): serialize bounded native probes and retain refusal diagnostics --- package.json | 2 +- test/handoff-delivery.test.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 407103c..15ea024 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "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-concurrency=2 test/*.test.ts", + "test": "npm run build && npm run test:types && node --import ./test/fixtures/isolate-env.mjs --experimental-strip-types --test --test-concurrency=1 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/claude-profiles.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/receipt-protocol.test.ts test/receipt-state.test.ts test/receipt-collector.test.ts test/receipt-command.test.ts test/receipt-send.test.ts test/receipt-transport-boundaries.test.ts test/receipt-acl.test.ts test/receipt-cli.test.ts test/receipt-producer.test.ts", "test:package": "npm run build && node --experimental-strip-types test/package-smoke.ts" diff --git a/test/handoff-delivery.test.ts b/test/handoff-delivery.test.ts index 1fed086..3df8709 100644 --- a/test/handoff-delivery.test.ts +++ b/test/handoff-delivery.test.ts @@ -236,7 +236,11 @@ 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',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'); + const f=await fixture(t,'loss');const r=await f.invoke([...f.args,'--wait-for','delivered']); + // A preflight refusal is not evidence of post-write loss. Retain both actual + // fixture call logs in the failure, without broadening any effect assertion. + const diagnostic=JSON.stringify({result:r.value,queueCalls:f.calls(),nativeCalls:f.nativeCalls()}); + assert.equal(r.code,1,diagnostic);assert.equal(r.value.status,'unknown',diagnostic);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');