diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 68c40c7..1bc5bc2 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -170,6 +170,69 @@ are ordinary command errors (JSON when requested, exit 1). No messages are submitted in either case. JSON results still describe submission rather than receipt; these flags introduce no structured JSON message-input protocol. + + +## Local route presets (development) + +Development for #324; unavailable in published v1.2.1. The opt-in `preset` commands remember routes, not recipients. Use `--preset` to select one saved local or SSH route for ordinary Claude Code or Codex listing/sending on Mac/Linux. Windows preset operations are refused; ordinary Windows commands remain unchanged. + +```bash +session-peer preset add work-mac --host user@work-mac --agent codex --codex-home /Users/user/.codex +session-peer preset list +session-peer preset show work-mac --json +session-peer list --preset work-mac +session-peer send --preset work-mac --to 'codex:' --message "Review the patch" --dry-run +session-peer send --preset work-mac --preset-revision 'dp1:' --to 'codex:' --message "Review the patch" +session-peer preset replace work-mac --expect-revision 'dp1:' --host user@new-mac --agent codex --codex-home /Users/user/.codex +session-peer preset remove work-mac --expect-revision 'dp1:' +``` + +Replace the UUID with a current discovery result and the hash with the exact revision printed by the selected preset. A real send requires `--preset-revision`; a dry run may omit it. The `dp1:` hash covers the preset name and exact validated route values, not DNS, SSH configuration, host keys, native ownership, permission, generation or ACK. Changed route values require a fresh review; restoring identical values yields the same hash. Replacement/removal require `--expect-revision`, and adding an existing name is refused. + +Recipient names, PIDs, UUIDs and generation tokens are never saved. `--to` remains mandatory; a name is freshly resolved by existing rules, not bound to an earlier session. Existing explicit `--target-generation`, writer/storage checks and unknown/no-automatic-resend behavior are retained. A preset label does not authenticate a person or prove delivery. + +Storage is `$XDG_CONFIG_HOME/session-peer/destinations.json`, or `~/.config/session-peer/destinations.json` when the variable is unset; the configured base must be absolute. The application directory is owner-only 0700 and files are owner-only 0600 or stricter. Symlinks, unsafe ownership/permissions, duplicate/unknown JSON keys, malformed UTF-8, more than 128 entries or more than 64 KiB are refused without falling back to another route. Names match `[a-z][a-z0-9_-]{0,63}`. The schema stores only transport/agent, one SSH host when needed, and an absolute Codex home for Codex routes: + +```json +{"schemaVersion":1,"presets":{"work-mac":{"transport":"ssh","agent":"codex","host":"user@work-mac","codexHome":"/Users/user/.codex"}}} +``` + +Combining a preset with explicit `--host`, `--codex-home`, `--agent`, `--ssh-opt` or `--codex-bin` is an error even if the values match; Reply-To URIs, paired devices, Antigravity, wake and experimental handoff combinations are also excluded. No credentials, executable paths, SSH commands/options, approval settings or receipt secrets are stored. Existing operator-owned SSH configuration still applies. + +`preset list` and `preset show` read configuration only: no native discovery, SSH, update notices or background refresh; missing storage is not initialized by a read. By contrast, `list --preset` explicitly runs the ordinary discovery for that one route, including any existing remote probes and partial-failure behavior. No other presets are contacted and no group broadcast is added. + +Text list/send operations print the chosen route and revision to stderr before the existing action. Existing native result JSON, stdout and exit contracts are unchanged; preset flags, revisions and local file paths are not forwarded over SSH. Each invocation uses one read snapshot. Later replacement/removal does not cancel an already expanded invocation; actual submission still revalidates the native target. This is not atomic storage/owner attestation, receiver deduplication, consumption, ACK or task completion. + +A `preset_commit_unconfirmed` error means an atomic replacement may already be visible but its durability could not be confirmed. Inspect the current configuration before retrying; no automatic rollback is attempted. + +Checked ancestors must be owned by root or the current user and not writable by others, except a root-owned sticky temporary directory; the immediate configuration parent must belong to the current user. All path components are opened without following symlinks. Extended ACL entries on private storage are conservatively refused; macOS ancestors may contain deny-only ACLs, but allow entries or unverifiable ACLs are refused. Linux POSIX ACLs are also checked. An empty or relative `XDG_CONFIG_HOME` is refused rather than silently selecting another path. Existing permissions are not repaired automatically: choose a safe, private absolute configuration root, or inspect and repair malformed configuration offline after keeping a private backup. Do not publish its contents. + +On macOS, mounts that ignore ownership are refused with `preset_ownership_unenforced`; an unavailable or unverifiable mount query is refused with `preset_mount_unverifiable`. Mode bits alone are not treated as proof that ownership is enforced. On Linux, ACLs on ancestors are also refused, as are filesystems whose ACL state cannot be verified. After an interrupted management command, inspect the current configuration because the change may already be visible; directory-sync failures are not silently accepted. + +### Recipient addressbook (development) + +This additional #324 scope is a v1.3 candidate, not part of published v1.2.1. A route preset still stores only a route. The separate, opt-in addressbook binds a user-supplied label to an explicitly selected Claude Code or Codex recipient on a local or SSH Mac/Linux route. Labels do not authenticate people. Paired devices, Antigravity and Windows are refused. Commands without an explicit addressbook operation or `--address` do not read addressbook configuration; no automatic migration or fallback is added. + +```bash +session-peer address resolve --preset work-mac --to 'codex:' --json +session-peer address add review --label Review --preset work-mac --to 'codex:' --expect-selection 'as1:' +session-peer address discover --address review --json +session-peer send --address review --dry-run --message 'Review the patch' +session-peer send --address review --address-revision 'ab1:' --confirm-target 'codex:' --message 'Review the patch' +session-peer address reselect review --expect-revision 'ab1:' --to 'codex:' --expect-selection 'as1:' +session-peer address remove review --expect-revision 'ab1:' +``` + +Replace placeholders with the exact reported values. Before `add`, inspect `address resolve` and use its `as1:` observation revision. Before `reselect`, resolve the intended new target with the same preset again and review its new `as1:` value. `as1:` is a revision of observed selection metadata, not authority, a native epoch or proof of consumption. Every successful add or reselect creates a new `bindingId`, covered by the `ab1:` address revision. Removing and recreating an identical address therefore cannot reuse the old revision. A route's `dp1:` remains a value hash: restoring identical preset values gives the same hash, not a new epoch. + +`address list` and `address show` read configuration only. `address resolve` and `address discover` explicitly inspect selected recipient metadata without a placeholder message, native CLI/version probe or model/session activation; existing OS process-metadata inspection may run. Discover accepts at most 16 distinct `--address` names, grouping only identical route/home and pinned SSH-key contexts so each group is inspected once. It retains per-address partial failures and returns exit 1 if any selection fails or no longer matches. It neither scans the network nor broadcasts to a group. A missing referenced preset or changed route/revision is refused; the stored old route is never used as a fallback. + +Claude addresses retain the selected `tg1:` generation and use the existing connected-peer precondition before submission. Codex addresses retain a full thread UUID, exact home and `aw1:` writer observation, with existing checks repeated before the effect. Codex generation remains **unsupported**: writer observation and revalidation are not an atomic native incarnation guarantee. A changed or unavailable writer requires explicit reselection; no automatic retargeting or replay is attempted. SSH selection requires a host key verified on the actual connection and pins that same key for the actual send. This authenticates neither a person nor the identity of SSH configuration or SQLite storage. + +An actual send requires both the current `--address-revision` and exact `--confirm-target`; dry-run may omit them. `--address` cannot be combined with `--to`, `--preset`, explicit host/native/SSH overrides, paired-device, wake, handoff or Reply-To URI routing. Normal message inputs and limits remain unchanged. Unknown outcomes never authorize automatic resend. These commands do not promise composition with the separate #323 delivery-explanation candidate. + +Storage is a separate `addresses.json` beside `destinations.json`, with `schemaVersion: 1`, a 64 KiB limit and at most 128 entries. It uses the same owner, permissions, ACL, mount and no-symlink-following checks as presets, without automatically repairing or migrating files. Address names follow the preset-name syntax; display labels are normalized to NFC and limited to 128 characters, rejecting control, bidirectional-control and format characters. An unconfirmed management commit may already be visible: inspect current configuration before retrying. Binding revisions do not add receiver deduplication, ACK or task-completion evidence. + ## Optional interactive shorthand For releases containing these assets, Bash/zsh and PowerShell users can opt in to a shell-local alias named `sp`. It invokes the currently selected `session-peer` command with the same arguments, stdin, output, exit codes and permissions. It is not a separate parser or a Python/TypeScript selector. Keep the canonical command in scripts, SSH requests and generated reply instructions. Do not install both implementations simultaneously; switch with the original installation manager and verify which command your shell resolves first. diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index 64cd6d8..1dbb75a 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,7 +15,7 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "90eeebe74625a9b10ed56ca543dd43ed7bffb0f5f4bc4c383b880162de3638f3", + "docs/cli-reference.md": "7cfa6c096a6924068631730e0becb3127a8ab4e1bc41610fc5539f16638e67f6", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 016f6ee..d776632 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -160,6 +160,69 @@ session-peer list --output-format json 送信されません。JSON 結果は受信ではなく送信を表すことに変わりはありません。 これらのフラグによって構造化された JSON メッセージ入力プロトコルが導入されるわけではありません。 + + +## ローカル経路プリセット(開発中) + +#324 の開発機能であり、公開済み v1.2.1 には含まれません。任意の `preset` コマンドが保存するのは受信者ではなく接続経路です。Mac/Linux では `--preset` でローカルまたは SSH の経路を一つ選び、通常の Claude Code・Codex の一覧取得や送信に利用します。Windows のプリセット操作は拒否し、既存の Windows コマンドは変更しません。 + +```bash +session-peer preset add work-mac --host user@work-mac --agent codex --codex-home /Users/user/.codex +session-peer preset list +session-peer preset show work-mac --json +session-peer list --preset work-mac +session-peer send --preset work-mac --to 'codex:' --message "Review the patch" --dry-run +session-peer send --preset work-mac --preset-revision 'dp1:' --to 'codex:' --message "Review the patch" +session-peer preset replace work-mac --expect-revision 'dp1:' --host user@new-mac --agent codex --codex-home /Users/user/.codex +session-peer preset remove work-mac --expect-revision 'dp1:' +``` + +UUID は現在の一覧で確認した値に、ハッシュは選択したプリセットに表示された正確な revision に置き換えます。実際の送信には `--preset-revision` が必要で、dry-run では省略できます。`dp1:` ハッシュが対象とするのはプリセット名と検証済み経路の正確な値だけであり、DNS、SSH 設定、ホスト鍵、native 所有権、権限、世代、ACK は証明しません。経路の値が変わったら再確認が必要で、同じ値に戻すと同じハッシュになります。変更・削除には `--expect-revision` が必要で、同じ名前の追加は拒否します。 + +受信者の名前、PID、UUID、世代トークンは保存しません。`--to` は引き続き必須で、名前は既存の規則で現在の宛先を検索する値であり、以前のセッションに固定されたアドレスではありません。既存の明示的な `--target-generation`、writer・保存先の検査、不確実な結果で自動再送しない原則を維持します。プリセット名は人物の身元や配信を証明しません。 + +保存先は `$XDG_CONFIG_HOME/session-peer/destinations.json` で、変数が未設定なら `~/.config/session-peer/destinations.json` です。指定する基点は絶対パスに限ります。アプリ用ディレクトリは所有者専用の 0700、ファイルは所有者専用の 0600 またはそれより厳しい権限です。シンボリックリンク、安全でない所有権・権限、JSON の重複・未知のキー、不正な UTF-8、128 件を超える項目、64 KiB を超えるファイルは、別経路に切り替えず拒否します。名前の形式は `[a-z][a-z0-9_-]{0,63}` です。スキーマが保存するのは接続方式・エージェント、必要な場合の SSH ホスト一つ、Codex 経路の絶対 home だけです: + +```json +{"schemaVersion":1,"presets":{"work-mac":{"transport":"ssh","agent":"codex","host":"user@work-mac","codexHome":"/Users/user/.codex"}}} +``` + +プリセットと明示的な `--host`、`--codex-home`、`--agent`、`--ssh-opt`、`--codex-bin` は値が同じでも併用できません。Reply-To URI、ペアリング済み機器、Antigravity、wake、実験的 handoff との組み合わせも対象外です。認証情報、実行ファイルのパス、SSH コマンドやオプション、承認設定、receipt の秘密値は保存しません。既存の運用者所有の SSH 設定は引き続き適用されます。 + +`preset list` と `preset show` は設定を読むだけで、native の検索、SSH、更新通知、バックグラウンド更新は実行しません。読み取りで存在しない保存先を初期化することもありません。一方、`list --preset` は既存のリモート照会や部分失敗の処理を含め、選択した一経路の通常の一覧取得を明示的に実行します。他のプリセットへの接続やグループ一斉送信は行いません。 + +テキスト形式の一覧取得・送信では、既存の処理前に選択した経路と revision を stderr に表示します。既存の native 結果 JSON、stdout、終了契約は維持し、プリセットのフラグ、revision、ローカルファイルのパスは SSH に転送しません。各呼び出しは読み取った一つのスナップショットを使います。後から変更・削除しても既に展開した呼び出しは取り消されず、実際の投入は native 宛先を再検証します。これは保存先・所有権の原子的な証明、受信側の重複抑止、消費、ACK、タスク完了を保証するものではありません。 + +`preset_commit_unconfirmed` は、原子的な置換が既に見えている可能性がある一方、永続化を確認できなかったことを示します。再試行する前に現在の設定を確認してください。自動的には元に戻しません。 + +検査する上位経路は root または現在のユーザーが所有し、他のユーザーが書き込めない必要があります。root 所有の sticky 一時ディレクトリは例外で、設定の直上ディレクトリは現在のユーザー所有に限ります。全ての経路要素をシンボリックリンクを追わずに開きます。専用保存先の拡張 ACL エントリーは保守的に拒否します。macOS の上位経路では deny-only ACL を許可しますが、allow エントリーや検証できない ACL は拒否し、Linux POSIX ACL も検査します。空または相対パスの `XDG_CONFIG_HOME` は別の場所に黙って切り替えず拒否します。既存の権限は自動修復しません。安全なユーザー専用の絶対経路を選ぶか、非公開のバックアップを保持してから不正な設定をオフラインで確認・修正し、内容は公開しないでください。 + +macOS で所有権を無視するマウントは `preset_ownership_unenforced`、マウント照会が利用できない、または検証できない場合は `preset_mount_unverifiable` で拒否します。モードビットだけを所有権が強制される証拠とは扱いません。Linux では上位経路の ACL も、ACL 状態を検証できないファイルシステムも拒否します。管理コマンドを中断した後は、変更が既に見えている可能性があるため現在の設定を確認してください。ディレクトリの同期失敗を黙って成功とは扱いません。 + +### 受信先アドレス帳(開発中) + +この #324 の追加範囲は v1.3 の候補で、公開済み v1.2.1 には含まれません。経路プリセットが保存するのは引き続き経路だけです。別途オプトインするアドレス帳は、ユーザーが付けた表示名を、ローカルまたは SSH の Mac/Linux 経路で明示的に選択した Claude Code・Codex の受信先に結び付けます。表示名は人物の身元を認証しません。ペアリング済みデバイス、Antigravity、Windows は拒否します。明示的なアドレス帳操作または `--address` がないコマンドはアドレス帳設定を読みません。自動移行や代替経路への切り替えは追加しません。 + +```bash +session-peer address resolve --preset work-mac --to 'codex:' --json +session-peer address add review --label Review --preset work-mac --to 'codex:' --expect-selection 'as1:' +session-peer address discover --address review --json +session-peer send --address review --dry-run --message 'Review the patch' +session-peer send --address review --address-revision 'ab1:' --confirm-target 'codex:' --message 'Review the patch' +session-peer address reselect review --expect-revision 'ab1:' --to 'codex:' --expect-selection 'as1:' +session-peer address remove review --expect-revision 'ab1:' +``` + +プレースホルダーは実際に報告された正確な値に置き換えます。`add` の前に `address resolve` の結果を確認し、その `as1:` 観測 revision を使います。`reselect` の前には同じプリセットで希望する新しい宛先をもう一度 resolve し、新しい `as1:` を確認してください。`as1:` は観測した選択メタデータの revision であり、権限、native epoch、消費の証拠ではありません。追加または再選択が成功するたびに新しい `bindingId` が作られ、`ab1:` アドレス revision の計算対象になります。同一のアドレスを削除して作り直しても、以前の revision は再利用できません。経路の `dp1:` は引き続き値のハッシュです。同じプリセット値を復元すると、新しい epoch ではなく同じハッシュになります。 + +`address list` と `address show` は設定だけを読みます。`address resolve` と `address discover` は、仮のメッセージ、native CLI・バージョンの照会、モデル・セッションの起動を行わず、選択した受信先のメタデータを明示的に調べます。既存の OS プロセスメタデータ検査は行われる場合があります。Discover は重複しない `--address` 名を最大 16 個受け付け、経路・home と固定された SSH 鍵の context がすべて同じ場合だけグループ化し、各グループを一度調べます。アドレスごとの部分失敗を保持し、どれか一つでも検査に失敗するか保存された選択と一致しなくなると終了コード 1 を返します。ネットワーク全体の探索やグループ一斉送信は行いません。参照先プリセットの欠落や経路・revision の変更は拒否し、保存された古い経路には切り替えません。 + +Claude アドレスは選択した `tg1:` 世代を保存し、投入前に既存の接続相手を検証する前提条件を適用します。Codex アドレスは完全なスレッド UUID、正確な home、`aw1:` writer 観測を保存し、副作用が発生する前に既存の検査を繰り返します。Codex の世代は引き続き**未対応**です。Writer の観測と再検証は、元の native インスタンス世代を原子的に検証する保証ではありません。Writer が変わった、または確認できない場合は明示的な再選択が必要で、自動的な宛先変更や再実行は行いません。SSH の選択には実際の接続で検証されたホスト鍵が必要で、実際の送信でも同じ鍵に固定します。これは人物を認証するものでも、SSH 設定や SQLite 保存先の同一性を証明するものでもありません。 + +実際の送信には現在の `--address-revision` と正確な `--confirm-target` の両方が必要で、dry-run では省略できます。`--address` は `--to`、`--preset`、明示的なホスト・native・SSH の上書き、ペアリング済みデバイス、wake、handoff、Reply-To URI の経路指定とは併用できません。通常のメッセージ入力と上限は変わりません。不確実な結果でも自動再送は許可されません。これらのコマンドは、別途開発中の #323 配信説明機能との組み合わせを保証しません。 + +保存先は `destinations.json` の隣にある別の `addresses.json` です。`schemaVersion: 1`、64 KiB の上限、最大 128 件の項目を使用します。プリセットと同じ所有者・権限・ACL・マウントの検査と、シンボリックリンクをたどらない経路検査を適用し、ファイルの自動修復や移行は行いません。アドレス名はプリセット名の構文に従います。表示名は NFC に正規化して 128 文字に制限し、制御文字・双方向制御文字・書式文字を拒否します。管理操作の commit が未確認でも変更が既に見えている可能性があるため、再試行前に現在の設定を確認してください。結び付けの revision は、受信側の重複抑止、ACK、タスク完了の証拠を追加しません。 + ## 任意の対話型短縮コマンド diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index 07f2344..91492b5 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -160,6 +160,69 @@ session-peer list --output-format json 여전히 제출만을 설명합니다. 이 플래그들은 구조화된 JSON 메시지 입력 프로토콜을 도입하지 않습니다. + + +## 로컬 경로 프리셋 (개발 중) + +#324의 개발 기능이며 공개 v1.2.1에는 없습니다. 선택적으로 사용하는 `preset` 명령은 수신자가 아니라 접속 경로를 저장합니다. Mac/Linux에서 `--preset`으로 로컬 또는 SSH 경로 하나를 골라 일반 Claude Code·Codex 목록 조회나 전송에 사용합니다. Windows의 프리셋 작업은 거부하며 기존 Windows 명령은 바꾸지 않습니다. + +```bash +session-peer preset add work-mac --host user@work-mac --agent codex --codex-home /Users/user/.codex +session-peer preset list +session-peer preset show work-mac --json +session-peer list --preset work-mac +session-peer send --preset work-mac --to 'codex:' --message "Review the patch" --dry-run +session-peer send --preset work-mac --preset-revision 'dp1:' --to 'codex:' --message "Review the patch" +session-peer preset replace work-mac --expect-revision 'dp1:' --host user@new-mac --agent codex --codex-home /Users/user/.codex +session-peer preset remove work-mac --expect-revision 'dp1:' +``` + +UUID는 현재 목록에서 확인한 값으로, 해시는 선택한 프리셋에 표시된 정확한 revision으로 바꿉니다. 실제 전송에는 `--preset-revision`이 필요하고 dry-run에서는 생략할 수 있습니다. `dp1:` 해시는 프리셋 이름과 검증된 경로의 정확한 값만 포함하며 DNS·SSH 설정·호스트 키·native 소유권·권한·세대·ACK를 증명하지 않습니다. 경로 값이 바뀌면 다시 확인해야 하고, 동일한 값을 복원하면 해시도 같습니다. 수정·삭제에는 `--expect-revision`이 필요하며 같은 이름의 추가는 거부합니다. + +수신자의 이름·PID·UUID·세대 토큰은 저장하지 않습니다. `--to`는 계속 필수이며 이름은 기존 규칙으로 현재 대상을 새로 찾는 값이지 이전 세션에 고정된 주소가 아닙니다. 명시적인 기존 `--target-generation`, writer·저장소 검사, 결과가 불확실할 때 자동 재전송하지 않는 원칙은 유지합니다. 프리셋 이름은 사람의 신원이나 전달 사실을 증명하지 않습니다. + +저장 위치는 `$XDG_CONFIG_HOME/session-peer/destinations.json`이며 변수가 없으면 `~/.config/session-peer/destinations.json`입니다. 지정한 기본 경로는 절대 경로여야 합니다. 앱 디렉터리는 소유자 전용 0700, 파일은 소유자 전용 0600 또는 더 제한적인 권한을 사용합니다. 심볼릭 링크, 안전하지 않은 소유권·권한, 중복·알 수 없는 JSON 키, 잘못된 UTF-8, 128개 초과 항목, 64 KiB 초과 파일은 다른 경로로 우회하지 않고 거부합니다. 이름은 `[a-z][a-z0-9_-]{0,63}` 형식입니다. 스키마에는 전송 방식·에이전트, 필요한 경우 SSH 호스트 하나, Codex 경로의 절대 home만 저장합니다: + +```json +{"schemaVersion":1,"presets":{"work-mac":{"transport":"ssh","agent":"codex","host":"user@work-mac","codexHome":"/Users/user/.codex"}}} +``` + +프리셋과 명시적인 `--host`, `--codex-home`, `--agent`, `--ssh-opt`, `--codex-bin`은 값이 같아도 함께 사용할 수 없습니다. Reply-To URI, 페어링 기기, Antigravity, wake, 실험적 handoff 조합도 제외합니다. 자격증명·실행 파일 경로·SSH 명령이나 옵션·승인 설정·receipt 비밀값은 저장하지 않습니다. 기존 운영자 소유 SSH 설정은 그대로 적용됩니다. + +`preset list`와 `preset show`는 설정만 읽으며 native 목록 조회·SSH·업데이트 알림·백그라운드 갱신을 실행하지 않습니다. 읽기 작업은 없는 저장소를 초기화하지 않습니다. 반면 `list --preset`은 기존 원격 조회와 부분 실패 처리를 포함해 선택한 경로 하나의 일반 목록 조회를 명시적으로 실행합니다. 다른 프리셋에 접속하거나 그룹에 일괄 전송하지 않습니다. + +텍스트 목록 조회·전송은 기존 작업 전에 선택한 경로와 revision을 stderr에 표시합니다. 기존 native 결과 JSON·stdout·종료 계약은 유지하며 프리셋 플래그·revision·로컬 파일 경로를 SSH로 전달하지 않습니다. 호출마다 읽은 스냅샷 하나를 사용합니다. 나중에 프리셋을 수정·삭제해도 이미 확장한 호출은 취소되지 않으며 실제 제출은 native 대상을 다시 검증합니다. 이는 저장소·소유권의 원자적 증명, 수신측 중복 억제, 소비, ACK, 작업 완료를 보장하지 않습니다. + +`preset_commit_unconfirmed` 오류는 원자적 교체가 이미 보일 수 있지만 디스크에 안전하게 기록됐는지 확인하지 못했다는 뜻입니다. 재시도 전에 현재 설정을 확인하세요. 자동으로 되돌리지는 않습니다. + +검사하는 상위 경로는 root 또는 현재 사용자 소유이며 다른 사용자가 쓸 수 없어야 합니다. root 소유의 sticky 임시 디렉터리는 예외이고, 설정의 직계 부모는 현재 사용자 소유여야 합니다. 모든 경로 구성 요소는 심볼릭 링크를 따라가지 않고 엽니다. 전용 저장소의 확장 ACL 항목은 보수적으로 거부합니다. Mac 상위 경로의 deny-only ACL은 허용하지만 allow 항목이나 확인할 수 없는 ACL은 거부하며 Linux POSIX ACL도 검사합니다. 비어 있거나 상대 경로인 `XDG_CONFIG_HOME`은 다른 위치로 조용히 우회하지 않고 거부합니다. 기존 권한을 자동으로 고치지 않으므로 안전한 사용자 전용 절대 경로를 선택하세요. 잘못된 설정은 비공개 백업을 보관한 뒤 오프라인에서 확인·수정하고, 내용은 공개하지 마세요. + +Mac에서 소유권을 무시하는 마운트는 `preset_ownership_unenforced`로, 마운트 조회를 수행하거나 검증할 수 없으면 `preset_mount_unverifiable`로 거부합니다. 파일 모드만으로 소유권 강제를 증명했다고 취급하지 않습니다. Linux에서는 상위 경로의 ACL도 거부하며 ACL 상태를 검증할 수 없는 파일시스템도 거부합니다. 관리 명령을 중단했을 때는 변경이 이미 보일 수 있으므로 현재 설정을 확인하세요. 디렉터리 동기화 실패를 조용히 성공으로 처리하지 않습니다. + +### 수신 대상 주소록 (개발 중) + +이 추가 #324 범위는 v1.3 후보이며 공개 v1.2.1에는 없습니다. 경로 프리셋은 계속 경로만 저장합니다. 별도로 선택해 사용하는 주소록은 사용자 지정 표시 이름을 로컬 또는 SSH Mac/Linux 경로에서 명시적으로 선택한 Claude Code·Codex 수신 대상에 연결합니다. 표시 이름은 사람의 신원을 인증하지 않습니다. 페어링 기기, Antigravity와 Windows는 거부합니다. 명시적인 주소록 작업이나 `--address`가 없는 명령은 주소록 설정을 읽지 않으며, 자동 이전이나 대체 경로 시도를 추가하지 않습니다. + +```bash +session-peer address resolve --preset work-mac --to 'codex:' --json +session-peer address add review --label Review --preset work-mac --to 'codex:' --expect-selection 'as1:' +session-peer address discover --address review --json +session-peer send --address review --dry-run --message 'Review the patch' +session-peer send --address review --address-revision 'ab1:' --confirm-target 'codex:' --message 'Review the patch' +session-peer address reselect review --expect-revision 'ab1:' --to 'codex:' --expect-selection 'as1:' +session-peer address remove review --expect-revision 'ab1:' +``` + +자리표시자를 실제로 보고된 정확한 값으로 바꿉니다. `add` 전에 `address resolve` 결과를 검토하고 해당 `as1:` 관찰 revision을 사용합니다. `reselect` 전에는 같은 프리셋으로 원하는 새 대상을 다시 resolve하고 새 `as1:` 값을 검토합니다. `as1:`은 관찰된 선택 메타데이터의 revision이며 권한, native epoch나 소비 증거가 아닙니다. 추가 또는 재선택에 성공할 때마다 새 `bindingId`를 만들고 이를 `ab1:` 주소 revision에 포함합니다. 따라서 동일한 주소를 삭제 후 다시 만들어도 이전 revision을 재사용할 수 없습니다. 경로의 `dp1:`은 계속 값의 해시이며, 동일한 프리셋 값을 복원하면 새 epoch가 아니라 같은 해시를 얻습니다. + +`address list`와 `address show`는 설정만 읽습니다. `address resolve`와 `address discover`는 임시 메시지, native CLI·버전 조회나 모델·세션 활성화 없이 선택한 수신 대상의 메타데이터를 명시적으로 확인합니다. 기존 OS 프로세스 메타데이터 확인은 수행될 수 있습니다. Discover는 서로 다른 `--address` 이름을 최대 16개까지 받으며, 경로·home과 고정된 SSH 키 context가 모두 같은 경우에만 묶어 그룹당 한 번 확인합니다. 주소별 부분 실패를 유지하고, 하나라도 확인에 실패하거나 저장된 선택과 달라지면 종료 코드 1을 반환합니다. 네트워크 검색이나 그룹 일괄 전송은 하지 않습니다. 참조 프리셋이 없거나 경로·revision이 달라지면 거부하며, 저장된 이전 경로로 대체하지 않습니다. + +Claude 주소는 선택한 `tg1:` 세대를 저장하고 제출 전에 기존 연결 상대 확인 조건을 적용합니다. Codex 주소는 전체 스레드 UUID, 정확한 home과 `aw1:` writer 관찰을 저장하며 효과가 발생하기 전에 기존 검사를 반복합니다. Codex 세대는 계속 **미지원**입니다. Writer 관찰과 재검증은 원래 native 인스턴스 세대를 원자적으로 검증하는 보장이 아닙니다. Writer가 달라지거나 확인할 수 없으면 명시적으로 다시 선택해야 하며, 자동 대상 변경이나 재실행은 하지 않습니다. SSH 선택은 실제 연결에서 검증한 호스트 키를 요구하며 실제 전송에도 같은 키를 고정합니다. 이것으로 사람을 인증하거나 SSH 설정·SQLite 저장소의 동일성을 증명하지는 않습니다. + +실제 전송에는 현재 `--address-revision`과 정확한 `--confirm-target`이 모두 필요하며 dry-run에서는 생략할 수 있습니다. `--address`는 `--to`, `--preset`, 명시적 호스트·native·SSH 재정의, 페어링 기기, wake, handoff나 Reply-To URI 경로와 함께 사용할 수 없습니다. 일반 메시지 입력과 한도는 그대로 유지됩니다. 결과가 불확실해도 자동 재전송은 허용되지 않습니다. 이 명령들은 별도의 #323 전달 설명 후보 기능과의 조합을 보장하지 않습니다. + +저장소는 `destinations.json` 옆의 별도 `addresses.json`이며 `schemaVersion: 1`, 64 KiB 한도와 최대 128개 항목을 사용합니다. 프리셋과 같은 소유자·권한·ACL·마운트 검사 및 심볼릭 링크를 따르지 않는 경로 검사를 적용하고 파일을 자동 복구하거나 이전하지 않습니다. 주소 이름은 프리셋 이름 문법을 따릅니다. 표시 이름은 NFC로 정규화하고 128자로 제한하며 제어 문자·양방향 제어 문자·서식 문자를 거부합니다. 관리 작업의 commit을 확인하지 못했어도 변경이 이미 보일 수 있으므로 재시도 전에 현재 설정을 확인하세요. 수신자 바인딩 revision은 수신 측 중복 방지, ACK나 작업 완료 증거를 추가하지 않습니다. + ## 선택적 대화형 단축 명령 diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index 0c19321..3869d81 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -115,6 +115,69 @@ session-peer list --output-format json `--json --output-format json` 是合法的;无论先后顺序,将 `--json` 与 `--output-format text` 组合都会报错。无效或矛盾的输出选项属于 argparse 用法错误(stderr,退出码 2);消息来源冲突属于普通命令错误(请求时为 JSON,退出码 1)。无论哪种情况均不会提交任何消息。JSON 结果仍然描述提交情况而非接收情况;这些标志并未引入结构化的 JSON 消息输入协议。 + + +## 本地路径预设(开发中) + +这是 #324 的开发功能,尚未包含在已发布的 v1.2.1 中。可选的 `preset` 命令保存的是连接路径,而不是接收者。在 Mac/Linux 上,使用 `--preset` 选择一个本地或 SSH 路径,用于常规 Claude Code、Codex 会话列表查询或发送。Windows 预设操作会被拒绝,现有 Windows 命令保持不变。 + +```bash +session-peer preset add work-mac --host user@work-mac --agent codex --codex-home /Users/user/.codex +session-peer preset list +session-peer preset show work-mac --json +session-peer list --preset work-mac +session-peer send --preset work-mac --to 'codex:' --message "Review the patch" --dry-run +session-peer send --preset work-mac --preset-revision 'dp1:' --to 'codex:' --message "Review the patch" +session-peer preset replace work-mac --expect-revision 'dp1:' --host user@new-mac --agent codex --codex-home /Users/user/.codex +session-peer preset remove work-mac --expect-revision 'dp1:' +``` + +将 UUID 替换为当前列表中确认的值,将哈希替换为所选预设显示的准确 revision。实际发送必须提供 `--preset-revision`,dry-run 可以省略。`dp1:` 哈希仅涵盖预设名称和已验证路径的准确值,不证明 DNS、SSH 配置、主机密钥、native 所有权、权限、代际或 ACK。路径值变化后必须重新确认;恢复相同值会得到相同哈希。修改、删除需要 `--expect-revision`,重复名称的新增操作会被拒绝。 + +接收者的名称、PID、UUID、代际令牌均不保存。`--to` 仍然必填;名称按现有规则重新查找当前目标,并非绑定到先前会话的地址。现有显式 `--target-generation`、writer 和存储检查,以及结果不确定时禁止自动重发的原则保持不变。预设名称不证明人员身份或消息已送达。 + +存储位置为 `$XDG_CONFIG_HOME/session-peer/destinations.json`;变量未设置时使用 `~/.config/session-peer/destinations.json`。指定的基础路径必须是绝对路径。应用目录采用仅所有者可访问的 0700 权限,文件采用仅所有者可访问的 0600 或更严格权限。符号链接、不安全的所有权或权限、重复或未知 JSON 键、无效 UTF-8、超过 128 项或超过 64 KiB 的文件均被拒绝,不回退到其他路径。名称格式为 `[a-z][a-z0-9_-]{0,63}`。模式仅保存传输方式、代理类型、需要时的一个 SSH 主机,以及 Codex 路径的绝对 home: + +```json +{"schemaVersion":1,"presets":{"work-mac":{"transport":"ssh","agent":"codex","host":"user@work-mac","codexHome":"/Users/user/.codex"}}} +``` + +预设不能与显式 `--host`、`--codex-home`、`--agent`、`--ssh-opt` 或 `--codex-bin` 组合,即使值相同也不例外;Reply-To URI、配对设备、Antigravity、wake 和实验性 handoff 组合也不在范围内。不保存凭据、可执行文件路径、SSH 命令或选项、审批设置或 receipt 秘密值。现有操作员拥有的 SSH 配置仍然适用。 + +`preset list` 和 `preset show` 只读取配置,不执行 native 发现、SSH、更新通知或后台刷新,也不会因读取而初始化缺失的存储。相比之下,`list --preset` 会显式执行所选单一路径的常规发现,包括现有的远程探测和部分失败处理。不会连接其他预设,也不会添加群组广播。 + +文本列表查询和发送会在现有操作之前,将所选路径和 revision 输出到 stderr。现有 native 结果 JSON、stdout 和退出契约保持不变;预设标志、revision 和本地文件路径不会通过 SSH 转发。每次调用使用一次读取的快照。之后修改或删除预设不会取消已展开的调用,实际提交仍会重新验证 native 目标。这不保证存储或所有权的原子证明、接收侧去重、消费、ACK 或任务完成。 + +`preset_commit_unconfirmed` 表示原子替换可能已经可见,但无法确认其持久性。重试前请检查当前配置;不会自动回滚。 + +受检上级路径必须归 root 或当前用户所有,且不能由其他用户写入;root 所有的 sticky 临时目录除外,配置的直接父目录必须归当前用户所有。打开所有路径组成部分时均不跟随符号链接。专用存储中的扩展 ACL 条目会被保守地拒绝;macOS 上级路径允许 deny-only ACL,但 allow 条目或无法验证的 ACL 会被拒绝,同时也会检查 Linux POSIX ACL。空值或相对路径的 `XDG_CONFIG_HOME` 会被拒绝,不会静默改用其他位置。不会自动修复现有权限:请选择安全的用户专用绝对配置路径,或保留私有备份后离线检查、修复无效配置;不要公开其内容。 + +在 macOS 上,忽略所有权的挂载会以 `preset_ownership_unenforced` 被拒绝,无法查询或验证挂载状态时则以 `preset_mount_unverifiable` 被拒绝。不会仅凭模式位断定所有权已强制执行。Linux 也会拒绝上级路径中的 ACL,以及无法验证 ACL 状态的文件系统。中断管理命令后请检查当前配置,因为更改可能已经可见;目录同步失败不会被静默当作成功。 + +### 接收者地址簿(开发中) + +这项 #324 的新增范围是 v1.3 的候选功能,尚未包含在已发布的 v1.2.1 中。路径预设仍只保存路径。单独启用的地址簿将用户提供的显示标签绑定到在本地或 SSH Mac/Linux 路径上明确选择的 Claude Code 或 Codex 接收者。标签不认证人员身份。配对设备、Antigravity 和 Windows 均被拒绝。未显式执行地址簿操作或使用 `--address` 的命令不会读取地址簿配置,也不添加自动迁移或回退。 + +```bash +session-peer address resolve --preset work-mac --to 'codex:' --json +session-peer address add review --label Review --preset work-mac --to 'codex:' --expect-selection 'as1:' +session-peer address discover --address review --json +session-peer send --address review --dry-run --message 'Review the patch' +session-peer send --address review --address-revision 'ab1:' --confirm-target 'codex:' --message 'Review the patch' +session-peer address reselect review --expect-revision 'ab1:' --to 'codex:' --expect-selection 'as1:' +session-peer address remove review --expect-revision 'ab1:' +``` + +请将占位符替换为实际报告的准确值。在 `add` 前检查 `address resolve` 的结果,并使用其中的 `as1:` 观察 revision。在 `reselect` 前,先使用同一预设再次 resolve 所需的新目标,并检查新的 `as1:` 值。`as1:` 是所观察到的选择元数据的 revision,不是授权、native epoch 或消费证据。每次成功新增或重新选择都会生成新的 `bindingId`,并将其纳入 `ab1:` 地址 revision。因此,即使删除后重建完全相同的地址,也不能复用旧 revision。路径的 `dp1:` 仍是值的哈希:恢复相同预设值会得到相同哈希,而不是新的 epoch。 + +`address list` 和 `address show` 只读取配置。`address resolve` 和 `address discover` 显式检查所选接收者的元数据,不生成占位消息、不执行 native CLI 或版本探测,也不激活模型或会话;现有的 OS 进程元数据检查仍可能运行。Discover 最多接受 16 个不同的 `--address` 名称,仅在路径、home 和固定的 SSH 密钥 context 完全相同时分组,每组检查一次。它保留每个地址的部分失败;只要有一个选择检查失败或不再匹配,就返回退出码 1。它不会扫描网络或进行群组广播。引用的预设缺失,或路径、revision 发生变化时会被拒绝,绝不回退到保存的旧路径。 + +Claude 地址保存选定的 `tg1:` 代次,并在提交前使用现有的连接对端前置检查。Codex 地址保存完整线程 UUID、准确的 home 和 `aw1:` writer 观察,并在产生副作用前重复现有检查。Codex 代次仍**不受支持**:writer 观察和重新验证并不提供对原有 native 实例代次的原子验证保证。Writer 已更改或无法确认时,必须显式重新选择,不会自动改选目标或重放。SSH 选择要求在实际连接上验证主机密钥,并在实际发送时固定使用同一密钥。这既不认证人员身份,也不证明 SSH 配置或 SQLite 存储的同一性。 + +实际发送必须同时提供当前 `--address-revision` 和准确的 `--confirm-target`,dry-run 可以省略。`--address` 不能与 `--to`、`--preset`、显式主机、native 或 SSH 覆盖项、配对设备、wake、handoff 或 Reply-To URI 路由组合使用。常规消息输入和限制保持不变。结果未知时也不允许自动重发。这些命令不保证与单独开发的 #323 传递说明候选功能组合使用。 + +存储是位于 `destinations.json` 旁的独立 `addresses.json`,采用 `schemaVersion: 1`,大小限制为 64 KiB,最多 128 项。它使用与预设相同的所有者、权限、ACL、挂载和不跟随符号链接的路径检查,不自动修复或迁移文件。地址名称遵循预设名称语法;显示标签会规范化为 NFC,最多 128 个字符,并拒绝控制字符、双向控制字符和格式字符。管理操作的 commit 未确认时,更改也可能已经可见,重试前请检查当前配置。绑定 revision 不增加接收侧去重、ACK 或任务完成证据。 + ## 可选的交互式短命令 diff --git a/session_peer.py b/session_peer.py index 8055e1c..9a72947 100644 --- a/session_peer.py +++ b/session_peer.py @@ -1616,6 +1616,8 @@ def _queue_codex(args: argparse.Namespace, text: str) -> dict: "consumptionConfirmed": False}) selected = codex_home(args) root, home_resolution = resolve_codex_home(args, selected, thread_id) + if getattr(args, "_address_expected_writer", None) is not None: + require_address_writer(args, root, thread_id, home_resolution) native_home = getattr(args, "codex_native_home", None) # Dry-run applies the same storage boundary as the real submission. queue_cwd = codex_queue_storage_preflight(args, root, thread_id) @@ -1636,7 +1638,12 @@ def _queue_codex(args: argparse.Namespace, text: str) -> dict: if not any(s["id"] == thread_id for s in discover_codex(discovery_args)): raise NoTargetError(f"No saved Codex thread {thread_id} in {root}") return result - revalidate_codex_home(root, thread_id, home_resolution) + try: + revalidate_codex_home(root, thread_id, home_resolution) + except CcPeerError: + if getattr(args, "_address_expected_writer", None) is not None: + raise address_refused("address_writer_changed") from None + raise # Relay receivers on WSL inspect the native Windows state DB through its # mounted POSIX path, but the native codex.exe process needs the Windows # spelling of that same home. This private adapter value is derived from @@ -1646,6 +1653,14 @@ def _queue_codex(args: argparse.Namespace, text: str) -> dict: check_codex_consumer_storage(root, thread_id, native_home=native_home, include_ordinary_preferences=include_ordinary_preferences) try: + if getattr(args, "_address_expected_writer", None) is not None: + # Compare to the selected observation again at the effect boundary; + # this is not an atomic native incarnation precondition. + try: + revalidate_codex_home(root, thread_id, home_resolution) + except CcPeerError: + raise address_refused("address_writer_changed") from None + require_address_writer(args, root, thread_id, home_resolution) # Keep leading dashes inside the option value, including with --no-from. done = subprocess.run(argv, cwd=None if native_home else str(queue_cwd), env=env, capture_output=True, encoding="utf-8", errors="replace", @@ -4075,6 +4090,12 @@ def incomplete_response_error(message: str, details: dict | None = None) -> CcPe # The far end reports its own failures in-band; surface them here rather # than letting a caller read an error payload as a success. if isinstance(result, dict) and result.get("ok") is False: + if (argv[:2] == ["address", "_inspect"] and valid_result + and type(result.get("selectionSchemaVersion")) is int and result["selectionSchemaVersion"] == 1 + and isinstance(result.get("selections"), list) and 1 <= len(result["selections"]) <= 16): + # Preserve partial metadata so the address consumer can validate + # each exact target and retain good rows. No submission is involved. + return result if (argv and argv[0] == "send" and result.get("command") == "send" and result.get("schemaVersion") == JSON_RESPONSE_SCHEMA_VERSION and result.get("agent") == "antigravity" @@ -6425,6 +6446,1313 @@ def read_message(args: argparse.Namespace) -> str: return message +"""Explicit, route-only POSIX presets; never a session identity or authority.""" + +import hashlib + + +PRESET_MAX_BYTES = 65536 +PRESET_MAX_ENTRIES = 128 +PRESET_LOCK_SECONDS = 1.0 + + +def _preset_error(reason): + return CcPeerError("preset configuration refused: " + reason, {"reason": reason}) + + +def _preset_cleanup(action, *args, preserve=False, **kwargs): + """A secondary ordinary cleanup error must not replace an active failure. + + New KeyboardInterrupt/SystemExit are never swallowed. On a normal return, + cleanup failure is still reported, without reflecting raw diagnostics. + """ + try: + return action(*args, **kwargs) + except Exception as error: + if not preserve: + if isinstance(error, CcPeerError): + raise + raise _preset_error("preset_cleanup_failed") from None + + +@contextlib.contextmanager +def _preset_cleanup_scope(action, *args, **kwargs): + """Derive preservation from this body, never an ambient caller exception.""" + try: + yield + except BaseException: + _preset_cleanup(action, *args, preserve=True, **kwargs) + raise + else: + _preset_cleanup(action, *args, **kwargs) + + +def _preset_platform(): + if IS_WINDOWS or fcntl is None or sys.platform not in ("darwin", "linux"): + raise _preset_error("preset_platform_unsupported") + + +def _preset_darwin_mount_flags(fd): + """Use the explicit statfs64 ABI, never the ambiguous legacy statfs ABI.""" + import ctypes + try: + class Statfs64(ctypes.Structure): + # sys/mount.h __DARWIN_STRUCT_STATFS64. The reserved tail remains + # 32 bytes across the f_flags_ext addition; no string is inspected. + _fields_ = [ + ("f_bsize", ctypes.c_uint32), ("f_iosize", ctypes.c_int32), + ("f_blocks", ctypes.c_uint64), ("f_bfree", ctypes.c_uint64), + ("f_bavail", ctypes.c_uint64), ("f_files", ctypes.c_uint64), + ("f_ffree", ctypes.c_uint64), ("f_fsid", ctypes.c_int32 * 2), + ("f_owner", ctypes.c_uint32), ("f_type", ctypes.c_uint32), + ("f_flags", ctypes.c_uint32), ("f_fssubtype", ctypes.c_uint32), + ("f_fstypename", ctypes.c_char * 16), + ("f_mntonname", ctypes.c_char * 1024), + ("f_mntfromname", ctypes.c_char * 1024), + ("f_reserved", ctypes.c_uint32 * 8), + ] + if (ctypes.sizeof(ctypes.c_void_p) != 8 or ctypes.sizeof(Statfs64) != 2168 + or Statfs64.f_flags.offset != 64): + raise _preset_error("preset_mount_unverifiable") + api = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + api.fstatfs64.argtypes = [ctypes.c_int, ctypes.POINTER(Statfs64)] + api.fstatfs64.restype = ctypes.c_int + value = Statfs64() + if api.fstatfs64(fd, ctypes.byref(value)) != 0: + raise _preset_error("preset_mount_unverifiable") + return value.f_flags + except (OSError, AttributeError, TypeError, ValueError, OverflowError): + raise _preset_error("preset_mount_unverifiable") from None + + +def _preset_check_ownership(fd): + if sys.platform == "darwin" and _preset_darwin_mount_flags(fd) & 0x00200000: + # MNT_IGNORE_OWNERSHIP can synthesize owner/mode metadata. Do not call + # such metadata an owner-only store, even if its ACL is empty. + raise _preset_error("preset_ownership_unenforced") + + +def _preset_check_fd(fd, *, private, directory=False): + _preset_check_ownership(fd) + _preset_check_acl(fd, private=private, directory=directory) + + +def _preset_darwin_acl_tags(fd): + """Read only Darwin's extended ACL, without paths or a child process. + + Darwin returns 0 for an entry, -1/EINVAL at exhaustion (unlike Linux). + Validate the independent ACL handle first; release it on every path. + Constants and prototypes are from the macOS SDK sys/acl.h. + """ + import ctypes + try: + api = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + api.acl_get_fd_np.argtypes = [ctypes.c_int, ctypes.c_int] + api.acl_get_fd_np.restype = ctypes.c_void_p + api.acl_valid.argtypes = [ctypes.c_void_p] + api.acl_valid.restype = ctypes.c_int + api.acl_get_entry.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(ctypes.c_void_p)] + api.acl_get_entry.restype = ctypes.c_int + api.acl_get_tag_type.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)] + api.acl_get_tag_type.restype = ctypes.c_int + api.acl_free.argtypes = [ctypes.c_void_p] + api.acl_free.restype = ctypes.c_int + ctypes.set_errno(0) + acl = api.acl_get_fd_np(fd, 0x100) # ACL_TYPE_EXTENDED + if not acl: + # Darwin's fd getter returns NULL/ENOENT when FILESEC_ACL is absent + # (Libc posix1e/acl_file.c and gen/filesec.c), not an empty object. + if ctypes.get_errno() == errno.ENOENT: + return () + raise _preset_error("preset_acl_unverifiable") + acl_failed = False + try: + if api.acl_valid(acl) != 0: + raise _preset_error("preset_acl_unverifiable") + tags = [] + for index in range(129): + entry = ctypes.c_void_p() + ctypes.set_errno(0) + result = api.acl_get_entry(acl, 0 if index == 0 else -1, ctypes.byref(entry)) + if result == -1 and ctypes.get_errno() == errno.EINVAL: + return tuple(tags) + if result != 0 or not entry.value or index == 128: + raise _preset_error("preset_acl_unverifiable") + tag = ctypes.c_int() + if api.acl_get_tag_type(entry, ctypes.byref(tag)) != 0 or tag.value not in (1, 2): + raise _preset_error("preset_acl_unverifiable") + tags.append(tag.value) # ACL_EXTENDED_ALLOW=1, DENY=2 + raise _preset_error("preset_acl_unverifiable") + except BaseException: + acl_failed = True + raise + finally: + def release_acl(): + if api.acl_free(acl) != 0: + raise _preset_error("preset_acl_unverifiable") + _preset_cleanup(release_acl, preserve=acl_failed) + except (OSError, AttributeError, TypeError, ValueError, OverflowError): + raise _preset_error("preset_acl_unverifiable") from None + + +def _preset_check_acl(fd, *, private, directory=False): + """Conservative ACL policy, not an effective-permissions interpreter.""" + if sys.platform == "darwin": + tags = _preset_darwin_acl_tags(fd) + if any(tag not in (1, 2) for tag in tags): + raise _preset_error("preset_acl_unverifiable") + if (private and tags) or (not private and 1 in tags): + raise _preset_error("preset_unsafe_acl") + return + if sys.platform == "linux": + try: + for attribute in (("system.posix_acl_access", "system.posix_acl_default") + if directory else ("system.posix_acl_access",)): + try: + os.getxattr(fd, attribute) + except OSError as error: + if error.errno == errno.ENODATA: + continue + raise _preset_error("preset_acl_unverifiable") from None + raise _preset_error("preset_unsafe_acl") + except (AttributeError, TypeError, ValueError): + raise _preset_error("preset_acl_unverifiable") from None + return + raise _preset_error("preset_platform_unsupported") + + +def _preset_name(name): + if not isinstance(name, str) or re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", name) is None: + raise _preset_error("preset_invalid_name") + + +def _preset_string(value, limit): + if (not isinstance(value, str) or not value or len(value) > limit + or any(ord(c) < 32 or 127 <= ord(c) <= 159 or 0xD800 <= ord(c) <= 0xDFFF for c in value)): + raise _preset_error("preset_invalid_route") + + +def preset_validate_entry(entry): + """Return a fresh allowlisted route, without executing or resolving it.""" + if not isinstance(entry, dict): + raise _preset_error("preset_invalid_route") + transport, agent = entry.get("transport"), entry.get("agent") + if transport not in ("local", "ssh") or agent not in ("claude", "codex"): + raise _preset_error("preset_invalid_route") + expected = {"transport", "agent"} + if transport == "ssh": + expected.add("host") + if agent == "codex": + expected.add("codexHome") + if set(entry) != expected: + raise _preset_error("preset_invalid_route") + if transport == "ssh": + host = entry["host"] + _preset_string(host, 1024) + if (host.count("@") > 1 or ("@" in host and not all(host.split("@"))) + or ("@" in host and ":" in host.split("@", 1)[0])): + raise _preset_error("preset_invalid_route") + try: + check_ssh_argument(host, "--host") + except CcPeerError: + raise _preset_error("preset_invalid_route") from None + if agent == "codex": + home = entry["codexHome"] + _preset_string(home, 4096) + if not PurePosixPath(home).is_absolute() or home.startswith("//") or "\\" in home: + raise _preset_error("preset_invalid_route") + return dict(entry) + + +def preset_revision(name, entry): + """Change token over exact validated values, NOT identity or permission.""" + _preset_name(name) + route = preset_validate_entry(entry) + raw = json.dumps({"schemaVersion": 1, "name": name, "route": route}, + sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii") + return "dp1:" + hashlib.sha256(raw).hexdigest() + + +def _preset_pairs(pairs): + result = {} + for key, value in pairs: + if key in result: + raise _preset_error("preset_corrupt") + result[key] = value + return result + + +def _preset_decode(raw): + try: + value = json.loads(raw.decode("utf-8", "strict"), object_pairs_hook=_preset_pairs, + parse_constant=lambda _: (_ for _ in ()).throw(ValueError())) + if (not isinstance(value, dict) or set(value) != {"schemaVersion", "presets"} + or type(value["schemaVersion"]) is not int or value["schemaVersion"] != 1 + or not isinstance(value["presets"], dict)): + raise ValueError() + entries = value["presets"] + if len(entries) > PRESET_MAX_ENTRIES: + raise _preset_error("preset_capacity") + validated = {} + for name, entry in entries.items(): + _preset_name(name) + validated[name] = preset_validate_entry(entry) + return validated + except (UnicodeError, ValueError, TypeError, RecursionError): + raise _preset_error("preset_corrupt") from None + + +def _preset_path(): + configured = os.environ.get("XDG_CONFIG_HOME") + raw_parent = configured if configured is not None else str(Path.home() / ".config") + if (not raw_parent or len(raw_parent) > 4096 + or any(ord(c) < 32 or 127 <= ord(c) <= 159 or 0xD800 <= ord(c) <= 0xDFFF for c in raw_parent)): + raise _preset_error("preset_invalid_config_root") + parent = Path(raw_parent) + if not parent.is_absolute() or ".." in parent.parts: + raise _preset_error("preset_invalid_config_root") + return parent / "session-peer" + + +def _preset_identity(info): + return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns) + + +def _preset_private(info, directory=False): + if (info.st_uid != os.geteuid() or info.st_mode & (0o7077 if directory else 0o7177) + or not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)) + or (not directory and info.st_nlink != 1)): + raise _preset_error("preset_unsafe_storage") + + +def _preset_directory(create=False): + """Open every component without following symlinks; retain the app dir fd. + + App files are relative to this descriptor. Ancestors must not be writable by + other users (a root-owned sticky temporary ancestor is permitted). This is + local same-user configuration, not protection from the user's own processes. + """ + root = _preset_path() + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_NONBLOCK + fd = os.open("/", flags) + directory_failed = False + try: + _preset_check_fd(fd, private=False, directory=True) + for index, part in enumerate(root.parts[1:]): + final = index == len(root.parts[1:]) - 1 + try: + child = os.open(part, flags, dir_fd=fd) + except FileNotFoundError: + if not create: + return None + try: + os.mkdir(part, 0o700, dir_fd=fd) + except FileExistsError: + pass + child = os.open(part, flags, dir_fd=fd) + try: + info = os.fstat(child) + if final: + _preset_private(info, directory=True) + else: + if info.st_uid not in (0, os.geteuid()): + raise _preset_error("preset_unsafe_storage") + if info.st_mode & 0o022 and not (info.st_uid == 0 and info.st_mode & stat.S_ISVTX): + raise _preset_error("preset_unsafe_storage") + # The immediate configuration parent must belong to this user. + if not final and index == len(root.parts[1:]) - 2 and info.st_uid != os.geteuid(): + raise _preset_error("preset_unsafe_storage") + _preset_check_fd(child, private=final, directory=True) + except BaseException: + _preset_cleanup(os.close, child, preserve=True) + raise + previous, fd = fd, child + _preset_cleanup(os.close, previous) + result, fd = fd, None + return result + except BaseException: + directory_failed = True + raise + finally: + if fd is not None: + _preset_cleanup(os.close, fd, preserve=directory_failed) + + +def _preset_read_at(directory, filename="destinations.json", decoder=None): + try: + fd = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + except FileNotFoundError: + return {} + read_failed = False + try: + before = os.fstat(fd) + _preset_private(before) + _preset_check_fd(fd, private=True) + if before.st_size > PRESET_MAX_BYTES: + raise _preset_error("preset_capacity") + raw = bytearray() + while len(raw) <= PRESET_MAX_BYTES: + chunk = os.read(fd, min(8192, PRESET_MAX_BYTES + 1 - len(raw))) + if not chunk: + break + raw.extend(chunk) + if len(raw) > PRESET_MAX_BYTES: + raise _preset_error("preset_capacity") + after = os.fstat(fd) + _preset_check_fd(fd, private=True) + current = os.stat(filename, dir_fd=directory, follow_symlinks=False) + if _preset_identity(before) != _preset_identity(after) or _preset_identity(after) != _preset_identity(current): + raise _preset_error("preset_storage_changed") + return (decoder or _preset_decode)(bytes(raw)) + except BaseException: + read_failed = True + raise + finally: + _preset_cleanup(os.close, fd, preserve=read_failed) + + +def preset_read(): + """Read only: never creates a directory, file or lock.""" + _preset_platform() + directory = None + read_failed = False + try: + directory = _preset_directory() + return {} if directory is None else _preset_read_at(directory) + except OSError: + read_failed = True + raise _preset_error("preset_unsafe_storage") from None + except BaseException: + read_failed = True + raise + finally: + if directory is not None: + _preset_cleanup(os.close, directory, preserve=read_failed) + + +def preset_lookup(name): + _preset_platform() + _preset_name(name) + entries = preset_read() + if name not in entries: + raise _preset_error("preset_not_found") + route = dict(entries[name]) + return route, preset_revision(name, route) + + +def _preset_lock(directory, filename="destinations.lock"): + flags = os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK + try: + fd = os.open(filename, flags | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=directory) + except FileExistsError: + fd = os.open(filename, flags, dir_fd=directory) + try: + _preset_private(os.fstat(fd)) + _preset_check_fd(fd, private=True) + deadline = time.monotonic() + PRESET_LOCK_SECONDS + while True: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + raise _preset_error("preset_busy") + time.sleep(0.01) + info = os.stat(filename, dir_fd=directory, follow_symlinks=False) + if _preset_identity(info) != _preset_identity(os.fstat(fd)): + raise _preset_error("preset_storage_changed") + _preset_check_fd(fd, private=True) + return fd + except BaseException: + _preset_cleanup(os.close, fd, preserve=True) + raise + + +def _preset_entry_identity(directory, filename="destinations.json"): + try: + info = os.stat(filename, dir_fd=directory, follow_symlinks=False) + except FileNotFoundError: + return None + _preset_private(info) + fd = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + identity_failed = False + try: + opened = os.fstat(fd) + if _preset_identity(opened) != _preset_identity(info): + raise _preset_error("preset_storage_changed") + _preset_check_fd(fd, private=True) + except BaseException: + identity_failed = True + raise + finally: + _preset_cleanup(os.close, fd, preserve=identity_failed) + return _preset_identity(info) + + +def preset_mutate(action, name, entry=None, expected_revision=None, *, _storage=None): + """Explicit management only; serialize writers and CAS replace/remove.""" + _preset_platform() + _preset_name(name) + if action not in ("add", "replace", "remove"): + raise _preset_error("preset_invalid_action") + validate_entry = _storage["validate_entry"] if _storage else preset_validate_entry + revision = _storage["revision"] if _storage else preset_revision + revision_prefix = _storage["revision_prefix"] if _storage else "dp1" + filename = _storage["filename"] if _storage else "destinations.json" + route = validate_entry(entry) if action != "remove" else None + if action == "remove" and entry is not None: + raise _preset_error("preset_invalid_route") + if action in ("replace", "remove") and (not isinstance(expected_revision, str) + or re.fullmatch(revision_prefix + r":[0-9a-f]{64}", expected_revision) is None): + raise _preset_error("preset_revision_required") + if action == "add" and expected_revision is not None: + raise _preset_error("preset_invalid_revision") + directory = lock = None + temporary = None + committed = False + mutation_failed = False + try: + directory = _preset_directory(create=action == "add") + if directory is None: + raise _preset_error("preset_not_found") + lock = _preset_lock(directory, _storage["lockname"]) if _storage else _preset_lock(directory) + def identity(): + return _preset_entry_identity(directory, filename) if _storage else _preset_entry_identity(directory) + original_identity = identity() + entries = _preset_read_at(directory, filename, _storage["decoder"]) if _storage else _preset_read_at(directory) + if identity() != original_identity: + raise _preset_error("preset_storage_changed") + if action == "add": + if name in entries: + raise _preset_error("preset_already_exists") + else: + if name not in entries: + raise _preset_error("preset_not_found") + if revision(name, entries[name]) != expected_revision: + raise _preset_error("preset_revision_mismatch") + previous = entries.get(name) + if action == "remove": + del entries[name] + else: + entries[name] = route + if _storage: + _storage["validate_entries"](entries) + raw = json.dumps({"schemaVersion": 1, _storage["collection"] if _storage else "presets": entries}, sort_keys=True, + separators=(",", ":"), ensure_ascii=True).encode("ascii") + b"\n" + if len(entries) > PRESET_MAX_ENTRIES or len(raw) > PRESET_MAX_BYTES: + raise _preset_error("preset_capacity") + # Reject ancestry replacement before committing through the held fd. + check = _preset_directory() + check_failed = False + try: + if check is None or (os.fstat(check).st_dev, os.fstat(check).st_ino) != ( + os.fstat(directory).st_dev, os.fstat(directory).st_ino): + raise _preset_error("preset_storage_changed") + except BaseException: + check_failed = True + raise + finally: + if check is not None: + _preset_cleanup(os.close, check, preserve=check_failed) + temporary = "." + filename.removesuffix(".json") + "-" + uuid.uuid4().hex + ".tmp" + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=directory) + temporary_failed = False + try: + stream = None + stream_failed = False + try: + stream = os.fdopen(fd, "wb") + _preset_private(os.fstat(stream.fileno())) + _preset_check_fd(stream.fileno(), private=True) + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + _preset_check_fd(stream.fileno(), private=True) + except BaseException: + stream_failed = True + raise + finally: + if stream is not None: + _preset_cleanup(stream.close, preserve=stream_failed) + else: + _preset_cleanup(os.close, fd, preserve=stream_failed) + if identity() != original_identity: + raise _preset_error("preset_storage_changed") + os.replace(temporary, filename, src_dir_fd=directory, dst_dir_fd=directory) + temporary = None + committed = True + os.fsync(directory) + except BaseException: + temporary_failed = True + raise + finally: + if temporary is not None: + def remove_temporary(): + try: + os.unlink(temporary, dir_fd=directory) + except FileNotFoundError: + # Rename can finish immediately before an interrupt. + pass + _preset_cleanup(remove_temporary, preserve=temporary_failed) + temporary = None + result_route = previous if action == "remove" else route + result = {"name": name, _storage["result_key"] if _storage else "route": dict(result_route), + "revision": revision(name, result_route)} + if action == "remove": + result["removed"] = True + return result + except OSError: + mutation_failed = True + if committed: + raise CcPeerError("preset commit unconfirmed; inspect configuration before retrying", + {"reason": "preset_commit_unconfirmed"}) from None + raise _preset_error("preset_unsafe_storage") from None + except BaseException: + mutation_failed = True + raise + finally: + def close_storage(): + def close_directory(): + if directory is not None: + os.close(directory) + with _preset_cleanup_scope(close_directory): + if lock is not None: + _preset_cleanup(os.close, lock) + try: + _preset_cleanup(close_storage, preserve=mutation_failed) + except CcPeerError: + if committed: + raise CcPeerError("preset commit unconfirmed; inspect configuration before retrying", + {"reason": "preset_commit_unconfirmed"}) from None + raise + + +class PresetSingleUseAction(argparse.Action): + """A route selector never silently replaces an earlier explicit value.""" + def __call__(self, parser, namespace, values, option_string=None): + seen = getattr(namespace, "_preset_single_use", []) + if self.dest in seen: + parser.error(self.option_strings[0] + " may be specified only once") + namespace._preset_single_use = [*seen, self.dest] + setattr(namespace, self.dest, values) + + +def preset_explicit_options(parser): + """Collect only actual argparse actions, never scan message values.""" + explicit = set(getattr(parser, "_explicit_option_strings", ())) + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + for subparser in action.choices.values(): + explicit.update(preset_explicit_options(subparser)) + return explicit + + +def preset_operation_failed(args): + """Unexpected private-store failures never reflect diagnostics or paths.""" + message = "Route preset operation failed" + if args.json: + print(json.dumps(json_result(args.command, { + "error": message, "reason": "preset_operation_failed", + }, ok=False), ensure_ascii=False)) + else: + print("session-peer: " + message, file=sys.stderr) + return EXIT_ERROR + + +def prepare_preset_route(parser, args, raw_argv): + name = getattr(args, "preset", None) + revision = getattr(args, "preset_revision", None) + if revision is not None and name is None: + parser.error("--preset-revision requires --preset") + if name is None: + return + conflicting = ( + "--host", "--codex-home", "--agent", "--ssh-opt", "--codex-bin", + "--device", "--device-state", "--device-route", "--relay-admission-file", + "--relay-login", "--antigravity-home", "--antigravity-generation", + "--request-id", "--wake", "--wake-max-depth", "--wake-timeout", + "--allow-inactive-codex-home", "--correlation-id", "--request-ack", + "--observe-delivery", "--wait-for", "--wait-timeout", "--b64", + "--_handoff-native-context", "--_handoff-native-cutoff-ms", + "--_handoff-native-total-ms", "--_wake-depth", "--_wake-origin", "--_wake-limit", + ) + for flag in conflicting: + if flag in args._preset_explicit_options: + parser.error("--preset conflicts with " + flag) + if str(getattr(args, "to", "")).lower().startswith("session-peer:"): + parser.error("--preset conflicts with a Reply-To URI") + if args.command == "send" and AGENTS.for_target(args.to).name not in ("claude", "codex"): + parser.error("--preset supports only Claude Code and Codex targets") + if args.command == "send" and not args.dry_run and revision is None: + parser.error("actual --preset sends require --preset-revision from preset show or text dry-run stderr") + if IS_WINDOWS: + raise CcPeerError("Route presets are not supported on Windows", {"reason": "preset_platform_unsupported"}) + route, current = preset_lookup(name) + if revision is not None and revision != current: + raise CcPeerError("Route preset changed; review a new dry run", {"reason": "preset_revision_mismatch"}) + if args.command == "send": + if AGENTS.for_target(args.to).name != route["agent"]: + parser.error("--to agent conflicts with the selected preset") + else: + args.agent = route["agent"] + args.host = [route["host"]] if route["transport"] == "ssh" else [] + args.codex_home = route.get("codexHome") + if not args.json: + print(render_preset_route(name, route, current), file=sys.stderr) + + +def render_preset_route(name, route, revision): + name, route, revision = human_text(name), human_text(route), human_text(revision) + where = route.get("host", "this machine") + home = " / home " + route["codexHome"] if "codexHome" in route else "" + return f"Preset {name}: {route['transport']} / {where} / {route['agent']}{home}\nRevision: {revision}\nRoute configuration only; receiver, host identity and delivery are checked separately." + + +def cmd_preset(args): + if IS_WINDOWS: + raise CcPeerError("Route presets are not supported on Windows", {"reason": "preset_platform_unsupported"}) + action = args.preset_action + if action == "list": + routes = preset_read() + items = [{"name": name, "revision": preset_revision(name, route), "route": route} + for name, route in sorted(routes.items())] + payload = {"presets": items} + human = "\n\n".join(render_preset_route(item["name"], item["route"], item["revision"]) for item in items) or "No route presets." + elif action == "show": + route, revision = preset_lookup(args.name) + payload = {"name": args.name, "revision": revision, "route": route} + human = render_preset_route(args.name, route, revision) + else: + route = None + if action in ("add", "replace"): + route = {"transport": "ssh" if args.host is not None else "local", "agent": args.agent} + if args.host is not None: + route["host"] = args.host + if args.codex_home is not None: + route["codexHome"] = args.codex_home + payload = preset_mutate(action, args.name, route, getattr(args, "expect_revision", None)) + human = ("Removed route preset " + human_text(args.name) if action == "remove" + else render_preset_route(payload["name"], payload["route"], payload["revision"])) + emit(args.json, payload, human, command="preset") + return 0 + + +"""Private selected recipients bound to an explicit route revision; no discovery.""" +import base64 +import copy +import unicodedata + + +ADDRESS_MAX_BYTES = 65536 +ADDRESS_MAX_ENTRIES = 128 +# Shared storage helpers enforce these limits on both reads and commits. +assert (ADDRESS_MAX_BYTES, ADDRESS_MAX_ENTRIES) == (PRESET_MAX_BYTES, PRESET_MAX_ENTRIES), "address/preset storage limits differ" + + +def _address_error(reason): + return CcPeerError("address configuration refused: " + reason, {"reason": reason}) + + +def _address_translate(error): + reason = error.details.get("reason", "address_unsafe_storage") + if reason.startswith("preset_"): + reason = "address_" + reason[len("preset_"):] + if reason == "address_commit_unconfirmed": + return CcPeerError("address commit unconfirmed; inspect configuration before retrying", {"reason": reason}) + return _address_error(reason) + + +def address_platform(): + try: + _preset_platform() + except CcPeerError as error: + raise _address_translate(error) from None + + +def _address_name(name): + try: + _preset_name(name) + except CcPeerError as error: + raise _address_translate(error) from None + + +def _address_hash(prefix, value): + raw = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii") + return prefix + ":" + hashlib.sha256(raw).hexdigest() + + +def _address_selection_parts(preset, preset_rev, route, recipient, selection, ssh_host_key): + _address_name(preset) + try: + route = preset_validate_entry(route) + if preset_revision(preset, route) != preset_rev: + raise _address_error("address_preset_revision_mismatch") + except CcPeerError as error: + raise _address_translate(error) from None + if (type(recipient) is not dict or set(recipient) != {"agent", "target", "home"} + or recipient["agent"] != route["agent"]): + raise _address_error("address_invalid_recipient") + agent, target, home = recipient["agent"], recipient["target"], recipient["home"] + pattern = (r"claude:[1-9][0-9]*" if agent == "claude" else + r"codex:[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}") + if (not isinstance(target, str) or len(target) > 128 + or re.fullmatch(pattern, target) is None): + raise _address_error("address_invalid_recipient") + if agent == "claude": + if home is not None: + raise _address_error("address_invalid_recipient") + else: + if (not isinstance(home, str) or not home or len(home) > 4096 + or any(ord(c) < 32 or 127 <= ord(c) <= 159 or 0xD800 <= ord(c) <= 0xDFFF for c in home) + or not PurePosixPath(home).is_absolute() or home.startswith("//") or "\\" in home + or ".." in PurePosixPath(home).parts or str(PurePosixPath(home)) != home): + raise _address_error("address_invalid_recipient") + if (type(selection) is not dict or set(selection) != { + "generationStatus", "targetGeneration", "writerRevision", "observationRevision"}): + raise _address_error("address_invalid_selection") + if agent == "claude": + if (selection["generationStatus"] != "available" or selection["writerRevision"] is not None + or not isinstance(selection["targetGeneration"], str) + or re.fullmatch(r"tg1:[0-9a-f]{64}", selection["targetGeneration"]) is None): + raise _address_error("address_invalid_selection") + elif (selection["generationStatus"] != "unsupported" or selection["targetGeneration"] is not None + or not isinstance(selection["writerRevision"], str) + or re.fullmatch(r"aw1:[0-9a-f]{64}", selection["writerRevision"]) is None): + raise _address_error("address_invalid_selection") + if route["transport"] == "local": + if ssh_host_key is not None: + raise _address_error("address_invalid_host_key") + else: + if (not isinstance(ssh_host_key, str) + or re.fullmatch(r"SHA256:[A-Za-z0-9+/]{43}", ssh_host_key) is None): + raise _address_error("address_invalid_host_key") + try: + digest = base64.b64decode(ssh_host_key[7:] + "=", validate=True) + if len(digest) != 32 or base64.b64encode(digest).decode("ascii").rstrip("=") != ssh_host_key[7:]: + raise ValueError() + except (ValueError, TypeError): + raise _address_error("address_invalid_host_key") from None + return {"schemaVersion": 1, "preset": preset, "presetRevision": preset_rev, + "route": route, "recipient": dict(recipient), + "selection": {key: selection[key] for key in ( + "generationStatus", "targetGeneration", "writerRevision")}, + "sshHostKey": ssh_host_key} + + +def address_selection_revision(preset, presetRev, route, recipient, selection, sshHostKey): + """Bind selected evidence to route and peer key, excluding its own digest.""" + return _address_hash("as1", _address_selection_parts( + preset, presetRev, route, recipient, selection, sshHostKey)) + + +def address_validate_label(label): + if (not isinstance(label, str) or not label.strip() or len(label) > 128 + or unicodedata.normalize("NFC", label) != label + or any(unicodedata.category(c) in ("Cc", "Cf", "Cs", "Zl", "Zp") + or ord(c) in (0x034F, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x3164, 0xFFA0) + or 0xFE00 <= ord(c) <= 0xFE0F or 0xE0100 <= ord(c) <= 0xE01EF for c in label)): + raise _address_error("address_invalid_label") + return label + + +def address_validate_entry(entry): + if (type(entry) is not dict or set(entry) != {"bindingId", "label", "preset", + "presetRevision", "route", "recipient", "selection", "sshHostKey"}): + raise _address_error("address_invalid_entry") + binding = entry["bindingId"] + try: + parsed = uuid.UUID(binding) if isinstance(binding, str) else None + if parsed is None or str(parsed) != binding or parsed.version != 4: + raise ValueError() + except (ValueError, AttributeError): + raise _address_error("address_invalid_binding_id") from None + address_validate_label(entry["label"]) + revision = address_selection_revision(entry["preset"], entry["presetRevision"], entry["route"], + entry["recipient"], entry["selection"], entry["sshHostKey"]) + if entry["selection"]["observationRevision"] != revision: + raise _address_error("address_selection_revision_mismatch") + return copy.deepcopy(entry) + + +def address_revision(name, entry): + _address_name(name) + return _address_hash("ab1", {"schemaVersion": 1, "name": name, + "entry": address_validate_entry(entry)}) + + +def _address_validate_entries(entries): + labels = set() + for name, entry in entries.items(): + _address_name(name) + entry = address_validate_entry(entry) + if entry["label"] in labels: + raise _address_error("address_duplicate_label") + labels.add(entry["label"]) + + +def _address_decode(raw): + try: + value = json.loads(raw.decode("utf-8", "strict"), object_pairs_hook=_preset_pairs, + parse_constant=lambda _: (_ for _ in ()).throw(ValueError())) + if (type(value) is not dict or set(value) != {"schemaVersion", "addresses"} + or type(value["schemaVersion"]) is not int or value["schemaVersion"] != 1 + or type(value["addresses"]) is not dict): + raise ValueError() + entries = value["addresses"] + if len(entries) > ADDRESS_MAX_ENTRIES: + raise _address_error("address_capacity") + _address_validate_entries(entries) + return entries + except (UnicodeError, ValueError, TypeError, RecursionError): + raise _address_error("address_corrupt") from None + except CcPeerError as error: + raise _address_translate(error) from None + + +def address_read(): + """Read only; absence never initializes a directory, file, or lock.""" + directory = None + failed = False + try: + _preset_platform() + directory = _preset_directory() + return {} if directory is None else _preset_read_at(directory, "addresses.json", _address_decode) + except OSError: + failed = True + raise _address_error("address_unsafe_storage") from None + except CcPeerError as error: + failed = True + raise _address_translate(error) from None + except BaseException: + failed = True + raise + finally: + if directory is not None: + try: + _preset_cleanup(os.close, directory, preserve=failed) + except CcPeerError as error: + raise _address_translate(error) from None + + +def address_lookup(name): + _address_name(name) + entries = address_read() + if name not in entries: + raise _address_error("address_not_found") + entry = entries[name] + return entry, address_revision(name, entry) + + +def address_mutate(action, name, entry_withoutBindingId=None, expected_revision=None): + _address_name(name) + if action not in ("add", "reselect", "remove"): + raise _address_error("address_invalid_action") + entry = entry_withoutBindingId + if action != "remove": + if type(entry) is not dict or "bindingId" in entry: + raise _address_error("address_invalid_entry") + entry = address_validate_entry({**entry, "bindingId": str(uuid.uuid4())}) + try: + return preset_mutate("replace" if action == "reselect" else action, name, entry, expected_revision, + _storage={"filename": "addresses.json", "lockname": "addresses.lock", "collection": "addresses", + "decoder": _address_decode, "validate_entry": address_validate_entry, + "revision": address_revision, "revision_prefix": "ab1", "result_key": "entry", + "validate_entries": _address_validate_entries}) + except CcPeerError as error: + raise _address_translate(error) from None + + +"""Metadata-only address selection; observation hashes are never native epochs.""" + + +def address_refused(reason): + return CcPeerError("Address selection refused: " + reason, + {"reason": reason, "status": "refused", "submitted": False, + "retryAllowed": False}) + + +def address_writer_revision_argument(value): + if not isinstance(value, str) or re.fullmatch(r"aw1:[0-9a-f]{64}", value) is None: + raise argparse.ArgumentTypeError("expected an address writer observation revision") + return value + + +def address_codex_writer_revision(root, thread_id, resolution): + """Hash existing bounded live-writer observations, without new probes.""" + if not isinstance(resolution, dict) or resolution.get("selected") != str(root): + raise address_refused("address_writer_unavailable") + candidates = resolution.get("candidates") + if not isinstance(candidates, list) or len(candidates) > 256: + raise address_refused("address_writer_unavailable") + selected = [row for row in candidates if isinstance(row, dict) and row.get("codexHome") == str(root)] + if len(selected) != 1: + raise address_refused("address_writer_unavailable") + row = selected[0] + pid, birth = row.get("ownerPid"), row.get("ownerStartTime") + valid_birth = (type(birth) is int and birth > 0) or ( + isinstance(birth, str) and 0 < len(birth) <= 128 + and all(32 <= ord(c) < 127 for c in birth)) + if (row.get("activity") != "live_writer" or row.get("writerLock") != "held" + or row.get("ownerStable") is not True or type(pid) is not int or pid <= 1 + or not valid_birth): + raise address_refused("address_writer_unavailable") + raw = json.dumps([str(root), thread_id, pid, birth], separators=(",", ":"), ensure_ascii=True) + return "aw1:" + hashlib.sha256(raw.encode("ascii")).hexdigest() + + +def require_address_writer(args, root, thread_id, resolution): + expected = getattr(args, "_address_expected_writer", None) + if expected is None: + return + if not isinstance(expected, str) or re.fullmatch(r"aw1:[0-9a-f]{64}", expected) is None: + raise address_refused("address_invalid_writer_revision") + if address_codex_writer_revision(root, thread_id, resolution) != expected: + raise address_refused("address_writer_changed") + + +def address_inspect_selection(args): + address_platform() + agent, targets = args.agent, args.to + if (agent not in ("claude", "codex") or not isinstance(targets, list) + or not 1 <= len(targets) <= 16 + or any(not isinstance(t, str) or not t or len(t) > 256 for t in targets) + or len(set(targets)) != len(targets)): + raise address_refused("address_invalid_selection") + if agent == "codex" and not getattr(args, "codex_home", None): + raise address_refused("address_explicit_home_required") + if any(AGENTS.for_target(target).name != agent for target in targets): + raise address_refused("address_agent_conflict") + rows = discover(include_unreachable=True) if agent == "claude" else None + results = [] + for target in targets: + try: + if agent == "claude": + selector = target.removeprefix("claude:") + if re.fullmatch(r"[0-9]+", selector) is None and getattr(rows, "errors", []): + raise address_refused("address_selection_unavailable") + session = resolve_target(rows, selector) + generation = claude_generation(session) + if generation is None: + raise address_refused("address_generation_unavailable") + recipient = {"agent": agent, "target": "claude:" + str(session["pid"]), "home": None} + selection = {"generationStatus": "available", "targetGeneration": generation, + "writerRevision": None, "observationRevision": None} + else: + thread = codex_thread(target) + root, resolution = resolve_codex_home(args, codex_home(args), thread) + recipient = {"agent": agent, "target": "codex:" + thread, "home": str(root)} + selection = {"generationStatus": "unsupported", "targetGeneration": None, + "writerRevision": address_codex_writer_revision(root, thread, resolution), + "observationRevision": None} + results.append({"requestedTarget": target, "ok": True, "recipient": recipient, + "selection": selection}) + except (CcPeerError, OSError): + # Do not reflect arbitrary native diagnostics or another recipient. + results.append({"requestedTarget": target, "ok": False, + "reason": "address_selection_unavailable"}) + return {"selectionSchemaVersion": 1, "selections": results, + "homeSelector": getattr(args, "codex_home", None) if agent == "codex" else None, + "ok": all(row["ok"] for row in results)} + + +def cmd_address_inspect(args): + payload = address_inspect_selection(args) + emit(True, payload, "", command="address") + return 0 if payload["ok"] else EXIT_ERROR + + +def _address_validate_probe(value, route, targets): + if (not isinstance(value, dict) or type(value.get("selectionSchemaVersion")) is not int + or value["selectionSchemaVersion"] != 1 or type(value.get("ok")) is not bool + or not isinstance(value.get("selections"), list) + or len(value["selections"]) != len(targets) + or "homeSelector" not in value or value["homeSelector"] != route.get("codexHome")): + raise address_refused("address_invalid_inspection") + for row, requested in zip(value["selections"], targets): + if (not isinstance(row, dict) or row.get("requestedTarget") != requested + or type(row.get("ok")) is not bool): + raise address_refused("address_invalid_inspection") + if not row["ok"]: + if set(row) != {"requestedTarget", "ok", "reason"} or row["reason"] != "address_selection_unavailable": + raise address_refused("address_invalid_inspection") + continue + if set(row) != {"requestedTarget", "ok", "recipient", "selection"}: + raise address_refused("address_invalid_inspection") + recipient, selection = row["recipient"], row["selection"] + if (not isinstance(recipient, dict) or set(recipient) != {"agent", "target", "home"} + or recipient["agent"] != route["agent"] or not isinstance(selection, dict) + or set(selection) != {"generationStatus", "targetGeneration", "writerRevision", "observationRevision"} + or selection["observationRevision"] is not None): + raise address_refused("address_invalid_inspection") + target = recipient["target"] + if route["agent"] == "claude": + valid = (isinstance(target, str) and re.fullmatch(r"claude:[1-9][0-9]{0,9}", target) + and int(target.partition(":")[2]) > 1 and recipient["home"] is None + and selection["generationStatus"] == "available" + and isinstance(selection["targetGeneration"], str) + and re.fullmatch(r"tg1:[0-9a-f]{64}", selection["targetGeneration"]) + and selection["writerRevision"] is None) + literal_pid = requested.removeprefix("claude:") + if re.fullmatch(r"[0-9]+", literal_pid): + valid = valid and target == "claude:" + str(int(literal_pid)) + else: + home = recipient["home"] + valid_home = (isinstance(home, str) and 0 < len(home) <= 4096 + and PurePosixPath(home).is_absolute() and not home.startswith("//") + and "\\" not in home and ".." not in PurePosixPath(home).parts + and str(PurePosixPath(home)) == home + and all(not (ord(c) < 32 or 127 <= ord(c) <= 159 or 0xD800 <= ord(c) <= 0xDFFF) for c in home)) + valid = (isinstance(target, str) and target == "codex:" + codex_thread(target) + and target == "codex:" + codex_thread(requested) and valid_home + and selection["generationStatus"] == "unsupported" + and selection["targetGeneration"] is None + and isinstance(selection["writerRevision"], str) + and re.fullmatch(r"aw1:[0-9a-f]{64}", selection["writerRevision"])) + if not valid: + raise address_refused("address_invalid_inspection") + if value["ok"] != all(row["ok"] for row in value["selections"]): + raise address_refused("address_invalid_inspection") + return value["selections"] + + +def address_probe_route(route, targets, expected_ssh_key=None): + route = preset_validate_entry(route) + address_platform() + if (not isinstance(targets, list) or not 1 <= len(targets) <= 16 + or any(not isinstance(t, str) or not t or len(t) > 256 for t in targets) + or len(set(targets)) != len(targets)): + raise address_refused("address_invalid_selection") + if any(AGENTS.for_target(target).name != route["agent"] for target in targets): + raise address_refused("address_agent_conflict") + options = argparse.Namespace(agent=route["agent"], to=targets, codex_home=route.get("codexHome"), + allow_inactive_codex_home=False, wake=False, ssh_opt=[], ssh_identity=True, + require_ssh_host_key=expected_ssh_key) + fingerprint = None + if route["transport"] == "local": + if expected_ssh_key is not None: + raise address_refused("address_invalid_ssh_identity") + try: + result = address_inspect_selection(options) + except (CcPeerError, OSError): + raise address_refused("address_selection_unavailable") from None + else: + argv = ["address", "_inspect", "--agent", route["agent"], "--json"] + for target in targets: + argv.append("--to=" + target) + if "codexHome" in route: + argv.extend(["--codex-home", route["codexHome"]]) + try: + transport = SshTransport(route["host"], options, tailscale_status() or {}) + result = transport.execute(argv) + except (CcPeerError, OSError): + raise address_refused("address_selection_unavailable") from None + proof = result.get("sshIdentity", {}) + fingerprint = proof.get("fingerprint") + if (proof.get("status") != "verified" or not isinstance(fingerprint, str) + or re.fullmatch(SSH_FINGERPRINT_PATTERN, fingerprint) is None + or (expected_ssh_key is not None and fingerprint != expected_ssh_key)): + raise address_refused("address_ssh_identity_unverified") + return {"selections": _address_validate_probe(result, route, targets), "sshHostKey": fingerprint} + + +"""Explicit address lifecycle and immutable per-invocation recipient selection.""" + + +def _address_current_route(entry): + try: + route, revision = preset_lookup(entry["preset"]) + except CcPeerError as error: + if error.details.get("reason") == "preset_not_found": + raise address_refused("address_preset_changed") from None + raise + if route != entry["route"] or revision != entry["presetRevision"]: + raise address_refused("address_preset_changed") + return route, revision + + +def _address_selection(preset, revision, route, target, expected_key=None): + probe = address_probe_route(route, [target], expected_key) + row = probe["selections"][0] + if not row["ok"]: + raise address_refused("address_selection_unavailable") + recipient, selection = dict(row["recipient"]), dict(row["selection"]) + selection["observationRevision"] = address_selection_revision( + preset, revision, route, recipient, selection, probe["sshHostKey"]) + return recipient, selection, probe["sshHostKey"] + + +def _address_render(name, entry, revision): + clean = human_text({"name": name, "entry": entry, "revision": revision}) + item, recipient = clean["entry"], clean["entry"]["recipient"] + route = item["route"] + where = route.get("host", "this machine") + return (f"Address {clean['name']}: {item['label']}\n" + f"{route['transport']} / {where} / {recipient['target']}" + + (f" / home {recipient['home']}" if recipient["home"] else "") + + f"\nRevision: {clean['revision']}\n" + + f"Generation: {item['selection']['generationStatus']}; label is user-supplied, not authenticated identity.") + + +def _address_discover(args): + names = args.address + if (not isinstance(names, list) or not 1 <= len(names) <= 16 + or len(set(names)) != len(names)): + raise address_refused("address_invalid_selection") + groups, records = {}, [] + for name in names: + try: + entry, revision = address_lookup(name) + route, _ = _address_current_route(entry) + key = json.dumps([route, entry["sshHostKey"]], sort_keys=True, separators=(",", ":")) + group = groups.setdefault(key, {"route": route, "key": entry["sshHostKey"], "targets": [], "records": []}) + target = entry["recipient"]["target"] + if target not in group["targets"]: + group["targets"].append(target) + record = {"name": name, "label": entry["label"], "revision": revision, + "route": route, "recipient": entry["recipient"], "generationStatus": entry["selection"]["generationStatus"]} + records.append(record) + group["records"].append((record, entry)) + except (CcPeerError, OSError) as error: + reason = error.details.get("reason") if isinstance(error, CcPeerError) else None + records.append({"name": name, "status": "stale" if reason == "address_preset_changed" else "unverified", + "reason": reason if reason == "address_preset_changed" else "address_unavailable"}) + for group in groups.values(): + try: + probe = address_probe_route(group["route"], group["targets"], group["key"]) + by_target = {row["requestedTarget"]: row for row in probe["selections"]} + for record, entry in group["records"]: + row = by_target[entry["recipient"]["target"]] + if not row["ok"]: + record.update(status="unverified", reason="address_selection_unavailable") + continue + current = address_selection_revision(entry["preset"], entry["presetRevision"], + entry["route"], row["recipient"], row["selection"], probe["sshHostKey"]) + record["status"] = "matched" if current == entry["selection"]["observationRevision"] else "stale" + if record["status"] == "stale": + record["reason"] = "address_selection_changed" + except (CcPeerError, OSError): + for record, _ in group["records"]: + record.update(status="unverified", reason="address_discovery_failed") + payload = {"addresses": records, "ok": all(row["status"] == "matched" for row in records)} + human = "\n\n".join(human_text( + f"{row['name']} / {row.get('label', '')}: {row['status']}" + + (" / " + json.dumps(row["route"], ensure_ascii=False, sort_keys=True) if "route" in row else "") + + (" / " + row["recipient"]["target"] if "recipient" in row else "")) for row in records) + emit(args.json, payload, human, command="address") + return 0 if payload["ok"] else EXIT_ERROR + + +def cmd_address(args): + address_platform() + action = args.address_action + if action in ("add", "show", "reselect", "remove"): + _address_name(args.name) + if action in ("add", "reselect") and re.fullmatch(r"as1:[0-9a-f]{64}", args.expect_selection) is None: + raise address_refused("address_invalid_selection_revision") + if action in ("reselect", "remove") and re.fullmatch(r"ab1:[0-9a-f]{64}", args.expect_revision) is None: + raise address_refused("address_invalid_revision") + if action == "add": + args.label = address_validate_label(unicodedata.normalize("NFC", args.label if args.label is not None else args.name)) + if action == "discover": + return _address_discover(args) + if action == "list": + entries = address_read() + items = [{"name": name, "entry": entry, "revision": address_revision(name, entry)} + for name, entry in sorted(entries.items())] + payload = {"addresses": items} + human = "\n\n".join(_address_render(item["name"], item["entry"], item["revision"]) for item in items) or "No addresses." + elif action == "show": + entry, revision = address_lookup(args.name) + payload = {"name": args.name, "entry": entry, "revision": revision} + human = _address_render(args.name, entry, revision) + elif action == "remove": + payload = address_mutate("remove", args.name, None, args.expect_revision) + human = "Removed address " + human_text(args.name) + else: + previous = None + if action == "reselect": + previous, previous_revision = address_lookup(args.name) + if previous_revision != args.expect_revision: + raise address_refused("address_revision_mismatch") + preset = previous["preset"] + else: + preset = args.preset + route, revision = preset_lookup(preset) + recipient, selection, key = _address_selection(preset, revision, route, args.to) + payload = {"preset": preset, "presetRevision": revision, "route": route, + "recipient": recipient, "selection": selection, "sshHostKey": key} + if action == "resolve": + human = human_text(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + if args.expect_selection != selection["observationRevision"]: + raise address_refused("address_selection_changed") + # A preset edit during network discovery must not create a stale binding. + current_route, current_revision = preset_lookup(preset) + if current_route != route or current_revision != revision: + raise address_refused("address_preset_changed") + label = getattr(args, "label", None) + if label is None: + label = previous["label"] if previous else args.name + payload["label"] = unicodedata.normalize("NFC", label) + payload = address_mutate(action, args.name, payload, getattr(args, "expect_revision", None)) + human = _address_render(payload["name"], payload["entry"], payload["revision"]) + emit(args.json, payload, human, command="address") + return 0 + + +def prepare_address_route(parser, args, raw_argv): + name = getattr(args, "address", None) + revision = getattr(args, "address_revision", None) + confirmation = getattr(args, "confirm_target", None) + if name is None: + if revision is not None or confirmation is not None: + parser.error("--address-revision and --confirm-target require --address") + return + _address_name(name) + if revision is not None and re.fullmatch(r"ab1:[0-9a-f]{64}", revision) is None: + parser.error("--address-revision requires a complete address revision") + conflicting = ("--preset", "--preset-revision", "--to", "--host", "--agent", "--codex-home", + "--codex-bin", "--ssh-opt", "--ssh-config", "--trust-ssh-config", "--ssh-identity", "--require-ssh-host-key", "--device", + "--device-state", "--device-route", "--relay-admission-file", "--relay-login", + "--antigravity-home", "--antigravity-generation", "--target-generation", "--request-id", + "--wake", "--wake-max-depth", "--wake-timeout", "--allow-inactive-codex-home", "--b64", + "--correlation-id", "--request-ack", "--observe-delivery", "--wait-for", "--wait-timeout", + "--_address-writer-revision", "--_handoff-native-context", "--_handoff-native-cutoff-ms", + "--_handoff-native-total-ms", "--_wake-depth", "--_wake-origin", "--_wake-limit") + explicit = preset_explicit_options(parser) + for flag in conflicting: + if flag in explicit: + parser.error("--address conflicts with " + flag) + if not args.dry_run and (revision is None or confirmation is None): + parser.error("actual --address sends require --address-revision and --confirm-target") + address_platform() + entry, current = address_lookup(name) + if revision is not None and revision != current: + raise address_refused("address_revision_mismatch") + if confirmation is not None and confirmation != entry["recipient"]["target"]: + raise address_refused("address_confirmation_mismatch") + route, preset_revision_value = _address_current_route(entry) + recipient, selection, key = _address_selection(entry["preset"], preset_revision_value, + route, entry["recipient"]["target"], entry["sshHostKey"]) + if (recipient != entry["recipient"] or selection != entry["selection"] or key != entry["sshHostKey"]): + raise address_refused("address_selection_changed") + # Recheck local records after discovery, then freeze this invocation's values. + latest, latest_revision = address_lookup(name) + _address_current_route(latest) + if latest_revision != current or latest != entry: + raise address_refused("address_revision_mismatch") + args.to = recipient["target"].removeprefix("claude:") if recipient["agent"] == "claude" else recipient["target"] + args.host = [route["host"]] if route["transport"] == "ssh" else [] + args.codex_home = recipient["home"] + args.target_generation = selection["targetGeneration"] + args._address_expected_writer = selection["writerRevision"] + args.require_ssh_host_key = key + args.ssh_identity = key is not None + if not args.json: + print(_address_render(name, entry, current), file=sys.stderr) + + """Verify release bytes before compiling, running, or installing downloaded code.""" import ast @@ -10826,6 +12154,8 @@ def cmd_send(args: argparse.Namespace) -> int: remote_argv += adapter.remote_options(args) if expected_generation is not None: remote_argv += ["--target-generation", expected_generation] + if getattr(args, "_address_expected_writer", None) is not None: + remote_argv += ["--_address-writer-revision", args._address_expected_writer] if args.dry_run: remote_argv.append("--dry-run") result = transport.execute(remote_argv) @@ -10977,6 +12307,14 @@ def parse_known_args(self, args=None, namespace=None): self.error('pairing_link_argv_forbidden; use --link without a value') return super().parse_known_args(values, namespace) + def _get_values(self, action, arg_strings): + if action.option_strings: + explicit = getattr(self, "_explicit_option_strings", None) + if explicit is None: + explicit = self._explicit_option_strings = set() + explicit.update(action.option_strings) + return super()._get_values(action, arg_strings) + def error(self, message): # Preserve both the message-value and display-only escaping contracts. if 'session-peer://pair/' in message.lower(): @@ -11051,6 +12389,7 @@ def add_common(sub: argparse.ArgumentParser) -> None: add_common(listing) listing.add_argument("--agent", choices=AGENTS.names(), default=None, help="filter by agent (default: all registered adapters)") + listing.add_argument("--preset", action=PresetSingleUseAction, metavar="NAME", help="use one saved local or SSH route, not a saved receiver") home_help = ("select one Codex home on the destination (default: CODEX_HOME or ~/.codex); " "set explicitly for Orca/multiple homes") listing.add_argument("--codex-home", help="list only this destination home (default: known default, CODEX_HOME, Orca and configured homes)") @@ -11081,13 +12420,20 @@ def add_common(sub: argparse.ArgumentParser) -> None: sending = subparsers.add_parser("send", help="send one message to a session") add_common(sending) + sending.add_argument("--preset", action=PresetSingleUseAction, metavar="NAME", help="use one saved local or SSH route, not a saved receiver") + sending.add_argument("--preset-revision", action=PresetSingleUseAction, metavar="REV", help="require the route revision from preset show or text dry-run stderr (required for actual preset sends)") sending.add_argument( - "--to", required=True, metavar="TARGET|REPLY-URI", + "--to", metavar="TARGET|REPLY-URI", help="session name, PID, codex:UUID, antigravity:UUID, or session-peer://v1/reply address", ) sending.add_argument("--codex-home", help=home_help) sending.add_argument("--target-generation", metavar="TOKEN", help="require a previously discovered inbox generation; fail closed if unsupported") + sending.add_argument("--address", action=PresetSingleUseAction, help="explicit local saved-recipient reference") + sending.add_argument("--address-revision", action=PresetSingleUseAction, help="current address revision required for actual submission") + sending.add_argument("--confirm-target", action=PresetSingleUseAction, help="confirm the current canonical recipient, not an override") + sending.add_argument("--_address-writer-revision", dest="_address_expected_writer", type=address_writer_revision_argument, + help=argparse.SUPPRESS) sending.add_argument( "--allow-inactive-codex-home", action="store_true", help="with --codex-home, intentionally queue an inactive thread for a future resume", @@ -11135,6 +12481,22 @@ def add_common(sub: argparse.ArgumentParser) -> None: doctor.add_argument("--explain", action="store_true", help="explain existing diagnostic capabilities in text") sending.set_defaults(func=cmd_send) + preset = subparsers.add_parser("preset", help="manage private local route presets without contacting destinations") + preset_sub = preset.add_subparsers(dest="preset_action", required=True) + for action in ("add", "list", "show", "replace", "remove"): + sub = preset_sub.add_parser(action) + sub.add_argument("--json", action="store_true") + sub.add_argument("--output-format", choices=("text", "json")) + sub.set_defaults(func=cmd_preset, no_update_notice=True) + if action != "list": + sub.add_argument("name") + if action in ("add", "replace"): + sub.add_argument("--host", action=PresetSingleUseAction) + sub.add_argument("--agent", action=PresetSingleUseAction, choices=("claude", "codex"), required=True) + sub.add_argument("--codex-home", action=PresetSingleUseAction) + if action in ("replace", "remove"): + sub.add_argument("--expect-revision", action=PresetSingleUseAction, required=True, metavar="REV") + handoff = subparsers.add_parser("handoff", help="manage private correlated intents without submitting native messages") handoff_sub = handoff.add_subparsers(dest="handoff_action", required=True) for action in ("init", "prepare", "status", "watch", "wait", "confirm"): @@ -11238,6 +12600,33 @@ def add_common(sub: argparse.ArgumentParser) -> None: setup.add_argument('--route', choices=('auto', 'direct', 'relay'), default='auto') setup.add_argument('--seconds', type=int, choices=range(1, 86401), default=3600, metavar='SECONDS') setup.set_defaults(func=cmd_setup, json=False, no_update_notice=True, agent=None, all=False) + addresses = subparsers.add_parser("address", help="explicit local recipient references; not person identity or permissions") + address_sub = addresses.add_subparsers(dest="address_action", required=True) + for action in ("resolve", "add", "list", "show", "discover", "reselect", "remove", "_inspect"): + sub = address_sub.add_parser(action, help=argparse.SUPPRESS if action == "_inspect" else None) + sub.add_argument("--json", action="store_true") + sub.add_argument("--output-format", choices=("text", "json")) + sub.add_argument("--no-update-notice", action="store_true", default=True) + sub.set_defaults(func=cmd_address_inspect if action == "_inspect" else cmd_address, + no_update_notice=True, host=[], ssh_opt=[]) + if action in ("add", "show", "reselect", "remove"): + sub.add_argument("name") + if action in ("resolve", "add"): + sub.add_argument("--preset", required=True, action=PresetSingleUseAction) + if action in ("resolve", "add", "reselect"): + sub.add_argument("--to", required=True, action=PresetSingleUseAction) + if action in ("add", "reselect"): + sub.add_argument("--expect-selection", required=True, action=PresetSingleUseAction) + if action == "add": + sub.add_argument("--label", action=PresetSingleUseAction) + if action in ("reselect", "remove"): + sub.add_argument("--expect-revision", required=True, action=PresetSingleUseAction) + if action == "discover": + sub.add_argument("--address", required=True, action="append") + if action == "_inspect": + sub.add_argument("--agent", required=True, choices=("claude", "codex")) + sub.add_argument("--to", required=True, action="append") + sub.add_argument("--codex-home") return parser @@ -11253,6 +12642,15 @@ def json_error_result(args: argparse.Namespace, payload: dict) -> dict | list[di ]) +def address_operation_failed(args): + message = "Address operation failed; inspect current state before deciding what to do" + if args.json: + print(json.dumps(json_error_result(args, {"error": message, "reason": "address_operation_failed"}), ensure_ascii=False)) + else: + print("session-peer: " + message, file=sys.stderr) + return EXIT_ERROR + + def main(argv: list[str] | None = None) -> int: # Invocation-local authority, never an environment setting, MCP default or # leaked state from a previous/reentrant call in the same interpreter. @@ -11301,6 +12699,8 @@ def _main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(raw_argv) + if getattr(args, "preset", None) is not None: + args._preset_explicit_options = sorted(preset_explicit_options(parser)) output_format = getattr(args, "output_format", None) if args.json and output_format == "text": parser.error("--json conflicts with --output-format text") @@ -11322,6 +12722,42 @@ def _main(argv: list[str] | None = None) -> int: if runtime_notice_preference_routed(args): parser.error("Runtime notice preferences are local only; do not use host or SSH options") args.no_update_notice = True + if args.command == "send": + if not getattr(args, "address", None) and args.to is None: + parser.error("the following arguments are required: --to") + if getattr(args, "address", None) or getattr(args, "address_revision", None) or getattr(args, "confirm_target", None): + args._address_explicit_options = sorted(preset_explicit_options(parser)) + # Route checks precede advisory background work, stdin and transport. An + # opt-out invocation does not even read the presets configuration. + _CLIENT_UPDATE_NOTICE, _SKILL_UPDATE_NOTICES = None, [] + try: + if args.command == "send": + prepare_address_route(parser, args, raw_argv) + if args.command in ("list", "send"): + prepare_preset_route(parser, args, raw_argv) + except KeyboardInterrupt: + return 130 + except CcPeerError as exc: + if args.json: + print(json.dumps(json_error_result(args, {"error": str(exc), **exc.details}), ensure_ascii=False)) + else: + print(human_text(f"session-peer: {exc}"), file=sys.stderr) + return EXIT_ERROR + except Exception: + return address_operation_failed(args) if getattr(args, "address", None) else preset_operation_failed(args) + if args.command in ("preset", "address"): + try: + return args.func(args) + except KeyboardInterrupt: + return 130 + except CcPeerError as exc: + if args.json: + print(json.dumps(json_result(args.command, {"error": str(exc), **exc.details}, ok=False), ensure_ascii=False)) + else: + print(human_text(f"session-peer: {exc}"), file=sys.stderr) + return EXIT_ERROR + except Exception: + return address_operation_failed(args) if args.command == "address" else preset_operation_failed(args) try: prepare_trusted_ssh_config(parser, args) except CcPeerError as exc: diff --git a/session_peer_core/address_cli.py b/session_peer_core/address_cli.py new file mode 100644 index 0000000..bb5a047 --- /dev/null +++ b/session_peer_core/address_cli.py @@ -0,0 +1,195 @@ +"""Explicit address lifecycle and immutable per-invocation recipient selection.""" + + +def _address_current_route(entry): + try: + route, revision = preset_lookup(entry["preset"]) + except CcPeerError as error: + if error.details.get("reason") == "preset_not_found": + raise address_refused("address_preset_changed") from None + raise + if route != entry["route"] or revision != entry["presetRevision"]: + raise address_refused("address_preset_changed") + return route, revision + + +def _address_selection(preset, revision, route, target, expected_key=None): + probe = address_probe_route(route, [target], expected_key) + row = probe["selections"][0] + if not row["ok"]: + raise address_refused("address_selection_unavailable") + recipient, selection = dict(row["recipient"]), dict(row["selection"]) + selection["observationRevision"] = address_selection_revision( + preset, revision, route, recipient, selection, probe["sshHostKey"]) + return recipient, selection, probe["sshHostKey"] + + +def _address_render(name, entry, revision): + clean = human_text({"name": name, "entry": entry, "revision": revision}) + item, recipient = clean["entry"], clean["entry"]["recipient"] + route = item["route"] + where = route.get("host", "this machine") + return (f"Address {clean['name']}: {item['label']}\n" + f"{route['transport']} / {where} / {recipient['target']}" + + (f" / home {recipient['home']}" if recipient["home"] else "") + + f"\nRevision: {clean['revision']}\n" + + f"Generation: {item['selection']['generationStatus']}; label is user-supplied, not authenticated identity.") + + +def _address_discover(args): + names = args.address + if (not isinstance(names, list) or not 1 <= len(names) <= 16 + or len(set(names)) != len(names)): + raise address_refused("address_invalid_selection") + groups, records = {}, [] + for name in names: + try: + entry, revision = address_lookup(name) + route, _ = _address_current_route(entry) + key = json.dumps([route, entry["sshHostKey"]], sort_keys=True, separators=(",", ":")) + group = groups.setdefault(key, {"route": route, "key": entry["sshHostKey"], "targets": [], "records": []}) + target = entry["recipient"]["target"] + if target not in group["targets"]: + group["targets"].append(target) + record = {"name": name, "label": entry["label"], "revision": revision, + "route": route, "recipient": entry["recipient"], "generationStatus": entry["selection"]["generationStatus"]} + records.append(record) + group["records"].append((record, entry)) + except (CcPeerError, OSError) as error: + reason = error.details.get("reason") if isinstance(error, CcPeerError) else None + records.append({"name": name, "status": "stale" if reason == "address_preset_changed" else "unverified", + "reason": reason if reason == "address_preset_changed" else "address_unavailable"}) + for group in groups.values(): + try: + probe = address_probe_route(group["route"], group["targets"], group["key"]) + by_target = {row["requestedTarget"]: row for row in probe["selections"]} + for record, entry in group["records"]: + row = by_target[entry["recipient"]["target"]] + if not row["ok"]: + record.update(status="unverified", reason="address_selection_unavailable") + continue + current = address_selection_revision(entry["preset"], entry["presetRevision"], + entry["route"], row["recipient"], row["selection"], probe["sshHostKey"]) + record["status"] = "matched" if current == entry["selection"]["observationRevision"] else "stale" + if record["status"] == "stale": + record["reason"] = "address_selection_changed" + except (CcPeerError, OSError): + for record, _ in group["records"]: + record.update(status="unverified", reason="address_discovery_failed") + payload = {"addresses": records, "ok": all(row["status"] == "matched" for row in records)} + human = "\n\n".join(human_text( + f"{row['name']} / {row.get('label', '')}: {row['status']}" + + (" / " + json.dumps(row["route"], ensure_ascii=False, sort_keys=True) if "route" in row else "") + + (" / " + row["recipient"]["target"] if "recipient" in row else "")) for row in records) + emit(args.json, payload, human, command="address") + return 0 if payload["ok"] else EXIT_ERROR + + +def cmd_address(args): + address_platform() + action = args.address_action + if action in ("add", "show", "reselect", "remove"): + _address_name(args.name) + if action in ("add", "reselect") and re.fullmatch(r"as1:[0-9a-f]{64}", args.expect_selection) is None: + raise address_refused("address_invalid_selection_revision") + if action in ("reselect", "remove") and re.fullmatch(r"ab1:[0-9a-f]{64}", args.expect_revision) is None: + raise address_refused("address_invalid_revision") + if action == "add": + args.label = address_validate_label(unicodedata.normalize("NFC", args.label if args.label is not None else args.name)) + if action == "discover": + return _address_discover(args) + if action == "list": + entries = address_read() + items = [{"name": name, "entry": entry, "revision": address_revision(name, entry)} + for name, entry in sorted(entries.items())] + payload = {"addresses": items} + human = "\n\n".join(_address_render(item["name"], item["entry"], item["revision"]) for item in items) or "No addresses." + elif action == "show": + entry, revision = address_lookup(args.name) + payload = {"name": args.name, "entry": entry, "revision": revision} + human = _address_render(args.name, entry, revision) + elif action == "remove": + payload = address_mutate("remove", args.name, None, args.expect_revision) + human = "Removed address " + human_text(args.name) + else: + previous = None + if action == "reselect": + previous, previous_revision = address_lookup(args.name) + if previous_revision != args.expect_revision: + raise address_refused("address_revision_mismatch") + preset = previous["preset"] + else: + preset = args.preset + route, revision = preset_lookup(preset) + recipient, selection, key = _address_selection(preset, revision, route, args.to) + payload = {"preset": preset, "presetRevision": revision, "route": route, + "recipient": recipient, "selection": selection, "sshHostKey": key} + if action == "resolve": + human = human_text(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + if args.expect_selection != selection["observationRevision"]: + raise address_refused("address_selection_changed") + # A preset edit during network discovery must not create a stale binding. + current_route, current_revision = preset_lookup(preset) + if current_route != route or current_revision != revision: + raise address_refused("address_preset_changed") + label = getattr(args, "label", None) + if label is None: + label = previous["label"] if previous else args.name + payload["label"] = unicodedata.normalize("NFC", label) + payload = address_mutate(action, args.name, payload, getattr(args, "expect_revision", None)) + human = _address_render(payload["name"], payload["entry"], payload["revision"]) + emit(args.json, payload, human, command="address") + return 0 + + +def prepare_address_route(parser, args, raw_argv): + name = getattr(args, "address", None) + revision = getattr(args, "address_revision", None) + confirmation = getattr(args, "confirm_target", None) + if name is None: + if revision is not None or confirmation is not None: + parser.error("--address-revision and --confirm-target require --address") + return + _address_name(name) + if revision is not None and re.fullmatch(r"ab1:[0-9a-f]{64}", revision) is None: + parser.error("--address-revision requires a complete address revision") + conflicting = ("--preset", "--preset-revision", "--to", "--host", "--agent", "--codex-home", + "--codex-bin", "--ssh-opt", "--ssh-config", "--trust-ssh-config", "--ssh-identity", "--require-ssh-host-key", "--device", + "--device-state", "--device-route", "--relay-admission-file", "--relay-login", + "--antigravity-home", "--antigravity-generation", "--target-generation", "--request-id", + "--wake", "--wake-max-depth", "--wake-timeout", "--allow-inactive-codex-home", "--b64", + "--correlation-id", "--request-ack", "--observe-delivery", "--wait-for", "--wait-timeout", + "--_address-writer-revision", "--_handoff-native-context", "--_handoff-native-cutoff-ms", + "--_handoff-native-total-ms", "--_wake-depth", "--_wake-origin", "--_wake-limit") + explicit = preset_explicit_options(parser) + for flag in conflicting: + if flag in explicit: + parser.error("--address conflicts with " + flag) + if not args.dry_run and (revision is None or confirmation is None): + parser.error("actual --address sends require --address-revision and --confirm-target") + address_platform() + entry, current = address_lookup(name) + if revision is not None and revision != current: + raise address_refused("address_revision_mismatch") + if confirmation is not None and confirmation != entry["recipient"]["target"]: + raise address_refused("address_confirmation_mismatch") + route, preset_revision_value = _address_current_route(entry) + recipient, selection, key = _address_selection(entry["preset"], preset_revision_value, + route, entry["recipient"]["target"], entry["sshHostKey"]) + if (recipient != entry["recipient"] or selection != entry["selection"] or key != entry["sshHostKey"]): + raise address_refused("address_selection_changed") + # Recheck local records after discovery, then freeze this invocation's values. + latest, latest_revision = address_lookup(name) + _address_current_route(latest) + if latest_revision != current or latest != entry: + raise address_refused("address_revision_mismatch") + args.to = recipient["target"].removeprefix("claude:") if recipient["agent"] == "claude" else recipient["target"] + args.host = [route["host"]] if route["transport"] == "ssh" else [] + args.codex_home = recipient["home"] + args.target_generation = selection["targetGeneration"] + args._address_expected_writer = selection["writerRevision"] + args.require_ssh_host_key = key + args.ssh_identity = key is not None + if not args.json: + print(_address_render(name, entry, current), file=sys.stderr) diff --git a/session_peer_core/address_selection.py b/session_peer_core/address_selection.py new file mode 100644 index 0000000..899f31f --- /dev/null +++ b/session_peer_core/address_selection.py @@ -0,0 +1,191 @@ +"""Metadata-only address selection; observation hashes are never native epochs.""" + + +def address_refused(reason): + return CcPeerError("Address selection refused: " + reason, + {"reason": reason, "status": "refused", "submitted": False, + "retryAllowed": False}) + + +def address_writer_revision_argument(value): + if not isinstance(value, str) or re.fullmatch(r"aw1:[0-9a-f]{64}", value) is None: + raise argparse.ArgumentTypeError("expected an address writer observation revision") + return value + + +def address_codex_writer_revision(root, thread_id, resolution): + """Hash existing bounded live-writer observations, without new probes.""" + if not isinstance(resolution, dict) or resolution.get("selected") != str(root): + raise address_refused("address_writer_unavailable") + candidates = resolution.get("candidates") + if not isinstance(candidates, list) or len(candidates) > 256: + raise address_refused("address_writer_unavailable") + selected = [row for row in candidates if isinstance(row, dict) and row.get("codexHome") == str(root)] + if len(selected) != 1: + raise address_refused("address_writer_unavailable") + row = selected[0] + pid, birth = row.get("ownerPid"), row.get("ownerStartTime") + valid_birth = (type(birth) is int and birth > 0) or ( + isinstance(birth, str) and 0 < len(birth) <= 128 + and all(32 <= ord(c) < 127 for c in birth)) + if (row.get("activity") != "live_writer" or row.get("writerLock") != "held" + or row.get("ownerStable") is not True or type(pid) is not int or pid <= 1 + or not valid_birth): + raise address_refused("address_writer_unavailable") + raw = json.dumps([str(root), thread_id, pid, birth], separators=(",", ":"), ensure_ascii=True) + return "aw1:" + hashlib.sha256(raw.encode("ascii")).hexdigest() + + +def require_address_writer(args, root, thread_id, resolution): + expected = getattr(args, "_address_expected_writer", None) + if expected is None: + return + if not isinstance(expected, str) or re.fullmatch(r"aw1:[0-9a-f]{64}", expected) is None: + raise address_refused("address_invalid_writer_revision") + if address_codex_writer_revision(root, thread_id, resolution) != expected: + raise address_refused("address_writer_changed") + + +def address_inspect_selection(args): + address_platform() + agent, targets = args.agent, args.to + if (agent not in ("claude", "codex") or not isinstance(targets, list) + or not 1 <= len(targets) <= 16 + or any(not isinstance(t, str) or not t or len(t) > 256 for t in targets) + or len(set(targets)) != len(targets)): + raise address_refused("address_invalid_selection") + if agent == "codex" and not getattr(args, "codex_home", None): + raise address_refused("address_explicit_home_required") + if any(AGENTS.for_target(target).name != agent for target in targets): + raise address_refused("address_agent_conflict") + rows = discover(include_unreachable=True) if agent == "claude" else None + results = [] + for target in targets: + try: + if agent == "claude": + selector = target.removeprefix("claude:") + if re.fullmatch(r"[0-9]+", selector) is None and getattr(rows, "errors", []): + raise address_refused("address_selection_unavailable") + session = resolve_target(rows, selector) + generation = claude_generation(session) + if generation is None: + raise address_refused("address_generation_unavailable") + recipient = {"agent": agent, "target": "claude:" + str(session["pid"]), "home": None} + selection = {"generationStatus": "available", "targetGeneration": generation, + "writerRevision": None, "observationRevision": None} + else: + thread = codex_thread(target) + root, resolution = resolve_codex_home(args, codex_home(args), thread) + recipient = {"agent": agent, "target": "codex:" + thread, "home": str(root)} + selection = {"generationStatus": "unsupported", "targetGeneration": None, + "writerRevision": address_codex_writer_revision(root, thread, resolution), + "observationRevision": None} + results.append({"requestedTarget": target, "ok": True, "recipient": recipient, + "selection": selection}) + except (CcPeerError, OSError): + # Do not reflect arbitrary native diagnostics or another recipient. + results.append({"requestedTarget": target, "ok": False, + "reason": "address_selection_unavailable"}) + return {"selectionSchemaVersion": 1, "selections": results, + "homeSelector": getattr(args, "codex_home", None) if agent == "codex" else None, + "ok": all(row["ok"] for row in results)} + + +def cmd_address_inspect(args): + payload = address_inspect_selection(args) + emit(True, payload, "", command="address") + return 0 if payload["ok"] else EXIT_ERROR + + +def _address_validate_probe(value, route, targets): + if (not isinstance(value, dict) or type(value.get("selectionSchemaVersion")) is not int + or value["selectionSchemaVersion"] != 1 or type(value.get("ok")) is not bool + or not isinstance(value.get("selections"), list) + or len(value["selections"]) != len(targets) + or "homeSelector" not in value or value["homeSelector"] != route.get("codexHome")): + raise address_refused("address_invalid_inspection") + for row, requested in zip(value["selections"], targets): + if (not isinstance(row, dict) or row.get("requestedTarget") != requested + or type(row.get("ok")) is not bool): + raise address_refused("address_invalid_inspection") + if not row["ok"]: + if set(row) != {"requestedTarget", "ok", "reason"} or row["reason"] != "address_selection_unavailable": + raise address_refused("address_invalid_inspection") + continue + if set(row) != {"requestedTarget", "ok", "recipient", "selection"}: + raise address_refused("address_invalid_inspection") + recipient, selection = row["recipient"], row["selection"] + if (not isinstance(recipient, dict) or set(recipient) != {"agent", "target", "home"} + or recipient["agent"] != route["agent"] or not isinstance(selection, dict) + or set(selection) != {"generationStatus", "targetGeneration", "writerRevision", "observationRevision"} + or selection["observationRevision"] is not None): + raise address_refused("address_invalid_inspection") + target = recipient["target"] + if route["agent"] == "claude": + valid = (isinstance(target, str) and re.fullmatch(r"claude:[1-9][0-9]{0,9}", target) + and int(target.partition(":")[2]) > 1 and recipient["home"] is None + and selection["generationStatus"] == "available" + and isinstance(selection["targetGeneration"], str) + and re.fullmatch(r"tg1:[0-9a-f]{64}", selection["targetGeneration"]) + and selection["writerRevision"] is None) + literal_pid = requested.removeprefix("claude:") + if re.fullmatch(r"[0-9]+", literal_pid): + valid = valid and target == "claude:" + str(int(literal_pid)) + else: + home = recipient["home"] + valid_home = (isinstance(home, str) and 0 < len(home) <= 4096 + and PurePosixPath(home).is_absolute() and not home.startswith("//") + and "\\" not in home and ".." not in PurePosixPath(home).parts + and str(PurePosixPath(home)) == home + and all(not (ord(c) < 32 or 127 <= ord(c) <= 159 or 0xD800 <= ord(c) <= 0xDFFF) for c in home)) + valid = (isinstance(target, str) and target == "codex:" + codex_thread(target) + and target == "codex:" + codex_thread(requested) and valid_home + and selection["generationStatus"] == "unsupported" + and selection["targetGeneration"] is None + and isinstance(selection["writerRevision"], str) + and re.fullmatch(r"aw1:[0-9a-f]{64}", selection["writerRevision"])) + if not valid: + raise address_refused("address_invalid_inspection") + if value["ok"] != all(row["ok"] for row in value["selections"]): + raise address_refused("address_invalid_inspection") + return value["selections"] + + +def address_probe_route(route, targets, expected_ssh_key=None): + route = preset_validate_entry(route) + address_platform() + if (not isinstance(targets, list) or not 1 <= len(targets) <= 16 + or any(not isinstance(t, str) or not t or len(t) > 256 for t in targets) + or len(set(targets)) != len(targets)): + raise address_refused("address_invalid_selection") + if any(AGENTS.for_target(target).name != route["agent"] for target in targets): + raise address_refused("address_agent_conflict") + options = argparse.Namespace(agent=route["agent"], to=targets, codex_home=route.get("codexHome"), + allow_inactive_codex_home=False, wake=False, ssh_opt=[], ssh_identity=True, + require_ssh_host_key=expected_ssh_key) + fingerprint = None + if route["transport"] == "local": + if expected_ssh_key is not None: + raise address_refused("address_invalid_ssh_identity") + try: + result = address_inspect_selection(options) + except (CcPeerError, OSError): + raise address_refused("address_selection_unavailable") from None + else: + argv = ["address", "_inspect", "--agent", route["agent"], "--json"] + for target in targets: + argv.append("--to=" + target) + if "codexHome" in route: + argv.extend(["--codex-home", route["codexHome"]]) + try: + transport = SshTransport(route["host"], options, tailscale_status() or {}) + result = transport.execute(argv) + except (CcPeerError, OSError): + raise address_refused("address_selection_unavailable") from None + proof = result.get("sshIdentity", {}) + fingerprint = proof.get("fingerprint") + if (proof.get("status") != "verified" or not isinstance(fingerprint, str) + or re.fullmatch(SSH_FINGERPRINT_PATTERN, fingerprint) is None + or (expected_ssh_key is not None and fingerprint != expected_ssh_key)): + raise address_refused("address_ssh_identity_unverified") + return {"selections": _address_validate_probe(result, route, targets), "sshHostKey": fingerprint} diff --git a/session_peer_core/addresses.py b/session_peer_core/addresses.py new file mode 100644 index 0000000..50f8667 --- /dev/null +++ b/session_peer_core/addresses.py @@ -0,0 +1,223 @@ +"""Private selected recipients bound to an explicit route revision; no discovery.""" +import base64 +import copy +import unicodedata + + +ADDRESS_MAX_BYTES = 65536 +ADDRESS_MAX_ENTRIES = 128 +# Shared storage helpers enforce these limits on both reads and commits. +assert (ADDRESS_MAX_BYTES, ADDRESS_MAX_ENTRIES) == (PRESET_MAX_BYTES, PRESET_MAX_ENTRIES), "address/preset storage limits differ" + + +def _address_error(reason): + return CcPeerError("address configuration refused: " + reason, {"reason": reason}) + + +def _address_translate(error): + reason = error.details.get("reason", "address_unsafe_storage") + if reason.startswith("preset_"): + reason = "address_" + reason[len("preset_"):] + if reason == "address_commit_unconfirmed": + return CcPeerError("address commit unconfirmed; inspect configuration before retrying", {"reason": reason}) + return _address_error(reason) + + +def address_platform(): + try: + _preset_platform() + except CcPeerError as error: + raise _address_translate(error) from None + + +def _address_name(name): + try: + _preset_name(name) + except CcPeerError as error: + raise _address_translate(error) from None + + +def _address_hash(prefix, value): + raw = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii") + return prefix + ":" + hashlib.sha256(raw).hexdigest() + + +def _address_selection_parts(preset, preset_rev, route, recipient, selection, ssh_host_key): + _address_name(preset) + try: + route = preset_validate_entry(route) + if preset_revision(preset, route) != preset_rev: + raise _address_error("address_preset_revision_mismatch") + except CcPeerError as error: + raise _address_translate(error) from None + if (type(recipient) is not dict or set(recipient) != {"agent", "target", "home"} + or recipient["agent"] != route["agent"]): + raise _address_error("address_invalid_recipient") + agent, target, home = recipient["agent"], recipient["target"], recipient["home"] + pattern = (r"claude:[1-9][0-9]*" if agent == "claude" else + r"codex:[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}") + if (not isinstance(target, str) or len(target) > 128 + or re.fullmatch(pattern, target) is None): + raise _address_error("address_invalid_recipient") + if agent == "claude": + if home is not None: + raise _address_error("address_invalid_recipient") + else: + if (not isinstance(home, str) or not home or len(home) > 4096 + or any(ord(c) < 32 or 127 <= ord(c) <= 159 or 0xD800 <= ord(c) <= 0xDFFF for c in home) + or not PurePosixPath(home).is_absolute() or home.startswith("//") or "\\" in home + or ".." in PurePosixPath(home).parts or str(PurePosixPath(home)) != home): + raise _address_error("address_invalid_recipient") + if (type(selection) is not dict or set(selection) != { + "generationStatus", "targetGeneration", "writerRevision", "observationRevision"}): + raise _address_error("address_invalid_selection") + if agent == "claude": + if (selection["generationStatus"] != "available" or selection["writerRevision"] is not None + or not isinstance(selection["targetGeneration"], str) + or re.fullmatch(r"tg1:[0-9a-f]{64}", selection["targetGeneration"]) is None): + raise _address_error("address_invalid_selection") + elif (selection["generationStatus"] != "unsupported" or selection["targetGeneration"] is not None + or not isinstance(selection["writerRevision"], str) + or re.fullmatch(r"aw1:[0-9a-f]{64}", selection["writerRevision"]) is None): + raise _address_error("address_invalid_selection") + if route["transport"] == "local": + if ssh_host_key is not None: + raise _address_error("address_invalid_host_key") + else: + if (not isinstance(ssh_host_key, str) + or re.fullmatch(r"SHA256:[A-Za-z0-9+/]{43}", ssh_host_key) is None): + raise _address_error("address_invalid_host_key") + try: + digest = base64.b64decode(ssh_host_key[7:] + "=", validate=True) + if len(digest) != 32 or base64.b64encode(digest).decode("ascii").rstrip("=") != ssh_host_key[7:]: + raise ValueError() + except (ValueError, TypeError): + raise _address_error("address_invalid_host_key") from None + return {"schemaVersion": 1, "preset": preset, "presetRevision": preset_rev, + "route": route, "recipient": dict(recipient), + "selection": {key: selection[key] for key in ( + "generationStatus", "targetGeneration", "writerRevision")}, + "sshHostKey": ssh_host_key} + + +def address_selection_revision(preset, presetRev, route, recipient, selection, sshHostKey): + """Bind selected evidence to route and peer key, excluding its own digest.""" + return _address_hash("as1", _address_selection_parts( + preset, presetRev, route, recipient, selection, sshHostKey)) + + +def address_validate_label(label): + if (not isinstance(label, str) or not label.strip() or len(label) > 128 + or unicodedata.normalize("NFC", label) != label + or any(unicodedata.category(c) in ("Cc", "Cf", "Cs", "Zl", "Zp") + or ord(c) in (0x034F, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x3164, 0xFFA0) + or 0xFE00 <= ord(c) <= 0xFE0F or 0xE0100 <= ord(c) <= 0xE01EF for c in label)): + raise _address_error("address_invalid_label") + return label + + +def address_validate_entry(entry): + if (type(entry) is not dict or set(entry) != {"bindingId", "label", "preset", + "presetRevision", "route", "recipient", "selection", "sshHostKey"}): + raise _address_error("address_invalid_entry") + binding = entry["bindingId"] + try: + parsed = uuid.UUID(binding) if isinstance(binding, str) else None + if parsed is None or str(parsed) != binding or parsed.version != 4: + raise ValueError() + except (ValueError, AttributeError): + raise _address_error("address_invalid_binding_id") from None + address_validate_label(entry["label"]) + revision = address_selection_revision(entry["preset"], entry["presetRevision"], entry["route"], + entry["recipient"], entry["selection"], entry["sshHostKey"]) + if entry["selection"]["observationRevision"] != revision: + raise _address_error("address_selection_revision_mismatch") + return copy.deepcopy(entry) + + +def address_revision(name, entry): + _address_name(name) + return _address_hash("ab1", {"schemaVersion": 1, "name": name, + "entry": address_validate_entry(entry)}) + + +def _address_validate_entries(entries): + labels = set() + for name, entry in entries.items(): + _address_name(name) + entry = address_validate_entry(entry) + if entry["label"] in labels: + raise _address_error("address_duplicate_label") + labels.add(entry["label"]) + + +def _address_decode(raw): + try: + value = json.loads(raw.decode("utf-8", "strict"), object_pairs_hook=_preset_pairs, + parse_constant=lambda _: (_ for _ in ()).throw(ValueError())) + if (type(value) is not dict or set(value) != {"schemaVersion", "addresses"} + or type(value["schemaVersion"]) is not int or value["schemaVersion"] != 1 + or type(value["addresses"]) is not dict): + raise ValueError() + entries = value["addresses"] + if len(entries) > ADDRESS_MAX_ENTRIES: + raise _address_error("address_capacity") + _address_validate_entries(entries) + return entries + except (UnicodeError, ValueError, TypeError, RecursionError): + raise _address_error("address_corrupt") from None + except CcPeerError as error: + raise _address_translate(error) from None + + +def address_read(): + """Read only; absence never initializes a directory, file, or lock.""" + directory = None + failed = False + try: + _preset_platform() + directory = _preset_directory() + return {} if directory is None else _preset_read_at(directory, "addresses.json", _address_decode) + except OSError: + failed = True + raise _address_error("address_unsafe_storage") from None + except CcPeerError as error: + failed = True + raise _address_translate(error) from None + except BaseException: + failed = True + raise + finally: + if directory is not None: + try: + _preset_cleanup(os.close, directory, preserve=failed) + except CcPeerError as error: + raise _address_translate(error) from None + + +def address_lookup(name): + _address_name(name) + entries = address_read() + if name not in entries: + raise _address_error("address_not_found") + entry = entries[name] + return entry, address_revision(name, entry) + + +def address_mutate(action, name, entry_withoutBindingId=None, expected_revision=None): + _address_name(name) + if action not in ("add", "reselect", "remove"): + raise _address_error("address_invalid_action") + entry = entry_withoutBindingId + if action != "remove": + if type(entry) is not dict or "bindingId" in entry: + raise _address_error("address_invalid_entry") + entry = address_validate_entry({**entry, "bindingId": str(uuid.uuid4())}) + try: + return preset_mutate("replace" if action == "reselect" else action, name, entry, expected_revision, + _storage={"filename": "addresses.json", "lockname": "addresses.lock", "collection": "addresses", + "decoder": _address_decode, "validate_entry": address_validate_entry, + "revision": address_revision, "revision_prefix": "ab1", "result_key": "entry", + "validate_entries": _address_validate_entries}) + except CcPeerError as error: + raise _address_translate(error) from None diff --git a/session_peer_core/cli.py b/session_peer_core/cli.py index 48cd82e..5636cb2 100644 --- a/session_peer_core/cli.py +++ b/session_peer_core/cli.py @@ -22,6 +22,14 @@ def parse_known_args(self, args=None, namespace=None): self.error('pairing_link_argv_forbidden; use --link without a value') return super().parse_known_args(values, namespace) + def _get_values(self, action, arg_strings): + if action.option_strings: + explicit = getattr(self, "_explicit_option_strings", None) + if explicit is None: + explicit = self._explicit_option_strings = set() + explicit.update(action.option_strings) + return super()._get_values(action, arg_strings) + def error(self, message): # Preserve both the message-value and display-only escaping contracts. if 'session-peer://pair/' in message.lower(): @@ -96,6 +104,7 @@ def add_common(sub: argparse.ArgumentParser) -> None: add_common(listing) listing.add_argument("--agent", choices=AGENTS.names(), default=None, help="filter by agent (default: all registered adapters)") + listing.add_argument("--preset", action=PresetSingleUseAction, metavar="NAME", help="use one saved local or SSH route, not a saved receiver") home_help = ("select one Codex home on the destination (default: CODEX_HOME or ~/.codex); " "set explicitly for Orca/multiple homes") listing.add_argument("--codex-home", help="list only this destination home (default: known default, CODEX_HOME, Orca and configured homes)") @@ -126,13 +135,20 @@ def add_common(sub: argparse.ArgumentParser) -> None: sending = subparsers.add_parser("send", help="send one message to a session") add_common(sending) + sending.add_argument("--preset", action=PresetSingleUseAction, metavar="NAME", help="use one saved local or SSH route, not a saved receiver") + sending.add_argument("--preset-revision", action=PresetSingleUseAction, metavar="REV", help="require the route revision from preset show or text dry-run stderr (required for actual preset sends)") sending.add_argument( - "--to", required=True, metavar="TARGET|REPLY-URI", + "--to", metavar="TARGET|REPLY-URI", help="session name, PID, codex:UUID, antigravity:UUID, or session-peer://v1/reply address", ) sending.add_argument("--codex-home", help=home_help) sending.add_argument("--target-generation", metavar="TOKEN", help="require a previously discovered inbox generation; fail closed if unsupported") + sending.add_argument("--address", action=PresetSingleUseAction, help="explicit local saved-recipient reference") + sending.add_argument("--address-revision", action=PresetSingleUseAction, help="current address revision required for actual submission") + sending.add_argument("--confirm-target", action=PresetSingleUseAction, help="confirm the current canonical recipient, not an override") + sending.add_argument("--_address-writer-revision", dest="_address_expected_writer", type=address_writer_revision_argument, + help=argparse.SUPPRESS) sending.add_argument( "--allow-inactive-codex-home", action="store_true", help="with --codex-home, intentionally queue an inactive thread for a future resume", @@ -180,6 +196,22 @@ def add_common(sub: argparse.ArgumentParser) -> None: doctor.add_argument("--explain", action="store_true", help="explain existing diagnostic capabilities in text") sending.set_defaults(func=cmd_send) + preset = subparsers.add_parser("preset", help="manage private local route presets without contacting destinations") + preset_sub = preset.add_subparsers(dest="preset_action", required=True) + for action in ("add", "list", "show", "replace", "remove"): + sub = preset_sub.add_parser(action) + sub.add_argument("--json", action="store_true") + sub.add_argument("--output-format", choices=("text", "json")) + sub.set_defaults(func=cmd_preset, no_update_notice=True) + if action != "list": + sub.add_argument("name") + if action in ("add", "replace"): + sub.add_argument("--host", action=PresetSingleUseAction) + sub.add_argument("--agent", action=PresetSingleUseAction, choices=("claude", "codex"), required=True) + sub.add_argument("--codex-home", action=PresetSingleUseAction) + if action in ("replace", "remove"): + sub.add_argument("--expect-revision", action=PresetSingleUseAction, required=True, metavar="REV") + handoff = subparsers.add_parser("handoff", help="manage private correlated intents without submitting native messages") handoff_sub = handoff.add_subparsers(dest="handoff_action", required=True) for action in ("init", "prepare", "status", "watch", "wait", "confirm"): @@ -283,6 +315,33 @@ def add_common(sub: argparse.ArgumentParser) -> None: setup.add_argument('--route', choices=('auto', 'direct', 'relay'), default='auto') setup.add_argument('--seconds', type=int, choices=range(1, 86401), default=3600, metavar='SECONDS') setup.set_defaults(func=cmd_setup, json=False, no_update_notice=True, agent=None, all=False) + addresses = subparsers.add_parser("address", help="explicit local recipient references; not person identity or permissions") + address_sub = addresses.add_subparsers(dest="address_action", required=True) + for action in ("resolve", "add", "list", "show", "discover", "reselect", "remove", "_inspect"): + sub = address_sub.add_parser(action, help=argparse.SUPPRESS if action == "_inspect" else None) + sub.add_argument("--json", action="store_true") + sub.add_argument("--output-format", choices=("text", "json")) + sub.add_argument("--no-update-notice", action="store_true", default=True) + sub.set_defaults(func=cmd_address_inspect if action == "_inspect" else cmd_address, + no_update_notice=True, host=[], ssh_opt=[]) + if action in ("add", "show", "reselect", "remove"): + sub.add_argument("name") + if action in ("resolve", "add"): + sub.add_argument("--preset", required=True, action=PresetSingleUseAction) + if action in ("resolve", "add", "reselect"): + sub.add_argument("--to", required=True, action=PresetSingleUseAction) + if action in ("add", "reselect"): + sub.add_argument("--expect-selection", required=True, action=PresetSingleUseAction) + if action == "add": + sub.add_argument("--label", action=PresetSingleUseAction) + if action in ("reselect", "remove"): + sub.add_argument("--expect-revision", required=True, action=PresetSingleUseAction) + if action == "discover": + sub.add_argument("--address", required=True, action="append") + if action == "_inspect": + sub.add_argument("--agent", required=True, choices=("claude", "codex")) + sub.add_argument("--to", required=True, action="append") + sub.add_argument("--codex-home") return parser @@ -298,6 +357,15 @@ def json_error_result(args: argparse.Namespace, payload: dict) -> dict | list[di ]) +def address_operation_failed(args): + message = "Address operation failed; inspect current state before deciding what to do" + if args.json: + print(json.dumps(json_error_result(args, {"error": message, "reason": "address_operation_failed"}), ensure_ascii=False)) + else: + print("session-peer: " + message, file=sys.stderr) + return EXIT_ERROR + + def main(argv: list[str] | None = None) -> int: # Invocation-local authority, never an environment setting, MCP default or # leaked state from a previous/reentrant call in the same interpreter. @@ -346,6 +414,8 @@ def _main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(raw_argv) + if getattr(args, "preset", None) is not None: + args._preset_explicit_options = sorted(preset_explicit_options(parser)) output_format = getattr(args, "output_format", None) if args.json and output_format == "text": parser.error("--json conflicts with --output-format text") @@ -367,6 +437,42 @@ def _main(argv: list[str] | None = None) -> int: if runtime_notice_preference_routed(args): parser.error("Runtime notice preferences are local only; do not use host or SSH options") args.no_update_notice = True + if args.command == "send": + if not getattr(args, "address", None) and args.to is None: + parser.error("the following arguments are required: --to") + if getattr(args, "address", None) or getattr(args, "address_revision", None) or getattr(args, "confirm_target", None): + args._address_explicit_options = sorted(preset_explicit_options(parser)) + # Route checks precede advisory background work, stdin and transport. An + # opt-out invocation does not even read the presets configuration. + _CLIENT_UPDATE_NOTICE, _SKILL_UPDATE_NOTICES = None, [] + try: + if args.command == "send": + prepare_address_route(parser, args, raw_argv) + if args.command in ("list", "send"): + prepare_preset_route(parser, args, raw_argv) + except KeyboardInterrupt: + return 130 + except CcPeerError as exc: + if args.json: + print(json.dumps(json_error_result(args, {"error": str(exc), **exc.details}), ensure_ascii=False)) + else: + print(human_text(f"session-peer: {exc}"), file=sys.stderr) + return EXIT_ERROR + except Exception: + return address_operation_failed(args) if getattr(args, "address", None) else preset_operation_failed(args) + if args.command in ("preset", "address"): + try: + return args.func(args) + except KeyboardInterrupt: + return 130 + except CcPeerError as exc: + if args.json: + print(json.dumps(json_result(args.command, {"error": str(exc), **exc.details}, ok=False), ensure_ascii=False)) + else: + print(human_text(f"session-peer: {exc}"), file=sys.stderr) + return EXIT_ERROR + except Exception: + return address_operation_failed(args) if args.command == "address" else preset_operation_failed(args) try: prepare_trusted_ssh_config(parser, args) except CcPeerError as exc: diff --git a/session_peer_core/codex.py b/session_peer_core/codex.py index 861539a..cfd87fd 100644 --- a/session_peer_core/codex.py +++ b/session_peer_core/codex.py @@ -1397,6 +1397,8 @@ def _queue_codex(args: argparse.Namespace, text: str) -> dict: "consumptionConfirmed": False}) selected = codex_home(args) root, home_resolution = resolve_codex_home(args, selected, thread_id) + if getattr(args, "_address_expected_writer", None) is not None: + require_address_writer(args, root, thread_id, home_resolution) native_home = getattr(args, "codex_native_home", None) # Dry-run applies the same storage boundary as the real submission. queue_cwd = codex_queue_storage_preflight(args, root, thread_id) @@ -1417,7 +1419,12 @@ def _queue_codex(args: argparse.Namespace, text: str) -> dict: if not any(s["id"] == thread_id for s in discover_codex(discovery_args)): raise NoTargetError(f"No saved Codex thread {thread_id} in {root}") return result - revalidate_codex_home(root, thread_id, home_resolution) + try: + revalidate_codex_home(root, thread_id, home_resolution) + except CcPeerError: + if getattr(args, "_address_expected_writer", None) is not None: + raise address_refused("address_writer_changed") from None + raise # Relay receivers on WSL inspect the native Windows state DB through its # mounted POSIX path, but the native codex.exe process needs the Windows # spelling of that same home. This private adapter value is derived from @@ -1427,6 +1434,14 @@ def _queue_codex(args: argparse.Namespace, text: str) -> dict: check_codex_consumer_storage(root, thread_id, native_home=native_home, include_ordinary_preferences=include_ordinary_preferences) try: + if getattr(args, "_address_expected_writer", None) is not None: + # Compare to the selected observation again at the effect boundary; + # this is not an atomic native incarnation precondition. + try: + revalidate_codex_home(root, thread_id, home_resolution) + except CcPeerError: + raise address_refused("address_writer_changed") from None + require_address_writer(args, root, thread_id, home_resolution) # Keep leading dashes inside the option value, including with --no-from. done = subprocess.run(argv, cwd=None if native_home else str(queue_cwd), env=env, capture_output=True, encoding="utf-8", errors="replace", diff --git a/session_peer_core/preset_cli.py b/session_peer_core/preset_cli.py new file mode 100644 index 0000000..a7aedb0 --- /dev/null +++ b/session_peer_core/preset_cli.py @@ -0,0 +1,108 @@ +class PresetSingleUseAction(argparse.Action): + """A route selector never silently replaces an earlier explicit value.""" + def __call__(self, parser, namespace, values, option_string=None): + seen = getattr(namespace, "_preset_single_use", []) + if self.dest in seen: + parser.error(self.option_strings[0] + " may be specified only once") + namespace._preset_single_use = [*seen, self.dest] + setattr(namespace, self.dest, values) + + +def preset_explicit_options(parser): + """Collect only actual argparse actions, never scan message values.""" + explicit = set(getattr(parser, "_explicit_option_strings", ())) + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + for subparser in action.choices.values(): + explicit.update(preset_explicit_options(subparser)) + return explicit + + +def preset_operation_failed(args): + """Unexpected private-store failures never reflect diagnostics or paths.""" + message = "Route preset operation failed" + if args.json: + print(json.dumps(json_result(args.command, { + "error": message, "reason": "preset_operation_failed", + }, ok=False), ensure_ascii=False)) + else: + print("session-peer: " + message, file=sys.stderr) + return EXIT_ERROR + + +def prepare_preset_route(parser, args, raw_argv): + name = getattr(args, "preset", None) + revision = getattr(args, "preset_revision", None) + if revision is not None and name is None: + parser.error("--preset-revision requires --preset") + if name is None: + return + conflicting = ( + "--host", "--codex-home", "--agent", "--ssh-opt", "--codex-bin", + "--device", "--device-state", "--device-route", "--relay-admission-file", + "--relay-login", "--antigravity-home", "--antigravity-generation", + "--request-id", "--wake", "--wake-max-depth", "--wake-timeout", + "--allow-inactive-codex-home", "--correlation-id", "--request-ack", + "--observe-delivery", "--wait-for", "--wait-timeout", "--b64", + "--_handoff-native-context", "--_handoff-native-cutoff-ms", + "--_handoff-native-total-ms", "--_wake-depth", "--_wake-origin", "--_wake-limit", + ) + for flag in conflicting: + if flag in args._preset_explicit_options: + parser.error("--preset conflicts with " + flag) + if str(getattr(args, "to", "")).lower().startswith("session-peer:"): + parser.error("--preset conflicts with a Reply-To URI") + if args.command == "send" and AGENTS.for_target(args.to).name not in ("claude", "codex"): + parser.error("--preset supports only Claude Code and Codex targets") + if args.command == "send" and not args.dry_run and revision is None: + parser.error("actual --preset sends require --preset-revision from preset show or text dry-run stderr") + if IS_WINDOWS: + raise CcPeerError("Route presets are not supported on Windows", {"reason": "preset_platform_unsupported"}) + route, current = preset_lookup(name) + if revision is not None and revision != current: + raise CcPeerError("Route preset changed; review a new dry run", {"reason": "preset_revision_mismatch"}) + if args.command == "send": + if AGENTS.for_target(args.to).name != route["agent"]: + parser.error("--to agent conflicts with the selected preset") + else: + args.agent = route["agent"] + args.host = [route["host"]] if route["transport"] == "ssh" else [] + args.codex_home = route.get("codexHome") + if not args.json: + print(render_preset_route(name, route, current), file=sys.stderr) + + +def render_preset_route(name, route, revision): + name, route, revision = human_text(name), human_text(route), human_text(revision) + where = route.get("host", "this machine") + home = " / home " + route["codexHome"] if "codexHome" in route else "" + return f"Preset {name}: {route['transport']} / {where} / {route['agent']}{home}\nRevision: {revision}\nRoute configuration only; receiver, host identity and delivery are checked separately." + + +def cmd_preset(args): + if IS_WINDOWS: + raise CcPeerError("Route presets are not supported on Windows", {"reason": "preset_platform_unsupported"}) + action = args.preset_action + if action == "list": + routes = preset_read() + items = [{"name": name, "revision": preset_revision(name, route), "route": route} + for name, route in sorted(routes.items())] + payload = {"presets": items} + human = "\n\n".join(render_preset_route(item["name"], item["route"], item["revision"]) for item in items) or "No route presets." + elif action == "show": + route, revision = preset_lookup(args.name) + payload = {"name": args.name, "revision": revision, "route": route} + human = render_preset_route(args.name, route, revision) + else: + route = None + if action in ("add", "replace"): + route = {"transport": "ssh" if args.host is not None else "local", "agent": args.agent} + if args.host is not None: + route["host"] = args.host + if args.codex_home is not None: + route["codexHome"] = args.codex_home + payload = preset_mutate(action, args.name, route, getattr(args, "expect_revision", None)) + human = ("Removed route preset " + human_text(args.name) if action == "remove" + else render_preset_route(payload["name"], payload["route"], payload["revision"])) + emit(args.json, payload, human, command="preset") + return 0 diff --git a/session_peer_core/presets.py b/session_peer_core/presets.py new file mode 100644 index 0000000..a2e7d23 --- /dev/null +++ b/session_peer_core/presets.py @@ -0,0 +1,580 @@ +"""Explicit, route-only POSIX presets; never a session identity or authority.""" + +import hashlib + + +PRESET_MAX_BYTES = 65536 +PRESET_MAX_ENTRIES = 128 +PRESET_LOCK_SECONDS = 1.0 + + +def _preset_error(reason): + return CcPeerError("preset configuration refused: " + reason, {"reason": reason}) + + +def _preset_cleanup(action, *args, preserve=False, **kwargs): + """A secondary ordinary cleanup error must not replace an active failure. + + New KeyboardInterrupt/SystemExit are never swallowed. On a normal return, + cleanup failure is still reported, without reflecting raw diagnostics. + """ + try: + return action(*args, **kwargs) + except Exception as error: + if not preserve: + if isinstance(error, CcPeerError): + raise + raise _preset_error("preset_cleanup_failed") from None + + +@contextlib.contextmanager +def _preset_cleanup_scope(action, *args, **kwargs): + """Derive preservation from this body, never an ambient caller exception.""" + try: + yield + except BaseException: + _preset_cleanup(action, *args, preserve=True, **kwargs) + raise + else: + _preset_cleanup(action, *args, **kwargs) + + +def _preset_platform(): + if IS_WINDOWS or fcntl is None or sys.platform not in ("darwin", "linux"): + raise _preset_error("preset_platform_unsupported") + + +def _preset_darwin_mount_flags(fd): + """Use the explicit statfs64 ABI, never the ambiguous legacy statfs ABI.""" + import ctypes + try: + class Statfs64(ctypes.Structure): + # sys/mount.h __DARWIN_STRUCT_STATFS64. The reserved tail remains + # 32 bytes across the f_flags_ext addition; no string is inspected. + _fields_ = [ + ("f_bsize", ctypes.c_uint32), ("f_iosize", ctypes.c_int32), + ("f_blocks", ctypes.c_uint64), ("f_bfree", ctypes.c_uint64), + ("f_bavail", ctypes.c_uint64), ("f_files", ctypes.c_uint64), + ("f_ffree", ctypes.c_uint64), ("f_fsid", ctypes.c_int32 * 2), + ("f_owner", ctypes.c_uint32), ("f_type", ctypes.c_uint32), + ("f_flags", ctypes.c_uint32), ("f_fssubtype", ctypes.c_uint32), + ("f_fstypename", ctypes.c_char * 16), + ("f_mntonname", ctypes.c_char * 1024), + ("f_mntfromname", ctypes.c_char * 1024), + ("f_reserved", ctypes.c_uint32 * 8), + ] + if (ctypes.sizeof(ctypes.c_void_p) != 8 or ctypes.sizeof(Statfs64) != 2168 + or Statfs64.f_flags.offset != 64): + raise _preset_error("preset_mount_unverifiable") + api = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + api.fstatfs64.argtypes = [ctypes.c_int, ctypes.POINTER(Statfs64)] + api.fstatfs64.restype = ctypes.c_int + value = Statfs64() + if api.fstatfs64(fd, ctypes.byref(value)) != 0: + raise _preset_error("preset_mount_unverifiable") + return value.f_flags + except (OSError, AttributeError, TypeError, ValueError, OverflowError): + raise _preset_error("preset_mount_unverifiable") from None + + +def _preset_check_ownership(fd): + if sys.platform == "darwin" and _preset_darwin_mount_flags(fd) & 0x00200000: + # MNT_IGNORE_OWNERSHIP can synthesize owner/mode metadata. Do not call + # such metadata an owner-only store, even if its ACL is empty. + raise _preset_error("preset_ownership_unenforced") + + +def _preset_check_fd(fd, *, private, directory=False): + _preset_check_ownership(fd) + _preset_check_acl(fd, private=private, directory=directory) + + +def _preset_darwin_acl_tags(fd): + """Read only Darwin's extended ACL, without paths or a child process. + + Darwin returns 0 for an entry, -1/EINVAL at exhaustion (unlike Linux). + Validate the independent ACL handle first; release it on every path. + Constants and prototypes are from the macOS SDK sys/acl.h. + """ + import ctypes + try: + api = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + api.acl_get_fd_np.argtypes = [ctypes.c_int, ctypes.c_int] + api.acl_get_fd_np.restype = ctypes.c_void_p + api.acl_valid.argtypes = [ctypes.c_void_p] + api.acl_valid.restype = ctypes.c_int + api.acl_get_entry.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(ctypes.c_void_p)] + api.acl_get_entry.restype = ctypes.c_int + api.acl_get_tag_type.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)] + api.acl_get_tag_type.restype = ctypes.c_int + api.acl_free.argtypes = [ctypes.c_void_p] + api.acl_free.restype = ctypes.c_int + ctypes.set_errno(0) + acl = api.acl_get_fd_np(fd, 0x100) # ACL_TYPE_EXTENDED + if not acl: + # Darwin's fd getter returns NULL/ENOENT when FILESEC_ACL is absent + # (Libc posix1e/acl_file.c and gen/filesec.c), not an empty object. + if ctypes.get_errno() == errno.ENOENT: + return () + raise _preset_error("preset_acl_unverifiable") + acl_failed = False + try: + if api.acl_valid(acl) != 0: + raise _preset_error("preset_acl_unverifiable") + tags = [] + for index in range(129): + entry = ctypes.c_void_p() + ctypes.set_errno(0) + result = api.acl_get_entry(acl, 0 if index == 0 else -1, ctypes.byref(entry)) + if result == -1 and ctypes.get_errno() == errno.EINVAL: + return tuple(tags) + if result != 0 or not entry.value or index == 128: + raise _preset_error("preset_acl_unverifiable") + tag = ctypes.c_int() + if api.acl_get_tag_type(entry, ctypes.byref(tag)) != 0 or tag.value not in (1, 2): + raise _preset_error("preset_acl_unverifiable") + tags.append(tag.value) # ACL_EXTENDED_ALLOW=1, DENY=2 + raise _preset_error("preset_acl_unverifiable") + except BaseException: + acl_failed = True + raise + finally: + def release_acl(): + if api.acl_free(acl) != 0: + raise _preset_error("preset_acl_unverifiable") + _preset_cleanup(release_acl, preserve=acl_failed) + except (OSError, AttributeError, TypeError, ValueError, OverflowError): + raise _preset_error("preset_acl_unverifiable") from None + + +def _preset_check_acl(fd, *, private, directory=False): + """Conservative ACL policy, not an effective-permissions interpreter.""" + if sys.platform == "darwin": + tags = _preset_darwin_acl_tags(fd) + if any(tag not in (1, 2) for tag in tags): + raise _preset_error("preset_acl_unverifiable") + if (private and tags) or (not private and 1 in tags): + raise _preset_error("preset_unsafe_acl") + return + if sys.platform == "linux": + try: + for attribute in (("system.posix_acl_access", "system.posix_acl_default") + if directory else ("system.posix_acl_access",)): + try: + os.getxattr(fd, attribute) + except OSError as error: + if error.errno == errno.ENODATA: + continue + raise _preset_error("preset_acl_unverifiable") from None + raise _preset_error("preset_unsafe_acl") + except (AttributeError, TypeError, ValueError): + raise _preset_error("preset_acl_unverifiable") from None + return + raise _preset_error("preset_platform_unsupported") + + +def _preset_name(name): + if not isinstance(name, str) or re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", name) is None: + raise _preset_error("preset_invalid_name") + + +def _preset_string(value, limit): + if (not isinstance(value, str) or not value or len(value) > limit + or any(ord(c) < 32 or 127 <= ord(c) <= 159 or 0xD800 <= ord(c) <= 0xDFFF for c in value)): + raise _preset_error("preset_invalid_route") + + +def preset_validate_entry(entry): + """Return a fresh allowlisted route, without executing or resolving it.""" + if not isinstance(entry, dict): + raise _preset_error("preset_invalid_route") + transport, agent = entry.get("transport"), entry.get("agent") + if transport not in ("local", "ssh") or agent not in ("claude", "codex"): + raise _preset_error("preset_invalid_route") + expected = {"transport", "agent"} + if transport == "ssh": + expected.add("host") + if agent == "codex": + expected.add("codexHome") + if set(entry) != expected: + raise _preset_error("preset_invalid_route") + if transport == "ssh": + host = entry["host"] + _preset_string(host, 1024) + if (host.count("@") > 1 or ("@" in host and not all(host.split("@"))) + or ("@" in host and ":" in host.split("@", 1)[0])): + raise _preset_error("preset_invalid_route") + try: + check_ssh_argument(host, "--host") + except CcPeerError: + raise _preset_error("preset_invalid_route") from None + if agent == "codex": + home = entry["codexHome"] + _preset_string(home, 4096) + if not PurePosixPath(home).is_absolute() or home.startswith("//") or "\\" in home: + raise _preset_error("preset_invalid_route") + return dict(entry) + + +def preset_revision(name, entry): + """Change token over exact validated values, NOT identity or permission.""" + _preset_name(name) + route = preset_validate_entry(entry) + raw = json.dumps({"schemaVersion": 1, "name": name, "route": route}, + sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii") + return "dp1:" + hashlib.sha256(raw).hexdigest() + + +def _preset_pairs(pairs): + result = {} + for key, value in pairs: + if key in result: + raise _preset_error("preset_corrupt") + result[key] = value + return result + + +def _preset_decode(raw): + try: + value = json.loads(raw.decode("utf-8", "strict"), object_pairs_hook=_preset_pairs, + parse_constant=lambda _: (_ for _ in ()).throw(ValueError())) + if (not isinstance(value, dict) or set(value) != {"schemaVersion", "presets"} + or type(value["schemaVersion"]) is not int or value["schemaVersion"] != 1 + or not isinstance(value["presets"], dict)): + raise ValueError() + entries = value["presets"] + if len(entries) > PRESET_MAX_ENTRIES: + raise _preset_error("preset_capacity") + validated = {} + for name, entry in entries.items(): + _preset_name(name) + validated[name] = preset_validate_entry(entry) + return validated + except (UnicodeError, ValueError, TypeError, RecursionError): + raise _preset_error("preset_corrupt") from None + + +def _preset_path(): + configured = os.environ.get("XDG_CONFIG_HOME") + raw_parent = configured if configured is not None else str(Path.home() / ".config") + if (not raw_parent or len(raw_parent) > 4096 + or any(ord(c) < 32 or 127 <= ord(c) <= 159 or 0xD800 <= ord(c) <= 0xDFFF for c in raw_parent)): + raise _preset_error("preset_invalid_config_root") + parent = Path(raw_parent) + if not parent.is_absolute() or ".." in parent.parts: + raise _preset_error("preset_invalid_config_root") + return parent / "session-peer" + + +def _preset_identity(info): + return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns) + + +def _preset_private(info, directory=False): + if (info.st_uid != os.geteuid() or info.st_mode & (0o7077 if directory else 0o7177) + or not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)) + or (not directory and info.st_nlink != 1)): + raise _preset_error("preset_unsafe_storage") + + +def _preset_directory(create=False): + """Open every component without following symlinks; retain the app dir fd. + + App files are relative to this descriptor. Ancestors must not be writable by + other users (a root-owned sticky temporary ancestor is permitted). This is + local same-user configuration, not protection from the user's own processes. + """ + root = _preset_path() + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_NONBLOCK + fd = os.open("/", flags) + directory_failed = False + try: + _preset_check_fd(fd, private=False, directory=True) + for index, part in enumerate(root.parts[1:]): + final = index == len(root.parts[1:]) - 1 + try: + child = os.open(part, flags, dir_fd=fd) + except FileNotFoundError: + if not create: + return None + try: + os.mkdir(part, 0o700, dir_fd=fd) + except FileExistsError: + pass + child = os.open(part, flags, dir_fd=fd) + try: + info = os.fstat(child) + if final: + _preset_private(info, directory=True) + else: + if info.st_uid not in (0, os.geteuid()): + raise _preset_error("preset_unsafe_storage") + if info.st_mode & 0o022 and not (info.st_uid == 0 and info.st_mode & stat.S_ISVTX): + raise _preset_error("preset_unsafe_storage") + # The immediate configuration parent must belong to this user. + if not final and index == len(root.parts[1:]) - 2 and info.st_uid != os.geteuid(): + raise _preset_error("preset_unsafe_storage") + _preset_check_fd(child, private=final, directory=True) + except BaseException: + _preset_cleanup(os.close, child, preserve=True) + raise + previous, fd = fd, child + _preset_cleanup(os.close, previous) + result, fd = fd, None + return result + except BaseException: + directory_failed = True + raise + finally: + if fd is not None: + _preset_cleanup(os.close, fd, preserve=directory_failed) + + +def _preset_read_at(directory, filename="destinations.json", decoder=None): + try: + fd = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + except FileNotFoundError: + return {} + read_failed = False + try: + before = os.fstat(fd) + _preset_private(before) + _preset_check_fd(fd, private=True) + if before.st_size > PRESET_MAX_BYTES: + raise _preset_error("preset_capacity") + raw = bytearray() + while len(raw) <= PRESET_MAX_BYTES: + chunk = os.read(fd, min(8192, PRESET_MAX_BYTES + 1 - len(raw))) + if not chunk: + break + raw.extend(chunk) + if len(raw) > PRESET_MAX_BYTES: + raise _preset_error("preset_capacity") + after = os.fstat(fd) + _preset_check_fd(fd, private=True) + current = os.stat(filename, dir_fd=directory, follow_symlinks=False) + if _preset_identity(before) != _preset_identity(after) or _preset_identity(after) != _preset_identity(current): + raise _preset_error("preset_storage_changed") + return (decoder or _preset_decode)(bytes(raw)) + except BaseException: + read_failed = True + raise + finally: + _preset_cleanup(os.close, fd, preserve=read_failed) + + +def preset_read(): + """Read only: never creates a directory, file or lock.""" + _preset_platform() + directory = None + read_failed = False + try: + directory = _preset_directory() + return {} if directory is None else _preset_read_at(directory) + except OSError: + read_failed = True + raise _preset_error("preset_unsafe_storage") from None + except BaseException: + read_failed = True + raise + finally: + if directory is not None: + _preset_cleanup(os.close, directory, preserve=read_failed) + + +def preset_lookup(name): + _preset_platform() + _preset_name(name) + entries = preset_read() + if name not in entries: + raise _preset_error("preset_not_found") + route = dict(entries[name]) + return route, preset_revision(name, route) + + +def _preset_lock(directory, filename="destinations.lock"): + flags = os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK + try: + fd = os.open(filename, flags | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=directory) + except FileExistsError: + fd = os.open(filename, flags, dir_fd=directory) + try: + _preset_private(os.fstat(fd)) + _preset_check_fd(fd, private=True) + deadline = time.monotonic() + PRESET_LOCK_SECONDS + while True: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + raise _preset_error("preset_busy") + time.sleep(0.01) + info = os.stat(filename, dir_fd=directory, follow_symlinks=False) + if _preset_identity(info) != _preset_identity(os.fstat(fd)): + raise _preset_error("preset_storage_changed") + _preset_check_fd(fd, private=True) + return fd + except BaseException: + _preset_cleanup(os.close, fd, preserve=True) + raise + + +def _preset_entry_identity(directory, filename="destinations.json"): + try: + info = os.stat(filename, dir_fd=directory, follow_symlinks=False) + except FileNotFoundError: + return None + _preset_private(info) + fd = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + identity_failed = False + try: + opened = os.fstat(fd) + if _preset_identity(opened) != _preset_identity(info): + raise _preset_error("preset_storage_changed") + _preset_check_fd(fd, private=True) + except BaseException: + identity_failed = True + raise + finally: + _preset_cleanup(os.close, fd, preserve=identity_failed) + return _preset_identity(info) + + +def preset_mutate(action, name, entry=None, expected_revision=None, *, _storage=None): + """Explicit management only; serialize writers and CAS replace/remove.""" + _preset_platform() + _preset_name(name) + if action not in ("add", "replace", "remove"): + raise _preset_error("preset_invalid_action") + validate_entry = _storage["validate_entry"] if _storage else preset_validate_entry + revision = _storage["revision"] if _storage else preset_revision + revision_prefix = _storage["revision_prefix"] if _storage else "dp1" + filename = _storage["filename"] if _storage else "destinations.json" + route = validate_entry(entry) if action != "remove" else None + if action == "remove" and entry is not None: + raise _preset_error("preset_invalid_route") + if action in ("replace", "remove") and (not isinstance(expected_revision, str) + or re.fullmatch(revision_prefix + r":[0-9a-f]{64}", expected_revision) is None): + raise _preset_error("preset_revision_required") + if action == "add" and expected_revision is not None: + raise _preset_error("preset_invalid_revision") + directory = lock = None + temporary = None + committed = False + mutation_failed = False + try: + directory = _preset_directory(create=action == "add") + if directory is None: + raise _preset_error("preset_not_found") + lock = _preset_lock(directory, _storage["lockname"]) if _storage else _preset_lock(directory) + def identity(): + return _preset_entry_identity(directory, filename) if _storage else _preset_entry_identity(directory) + original_identity = identity() + entries = _preset_read_at(directory, filename, _storage["decoder"]) if _storage else _preset_read_at(directory) + if identity() != original_identity: + raise _preset_error("preset_storage_changed") + if action == "add": + if name in entries: + raise _preset_error("preset_already_exists") + else: + if name not in entries: + raise _preset_error("preset_not_found") + if revision(name, entries[name]) != expected_revision: + raise _preset_error("preset_revision_mismatch") + previous = entries.get(name) + if action == "remove": + del entries[name] + else: + entries[name] = route + if _storage: + _storage["validate_entries"](entries) + raw = json.dumps({"schemaVersion": 1, _storage["collection"] if _storage else "presets": entries}, sort_keys=True, + separators=(",", ":"), ensure_ascii=True).encode("ascii") + b"\n" + if len(entries) > PRESET_MAX_ENTRIES or len(raw) > PRESET_MAX_BYTES: + raise _preset_error("preset_capacity") + # Reject ancestry replacement before committing through the held fd. + check = _preset_directory() + check_failed = False + try: + if check is None or (os.fstat(check).st_dev, os.fstat(check).st_ino) != ( + os.fstat(directory).st_dev, os.fstat(directory).st_ino): + raise _preset_error("preset_storage_changed") + except BaseException: + check_failed = True + raise + finally: + if check is not None: + _preset_cleanup(os.close, check, preserve=check_failed) + temporary = "." + filename.removesuffix(".json") + "-" + uuid.uuid4().hex + ".tmp" + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=directory) + temporary_failed = False + try: + stream = None + stream_failed = False + try: + stream = os.fdopen(fd, "wb") + _preset_private(os.fstat(stream.fileno())) + _preset_check_fd(stream.fileno(), private=True) + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + _preset_check_fd(stream.fileno(), private=True) + except BaseException: + stream_failed = True + raise + finally: + if stream is not None: + _preset_cleanup(stream.close, preserve=stream_failed) + else: + _preset_cleanup(os.close, fd, preserve=stream_failed) + if identity() != original_identity: + raise _preset_error("preset_storage_changed") + os.replace(temporary, filename, src_dir_fd=directory, dst_dir_fd=directory) + temporary = None + committed = True + os.fsync(directory) + except BaseException: + temporary_failed = True + raise + finally: + if temporary is not None: + def remove_temporary(): + try: + os.unlink(temporary, dir_fd=directory) + except FileNotFoundError: + # Rename can finish immediately before an interrupt. + pass + _preset_cleanup(remove_temporary, preserve=temporary_failed) + temporary = None + result_route = previous if action == "remove" else route + result = {"name": name, _storage["result_key"] if _storage else "route": dict(result_route), + "revision": revision(name, result_route)} + if action == "remove": + result["removed"] = True + return result + except OSError: + mutation_failed = True + if committed: + raise CcPeerError("preset commit unconfirmed; inspect configuration before retrying", + {"reason": "preset_commit_unconfirmed"}) from None + raise _preset_error("preset_unsafe_storage") from None + except BaseException: + mutation_failed = True + raise + finally: + def close_storage(): + def close_directory(): + if directory is not None: + os.close(directory) + with _preset_cleanup_scope(close_directory): + if lock is not None: + _preset_cleanup(os.close, lock) + try: + _preset_cleanup(close_storage, preserve=mutation_failed) + except CcPeerError: + if committed: + raise CcPeerError("preset commit unconfirmed; inspect configuration before retrying", + {"reason": "preset_commit_unconfirmed"}) from None + raise diff --git a/session_peer_core/sending.py b/session_peer_core/sending.py index 7f9d3fa..4869d2b 100644 --- a/session_peer_core/sending.py +++ b/session_peer_core/sending.py @@ -110,6 +110,8 @@ def cmd_send(args: argparse.Namespace) -> int: remote_argv += adapter.remote_options(args) if expected_generation is not None: remote_argv += ["--target-generation", expected_generation] + if getattr(args, "_address_expected_writer", None) is not None: + remote_argv += ["--_address-writer-revision", args._address_expected_writer] if args.dry_run: remote_argv.append("--dry-run") result = transport.execute(remote_argv) diff --git a/session_peer_core/ssh.py b/session_peer_core/ssh.py index af24959..b746a99 100644 --- a/session_peer_core/ssh.py +++ b/session_peer_core/ssh.py @@ -527,6 +527,12 @@ def incomplete_response_error(message: str, details: dict | None = None) -> CcPe # The far end reports its own failures in-band; surface them here rather # than letting a caller read an error payload as a success. if isinstance(result, dict) and result.get("ok") is False: + if (argv[:2] == ["address", "_inspect"] and valid_result + and type(result.get("selectionSchemaVersion")) is int and result["selectionSchemaVersion"] == 1 + and isinstance(result.get("selections"), list) and 1 <= len(result["selections"]) <= 16): + # Preserve partial metadata so the address consumer can validate + # each exact target and retain good rows. No submission is involved. + return result if (argv and argv[0] == "send" and result.get("command") == "send" and result.get("schemaVersion") == JSON_RESPONSE_SCHEMA_VERSION and result.get("agent") == "antigravity" diff --git a/tests/core/test_address_flow.py b/tests/core/test_address_flow.py new file mode 100644 index 0000000..efb3432 --- /dev/null +++ b/tests/core/test_address_flow.py @@ -0,0 +1,265 @@ +"""Offline lifecycle I/O with controlled metadata, not native-volume certification.""" +import contextlib +import copy +import io +import json +import os +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +import session_peer as peer + + +@unittest.skipIf(peer.IS_WINDOWS or peer.fcntl is None, "POSIX address storage") +class AddressFlowTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="codex-address-flow-", dir=os.environ.get("SESSION_PEER_TEST_TMP")) + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.root.chmod(0o700) + patch = mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": str(self.root)}) + patch.start() + self.addCleanup(patch.stop) + # The prescribed external-volume ancestors are group writable and may + # ignore ownership. Model those pre-existing ancestors for lifecycle + # tests; production refuses them and separate native guard tests remain. + ownership = mock.patch.object(peer, "_preset_check_ownership") + ownership.start() + self.addCleanup(ownership.stop) + real_fstat = os.fstat + modeled = {(p.stat().st_dev, p.stat().st_ino) for p in self.root.parents + if p.stat().st_uid == os.geteuid() and p.stat().st_mode & 0o022} + def metadata(fd): + info = real_fstat(fd) + if (info.st_dev, info.st_ino) in modeled: + fields = {name: getattr(info, name) for name in dir(info) if name.startswith("st_")} + fields["st_mode"] &= ~0o022 + return SimpleNamespace(**fields) + return info + metadata_patch = mock.patch.object(peer.os, "fstat", side_effect=metadata) + metadata_patch.start() + self.addCleanup(metadata_patch.stop) + self.route = {"transport": "local", "agent": "claude"} + self.preset = peer.preset_mutate("add", "work", self.route) + self.generation = "tg1:" + "a" * 64 + self.probe = mock.patch.object(peer, "address_probe_route", side_effect=self.probe_route) + self.probe_mock = self.probe.start() + self.addCleanup(self.probe.stop) + + def probe_route(self, route, targets, expected_ssh_key=None): + return {"sshHostKey": "SHA256:" + "A" * 43 if route["transport"] == "ssh" else None, + "selections": [{"requestedTarget": target, "ok": True, + "recipient": {"agent": "claude", "target": "claude:42", "home": None}, + "selection": {"generationStatus": "available", "targetGeneration": self.generation, + "writerRevision": None, "observationRevision": None}} for target in targets]} + + def invoke(self, argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + try: + code = peer.main(argv) + except SystemExit as exc: + code = exc.code + return code, json.loads(out.getvalue()) if out.getvalue() else None, err.getvalue() + + def add(self, name="research"): + code, resolved, _ = self.invoke(["address", "resolve", "--preset", "work", "--to", "worker", "--json"]) + self.assertEqual(code, 0) + code, added, _ = self.invoke(["address", "add", name, "--preset", "work", "--to", "worker", + "--expect-selection", resolved["selection"]["observationRevision"], "--json"]) + self.assertEqual(code, 0, added) + return added + + def test_resolve_add_show_and_real_send_uses_saved_canonical_target(self): + added = self.add() + with mock.patch.object(peer.LocalTransport, "execute", return_value={"ok": True, "dryRun": False, + "target": {"pid": 42, "name": "worker"}, "chars": 1}) as submission: + code, _, _ = self.invoke(["send", "--address", "research", "--address-revision", added["revision"], + "--confirm-target", "claude:42", "--message", "x", "--json", "--no-from", "--no-reply-to"]) + self.assertEqual(code, 0) + args = submission.call_args.args[2] + self.assertEqual(args.to, "42") + self.assertEqual(args.target_generation, self.generation) + + def test_invalid_label_and_selection_revision_never_probe(self): + for extra in (["--label", "bad\u202elabel", "--expect-selection", "as1:" + "a" * 64], + ["--label", "Worker", "--expect-selection", "invalid"]): + self.probe_mock.reset_mock() + code, result, _ = self.invoke(["address", "add", "new", "--preset", "work", "--to", "worker", "--json", *extra]) + self.assertEqual(code, 1) + self.assertFalse(result["ok"]) + self.probe_mock.assert_not_called() + + def test_invalid_send_revision_fails_before_storage_or_message_input(self): + with mock.patch.object(peer, "address_lookup", side_effect=AssertionError("store")), \ + mock.patch.object(peer, "read_message", side_effect=AssertionError("body")): + code, _, _ = self.invoke(["send", "--address", "research", "--address-revision", "invalid", + "--confirm-target", "claude:42", "--message", "fixture", "--json"]) + self.assertEqual(code, 2) + + def test_preview_needs_no_confirmation_and_reads_no_body_before_selection(self): + self.add() + self.generation = "tg1:" + "b" * 64 + with mock.patch.object(peer, "read_message", side_effect=AssertionError("body")), \ + mock.patch.object(peer.LocalTransport, "execute", side_effect=AssertionError("send")): + code, payload, _ = self.invoke(["send", "--address", "research", "--dry-run", "--message", "x", "--json"]) + self.assertNotEqual(code, 0) + self.assertEqual(payload["reason"], "address_selection_changed") + + def test_stale_route_never_queries_saved_route(self): + self.add() + peer.preset_mutate("replace", "work", {"transport": "ssh", "agent": "claude", "host": "other"}, self.preset["revision"]) + self.probe_mock.reset_mock() + code, payload, _ = self.invoke(["address", "discover", "--address", "research", "--json"]) + self.assertNotEqual(code, 0) + self.assertEqual(payload["addresses"][0]["status"], "stale") + self.probe_mock.assert_not_called() + + def test_deleted_preset_is_stale_and_send_never_uses_saved_route(self): + self.add() + peer.preset_mutate("remove", "work", expected_revision=self.preset["revision"]) + self.probe_mock.reset_mock() + code, payload, _ = self.invoke(["address", "discover", "--address", "research", "--json"]) + self.assertEqual(code, 1) + self.assertEqual(payload["addresses"][0]["status"], "stale") + self.assertEqual(payload["addresses"][0]["reason"], "address_preset_changed") + with mock.patch.object(peer, "read_message", side_effect=AssertionError("body")): + code, payload, _ = self.invoke(["send", "--address", "research", "--dry-run", "--json"]) + self.assertEqual(code, 1) + self.assertEqual(payload["reason"], "address_preset_changed") + self.probe_mock.assert_not_called() + + def test_metadata_transport_errors_never_escape_text_or_json_lifecycle(self): + self.route = {"transport": "ssh", "agent": "claude", "host": "fixture"} + self.preset = peer.preset_mutate("replace", "work", self.route, self.preset["revision"]) + added = self.add() + self.probe.stop() + commands = [ + ["address", "resolve", "--preset", "work", "--to", "worker"], + ["address", "add", "new", "--preset", "work", "--to", "worker", "--expect-selection", "as1:" + "a" * 64], + ["address", "reselect", "research", "--to", "worker", "--expect-revision", added["revision"], + "--expect-selection", "as1:" + "a" * 64], + ["send", "--address", "research", "--dry-run", "--message", "fixture"]] + for command in commands: + for encoded in (False, True): + output, error = io.StringIO(), io.StringIO() + argv = command + (["--json"] if encoded else []) + with mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport") as transport, \ + mock.patch.object(peer, "read_message", side_effect=AssertionError("body")), \ + contextlib.redirect_stdout(output), contextlib.redirect_stderr(error): + transport.return_value.execute.side_effect = peer.CcPeerError( + "PRIVATE remote stderr", {"error": "PRIVATE path", "reason": "PRIVATE reason"}) + self.assertEqual(peer.main(argv), 1) + rendered = output.getvalue() + error.getvalue() + self.assertNotIn("PRIVATE", rendered) + self.assertIn("address_selection_unavailable", rendered) + if encoded: + self.assertEqual(json.loads(output.getvalue())["reason"], "address_selection_unavailable") + + def test_ssh_address_send_pins_saved_key_and_forwards_generation_or_writer(self): + key = "SHA256:" + "A" * 43 + thread = "00000000-0000-4000-8000-000000000001" + for agent in ("claude", "codex"): + name = "saved" + agent + preset = "route" + agent + route = {"transport": "ssh", "agent": agent, "host": "fixture"} + if agent == "codex": + route["codexHome"] = "/fixture/home" + saved_preset = peer.preset_mutate("add", preset, route) + recipient = {"agent": agent, "target": "claude:42" if agent == "claude" else "codex:" + thread, + "home": None if agent == "claude" else "/fixture/home"} + selection = {"generationStatus": "available" if agent == "claude" else "unsupported", + "targetGeneration": self.generation if agent == "claude" else None, + "writerRevision": "aw1:" + "a" * 64 if agent == "codex" else None, + "observationRevision": None} + selection["observationRevision"] = peer.address_selection_revision( + preset, saved_preset["revision"], route, recipient, selection, key) + added = peer.address_mutate("add", name, {"label": name, "preset": preset, + "presetRevision": saved_preset["revision"], "route": route, "recipient": recipient, + "selection": selection, "sshHostKey": key}) + probe_selection = {**selection, "observationRevision": None} + probe = {"sshHostKey": key, "selections": [{"requestedTarget": recipient["target"], "ok": True, + "recipient": recipient, "selection": probe_selection}]} + native = {"ok": True, "dryRun": False, "target": {"pid": 42, "name": "worker"} if agent == "claude" else + {"agent": "codex", "id": thread}, "chars": 1} + if agent == "claude": + native["targetGeneration"] = self.generation + command = ["send", "--address", name, "--address-revision", added["revision"], + "--confirm-target", recipient["target"], "--message", "x", "--json", "--no-from", "--no-reply-to"] + with mock.patch.object(peer, "address_probe_route", return_value=probe) as metadata, \ + mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport") as transport: + transport.return_value.host = "fixture" + transport.return_value.ssh_opts = [] + transport.return_value.execute.return_value = native + code, payload, error = self.invoke(command) + self.assertEqual(code, 0, (agent, payload, error)) + metadata.assert_called_once_with(route, [recipient["target"]], key) + options = transport.call_args.args[1] + self.assertEqual(options.require_ssh_host_key, key) + self.assertTrue(options.ssh_identity) + argv = transport.return_value.execute.call_args.args[0] + parsed = peer.build_parser().parse_args(argv) + if agent == "claude": + self.assertEqual(parsed.target_generation, self.generation) + else: + self.assertEqual(parsed._address_expected_writer, selection["writerRevision"]) + self.assertEqual(parsed.codex_home, "/fixture/home") + with mock.patch.object(peer, "address_probe_route", return_value={**probe, "sshHostKey": "SHA256:" + "B" * 42 + "A"}), \ + mock.patch.object(peer, "SshTransport") as transport, \ + mock.patch.object(peer, "read_message", side_effect=AssertionError("body")): + code, refused, _ = self.invoke(command) + self.assertEqual(code, 1) + self.assertEqual(refused["reason"], "address_selection_changed") + transport.assert_not_called() + + def test_same_route_discovery_is_grouped_and_explicit_only(self): + self.add("research") + self.add("review") + self.add("ignored") + self.probe_mock.reset_mock() + code, payload, _ = self.invoke(["address", "discover", "--address", "research", "--address", "review", "--json"]) + self.assertEqual(code, 0) + self.probe_mock.assert_called_once_with(self.route, ["claude:42"], None) + self.assertEqual([row["name"] for row in payload["addresses"]], ["research", "review"]) + + def test_partial_discovery_preserves_success(self): + self.add() + code, payload, _ = self.invoke(["address", "discover", "--address", "research", "--address", "missing", "--json"]) + self.assertNotEqual(code, 0) + self.assertEqual(payload["addresses"][0]["status"], "matched") + self.assertEqual(payload["addresses"][1]["status"], "unverified") + + def test_changed_selection_requires_explicit_reselect(self): + added = self.add() + self.generation = "tg1:" + "b" * 64 + _, resolved, _ = self.invoke(["address", "resolve", "--preset", "work", "--to", "worker", "--json"]) + code, changed, _ = self.invoke(["address", "reselect", "research", "--expect-revision", added["revision"], + "--to", "worker", "--expect-selection", resolved["selection"]["observationRevision"], "--json"]) + self.assertEqual(code, 0) + self.assertNotEqual(changed["revision"], added["revision"]) + self.assertNotEqual(changed["entry"]["bindingId"], added["entry"]["bindingId"]) + + def test_conflicting_override_rejected_before_store_or_probe(self): + for extra in (["--to", "42"], ["--host", "fixture"], ["--codex-home", "/home/other"], ["--wake"]): + with self.subTest(extra=extra), mock.patch.object(peer, "address_lookup", side_effect=AssertionError("store")): + code, _, error = self.invoke(["send", "--address", "research", "--dry-run", "--message", "x", *extra]) + self.assertEqual(code, 2) + self.assertIn("--address conflicts", error) + + def test_actual_send_requires_revision_and_current_target_confirmation(self): + with mock.patch.object(peer, "address_lookup", side_effect=AssertionError("store")): + code, _, error = self.invoke(["send", "--address", "research", "--message", "x"]) + self.assertEqual(code, 2) + self.assertIn("require --address-revision and --confirm-target", error) + + def test_no_address_means_no_address_store_io(self): + with mock.patch.object(peer, "address_lookup", side_effect=AssertionError("store")), \ + mock.patch.object(peer, "address_read", side_effect=AssertionError("store")), \ + mock.patch.object(peer, "cmd_list", return_value=0): + code, _, _ = self.invoke(["list"]) + self.assertEqual(code, 0) diff --git a/tests/core/test_address_selection.py b/tests/core/test_address_selection.py new file mode 100644 index 0000000..1fbd846 --- /dev/null +++ b/tests/core/test_address_selection.py @@ -0,0 +1,229 @@ +"""Address snapshots use existing metadata, never native delivery probes.""" +import argparse +import copy +from pathlib import Path +import unittest +from unittest import mock + +import session_peer as peer + + +THREAD = "00000000-0000-4000-8000-000000000001" + + +class AddressSelectionTests(unittest.TestCase): + def setUp(self): + # Pure metadata fixtures model a supported platform explicitly. + self.real_platform = peer.address_platform + platform = mock.patch.object(peer, "address_platform") + platform.start() + self.addCleanup(platform.stop) + + def test_real_unsupported_platform_refuses_before_discovery(self): + args = argparse.Namespace(agent="claude", to=["worker"], codex_home=None) + with mock.patch.object(peer, "address_platform", self.real_platform), \ + mock.patch.object(peer, "IS_WINDOWS", True), \ + mock.patch.object(peer, "discover") as discover: + with self.assertRaises(peer.CcPeerError) as caught: + peer.address_inspect_selection(args) + self.assertEqual(caught.exception.details["reason"], "address_platform_unsupported") + discover.assert_not_called() + + def resolution(self): + return {"selected": "/fixture/home", "candidates": [{"codexHome": "/fixture/home", + "activity": "live_writer", "writerLock": "held", "ownerStable": True, + "ownerPid": 42, "ownerStartTime": 123456}]} + + def test_writer_revision_changes_with_owner_and_thread(self): + original = self.resolution() + first = peer.address_codex_writer_revision("/fixture/home", THREAD, original) + self.assertRegex(first, r"^aw1:[0-9a-f]{64}$") + changed = copy.deepcopy(original) + changed["candidates"][0]["ownerStartTime"] += 1 + self.assertNotEqual(first, peer.address_codex_writer_revision("/fixture/home", THREAD, changed)) + self.assertNotEqual(first, peer.address_codex_writer_revision("/fixture/home", "another", original)) + + def test_invalid_writer_evidence_refuses(self): + for key, value in [("ownerPid", True), ("ownerPid", 1), ("ownerStable", False), + ("ownerStartTime", float("nan")), ("ownerStartTime", "\x1bsecret"), + ("activity", "inactive"), ("writerLock", "unknown")]: + with self.subTest(key=key, value=value): + resolution = self.resolution() + resolution["candidates"][0][key] = value + with self.assertRaises(peer.CcPeerError) as caught: + peer.address_codex_writer_revision("/fixture/home", THREAD, resolution) + self.assertFalse(caught.exception.details["submitted"]) + + def test_require_writer_optout_does_not_inspect(self): + with mock.patch.object(peer, "address_codex_writer_revision", side_effect=AssertionError("probe")): + peer.require_address_writer(argparse.Namespace(), None, None, None) + + def test_changed_writer_refuses_without_retry(self): + args = argparse.Namespace(_address_expected_writer="aw1:" + "0" * 64) + with self.assertRaises(peer.CcPeerError) as caught: + peer.require_address_writer(args, "/fixture/home", THREAD, self.resolution()) + self.assertEqual(caught.exception.details["reason"], "address_writer_changed") + self.assertFalse(caught.exception.details["retryAllowed"]) + + def test_claude_inspection_discovers_once_and_never_submits(self): + args = argparse.Namespace(agent="claude", to=["worker", "claude:42"], codex_home=None) + row = {"pid": 42} + with mock.patch.object(peer, "discover", return_value=[row]) as discovery, \ + mock.patch.object(peer, "resolve_target", return_value=row), \ + mock.patch.object(peer, "claude_generation", return_value="tg1:" + "a" * 64), \ + mock.patch.object(peer, "codex_executable", side_effect=AssertionError("native")), \ + mock.patch.object(peer, "read_message", side_effect=AssertionError("body")): + result = peer.address_inspect_selection(args) + discovery.assert_called_once() + self.assertTrue(result["ok"]) + self.assertEqual(result["selections"][0]["recipient"], {"agent": "claude", "target": "claude:42", "home": None}) + + def test_claude_missing_generation_is_closed(self): + args = argparse.Namespace(agent="claude", to=["worker"], codex_home=None) + with mock.patch.object(peer, "discover", return_value=[]), \ + mock.patch.object(peer, "resolve_target", return_value={"pid": 42}), \ + mock.patch.object(peer, "claude_generation", return_value=None): + result = peer.address_inspect_selection(args) + self.assertFalse(result["ok"]) + self.assertNotIn("recipient", result["selections"][0]) + + def test_partial_profile_discovery_refuses_names_but_preserves_literal_pid(self): + class Rows(list): + errors = ["PRIVATE unreadable profile"] + args = argparse.Namespace(agent="claude", to=["worker", "claude:42", "42"], codex_home=None) + with mock.patch.object(peer, "discover", return_value=Rows([{"pid": 42}])), \ + mock.patch.object(peer, "resolve_target", return_value={"pid": 42}) as resolve, \ + mock.patch.object(peer, "claude_generation", return_value="tg1:" + "a" * 64): + result = peer.address_inspect_selection(args) + self.assertEqual([row["ok"] for row in result["selections"]], [False, True, True]) + self.assertEqual(resolve.call_count, 2) + self.assertNotIn("PRIVATE", repr(result)) + + def test_codex_inspection_never_fabricates_generation(self): + args = argparse.Namespace(agent="codex", to=["codex:" + THREAD], codex_home="/fixture/home") + with mock.patch.object(peer, "codex_home", return_value=peer.PurePosixPath("/fixture/home")), \ + mock.patch.object(peer, "resolve_codex_home", return_value=(peer.PurePosixPath("/fixture/home"), self.resolution())), \ + mock.patch.object(peer, "codex_executable", side_effect=AssertionError("native")), \ + mock.patch.object(peer.subprocess, "run", side_effect=AssertionError("effect")): + result = peer.address_inspect_selection(args) + self.assertTrue(result["ok"]) + selection = result["selections"][0]["selection"] + self.assertEqual(selection["generationStatus"], "unsupported") + self.assertIsNone(selection["targetGeneration"]) + self.assertNotIn("ownerPid", repr(result)) + + def wire(self): + return {"selectionSchemaVersion": 1, "homeSelector": None, "ok": True, "selections": [{ + "requestedTarget": "worker", "ok": True, + "recipient": {"agent": "claude", "target": "claude:42", "home": None}, + "selection": {"generationStatus": "available", "targetGeneration": "tg1:" + "a" * 64, + "writerRevision": None, "observationRevision": None}}]} + + def test_probe_rejects_missing_connection_identity(self): + route = {"agent": "claude", "transport": "ssh", "host": "fixture"} + with mock.patch.object(peer, "_preset_platform"), \ + mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport") as transport: + transport.return_value.execute.return_value = self.wire() + with self.assertRaises(peer.CcPeerError) as caught: + peer.address_probe_route(route, ["worker"]) + self.assertEqual(caught.exception.details["reason"], "address_ssh_identity_unverified") + + def test_probe_single_metadata_ssh_operation(self): + route = {"agent": "claude", "transport": "ssh", "host": "fixture"} + result = self.wire() + key = "SHA256:" + "a" * 43 + result["sshIdentity"] = {"status": "verified", "fingerprint": key} + with mock.patch.object(peer, "_preset_platform"), \ + mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "remote_installed_version", side_effect=AssertionError("version")), \ + mock.patch.object(peer, "SshTransport") as transport: + transport.return_value.execute.return_value = result + probe = peer.address_probe_route(route, ["worker"], key) + self.assertEqual(probe["sshHostKey"], key) + transport.return_value.execute.assert_called_once_with(["address", "_inspect", "--agent", "claude", "--json", "--to=worker"]) + + def test_dash_prefixed_remote_selector_uses_equals_argument(self): + route = {"agent": "claude", "transport": "ssh", "host": "fixture"} + result = self.wire() + result["selections"][0]["requestedTarget"] = "-worker" + result["sshIdentity"] = {"status": "verified", "fingerprint": "SHA256:" + "A" * 43} + with mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport") as transport: + transport.return_value.execute.return_value = result + peer.address_probe_route(route, ["-worker"]) + argv = transport.return_value.execute.call_args.args[0] + parsed = peer.build_parser().parse_args(argv) + self.assertEqual(parsed.to, ["-worker"]) + + def test_transport_metadata_errors_are_fixed_refusals(self): + route = {"agent": "claude", "transport": "ssh", "host": "fixture"} + errors = [peer.CcPeerError("PRIVATE stderr", {"error": "PRIVATE remote", "reason": "PRIVATE reason"}), + OSError("PRIVATE path")] + for error in errors: + for stage in ("constructor", "execute", "tailscale"): + with mock.patch.object(peer, "tailscale_status", return_value={}) as status, \ + mock.patch.object(peer, "SshTransport") as transport: + if stage == "constructor": + transport.side_effect = error + elif stage == "execute": + transport.return_value.execute.side_effect = error + else: + status.side_effect = error + with self.assertRaises(peer.CcPeerError) as caught: + peer.address_probe_route(route, ["worker"]) + self.assertEqual(caught.exception.details, {"reason": "address_selection_unavailable", + "status": "refused", "submitted": False, "retryAllowed": False}) + self.assertNotIn("PRIVATE", str(caught.exception)) + self.assertTrue(caught.exception.__suppress_context__) + + def test_shared_storage_limits_cannot_silently_diverge(self): + source = (Path(peer.__file__).parent / "session_peer_core" / "addresses.py").read_text() + for changed in ("PRESET_MAX_BYTES", "PRESET_MAX_ENTRIES"): + namespace = {**peer.__dict__, changed: getattr(peer, changed) + 1} + with self.assertRaisesRegex(AssertionError, "storage limits differ"): + exec(compile(source, "addresses.py", "exec"), namespace) + + def test_closed_wire_rejects_extra_recipient_fields(self): + value = self.wire() + value["selections"][0]["recipient"]["credential"] = "private" + with self.assertRaises(peer.CcPeerError): + peer._address_validate_probe(value, {"agent": "claude", "transport": "local"}, ["worker"]) + + def test_canonical_home_alias_is_valid_only_with_explicit_selector_echo(self): + route = {"agent": "codex", "transport": "local", "codexHome": "/alias/home"} + target = "codex:" + THREAD + value = {"selectionSchemaVersion": 1, "homeSelector": "/alias/home", "ok": True, + "selections": [{"requestedTarget": target, "ok": True, + "recipient": {"agent": "codex", "target": target, "home": "/actual/home"}, + "selection": {"generationStatus": "unsupported", "targetGeneration": None, + "writerRevision": "aw1:" + "a" * 64, "observationRevision": None}}]} + self.assertEqual(peer._address_validate_probe(value, route, [target])[0]["recipient"]["home"], "/actual/home") + for selector in (None, "/wrong/home"): + value["homeSelector"] = selector + with self.assertRaises(peer.CcPeerError): + peer._address_validate_probe(value, route, [target]) + del value["homeSelector"] + with self.assertRaises(peer.CcPeerError): + peer._address_validate_probe(value, route, [target]) + + def test_literal_claude_pid_cannot_be_replaced_in_metadata_response(self): + route = {"agent": "claude", "transport": "local"} + for requested in ("claude:42", "42"): + value = self.wire() + value["selections"][0]["requestedTarget"] = requested + value["selections"][0]["recipient"]["target"] = "claude:43" + with self.assertRaises(peer.CcPeerError): + peer._address_validate_probe(value, route, [requested]) + value["selections"][0]["recipient"]["target"] = "claude:42" + self.assertEqual(peer._address_validate_probe(value, route, [requested])[0]["recipient"]["target"], "claude:42") + + def test_inspection_partial_failure_does_not_expose_diagnostics(self): + args = argparse.Namespace(agent="claude", to=["worker", "missing"], codex_home=None) + with mock.patch.object(peer, "discover", return_value=[]), \ + mock.patch.object(peer, "resolve_target", side_effect=[{"pid": 42}, peer.CcPeerError("secret path")]), \ + mock.patch.object(peer, "claude_generation", return_value="tg1:" + "a" * 64): + result = peer.address_inspect_selection(args) + self.assertFalse(result["ok"]) + self.assertTrue(result["selections"][0]["ok"]) + self.assertNotIn("secret", repr(result)) diff --git a/tests/core/test_address_send_guards.py b/tests/core/test_address_send_guards.py new file mode 100644 index 0000000..a0de013 --- /dev/null +++ b/tests/core/test_address_send_guards.py @@ -0,0 +1,137 @@ +"""Address-specific effect guards reuse native ownership, never manufacture epochs.""" +import contextlib +import copy +import io +import json +import argparse +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest import mock + +import session_peer as peer +from tests.codex import test_dispatch as fixture +from tests.codex.support import THREAD + + +class AddressSendGuards(unittest.TestCase): + def setUp(self): + fixture.Codex.setUp(self) + self.args.allow_inactive_codex_home = False + self.owner = {"activity": "live_writer", "writerLock": "held", "ownerStable": True, + "ownerPid": 42, "ownerStartTime": 123456} + self.resolution = {"schemaVersion": 1, "status": "selected", "selected": str(self.root), + "reason": "explicit_live_writer", "candidates": [ + {"codexHome": str(self.root), "savedThread": True, **self.owner}]} + self.args._address_expected_writer = peer.address_codex_writer_revision(self.root, THREAD, self.resolution) + + @contextlib.contextmanager + def native(self, environment=None): + with contextlib.ExitStack() as stack: + stack.enter_context(mock.patch.object(peer, "codex_executable", return_value="codex-fixture")) + stack.enter_context(mock.patch.object(peer, "resolve_codex_home", return_value=(self.root, self.resolution))) + stack.enter_context(mock.patch.object(peer, "codex_queue_storage_preflight", return_value=self.root)) + stack.enter_context(mock.patch.object(peer, "codex_queue_preference_policy", return_value=False)) + stack.enter_context(mock.patch.object(peer, "check_codex_consumer_storage")) + stack.enter_context(mock.patch.object(peer, "codex_process_environment", side_effect=environment, + return_value={})) + inspection = stack.enter_context(mock.patch.object(peer, "inspect_codex_writer", side_effect=lambda *a: dict(self.owner))) + child = stack.enter_context(mock.patch.object(peer.subprocess, "run", return_value= + subprocess.CompletedProcess([], 0, stdout="", stderr=""))) + yield inspection, child + + def test_changed_saved_writer_refuses_preview_and_effect(self): + for preview in (False, True): + self.args.dry_run = preview + self.args._address_expected_writer = "aw1:" + "0" * 64 + with self.native() as (_, child), self.assertRaises(peer.CcPeerError) as error: + peer._queue_codex(self.args, "fixture") + self.assertEqual(error.exception.details["reason"], "address_writer_changed") + self.assertFalse(error.exception.details["submitted"]) + self.assertFalse(error.exception.details["retryAllowed"]) + child.assert_not_called() + + def test_writer_change_during_storage_checks_refuses_before_native_child(self): + def environment(*args, **kwargs): + self.owner["ownerPid"] = 43 + return {} + with self.native(environment) as (inspection, child), self.assertRaises(peer.CcPeerError) as error: + peer._queue_codex(self.args, "fixture") + self.assertEqual(error.exception.details["reason"], "address_writer_changed") + self.assertFalse(error.exception.details["submitted"]) + self.assertFalse(error.exception.details["retryAllowed"]) + self.assertEqual(inspection.call_count, 2) + child.assert_not_called() + + def test_matching_writer_submits_once_without_generation_claim(self): + with self.native() as (inspection, child): + result = peer._queue_codex(self.args, "fixture") + self.assertTrue(result["ok"]) + self.assertTrue(result["submitted"]) + self.assertFalse(result["consumptionConfirmed"]) + self.assertNotIn("targetGeneration", result) + self.assertEqual(inspection.call_count, 2) + child.assert_called_once() + + def test_remote_effect_carries_the_exact_writer_observation_guard(self): + result = {"ok": True, "target": {"agent": "codex", "id": THREAD}, "dryRun": True, + "submitted": False, "consumptionConfirmed": False, "status": "validated", "chars": 7} + out = io.StringIO() + with mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport") as transport, contextlib.redirect_stdout(out): + transport.return_value.host = "fixture" + transport.return_value.ssh_opts = [] + transport.return_value.execute.return_value = result + code = peer.main(["send", "--host", "fixture", "--to", "codex:" + THREAD, + "--_address-writer-revision", self.args._address_expected_writer, + "--message", "fixture", "--dry-run", "--json", "--no-from", "--no-reply-to"]) + self.assertEqual(code, 0) + argv = transport.return_value.execute.call_args.args[0] + index = argv.index("--_address-writer-revision") + self.assertEqual(argv[index + 1], self.args._address_expected_writer) + transport.return_value.execute.assert_called_once() + + +class AddressInspectionWire(unittest.TestCase): + def test_real_home_alias_resolves_and_inventory_deduplicates(self): + with tempfile.TemporaryDirectory(dir=os.environ.get("SESSION_PEER_TEST_TMP")) as directory: + base = Path(directory).resolve() + actual = base / "actual" + actual.mkdir() + (actual / "state_5.sqlite").touch() + alias = base / "alias" + try: + alias.symlink_to(actual, target_is_directory=True) + except OSError: + self.skipTest("symlink creation unavailable") + args = argparse.Namespace(codex_home=str(alias)) + self.assertEqual(peer.codex_home(args), actual) + with mock.patch.object(peer.Path, "home", return_value=base / "empty-user"), \ + mock.patch.object(peer, "configured_codex_home_paths", return_value=[alias, actual]): + self.assertEqual(peer.known_codex_homes(peer.codex_home(args)), [actual]) + + def test_public_inspection_keeps_partial_failure_in_json_and_exit(self): + payload = {"selectionSchemaVersion": 1, "homeSelector": None, "ok": False, + "selections": [{"requestedTarget": "missing", "ok": False, + "reason": "address_selection_unavailable"}]} + output = io.StringIO() + with mock.patch.object(peer, "address_inspect_selection", return_value=payload), \ + contextlib.redirect_stdout(output): + code = peer.main(["address", "_inspect", "--agent", "claude", "--to=missing", "--json"]) + result = json.loads(output.getvalue()) + self.assertEqual(code, 1) + self.assertFalse(result["ok"]) + self.assertEqual(result["selections"], payload["selections"]) + + def test_partial_private_metadata_survives_transport_for_exact_consumer_validation(self): + payload = {"schemaVersion": 1, "ok": False, "command": "address", "host": "fixture", + "selectionSchemaVersion": 1, "selections": [{"ok": False}]} + done = subprocess.CompletedProcess([], 1, stdout=json.dumps(payload), stderr="") + with mock.patch.object(peer, "ssh_user_metadata", return_value={}), \ + mock.patch.object(peer.subprocess, "run", return_value=done): + result = peer.run_remote("fixture", ["address", "_inspect", "--agent", "claude", "--to", "worker"], []) + self.assertEqual(result["selections"], payload["selections"]) + with self.assertRaises(peer.CcPeerError): + peer.run_remote("fixture", ["address", "show", "worker"], []) diff --git a/tests/core/test_addresses_storage.py b/tests/core/test_addresses_storage.py new file mode 100644 index 0000000..9cfbfb9 --- /dev/null +++ b/tests/core/test_addresses_storage.py @@ -0,0 +1,339 @@ +"""Offline private address storage; selected evidence is supplied by fixtures.""" +import concurrent.futures +import copy +import json +import os +from pathlib import Path +import stat +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock +import uuid + +import session_peer as peer + + +def selected(label="Worker", agent="claude", transport="local"): + route = {"transport": transport, "agent": agent} + if transport == "ssh": + route["host"] = "fixture-host" + if agent == "codex": + route["codexHome"] = "/fixture/home" + recipient = {"agent": agent, "target": "claude:42" if agent == "claude" else + "codex:00000000-0000-4000-8000-000000000001", + "home": None if agent == "claude" else "/fixture/home"} + selection = {"generationStatus": "available" if agent == "claude" else "unsupported", + "targetGeneration": "tg1:" + "a" * 64 if agent == "claude" else None, + "writerRevision": "aw1:" + "b" * 64 if agent == "codex" else None, + "observationRevision": None} + key = "SHA256:" + "A" * 43 if transport == "ssh" else None + revision = peer.preset_revision("fixture", route) + selection["observationRevision"] = peer.address_selection_revision( + "fixture", revision, route, recipient, selection, key) + return {"label": label, "preset": "fixture", "presetRevision": revision, + "route": route, "recipient": recipient, "selection": selection, "sshHostKey": key} + + +class AddressValidationTests(unittest.TestCase): + def entry(self, **kwargs): + return {"bindingId": str(uuid.uuid4()), **selected(**kwargs)} + + def refuse(self, function, *args, **kwargs): + with self.assertRaises(peer.CcPeerError) as caught: + function(*args, **kwargs) + self.assertTrue(caught.exception.details["reason"].startswith("address_")) + + def test_all_local_ssh_claude_codex_combinations(self): + for agent in ("claude", "codex"): + for transport in ("local", "ssh"): + entry = self.entry(agent=agent, transport=transport) + self.assertEqual(peer.address_validate_entry(entry), entry) + + def test_closed_entry_and_binding_uuid4_contract(self): + entry = self.entry() + for change in ({"secret": "forbidden"}, {"bindingId": None}, {"bindingId": str(uuid.uuid1())}, + {"bindingId": entry["bindingId"].upper()}, {"bindingId": []}, {"presetRevision": "dp1:" + "f" * 64}): + self.refuse(peer.address_validate_entry, {**entry, **change}) + self.refuse(peer.address_validate_entry, {k: v for k, v in entry.items() if k != "label"}) + + def test_labels_nfc_unicode_control_bidi_and_zero_width(self): + entry = self.entry(label="한글 日本語 Café") + self.assertEqual(peer.address_validate_entry(entry), entry) + for label in ("", " ", "x" * 129, "Cafe\u0301", "a\n", "a\x85", "a\u202e", "a\u200b", "a\u2066", "a\ud800", + "a\u034f", "a\u2028", "a\ufe0f", "a\u3164"): + self.refuse(peer.address_validate_entry, {**entry, "label": label}) + + def test_recipient_selection_and_route_cannot_be_mixed_or_injected(self): + for agent in ("claude", "codex"): + entry = self.entry(agent=agent) + for field, updates in (("recipient", {"target": "codex:ALIAS"}), + ("recipient", {"agent": "antigravity"}), + ("recipient", {"home": "/x/../home"}), + ("recipient", {"command": "injected"}), + ("selection", {"targetGeneration": "tg1:bad"}), + ("selection", {"writerRevision": "aw1:bad"}), + ("selection", {"observationRevision": "as1:" + "0" * 64}), + ("selection", {"timestamp": 1})): + modified = copy.deepcopy(entry) + modified[field].update(updates) + self.refuse(peer.address_validate_entry, modified) + for key in ("SHA256:" + "A" * 43, [], "private_key"): + self.refuse(peer.address_validate_entry, {**entry, "sshHostKey": key}) + ssh = self.entry(transport="ssh") + for key in (None, "SHA256:" + "A" * 42, "SHA256:" + "A" * 42 + "B", "SHA256:" + "A" * 43 + "=", "MD5:forbidden"): + self.refuse(peer.address_validate_entry, {**ssh, "sshHostKey": key}) + + def test_selection_revision_excludes_only_itself(self): + entry = self.entry(transport="ssh") + def revision(value): + return peer.address_selection_revision(value["preset"], value["presetRevision"], value["route"], + value["recipient"], value["selection"], value["sshHostKey"]) + expected = revision(entry) + self.assertRegex(expected, r"^as1:[0-9a-f]{64}$") + other = copy.deepcopy(entry) + other["selection"]["observationRevision"] = "arbitrary-self-field" + other["label"] = "Changed" + other["bindingId"] = str(uuid.uuid4()) + self.assertEqual(revision(other), expected) + other["recipient"]["target"] = "claude:43" + self.assertNotEqual(revision(other), expected) + other = copy.deepcopy(entry) + other["selection"]["targetGeneration"] = "tg1:" + "c" * 64 + self.assertNotEqual(revision(other), expected) + other = copy.deepcopy(entry) + other["sshHostKey"] = "SHA256:" + "B" * 42 + "A" + self.assertNotEqual(revision(other), expected) + + def test_binding_revision_covers_name_label_and_fresh_binding(self): + entry = self.entry() + token = peer.address_revision("worker", entry) + self.assertRegex(token, r"^ab1:[0-9a-f]{64}$") + self.assertEqual(token, peer.address_revision("worker", dict(reversed(list(entry.items()))))) + self.assertNotEqual(token, peer.address_revision("other", entry)) + self.assertNotEqual(token, peer.address_revision("worker", {**entry, "label": "Other"})) + self.assertNotEqual(token, peer.address_revision("worker", {**entry, "bindingId": str(uuid.uuid4())})) + + +@unittest.skipIf(peer.IS_WINDOWS or peer.fcntl is None, "POSIX private storage") +class AddressStorageTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="codex-addresses-", dir=os.environ.get("SESSION_PEER_TEST_TMP")) + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.root.chmod(0o700) + self.addCleanup(mock.patch.stopall) + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": str(self.root)}).start() + self.app = self.root / "session-peer" + self.file = self.app / "addresses.json" + # These are explicit unit metadata fixtures, not a production exception + # for owner-ignored volumes. The refusal path is tested separately. + mock.patch.object(peer, "_preset_check_ownership").start() + real_fstat = os.fstat + # The task's required external-volume root is group writable. Model + # only those pre-existing ancestors as private metadata for unit I/O. + modeled = {(p.stat().st_dev, p.stat().st_ino) for p in self.root.parents + if p.stat().st_uid == os.geteuid() and p.stat().st_mode & 0o022} + def metadata(fd): + info = real_fstat(fd) + if (info.st_dev, info.st_ino) in modeled: + fields = {name: getattr(info, name) for name in dir(info) if name.startswith("st_")} + fields["st_mode"] &= ~0o022 + return SimpleNamespace(**fields) + return info + mock.patch.object(peer.os, "fstat", side_effect=metadata).start() + self.entry = selected() + + def refuse(self, reason, function, *args, **kwargs): + with self.assertRaises(peer.CcPeerError) as caught: + function(*args, **kwargs) + self.assertEqual(caught.exception.details["reason"], reason) + self.assertNotIn(str(self.root), str(caught.exception)) + + def write_raw(self, raw): + self.app.mkdir(mode=0o700, exist_ok=True) + self.file.write_bytes(raw) + self.file.chmod(0o600) + + def test_absent_is_empty_without_creating_directory_or_lock(self): + self.assertEqual(peer.address_read(), {}) + self.refuse("address_not_found", peer.address_lookup, "worker") + self.assertEqual(list(self.root.iterdir()), []) + + def test_add_lookup_and_fresh_reselect_prevent_aba(self): + original = copy.deepcopy(self.entry) + first = peer.address_mutate("add", "worker", self.entry) + self.assertEqual(self.entry, original) + self.assertEqual(peer.address_lookup("worker"), (first["entry"], first["revision"])) + lock = self.app / "addresses.lock" + inode = lock.stat().st_ino + for path, mode in ((self.app, 0o700), (self.file, 0o600), (lock, 0o600)): + self.assertEqual(stat.S_IMODE(path.stat().st_mode), mode) + second = peer.address_mutate("reselect", "worker", self.entry, first["revision"]) + self.assertNotEqual(first["entry"]["bindingId"], second["entry"]["bindingId"]) + self.assertNotEqual(first["revision"], second["revision"]) + self.assertEqual(lock.stat().st_ino, inode) + self.refuse("address_revision_mismatch", peer.address_mutate, "remove", "worker", None, first["revision"]) + removed = peer.address_mutate("remove", "worker", expected_revision=second["revision"]) + self.assertTrue(removed["removed"]) + self.assertEqual(peer.address_read(), {}) + self.assertFalse((self.app / "destinations.json").exists()) + self.assertFalse((self.app / "destinations.lock").exists()) + + def test_no_overwrite_cas_and_binding_id_injection(self): + first = peer.address_mutate("add", "worker", self.entry) + before = self.file.read_bytes() + self.refuse("address_already_exists", peer.address_mutate, "add", "worker", self.entry) + for action in ("reselect", "remove"): + entry = self.entry if action == "reselect" else None + self.refuse("address_revision_required", peer.address_mutate, action, "worker", entry) + self.refuse("address_invalid_entry", peer.address_mutate, "add", "other", first["entry"]) + self.assertEqual(self.file.read_bytes(), before) + + def test_duplicate_labels_and_bad_names_refused(self): + peer.address_mutate("add", "worker", self.entry) + self.refuse("address_duplicate_label", peer.address_mutate, "add", "other", self.entry) + for name in ("UPPER", "a/b", "日本語", "a" * 65): + self.refuse("address_invalid_name", peer.address_mutate, "add", name, selected("Different")) + self.assertEqual(set(peer.address_read()), {"worker"}) + + def test_corrupt_future_duplicate_keys_and_version_types_never_repaired(self): + bad = [b"", b"\xff", b"{} trailing", b'{"schemaVersion":1,"schemaVersion":1,"addresses":{}}', + b'{"schemaVersion":1,"addresses":{"x":{},"\\u0078":{}}}', + b'{"schemaVersion":NaN,"addresses":{}}'] + for version in (True, 1.0, 2, None): + bad.append(json.dumps({"schemaVersion": version, "addresses": {}}).encode()) + for raw in bad: + self.write_raw(raw) + self.refuse("address_corrupt", peer.address_read) + self.refuse("address_corrupt", peer.address_mutate, "add", "worker", self.entry) + self.assertEqual(self.file.read_bytes(), raw) + + def test_preset_bytes_and_dp1_unchanged_by_address_mutation(self): + preset = peer.preset_mutate("add", "fixture", self.entry["route"]) + file = self.app / "destinations.json" + before = (file.read_bytes(), file.stat().st_ino, file.stat().st_mtime_ns) + peer.address_mutate("add", "worker", self.entry) + self.assertEqual(peer.preset_lookup("fixture"), (self.entry["route"], preset["revision"])) + self.assertEqual((file.read_bytes(), file.stat().st_ino, file.stat().st_mtime_ns), before) + + def test_symlink_hardlink_and_unsafe_modes_preserved(self): + peer.address_mutate("add", "worker", self.entry) + original = self.file.read_bytes() + outside = self.root / "preserved" + self.file.rename(outside) + self.file.symlink_to(outside) + self.refuse("address_unsafe_storage", peer.address_read) + self.refuse("address_unsafe_storage", peer.address_mutate, "add", "other", selected("Other")) + self.assertEqual(outside.read_bytes(), original) + self.file.unlink() + os.link(outside, self.file) + self.refuse("address_unsafe_storage", peer.address_read) + self.file.unlink() + outside.rename(self.file) + self.file.chmod(0o640) + self.refuse("address_unsafe_storage", peer.address_read) + self.assertEqual(stat.S_IMODE(self.file.stat().st_mode), 0o640) + + def test_owner_ignored_mount_and_acl_fail_closed(self): + with mock.patch.object(peer, "_preset_check_ownership", side_effect=peer._preset_error("preset_ownership_unenforced")): + self.refuse("address_ownership_unenforced", peer.address_read) + self.refuse("address_ownership_unenforced", peer.address_mutate, "add", "worker", self.entry) + with mock.patch.object(peer, "_preset_check_acl", side_effect=peer._preset_error("preset_unsafe_acl")): + self.refuse("address_unsafe_acl", peer.address_read) + self.assertFalse(self.app.exists()) + + def test_atomic_replace_failure_and_postcommit_classification(self): + peer.address_mutate("add", "worker", self.entry) + before = self.file.read_bytes() + with mock.patch.object(peer.os, "replace", side_effect=OSError("private diagnostic")): + self.refuse("address_unsafe_storage", peer.address_mutate, "add", "other", selected("Other")) + self.assertEqual(self.file.read_bytes(), before) + real_fsync = os.fsync + def fsync(fd): + if stat.S_ISDIR(os.fstat(fd).st_mode): + raise OSError("private diagnostic") + return real_fsync(fd) + with mock.patch.object(peer.os, "fsync", side_effect=fsync): + self.refuse("address_commit_unconfirmed", peer.address_mutate, "add", "other", selected("Other")) + self.assertEqual(set(peer.address_read()), {"worker", "other"}) + self.assertEqual(sorted(p.name for p in self.app.iterdir()), ["addresses.json", "addresses.lock"]) + + def test_capacity_and_concurrent_adds_and_single_winner_cas(self): + self.write_raw(b" " * (peer.ADDRESS_MAX_BYTES + 1)) + self.refuse("address_capacity", peer.address_read) + self.file.unlink() + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(lambda n: peer.address_mutate("add", "entry" + str(n), selected("Worker " + str(n))), range(8))) + revision = results[0]["revision"] + def reselect(label): + try: + peer.address_mutate("reselect", "entry0", selected(label), revision) + return "success" + except peer.CcPeerError as error: + return error.details["reason"] + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + outcomes = list(pool.map(reselect, ("First", "Second"))) + self.assertEqual(sorted(outcomes), ["address_revision_mismatch", "success"]) + self.assertEqual(len(peer.address_read()), 8) + + def test_inherited_entry_limit_and_duplicate_stored_labels(self): + entry = {"bindingId": str(uuid.uuid4()), **self.entry} + entries = {"entry" + str(n): {**entry, "label": "Worker " + str(n)} for n in range(129)} + self.refuse("address_capacity", peer._address_decode, + json.dumps({"schemaVersion": 1, "addresses": entries}).encode()) + entries = {"first": entry, "second": {**entry, "bindingId": str(uuid.uuid4())}} + self.refuse("address_duplicate_label", peer._address_decode, + json.dumps({"schemaVersion": 1, "addresses": entries}).encode()) + + def test_lock_deadline_and_unsafe_lock_preserve_store(self): + peer.address_mutate("add", "worker", self.entry) + lock = self.app / "addresses.lock" + before = self.file.read_bytes() + fd = os.open(lock, os.O_RDWR) + try: + peer.fcntl.flock(fd, peer.fcntl.LOCK_EX | peer.fcntl.LOCK_NB) + with mock.patch.object(peer, "PRESET_LOCK_SECONDS", 0.01): + self.refuse("address_busy", peer.address_mutate, "add", "other", selected("Other")) + finally: + os.close(fd) + lock.chmod(0o640) + self.refuse("address_unsafe_storage", peer.address_mutate, "add", "other", selected("Other")) + self.assertEqual(self.file.read_bytes(), before) + + def test_original_file_identity_rechecked_before_commit(self): + peer.address_mutate("add", "worker", self.entry) + before = self.file.read_bytes() + outside = self.root / "preserved" + real_fsync = os.fsync + swapped = False + def fsync(fd): + nonlocal swapped + real_fsync(fd) + if not swapped: + swapped = True + self.file.rename(outside) + self.file.symlink_to(outside) + with mock.patch.object(peer.os, "fsync", side_effect=fsync): + self.refuse("address_unsafe_storage", peer.address_mutate, "add", "other", selected("Other")) + self.assertTrue(self.file.is_symlink()) + self.assertEqual(outside.read_bytes(), before) + self.assertFalse(any(p.name.startswith(".addresses-") for p in self.app.iterdir())) + + def test_directory_identity_rechecked_before_commit(self): + peer.address_mutate("add", "worker", self.entry) + before = self.file.read_bytes() + original_directory = peer._preset_directory + moved = self.root / "moved" + calls = 0 + def directory(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 2: + self.app.rename(moved) + self.app.mkdir(mode=0o700) + return original_directory(*args, **kwargs) + with mock.patch.object(peer, "_preset_directory", side_effect=directory): + self.refuse("address_storage_changed", peer.address_mutate, "add", "other", selected("Other")) + self.assertEqual((moved / "addresses.json").read_bytes(), before) + self.assertEqual(list(self.app.iterdir()), []) diff --git a/tests/core/test_candidate_integration.py b/tests/core/test_candidate_integration.py new file mode 100644 index 0000000..44e2755 --- /dev/null +++ b/tests/core/test_candidate_integration.py @@ -0,0 +1,100 @@ +"""Focused offline CLI composition checks for the isolated integration tree.""" +import contextlib +import io +import unittest +from unittest import mock + +import session_peer as peer +from tests.core import test_address_flow as address_flow + + +@unittest.skipIf(peer.IS_WINDOWS or peer.fcntl is None, "controlled POSIX address fixture") +class CandidateIntegrationTests(unittest.TestCase): + def setUp(self): + # Compose the fixture, rather than import/subclass its TestCase and + # accidentally discover its existing test methods a second time. + self.fixture = address_flow.AddressFlowTests(methodName="runTest") + self.fixture.setUp() + self.addCleanup(self.fixture.doCleanups) + + def invoke(self, argv): + output, error = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error): + try: + code = peer.main(argv) + except SystemExit as exc: + code = exc.code + return code, output.getvalue(), error.getvalue() + + def test_address_dry_run_explanation_reuses_exactly_one_selection(self): + self.fixture.add() + self.fixture.probe_mock.reset_mock() + native = {"ok": True, "dryRun": True, + "target": {"pid": 42, "name": "worker"}, "chars": 1, + "targetGeneration": self.fixture.generation} + with mock.patch.object(peer.LocalTransport, "execute", return_value=native) as execute: + code, output, error = self.invoke(["send", "--address", "research", "--dry-run", "--explain", + "--message", "fixture", "--no-from", "--no-reply-to"]) + self.assertEqual(code, 0, error) + self.assertIn("Delivery preview", output) + self.assertIn("Validation: passed for this dry run", output) + self.assertIn("Resolved target: Claude PID 42", output) + self.assertIn("Address research:", error) + self.fixture.probe_mock.assert_called_once_with(self.fixture.route, ["claude:42"], None) + execute.assert_called_once() + self.assertTrue(execute.call_args.args[2].dry_run) + self.assertEqual(execute.call_args.args[2].target_generation, self.fixture.generation) + + def test_address_explanation_json_refused_before_configuration_or_input(self): + with mock.patch.object(peer, "address_lookup", side_effect=AssertionError("address read")), \ + mock.patch.object(peer, "read_message", side_effect=AssertionError("message read")): + code, output, error = self.invoke(["send", "--address", "research", "--dry-run", "--explain", "--json"]) + self.assertEqual(code, 2) + self.assertEqual(output, "") + self.assertIn("--explain requires text output", error) + self.fixture.probe_mock.assert_not_called() + + def test_address_trusted_ssh_flags_refused_before_any_configuration_read(self): + variants = (["--ssh-config", "/private/not-opened", "--trust-ssh-config"], + ["--ssh-config", "/private/not-opened"], ["--trust-ssh-config"]) + for flags in variants: + with self.subTest(flags=flags), \ + mock.patch.object(peer, "address_lookup", side_effect=AssertionError("address read")), \ + mock.patch.object(peer, "select_trusted_ssh_config", side_effect=AssertionError("SSH config read")), \ + mock.patch.object(peer, "read_message", side_effect=AssertionError("message read")): + code, output, error = self.invoke(["send", "--address", "research", "--dry-run", *flags]) + self.assertEqual(code, 2) + self.assertEqual(output, "") + self.assertIn("--address conflicts with --", error) + self.fixture.probe_mock.assert_not_called() + + def test_watch_and_status_explanation_keep_separate_parser_contracts(self): + correlation = "00000000-0000-4000-8000-000000000001" + watch = peer.build_parser().parse_args(["handoff", "watch", "--correlation-id", correlation, + "--watch-timeout", "1", "--interval-ms", "1000", "--json"]) + status = peer.build_parser().parse_args(["handoff", "status", "--correlation-id", correlation, "--explain"]) + self.assertIs(watch.func, peer.cmd_handoff_watch) + self.assertEqual(watch.watch_timeout, 1) + self.assertIs(status.func, peer.cmd_handoff) + self.assertTrue(status.explain) + self.assertFalse(getattr(watch, "explain", False)) + + def test_pairing_credential_argv_redaction_survives_explicit_option_tracking(self): + secret = "session-peer://pair/PRIVATE-CREDENTIAL" + code, output, error = self.invoke(["device", "pair", "--link", secret]) + self.assertEqual(code, 2) + self.assertEqual(output, "") + self.assertIn("pairing_link_argv_forbidden", error) + self.assertNotIn(secret, error) + + def test_routed_notice_preference_rejected_before_ssh_configuration_read(self): + with mock.patch.object(peer, "select_trusted_ssh_config", side_effect=AssertionError("SSH config read")): + code, output, error = self.invoke(["update", "--dismiss-runtime-notice", "2.0.0", "--host", "worker", + "--ssh-config", "/private/not-opened", "--trust-ssh-config"]) + self.assertEqual(code, 2) + self.assertEqual(output, "") + self.assertIn("Runtime notice preferences are local only", error) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/test_presets_cli.py b/tests/core/test_presets_cli.py new file mode 100644 index 0000000..3fed261 --- /dev/null +++ b/tests/core/test_presets_cli.py @@ -0,0 +1,271 @@ +"""Route presets are sender-only snapshots, never receiver bindings.""" +import contextlib +import io +import json +import unittest +from unittest import mock + +import session_peer as peer + + +class PresetCliTests(unittest.TestCase): + route = {"transport": "local", "agent": "claude"} + ssh_route = {"transport": "ssh", "agent": "codex", "host": "user@fixture", "codexHome": "/fixture/home"} + revision = "dp1:" + "a" * 64 + + def run_cli(self, argv, **kwargs): + output, error = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error): + try: + code = peer.main(argv) + except SystemExit as exc: + code = exc.code + return code, output.getvalue(), error.getvalue() + + def test_opt_out_does_not_read_presets(self): + with mock.patch.object(peer, "preset_lookup", side_effect=AssertionError("store")), \ + mock.patch.object(peer, "preset_read", side_effect=AssertionError("store")), \ + mock.patch.object(peer, "cmd_list", return_value=0) as listing: + self.assertEqual(self.run_cli(["list"])[0], 0) + listing.assert_called_once() + self.assertFalse(hasattr(listing.call_args.args[0], "_preset_explicit_options")) + + def test_management_has_no_notice_refresh_or_transport_even_as_real_invocation(self): + for action in (["list"], ["show", "work"]): + with self.subTest(action=action), \ + mock.patch.object(peer.sys, "argv", ["session-peer", "preset", *action, "--json"]), \ + mock.patch.object(peer, "IS_WINDOWS", False), \ + mock.patch.object(peer, "preset_read", return_value={"work": self.route}), \ + mock.patch.object(peer, "preset_lookup", return_value=(self.route, self.revision)), \ + mock.patch.object(peer, "preset_revision", return_value=self.revision), \ + mock.patch.object(peer, "prepare_client_update", side_effect=AssertionError("notice")), \ + mock.patch.object(peer, "prepare_skill_updates", side_effect=AssertionError("notice")), \ + mock.patch.object(peer, "validate_ssh_identity_options", side_effect=AssertionError("transport")): + code, output, _ = self.run_cli(None) + self.assertEqual(code, 0) + self.assertEqual(json.loads(output)["command"], "preset") + + def test_management_add_replace_remove_shapes(self): + cases = [ + (["add", "work", "--agent", "claude"], "add", self.route, None), + (["replace", "work", "--agent", "codex", "--host", "user@fixture", "--codex-home", "/fixture/home", "--expect-revision", self.revision], "replace", self.ssh_route, self.revision), + (["remove", "work", "--expect-revision", self.revision], "remove", None, self.revision), + ] + for argv, action, route, expected in cases: + with self.subTest(action=action), mock.patch.object(peer, "IS_WINDOWS", False), \ + mock.patch.object(peer, "preset_mutate", return_value={"name": "work", "revision": self.revision, "route": self.route}) as mutation: + code, output, _ = self.run_cli(["preset", *argv, "--json"]) + self.assertEqual(code, 0) + mutation.assert_called_once_with(action, "work", route, expected) + self.assertNotIn("clientUpdate", json.loads(output)) + + def test_conflicts_are_usage_errors_before_store_input_or_notices(self): + flags = [["--host", "fixture"], ["--host=fixture"], ["--ho=fixture"], + ["--codex-home", "/fixture"], ["--ssh-opt=-4"], ["--codex-bin", "codex"], + ["--device", "device"], ["--device-route", "auto"], ["--request-ack"], + ["--wait-timeout", "30"], ["--wake"], ["--wake-timeout", "30"], + ["--allow-inactive-codex-home"], ["--b64", "eA=="], + ["--_handoff-native-total-ms", "1000"], ["--_wake-depth", "0"]] + for extra in flags: + with self.subTest(extra=extra), \ + mock.patch.object(peer, "preset_lookup", side_effect=AssertionError("store")), \ + mock.patch.object(peer, "read_message", side_effect=AssertionError("stdin")), \ + mock.patch.object(peer, "prepare_client_update", side_effect=AssertionError("notice")): + code, _, error = self.run_cli(["send", "--preset", "work", "--to", "test", "--dry-run", *extra]) + self.assertEqual(code, 2) + self.assertIn("--preset conflicts with", error) + + def test_uri_and_agent_conflict_and_missing_revision(self): + for target in ("session-peer://v1/reply?token=do-not-echo", "SESSION-PEER://v1/reply"): + with mock.patch.object(peer, "preset_lookup", side_effect=AssertionError("store")): + code, _, error = self.run_cli(["send", "--preset", "work", "--dry-run", "--to", target]) + self.assertEqual(code, 2) + self.assertIn("Reply-To URI", error) + self.assertNotIn("token=do-not-echo", error) + with mock.patch.object(peer, "IS_WINDOWS", False), mock.patch.object(peer, "preset_lookup", return_value=(self.route, self.revision)): + code, _, error = self.run_cli(["send", "--preset", "work", "--dry-run", "--to", "codex:fixture"]) + self.assertEqual(code, 2) + self.assertIn("--to agent conflicts", error) + with mock.patch.object(peer, "preset_lookup", side_effect=AssertionError("store")): + code, _, error = self.run_cli(["send", "--preset", "work", "--to", "test"]) + self.assertEqual(code, 2) + self.assertIn("require --preset-revision", error) + + def test_antigravity_target_refuses_before_lookup_or_advisory_work(self): + argv = ["send", "--preset", "work", "--to", "antigravity:00000000-0000-4000-8000-000000000001", "--dry-run"] + with mock.patch.object(peer.sys, "argv", ["session-peer", *argv]), \ + mock.patch.object(peer, "preset_lookup") as lookup, \ + mock.patch.object(peer, "read_message") as message, \ + mock.patch.object(peer, "prepare_client_update") as client, \ + mock.patch.object(peer, "prepare_skill_updates") as skills, \ + mock.patch.object(peer, "cmd_send") as send, \ + mock.patch.object(peer.LocalTransport, "execute") as native: + code, _, error = self.run_cli(None) + self.assertEqual(code, 2) + self.assertIn("--preset supports only Claude Code and Codex targets", error) + for operation in (lookup, message, client, skills, send, native): + operation.assert_not_called() + + def test_duplicate_route_options_refuse_before_store_input_or_updates(self): + cases = [ + ["list", "--preset", "work", "--preset=work"], + ["send", "--to", "test", "--dry-run", "--preset", "work", "--preset", "other"], + ["send", "--to", "test", "--preset", "work", "--preset-revision", self.revision, "--preset-revi=" + self.revision], + ["preset", "add", "work", "--agent", "claude", "--host", "fixture", "--ho=fixture"], + ["preset", "add", "work", "--agent", "claude", "--ag=claude"], + ["preset", "add", "work", "--agent", "codex", "--codex-home", "/fixture", "--codex-h=/fixture"], + ["preset", "remove", "work", "--expect-revision", self.revision, "--expect-r=" + self.revision], + ] + for argv in cases: + with self.subTest(argv=argv), \ + mock.patch.object(peer.sys, "argv", ["session-peer", *argv]), \ + mock.patch.object(peer, "preset_lookup") as lookup, \ + mock.patch.object(peer, "preset_read") as read, \ + mock.patch.object(peer, "preset_mutate") as mutate, \ + mock.patch.object(peer, "read_message") as message, \ + mock.patch.object(peer, "prepare_client_update") as client, \ + mock.patch.object(peer, "prepare_skill_updates") as skills: + code, _, error = self.run_cli(None) + self.assertEqual(code, 2) + self.assertIn("may be specified only once", error) + for operation in (lookup, read, mutate, message, client, skills): + operation.assert_not_called() + + def test_revision_without_preset_and_list_agent_override(self): + for argv in (["send", "--to", "test", "--preset-revision", self.revision], + ["list", "--preset", "work", "--agent", "claude"]): + with mock.patch.object(peer, "preset_lookup", side_effect=AssertionError("store")): + self.assertEqual(self.run_cli(argv)[0], 2) + + def test_message_option_values_and_post_separator_values_are_not_flags(self): + for body in (["--message=--host"], ["--message=--"], ["--", "--wake"]): + with self.subTest(body=body), mock.patch.object(peer, "IS_WINDOWS", False), \ + mock.patch.object(peer, "preset_lookup", return_value=(self.route, self.revision)), \ + mock.patch.object(peer, "cmd_send", return_value=0) as send: + code, _, _ = self.run_cli(["send", "--preset", "work", "--to", "test", "--dry-run", *body]) + self.assertEqual(code, 0) + send.assert_called_once() + + def test_real_invocation_store_error_starts_no_advisory_work(self): + with mock.patch.object(peer.sys, "argv", ["session-peer", "list", "--preset", "work", "--json"]), \ + mock.patch.object(peer, "IS_WINDOWS", False), \ + mock.patch.object(peer, "preset_lookup", side_effect=peer.CcPeerError("Preset not found")), \ + mock.patch.object(peer, "prepare_client_update") as client, \ + mock.patch.object(peer, "prepare_skill_updates") as skills, \ + mock.patch.object(peer, "cmd_list") as listing: + code, _, _ = self.run_cli(None) + self.assertEqual(code, 1) + client.assert_not_called() + skills.assert_not_called() + listing.assert_not_called() + + def test_early_lookup_and_management_interrupt_or_unexpected_error_are_bounded(self): + for management in (False, True): + for failure in (KeyboardInterrupt(), RuntimeError("PRIVATE_PATH_AND_BODY")): + with self.subTest(management=management, failure=type(failure).__name__), \ + mock.patch.object(peer, "IS_WINDOWS", False), \ + mock.patch.object(peer.sys, "argv", ["session-peer", *(["preset", "list", "--json"] if management else ["list", "--preset", "work", "--json"])]), \ + mock.patch.object(peer, "preset_read", side_effect=failure), \ + mock.patch.object(peer, "preset_lookup", side_effect=failure), \ + mock.patch.object(peer, "prepare_client_update") as client, \ + mock.patch.object(peer, "prepare_skill_updates") as skills, \ + mock.patch.object(peer, "cmd_list") as listing: + code, output, error = self.run_cli(None) + client.assert_not_called() + skills.assert_not_called() + listing.assert_not_called() + self.assertNotIn("PRIVATE_PATH_AND_BODY", output + error) + self.assertNotIn("Traceback", output + error) + if isinstance(failure, KeyboardInterrupt): + self.assertEqual(code, 130) + self.assertEqual(output + error, "") + else: + self.assertEqual(code, 1) + result = json.loads(output) + self.assertEqual(result["reason"], "preset_operation_failed") + self.assertFalse(result["ok"]) + self.assertEqual(result["command"], "preset" if management else "list") + + def test_lookup_failure_and_changed_revision_use_error_envelope_before_effect(self): + for result in (peer.CcPeerError("Preset not found", {"reason": "preset_not_found"}), (self.route, "dp1:" + "b" * 64)): + patch = {"side_effect": result} if isinstance(result, Exception) else {"return_value": result} + with self.subTest(result=type(result).__name__), mock.patch.object(peer, "IS_WINDOWS", False), \ + mock.patch.object(peer, "preset_lookup", **patch), \ + mock.patch.object(peer, "read_message", side_effect=AssertionError("input")), \ + mock.patch.object(peer, "cmd_send", side_effect=AssertionError("effect")), \ + mock.patch.object(peer, "prepare_client_update", side_effect=AssertionError("notice")): + code, output, _ = self.run_cli(["send", "--preset", "work", "--preset-revision", self.revision, "--to", "test", "--json"]) + self.assertEqual(code, 1) + payload = json.loads(output) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["command"], "send") + self.assertNotIn("submitted", payload) + + def test_local_send_keeps_json_and_text_revision_on_stderr(self): + native = {"ok": True, "target": {"pid": 42, "name": "test"}, "chars": 1, "dryRun": True} + for output_flags in (["--json"], []): + with self.subTest(output_flags=output_flags), mock.patch.object(peer, "IS_WINDOWS", False), \ + mock.patch.object(peer, "preset_lookup", return_value=(self.route, self.revision)) as lookup, \ + mock.patch.object(peer.LocalTransport, "execute", return_value=dict(native)) as execute: + code, output, error = self.run_cli(["send", "--preset", "work", "--to", "test", "--message", "x", "--dry-run", "--no-from", "--no-reply-to", *output_flags]) + self.assertEqual(code, 0) + lookup.assert_called_once_with("work") + self.assertEqual(execute.call_args.args[2].host, []) + if output_flags: + payload = json.loads(output) + self.assertNotIn("preset", payload) + self.assertNotIn("submitted", payload) + self.assertEqual(error, "") + else: + self.assertIn(self.revision, error) + self.assertNotIn(self.revision, output) + + def test_ssh_list_expands_single_route_without_forwarding_preset(self): + transport = mock.Mock(host="fixture", ssh_opts=[], identity_requested=False) + transport.execute.return_value = {"ok": True, "sessions": []} + with mock.patch.object(peer, "IS_WINDOWS", False), \ + mock.patch.object(peer, "preset_lookup", return_value=(self.ssh_route, self.revision)), \ + mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport", return_value=transport) as factory, \ + mock.patch.object(peer, "remote_installed_version", return_value=peer.__version__): + code, output, _ = self.run_cli(["list", "--preset", "work", "--json", "--ssh-identity"]) + self.assertEqual(code, 0) + self.assertEqual(factory.call_args.args[0], "user@fixture") + args = factory.call_args.args[1] + self.assertEqual(args.agent, "codex") + self.assertEqual(args.codex_home, "/fixture/home") + argv = transport.execute.call_args.args[0] + self.assertEqual(argv, ["list", "--no-update-notice", "--agent", "codex", "--codex-home", "/fixture/home"]) + self.assertNotIn("preset", json.loads(output)) + + def test_actual_ssh_send_requires_snapshot_and_does_not_forward_it(self): + transport = mock.Mock(host="fixture", ssh_opts=[]) + transport.execute.return_value = {"ok": True, "target": {"agent": "codex", "id": "00000000-0000-4000-8000-000000000001"}, "status": "queued", "submitted": True, "consumptionConfirmed": False, "chars": 1, "dryRun": False} + with mock.patch.object(peer, "IS_WINDOWS", False), \ + mock.patch.object(peer, "preset_lookup", return_value=(self.ssh_route, self.revision)), \ + mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport", return_value=transport): + code, output, _ = self.run_cli(["send", "--preset", "work", "--preset-revision", self.revision, "--to", "codex:00000000-0000-4000-8000-000000000001", "--message", "x", "--no-from", "--no-reply-to", "--json"]) + self.assertEqual(code, 0) + argv = transport.execute.call_args.args[0] + self.assertNotIn("--preset", argv) + self.assertNotIn(self.revision, argv) + self.assertNotIn("--preset-revision", argv) + self.assertNotIn("preset", json.loads(output)) + + def test_windows_refuses_before_store_and_management_is_sanitized(self): + for argv in (["preset", "list", "--json"], ["list", "--preset", "work", "--json"]): + with mock.patch.object(peer, "IS_WINDOWS", True), \ + mock.patch.object(peer, "preset_lookup", side_effect=AssertionError("store")), \ + mock.patch.object(peer, "preset_read", side_effect=AssertionError("store")): + code, output, _ = self.run_cli(argv) + self.assertEqual(code, 1) + self.assertEqual(json.loads(output)["reason"], "preset_platform_unsupported") + shown = peer.render_preset_route("bad\x1bname", {"transport": "ssh", "host": "a\nhost", "agent": "codex", "codexHome": "/a\x07"}, self.revision) + self.assertNotIn("\x1b", shown) + self.assertNotIn("\x07", shown) + self.assertIn("a\\x0ahost", shown) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/test_presets_flow.py b/tests/core/test_presets_flow.py new file mode 100644 index 0000000..57890e5 --- /dev/null +++ b/tests/core/test_presets_flow.py @@ -0,0 +1,150 @@ +"""Real private preset storage with mocked ordinary native/SSH transports.""" +import contextlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +import session_peer as peer + + +THREAD = "00000000-0000-4000-8000-000000000001" + + +@unittest.skipIf(peer.IS_WINDOWS or peer.fcntl is None, "POSIX route preset storage") +class PresetFlow(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="codex-preset-flow-", dir=os.environ.get("SESSION_PEER_TEST_TMP")) + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.root.chmod(0o700) + self.environment = mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": str(self.root)}) + self.environment.start() + self.addCleanup(self.environment.stop) + + def invoke(self, argv, *, entrypoint=False): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + if entrypoint: + with mock.patch.object(peer.sys, "argv", ["session-peer", *argv]): + code = peer.main() + else: + code = peer.main(argv) + return code, json.loads(out.getvalue()), err.getvalue() + + def test_management_preview_changed_revision_and_single_remote_send(self): + with mock.patch.object(peer.subprocess, "Popen") as child, \ + mock.patch.object(peer, "prepare_client_update") as notice, \ + mock.patch.object(peer, "prepare_skill_updates") as skills: + code, added, _ = self.invoke(["preset", "add", "work", "--agent", "claude", "--json"], entrypoint=True) + self.assertEqual(code, 0) + old = added["revision"] + code, shown, _ = self.invoke(["preset", "show", "work", "--json"], entrypoint=True) + self.assertEqual(code, 0) + self.assertEqual(shown["revision"], old) + self.assertEqual(shown["route"], {"agent": "claude", "transport": "local"}) + native = {"ok": True, "target": {"pid": 42, "name": "worker"}, "dryRun": True, "chars": 1} + with mock.patch.object(peer.LocalTransport, "execute", return_value=native) as local: + code, preview, error = self.invoke([ + "send", "--preset", "work", "--to", "worker", "--message", "x", + "--dry-run", "--json", "--no-from", "--no-reply-to"]) + self.assertEqual(code, 0) + self.assertEqual(error, "") + self.assertNotIn("preset", preview) + self.assertNotIn("submitted", preview) + self.assertEqual(local.call_args.args[2].host, []) + self.assertTrue(local.call_args.args[2].dry_run) + + code, changed, _ = self.invoke([ + "preset", "replace", "work", "--expect-revision", old, + "--host", "user@fixture", "--agent", "codex", "--codex-home", "/fixture/home", "--json"], entrypoint=True) + self.assertEqual(code, 0) + current = changed["revision"] + self.assertNotEqual(old, current) + send = ["send", "--preset", "work", "--to", "codex:" + THREAD, + "--message", "x", "--json", "--no-from", "--no-reply-to"] + with mock.patch.object(peer, "SshTransport") as remote, \ + mock.patch.object(peer.LocalTransport, "execute") as local: + code, stale, _ = self.invoke([*send, "--preset-revision", old]) + self.assertEqual(code, 1) + self.assertEqual(stale["reason"], "preset_revision_mismatch") + self.assertNotIn("submitted", stale) + remote.assert_not_called() + local.assert_not_called() + transport = mock.Mock(host="fixture", ssh_opts=[]) + transport.execute.return_value = { + "ok": True, "target": {"agent": "codex", "id": THREAD}, + "chars": 1, "dryRun": False, "status": "queued", "submitted": True, + "queueId": "fixture-queue", "consumptionConfirmed": False} + with mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport", return_value=transport) as remote: + code, result, error = self.invoke([*send, "--preset-revision", current]) + self.assertEqual(code, 0) + self.assertEqual(error, "") + self.assertTrue(result["submitted"]) + self.assertFalse(result["consumptionConfirmed"]) + self.assertEqual(result["queueId"], "fixture-queue") + self.assertNotIn("preset", result) + remote.assert_called_once() + transport.execute.assert_called_once() + argv = transport.execute.call_args.args[0] + for excluded in ("--preset", "--preset-revision", current, str(self.root)): + self.assertNotIn(excluded, argv) + child.assert_not_called() + notice.assert_not_called() + skills.assert_not_called() + + def test_route_change_after_lookup_does_not_rebind_current_invocation(self): + added = peer.preset_mutate("add", "work", {"transport": "local", "agent": "claude"}) + original = added["revision"] + + def change_after_snapshot(args): + peer.preset_mutate("replace", "work", { + "transport": "ssh", "agent": "claude", "host": "user@other"}, original) + return "x" + + native = {"ok": True, "target": {"pid": 42, "name": "worker"}, "chars": 1, "dryRun": False} + with mock.patch.object(peer, "read_message", side_effect=change_after_snapshot), \ + mock.patch.object(peer.LocalTransport, "execute", return_value=native) as local, \ + mock.patch.object(peer, "SshTransport") as remote: + code, result, _ = self.invoke([ + "send", "--preset", "work", "--preset-revision", original, + "--to", "worker", "--message", "x", "--json", "--no-from", "--no-reply-to"]) + self.assertEqual(code, 0) + self.assertEqual(local.call_args.args[2].host, []) + remote.assert_not_called() + self.assertNotIn("submitted", result) + route, revision = peer.preset_lookup("work") + self.assertEqual(route["host"], "user@other") + self.assertNotEqual(revision, original) + + @unittest.skipUnless(sys.platform == "darwin", "Darwin native deny-only ancestor") + def test_native_darwin_deny_only_ancestor_allows_private_child_storage(self): + ancestor = self.root / "deny-ancestor" + ancestor.mkdir(mode=0o700) + subprocess.run(["/bin/chmod", "+a", "everyone deny delete", str(ancestor)], + check=True, capture_output=True) + try: + fd = os.open(ancestor, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + self.assertEqual(peer._preset_darwin_acl_tags(fd), (2,)) + peer._preset_check_acl(fd, private=False, directory=True) + with self.assertRaises(peer.CcPeerError) as refused: + peer._preset_check_acl(fd, private=True, directory=True) + self.assertEqual(refused.exception.details["reason"], "preset_unsafe_acl") + finally: + os.close(fd) + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": str(ancestor / "config")}): + result = peer.preset_mutate("add", "work", {"transport": "local", "agent": "claude"}) + self.assertEqual(peer.preset_lookup("work")[1], result["revision"]) + finally: + subprocess.run(["/bin/chmod", "-N", str(ancestor)], check=True, capture_output=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/test_presets_storage.py b/tests/core/test_presets_storage.py new file mode 100644 index 0000000..855fa7a --- /dev/null +++ b/tests/core/test_presets_storage.py @@ -0,0 +1,736 @@ +"""Offline route-preset storage boundaries; no native agent or SSH execution.""" +import concurrent.futures +import copy +import ctypes +import errno +import json +import os +from pathlib import Path +import stat +import struct +import subprocess +import sys +import tempfile +import threading +from types import SimpleNamespace +import unittest +from unittest import mock + +import session_peer as peer + + +@unittest.skipIf(peer.IS_WINDOWS or peer.fcntl is None, "POSIX presets only") +class PresetStorageTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="codex-presets-", dir=os.environ.get("SESSION_PEER_TEST_TMP")) + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.root.chmod(0o700) + self.addCleanup(mock.patch.stopall) + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": str(self.root)}).start() + self.app = self.root / "session-peer" + self.file = self.app / "destinations.json" + self.route = {"transport": "ssh", "agent": "codex", "host": "user@fixture-host", "codexHome": "/fixture/home"} + + def write_raw(self, raw): + self.app.mkdir(mode=0o700, exist_ok=True) + self.file.write_bytes(raw) + self.file.chmod(0o600) + + def write_store(self, value): + self.write_raw(json.dumps(value).encode("utf-8")) + + def refuse(self, reason, function, *args, **kwargs): + with self.assertRaises(peer.CcPeerError) as caught: + function(*args, **kwargs) + self.assertEqual(caught.exception.details.get("reason"), reason) + self.assertNotIn(str(self.root), str(caught.exception)) + return caught.exception + + def test_missing_read_does_not_initialize_or_lock(self): + self.assertEqual(peer.preset_read(), {}) + self.refuse("preset_not_found", peer.preset_lookup, "fixture") + self.assertEqual(list(self.root.iterdir()), []) + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": str(self.root / "absent") }): + self.assertEqual(peer.preset_read(), {}) + self.assertEqual(list(self.root.iterdir()), []) + + def test_add_lookup_copies_and_private_storage(self): + original = copy.deepcopy(self.route) + result = peer.preset_mutate("add", "fixture", self.route) + self.assertEqual(self.route, original) + self.assertEqual(result["route"], original) + route, revision = peer.preset_lookup("fixture") + route["host"] = "changed" + self.assertEqual(peer.preset_lookup("fixture"), (original, revision)) + self.assertEqual(revision, result["revision"]) + for path, mode in ((self.app, 0o700), (self.file, 0o600), (self.app / "destinations.lock", 0o600)): + self.assertEqual(stat.S_IMODE(path.stat().st_mode), mode) + + def test_revision_canonical_order_and_exact_route_values(self): + revision = peer.preset_revision("fixture", self.route) + self.assertEqual(revision, peer.preset_revision("fixture", dict(reversed(list(self.route.items()))))) + self.assertRegex(revision, r"^dp1:[0-9a-f]{64}$") + for field, value in (("host", "other-host"), ("codexHome", "/fixture/home/")): + changed = dict(self.route, **{field: value}) + self.assertNotEqual(revision, peer.preset_revision("fixture", changed)) + self.assertNotEqual(revision, peer.preset_revision("other", self.route)) + + def test_replace_remove_require_matching_revision(self): + before = peer.preset_mutate("add", "fixture", self.route) + raw = self.file.read_bytes() + for action in ("replace", "remove"): + entry = self.route if action == "replace" else None + self.refuse("preset_revision_required", peer.preset_mutate, action, "fixture", entry) + self.refuse("preset_revision_mismatch", peer.preset_mutate, action, "fixture", entry, "dp1:" + "0" * 64) + self.assertEqual(self.file.read_bytes(), raw) + changed = dict(self.route, host="other-host") + after = peer.preset_mutate("replace", "fixture", changed, before["revision"]) + self.refuse("preset_revision_mismatch", peer.preset_mutate, "remove", "fixture", None, before["revision"]) + removed = peer.preset_mutate("remove", "fixture", None, after["revision"]) + self.assertTrue(removed["removed"]) + self.assertEqual(removed["route"], changed) + self.assertEqual(peer.preset_read(), {}) + + def test_duplicate_add_does_not_replace(self): + peer.preset_mutate("add", "fixture", self.route) + raw = self.file.read_bytes() + self.refuse("preset_already_exists", peer.preset_mutate, "add", "fixture", dict(self.route, host="other")) + self.assertEqual(self.file.read_bytes(), raw) + + def test_names_routes_and_unrecognized_fields_rejected_before_io(self): + for name in ("", "A", "a/b", "a " , "1fixture", "a" * 65, "日本語"): + self.refuse("preset_invalid_name", peer.preset_mutate, "add", name, self.route) + bad = [None, {}, dict(self.route, target="id"), dict(self.route, generation="tg1:x"), + dict(self.route, bin="arbitrary"), dict(self.route, sshOptions=[]), + dict(self.route, host="a@@b"), dict(self.route, host="@host"), + dict(self.route, host="user:password@host"), dict(self.route, host="-oProxyCommand=x"), + dict(self.route, host="host;command"), dict(self.route, host="a\n"), + dict(self.route, host="a\x85"), dict(self.route, host="x" * 1025), + dict(self.route, codexHome="relative"), dict(self.route, codexHome="//host/path"), + dict(self.route, codexHome="C:\\home"), dict(self.route, codexHome="/x\x7f"), + dict(self.route, codexHome="/" + "x" * 4096), + dict(self.route, codexHome="/\ud800"), dict(self.route, agent="antigravity"), + {"transport": "local", "agent": "claude", "host": "host"}, + {"transport": "local", "agent": "claude", "codexHome": "/x"}] + for route in bad: + self.refuse("preset_invalid_route", peer.preset_mutate, "add", "fixture", route) + self.assertFalse(self.app.exists()) + + def test_local_claude_and_codex_allowlists(self): + for name, route in (("claude", {"transport": "local", "agent": "claude"}), + ("codex", {"transport": "local", "agent": "codex", "codexHome": "/fixture"})): + peer.preset_mutate("add", name, route) + self.assertEqual(peer.preset_lookup(name)[0], route) + + def test_corrupt_json_schema_utf8_and_duplicate_keys_fail_closed(self): + valid = {"schemaVersion": 1, "presets": {"fixture": self.route}} + corrupt = [b"", b"\xff", b"{}{}", b"{} trailing", b"\xef\xbb\xbf{}", b"{", + b'{"schemaVersion":1,"schemaVersion":1,"presets":{}}', + b'{"schemaVersion":1,"presets":{"x":{},"\\u0078":{}}}', + b'{"schemaVersion":NaN,"presets":{}}', + json.dumps(dict(valid, schemaVersion=True)).encode(), + json.dumps(dict(valid, schemaVersion=1.0)).encode(), + json.dumps(dict(valid, unexpected="field")).encode(), + json.dumps(dict(valid, presets=[])).encode()] + for raw in corrupt: + self.write_raw(raw) + self.refuse("preset_corrupt", peer.preset_read) + self.refuse("preset_corrupt", peer.preset_mutate, "add", "other", self.route) + self.assertEqual(self.file.read_bytes(), raw) + + def test_capacity_bytes_entries_and_failed_write_preservation(self): + self.write_raw(b" " * (peer.PRESET_MAX_BYTES + 1)) + self.refuse("preset_capacity", peer.preset_read) + routes = {"a" + str(index): {"transport": "local", "agent": "claude"} for index in range(128)} + self.write_store({"schemaVersion": 1, "presets": routes}) + self.assertEqual(len(peer.preset_read()), 128) + before = self.file.read_bytes() + self.refuse("preset_capacity", peer.preset_mutate, "add", "overflow", self.route) + self.assertEqual(self.file.read_bytes(), before) + routes["overflow"] = self.route + self.write_store({"schemaVersion": 1, "presets": routes}) + self.refuse("preset_capacity", peer.preset_read) + + def test_unsafe_directory_file_mode_and_uid_not_repaired(self): + peer.preset_mutate("add", "fixture", self.route) + for path, mode in ((self.app, 0o750), (self.file, 0o640), (self.file, 0o700)): + old = stat.S_IMODE(path.stat().st_mode) + path.chmod(mode) + self.refuse("preset_unsafe_storage", peer.preset_read) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), mode) + path.chmod(old) + with mock.patch.object(peer.os, "geteuid", return_value=os.geteuid() + 1): + self.refuse("preset_unsafe_storage", peer.preset_read) + self.assertTrue(self.file.exists()) + + def test_symlink_file_directory_parent_and_hardlink_not_followed(self): + peer.preset_mutate("add", "fixture", self.route) + original = self.file.read_bytes() + outside = self.root / "outside.json" + self.file.rename(outside) + self.file.symlink_to(outside) + self.refuse("preset_unsafe_storage", peer.preset_read) + self.refuse("preset_unsafe_storage", peer.preset_mutate, "add", "other", self.route) + self.assertTrue(self.file.is_symlink()) + self.assertEqual(outside.read_bytes(), original) + self.file.unlink() + os.link(outside, self.file) + self.refuse("preset_unsafe_storage", peer.preset_read) + self.file.unlink() + outside.rename(self.file) + moved = self.root / "moved" + self.app.rename(moved) + self.app.symlink_to(moved, target_is_directory=True) + self.refuse("preset_unsafe_storage", peer.preset_read) + self.assertTrue(self.app.is_symlink()) + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": str(self.app)}): + self.refuse("preset_unsafe_storage", peer.preset_read) + + def test_fifo_not_blocked_and_read_does_not_create_lock(self): + self.app.mkdir(mode=0o700) + os.mkfifo(self.file, 0o600) + self.refuse("preset_unsafe_storage", peer.preset_read) + self.assertTrue(stat.S_ISFIFO(self.file.lstat().st_mode)) + self.assertFalse((self.app / "destinations.lock").exists()) + + def test_unsafe_lock_is_preserved(self): + peer.preset_mutate("add", "fixture", self.route) + before = self.file.read_bytes() + lock = self.app / "destinations.lock" + lock.chmod(0o660) + self.refuse("preset_unsafe_storage", peer.preset_mutate, "add", "other", self.route) + self.assertEqual(self.file.read_bytes(), before) + self.assertEqual(stat.S_IMODE(lock.stat().st_mode), 0o660) + + def test_lock_wait_is_bounded_and_does_not_write(self): + peer.preset_mutate("add", "fixture", self.route) + fd = os.open(self.app / "destinations.lock", os.O_RDWR) + self.addCleanup(os.close, fd) + peer.fcntl.flock(fd, peer.fcntl.LOCK_EX | peer.fcntl.LOCK_NB) + before = self.file.read_bytes() + with mock.patch.object(peer, "PRESET_LOCK_SECONDS", 0.02): + self.refuse("preset_busy", peer.preset_mutate, "add", "other", self.route) + self.assertEqual(self.file.read_bytes(), before) + + def test_concurrent_writers_preserve_independent_adds_and_cas(self): + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool: + results = list(pool.map(lambda n: peer.preset_mutate("add", "entry" + str(n), self.route), range(12))) + self.assertEqual(len(peer.preset_read()), 12) + revision = results[0]["revision"] + def replace(host): + try: + peer.preset_mutate("replace", "entry0", dict(self.route, host=host), revision) + return "success" + except peer.CcPeerError as error: + return error.details.get("reason") + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + outcomes = list(pool.map(replace, ("first", "second"))) + self.assertEqual(sorted(outcomes), ["preset_revision_mismatch", "success"]) + self.assertEqual(len(peer.preset_read()), 12) + + def test_atomic_replace_failure_preserves_previous_store_and_removes_own_temp(self): + peer.preset_mutate("add", "fixture", self.route) + before = self.file.read_bytes() + with mock.patch.object(peer.os, "replace", side_effect=OSError("private diagnostic")): + error = self.refuse("preset_unsafe_storage", peer.preset_mutate, "add", "other", self.route) + self.assertNotIn("private diagnostic", str(error)) + self.assertEqual(self.file.read_bytes(), before) + self.assertEqual(sorted(path.name for path in self.app.iterdir()), ["destinations.json", "destinations.lock"]) + + def test_directory_fsync_failure_reports_commit_unconfirmed_without_rollback(self): + peer.preset_mutate("add", "fixture", self.route) + real_fsync = os.fsync + def fail_directory_fsync(fd): + if stat.S_ISDIR(os.fstat(fd).st_mode): + raise OSError("private diagnostic") + return real_fsync(fd) + with mock.patch.object(peer.os, "fsync", side_effect=fail_directory_fsync): + error = self.refuse("preset_commit_unconfirmed", peer.preset_mutate, "add", "other", self.route) + self.assertIn("inspect configuration before retrying", str(error)) + self.assertNotIn("private diagnostic", str(error)) + self.assertEqual(set(peer.preset_read()), {"fixture", "other"}) + self.assertFalse(any(path.name.startswith(".destinations-") for path in self.app.iterdir())) + + def test_interrupt_immediately_after_rename_is_not_masked_by_cleanup(self): + peer.preset_mutate("add", "fixture", self.route) + real_replace = os.replace + def replace_then_interrupt(*args, **kwargs): + real_replace(*args, **kwargs) + raise KeyboardInterrupt() + with mock.patch.object(peer.os, "replace", side_effect=replace_then_interrupt): + with self.assertRaises(KeyboardInterrupt): + peer.preset_mutate("add", "other", self.route) + self.assertEqual(set(peer.preset_read()), {"fixture", "other"}) + self.assertFalse(any(path.name.startswith(".destinations-") for path in self.app.iterdir())) + + def test_interrupt_is_not_masked_by_temporary_unlink_io_failure(self): + peer.preset_mutate("add", "fixture", self.route) + before = self.file.read_bytes() + with mock.patch.object(peer.os, "replace", side_effect=KeyboardInterrupt()), mock.patch.object(peer.os, "unlink", side_effect=OSError(errno.EIO, "private cleanup diagnostic")): + with self.assertRaises(KeyboardInterrupt): + peer.preset_mutate("add", "other", self.route) + self.assertEqual(self.file.read_bytes(), before) + remaining = [path for path in self.app.iterdir() if path.name.startswith(".destinations-")] + self.assertEqual(len(remaining), 1) + remaining[0].unlink() # Only the dedicated fixture's unlinked temp remains. + + def test_cleanup_reports_normal_errors_but_never_swallows_new_interrupts(self): + def failed_cleanup(): + raise OSError(errno.EIO, "private cleanup diagnostic") + error = self.refuse("preset_cleanup_failed", peer._preset_cleanup, failed_cleanup) + self.assertNotIn("private cleanup diagnostic", str(error)) + def interrupted_cleanup(): + raise KeyboardInterrupt() + with self.assertRaises(KeyboardInterrupt): + try: + raise peer._preset_error("original_failure") + finally: + peer._preset_cleanup(interrupted_cleanup) + + def test_handled_caller_exception_does_not_hide_normal_scope_or_acl_cleanup_failure(self): + def failed_cleanup(): + raise OSError(errno.EIO, "private cleanup diagnostic") + api = self.darwin_api(()) + api.acl_free.return_value = -1 + try: + raise ValueError("already handled by caller") + except ValueError: + with self.assertRaises(peer.CcPeerError) as caught: + with peer._preset_cleanup_scope(failed_cleanup): + pass + self.assertEqual(caught.exception.details["reason"], "preset_cleanup_failed") + with mock.patch.object(ctypes, "CDLL", return_value=api): + self.refuse("preset_acl_unverifiable", peer._preset_darwin_acl_tags, 123) + api.acl_free.assert_called_once_with(100) + + def test_handled_caller_exception_does_not_hide_successful_mutation_close_failure(self): + peer.preset_mutate("add", "fixture", self.route) + lock_identity = (self.app / "destinations.lock").stat() + real_close = os.close + def close_then_fail(fd): + info = os.fstat(fd) + real_close(fd) + if (info.st_dev, info.st_ino) == (lock_identity.st_dev, lock_identity.st_ino): + raise OSError(errno.EIO, "private close diagnostic") + try: + raise ValueError("already handled by caller") + except ValueError: + with mock.patch.object(peer.os, "close", side_effect=close_then_fail): + self.refuse("preset_commit_unconfirmed", peer.preset_mutate, "add", "other", self.route) + self.assertEqual(set(peer.preset_read()), {"fixture", "other"}) + + def test_read_interrupt_is_not_masked_by_descriptor_close_error(self): + peer.preset_mutate("add", "fixture", self.route) + identity = self.file.stat() + real_close = os.close + def close_then_fail(fd): + info = os.fstat(fd) + real_close(fd) + if (info.st_dev, info.st_ino) == (identity.st_dev, identity.st_ino): + raise OSError(errno.EIO, "private close diagnostic") + with mock.patch.object(peer.os, "read", side_effect=KeyboardInterrupt()), mock.patch.object(peer.os, "close", side_effect=close_then_fail): + with self.assertRaises(KeyboardInterrupt): + peer.preset_read() + self.assertEqual(set(peer.preset_read()), {"fixture"}) + + def test_foreign_owned_nonwritable_ancestor_is_rejected_before_creation(self): + ancestor = self.root / "ancestor" + ancestor.mkdir(mode=0o755) + config = ancestor / "config" + config.mkdir(mode=0o700) + identity = ancestor.stat() + real_fstat = os.fstat + def foreign_owner(fd): + info = real_fstat(fd) + if (info.st_dev, info.st_ino) == (identity.st_dev, identity.st_ino): + return SimpleNamespace(st_uid=os.geteuid() + 1, st_mode=info.st_mode) + return info + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config)}), mock.patch.object(peer.os, "fstat", side_effect=foreign_owner): + self.refuse("preset_unsafe_storage", peer.preset_read) + self.refuse("preset_unsafe_storage", peer.preset_mutate, "add", "fixture", self.route) + self.assertFalse((config / "session-peer").exists()) + + def test_read_detects_file_swap_and_preserves_both_files(self): + peer.preset_mutate("add", "fixture", self.route) + before = self.file.read_bytes() + replacement = self.app / "replacement.json" + replacement.write_bytes(before) + replacement.chmod(0o600) + preserved = self.app / "preserved.json" + real_read = os.read + swapped = False + def read_and_swap(fd, count): + nonlocal swapped + chunk = real_read(fd, count) + if not swapped: + swapped = True + self.file.rename(preserved) + replacement.rename(self.file) + return chunk + with mock.patch.object(peer.os, "read", side_effect=read_and_swap): + self.refuse("preset_storage_changed", peer.preset_read) + self.assertEqual(self.file.read_bytes(), before) + self.assertEqual(preserved.read_bytes(), before) + + def test_first_creation_concurrent_writers_do_not_lose_entries(self): + self.assertFalse(self.app.exists()) + barrier = threading.Barrier(6) + def create(index): + barrier.wait(timeout=5) + return peer.preset_mutate("add", "first" + str(index), self.route) + # This test asserts update serialization, not the production lock's + # one-second wait deadline (covered by test_lock_wait_is_bounded...). + with mock.patch.object(peer, "PRESET_LOCK_SECONDS", 5.0), concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool: + results = list(pool.map(create, range(6))) + self.assertEqual(len(results), 6) + self.assertEqual(set(peer.preset_read()), {"first" + str(index) for index in range(6)}) + + def test_atomic_write_rechecks_destination_and_preserves_replacement(self): + peer.preset_mutate("add", "fixture", self.route) + outside = self.root / "preserved.json" + before = self.file.read_bytes() + real_fsync = os.fsync + def replace_during_flush(fd): + real_fsync(fd) + self.file.rename(outside) + self.file.symlink_to(outside) + with mock.patch.object(peer.os, "fsync", side_effect=replace_during_flush): + self.refuse("preset_unsafe_storage", peer.preset_mutate, "add", "other", self.route) + self.assertTrue(self.file.is_symlink()) + self.assertEqual(outside.read_bytes(), before) + self.assertFalse(any(path.name.startswith(".destinations-") for path in self.app.iterdir())) + + def test_encoded_write_budget_is_enforced_under_entry_quota(self): + large = dict(self.route, codexHome="/" + "字" * 4000) + peer.preset_mutate("add", "first", large) + peer.preset_mutate("add", "second", large) + before = self.file.read_bytes() + self.refuse("preset_capacity", peer.preset_mutate, "add", "third", large) + self.assertEqual(self.file.read_bytes(), before) + self.assertEqual(len(peer.preset_read()), 2) + + def test_unsafe_ancestor_and_symlink_lock_do_not_get_repaired(self): + peer.preset_mutate("add", "fixture", self.route) + before = self.file.read_bytes() + self.root.chmod(0o770) + try: + self.refuse("preset_unsafe_storage", peer.preset_read) + self.assertEqual(stat.S_IMODE(self.root.stat().st_mode), 0o770) + finally: + self.root.chmod(0o700) + lock = self.app / "destinations.lock" + outside = self.root / "preserved.lock" + lock.rename(outside) + lock.symlink_to(outside) + self.refuse("preset_unsafe_storage", peer.preset_mutate, "add", "other", self.route) + self.assertTrue(lock.is_symlink()) + self.assertEqual(self.file.read_bytes(), before) + + def test_default_home_location_and_read_only_access_have_no_side_effects(self): + with mock.patch.dict(os.environ), mock.patch.object(peer.Path, "home", return_value=self.root): + os.environ.pop("XDG_CONFIG_HOME", None) + self.assertEqual(peer.preset_read(), {}) + self.assertFalse((self.root / ".config").exists()) + peer.preset_mutate("add", "fixture", self.route) + location = self.root / ".config" / "session-peer" + lock = location / "destinations.lock" + lock.unlink() + self.assertEqual(peer.preset_lookup("fixture")[0], self.route) + self.assertFalse(lock.exists()) + + def test_relative_config_root_and_windows_rejected_before_io(self): + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "relative"}): + self.refuse("preset_invalid_config_root", peer.preset_read) + with mock.patch.object(peer, "IS_WINDOWS", True), mock.patch.object(peer.os, "open") as opening: + self.refuse("preset_platform_unsupported", peer.preset_read) + self.refuse("preset_platform_unsupported", peer.preset_mutate, "add", "fixture", self.route) + opening.assert_not_called() + self.assertFalse(self.app.exists()) + + def test_config_root_controls_surrogates_and_size_rejected_before_io(self): + invalid = ("/fixture/\x00", "/fixture/\n", "/fixture/\x7f", "/fixture/\x85", + "/fixture/\ud800", "/" + "a" * 4096, "") + for value in invalid: + # os.environ itself rejects NUL, so provide the invalid configuration + # through its read interface without mutating the actual environment. + real_get = os.environ.get + def configured(name, default=None): + return value if name == "XDG_CONFIG_HOME" else real_get(name, default) + with mock.patch.object(peer.os.environ, "get", side_effect=configured), mock.patch.object(peer.os, "open") as opening: + self.refuse("preset_invalid_config_root", peer.preset_read) + self.refuse("preset_invalid_config_root", peer.preset_mutate, "add", "fixture", self.route) + opening.assert_not_called() + + def darwin_api(self, tags=()): + api = SimpleNamespace(acl_get_fd_np=mock.Mock(return_value=100), + acl_valid=mock.Mock(return_value=0), + acl_free=mock.Mock(return_value=0)) + position = [0] + def entry(acl, entry_id, output): + if position[0] == len(tags): + ctypes.set_errno(errno.EINVAL) + return -1 + ctypes.cast(output, ctypes.POINTER(ctypes.c_void_p))[0] = 1000 + position[0] + position[0] += 1 + return 0 + def tag(entry_pointer, output): + ctypes.cast(output, ctypes.POINTER(ctypes.c_int))[0] = tags[entry_pointer.value - 1000] + return 0 + api.acl_get_entry = mock.Mock(side_effect=entry) + api.acl_get_tag_type = mock.Mock(side_effect=tag) + return api + + def test_darwin_acl_empty_handle_absence_and_cleanup(self): + for tags in ((), (1,), (2, 2)): + api = self.darwin_api(tags) + with mock.patch.object(ctypes, "CDLL", return_value=api): + self.assertEqual(peer._preset_darwin_acl_tags(123), tags) + api.acl_get_fd_np.assert_called_once_with(123, 0x100) + api.acl_free.assert_called_once_with(100) + api = self.darwin_api() + def absent(*args): + ctypes.set_errno(errno.ENOENT) + return None + api.acl_get_fd_np.side_effect = absent + with mock.patch.object(ctypes, "CDLL", return_value=api): + self.assertEqual(peer._preset_darwin_acl_tags(123), ()) + api.acl_free.assert_not_called() + + def test_darwin_acl_failures_unknown_tags_and_bounds_fail_closed(self): + for getter_errno in (0, errno.EACCES, errno.EBADF, errno.EOPNOTSUPP): + api = self.darwin_api() + def rejected(*args): + ctypes.set_errno(getter_errno) + return None + api.acl_get_fd_np.side_effect = rejected + with mock.patch.object(ctypes, "CDLL", return_value=api): + self.refuse("preset_acl_unverifiable", peer._preset_darwin_acl_tags, 123) + api.acl_free.assert_not_called() + for tags in ((3,), (2,) * 129): + api = self.darwin_api(tags) + with mock.patch.object(ctypes, "CDLL", return_value=api): + self.refuse("preset_acl_unverifiable", peer._preset_darwin_acl_tags, 123) + api.acl_free.assert_called_once_with(100) + for operation in ("acl_valid", "acl_get_tag_type", "acl_free"): + api = self.darwin_api((2,)) + getattr(api, operation).return_value = -1 + getattr(api, operation).side_effect = None + with mock.patch.object(ctypes, "CDLL", return_value=api): + self.refuse("preset_acl_unverifiable", peer._preset_darwin_acl_tags, 123) + api.acl_free.assert_called_once_with(100) + api = self.darwin_api((2,)) + def invalid_entry(*args): + ctypes.set_errno(errno.EIO) + return -1 + api.acl_get_entry.side_effect = invalid_entry + with mock.patch.object(ctypes, "CDLL", return_value=api): + self.refuse("preset_acl_unverifiable", peer._preset_darwin_acl_tags, 123) + api.acl_free.assert_called_once_with(100) + + def test_darwin_acl_entry_interrupt_is_not_masked_by_free_failure(self): + api = self.darwin_api((2,)) + api.acl_get_entry.side_effect = KeyboardInterrupt() + api.acl_free.return_value = -1 + with mock.patch.object(ctypes, "CDLL", return_value=api): + with self.assertRaises(KeyboardInterrupt): + peer._preset_darwin_acl_tags(123) + api.acl_free.assert_called_once_with(100) + + def test_darwin_acl_policy_all_private_entries_refused_ancestor_deny_only_allowed(self): + with mock.patch.object(peer.sys, "platform", "darwin"): + for tags in ((), (2,), (2, 2)): + with mock.patch.object(peer, "_preset_darwin_acl_tags", return_value=tags): + peer._preset_check_acl(123, private=False, directory=True) + if tags: + self.refuse("preset_unsafe_acl", peer._preset_check_acl, 123, private=True) + else: + peer._preset_check_acl(123, private=True) + for tags, reason in (((1,), "preset_unsafe_acl"), ((2, 1), "preset_unsafe_acl"), + ((3,), "preset_acl_unverifiable")): + with mock.patch.object(peer, "_preset_darwin_acl_tags", return_value=tags): + self.refuse(reason, peer._preset_check_acl, 123, private=False, directory=True) + + def test_linux_acl_access_default_and_unverifiable_policy(self): + def absent(fd, attribute): + raise OSError(errno.ENODATA, "private diagnostic") + with mock.patch.object(peer.sys, "platform", "linux"), mock.patch.object(peer.os, "getxattr", side_effect=absent, create=True) as get: + peer._preset_check_acl(123, private=True, directory=True) + self.assertEqual([call.args[1] for call in get.call_args_list], ["system.posix_acl_access", "system.posix_acl_default"]) + for attribute in ("system.posix_acl_access", "system.posix_acl_default"): + def present(fd, name): + if name == attribute: + return b"opaque ACL bytes" + raise OSError(errno.ENODATA, "private diagnostic") + with mock.patch.object(peer.sys, "platform", "linux"), mock.patch.object(peer.os, "getxattr", side_effect=present, create=True): + self.refuse("preset_unsafe_acl", peer._preset_check_acl, 123, private=True, directory=True) + for code in (errno.EOPNOTSUPP, errno.EACCES, errno.EBADF): + with mock.patch.object(peer.sys, "platform", "linux"), mock.patch.object(peer.os, "getxattr", side_effect=OSError(code, "private diagnostic"), create=True): + error = self.refuse("preset_acl_unverifiable", peer._preset_check_acl, 123, private=True) + self.assertNotIn("private diagnostic", str(error)) + + def linux_acl_bytes(self, owner_permissions): + # Linux UAPI POSIX_ACL_XATTR_VERSION=2 and five fixed-size entries. + # The named-user entry has a zero mask, granting it no effective access. + entries = ((1, owner_permissions, 0xffffffff), (2, 4, os.geteuid() + 1), + (4, 0, 0xffffffff), (16, 0, 0xffffffff), (32, 0, 0xffffffff)) + return struct.pack(" 0 for size in file_sizes)) + + def test_acl_refusal_before_commit_preserves_previous_store(self): + peer.preset_mutate("add", "fixture", self.route) + before = self.file.read_bytes() + real_check = peer._preset_check_acl + lock_inode = (self.app / "destinations.lock").stat().st_ino + file_inode = self.file.stat().st_ino + def unsafe_temp(fd, *, private, directory=False): + info = os.fstat(fd) + if private and not directory and info.st_ino not in (lock_inode, file_inode): + raise peer._preset_error("preset_unsafe_acl") + return real_check(fd, private=private, directory=directory) + with mock.patch.object(peer, "_preset_check_acl", side_effect=unsafe_temp): + self.refuse("preset_unsafe_acl", peer.preset_mutate, "add", "other", self.route) + self.assertEqual(self.file.read_bytes(), before) + self.assertFalse(any(path.name.startswith(".destinations-") for path in self.app.iterdir())) + + @unittest.skipUnless(sys.platform == "darwin", "Darwin ACL fixture only") + def test_native_darwin_temp_file_acl_is_rejected_without_user_config_changes(self): + path = self.root / "acl-fixture" + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600) + try: + self.assertEqual(peer._preset_darwin_acl_tags(fd), ()) + result = subprocess.run(["/bin/chmod", "+a", "everyone allow read", str(path)], capture_output=True) + self.assertEqual(result.returncode, 0, "dedicated temporary ACL setup failed") + try: + self.assertEqual(peer._preset_darwin_acl_tags(fd), (1,)) + self.refuse("preset_unsafe_acl", peer._preset_check_acl, fd, private=True) + finally: + cleared = subprocess.run(["/bin/chmod", "-N", str(path)], capture_output=True) + self.assertEqual(cleared.returncode, 0, "dedicated temporary ACL cleanup failed") + finally: + os.close(fd) + + def test_unreviewed_posix_platform_is_rejected_before_io(self): + with mock.patch.object(peer.sys, "platform", "freebsd14"), mock.patch.object(peer.os, "open") as opening: + self.refuse("preset_platform_unsupported", peer.preset_read) + self.refuse("preset_platform_unsupported", peer.preset_mutate, "add", "fixture", self.route) + opening.assert_not_called() + + def test_darwin_mount_explicit_64bit_abi_and_flag_only_result(self): + def populate(fd, output): + self.assertEqual(fd, 123) + self.assertEqual(ctypes.sizeof(output._obj), 2168) + self.assertEqual(type(output._obj).f_flags.offset, 64) + output._obj.f_flags = 0x00200000 + return 0 + api = SimpleNamespace(fstatfs64=mock.Mock(side_effect=populate)) + with mock.patch.object(ctypes, "CDLL", return_value=api): + self.assertEqual(peer._preset_darwin_mount_flags(123), 0x00200000) + self.assertEqual(api.fstatfs64.restype, ctypes.c_int) + + def test_darwin_mount_missing_symbol_failed_query_and_abi_rejected(self): + for api in (SimpleNamespace(), SimpleNamespace(fstatfs64=mock.Mock(return_value=-1))): + with mock.patch.object(ctypes, "CDLL", return_value=api): + self.refuse("preset_mount_unverifiable", peer._preset_darwin_mount_flags, 123) + real_sizeof = ctypes.sizeof + for pointer_size, structure_size in ((4, 2168), (8, 2160)): + def incompatible(value): + if value is ctypes.c_void_p: + return pointer_size + if isinstance(value, type) and issubclass(value, ctypes.Structure): + return structure_size + return real_sizeof(value) + with mock.patch.object(ctypes, "sizeof", side_effect=incompatible), mock.patch.object(ctypes, "CDLL") as loading: + self.refuse("preset_mount_unverifiable", peer._preset_darwin_mount_flags, 123) + loading.assert_not_called() + + def test_darwin_ignore_ownership_is_refused_without_affecting_linux(self): + with mock.patch.object(peer.sys, "platform", "darwin"): + with mock.patch.object(peer, "_preset_darwin_mount_flags", return_value=0): + peer._preset_check_ownership(123) + with mock.patch.object(peer, "_preset_darwin_mount_flags", return_value=0x00200000): + self.refuse("preset_ownership_unenforced", peer._preset_check_ownership, 123) + with mock.patch.object(peer.sys, "platform", "linux"), mock.patch.object(peer, "_preset_darwin_mount_flags") as lookup: + peer._preset_check_ownership(123) + lookup.assert_not_called() + + def test_private_and_ancestor_mount_refusal_precedes_acl_lookup(self): + with mock.patch.object(peer, "_preset_check_ownership", side_effect=peer._preset_error("preset_ownership_unenforced")), mock.patch.object(peer, "_preset_check_acl") as acl: + self.refuse("preset_ownership_unenforced", peer._preset_check_fd, 123, private=True) + self.refuse("preset_ownership_unenforced", peer._preset_check_fd, 123, private=False, directory=True) + acl.assert_not_called() + with mock.patch.object(peer, "_preset_check_ownership") as ownership, mock.patch.object(peer, "_preset_check_acl") as acl: + peer._preset_check_fd(123, private=False, directory=True) + ownership.assert_called_once_with(123) + acl.assert_called_once_with(123, private=False, directory=True) diff --git a/tools/generate_session_peer.py b/tools/generate_session_peer.py index d1575b6..6a232b3 100644 --- a/tools/generate_session_peer.py +++ b/tools/generate_session_peer.py @@ -31,6 +31,11 @@ "registry.py", "commands.py", "messages.py", + "presets.py", + "preset_cli.py", + "addresses.py", + "address_selection.py", + "address_cli.py", "release_verification.py", "updates.py", "update_preferences.py",