diff --git a/Cargo.lock b/Cargo.lock index f7ef810..309084a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -947,6 +956,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -1349,6 +1369,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -3338,7 +3369,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3814,6 +3845,16 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5216,15 +5257,20 @@ dependencies = [ "qrcode", "regex", "rfd", + "security-framework", "serde", "serde_json", + "sha2 0.10.9", "syntect", + "tar", "toml 0.8.23", "two-face", "ureq", "vt100", "windows-sys 0.61.2", "winresource", + "zeroize", + "zip", ] [[package]] @@ -5406,12 +5452,41 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.19", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 956fb39..db9785b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,14 @@ base64 = "0.23" # * default-features を切っているのは gzip / json / cookies が要らないため # (要るのは GET / POST / DELETE と Bearer ヘッダだけ) ureq = { version = "3.4.0", default-features = false, features = ["rustls"] } +sha2 = "0.10.9" +zeroize = "1.9" +zip = { version = "2.4.2", default-features = false, features = ["deflate"] } +# Snapshot archives are built from validated bytes, never by a host PATH executable. +tar = { version = "0.4", default-features = false } + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3.7" # セッション破棄時のプロセス**グループ**への killpg (procx::kill_tree)。 # portable-pty は子を setsid するため pgid == pid で、グループごと落とせば diff --git a/docs/chatgpt.md b/docs/chatgpt.md index 78898d8..017c7de 100644 --- a/docs/chatgpt.md +++ b/docs/chatgpt.md @@ -3,7 +3,186 @@ 目的は、通常の ChatGPT **Chat conversation** から Zaivern に開発作業を依頼することです。 独自 Chat UI、Codex 専用フロントエンド、ChatGPT Work への切り替えは実装しません。 -## 起動と workspace の許可 +## Quickstart + +対象プロジェクトのディレクトリで実行します。 +この導線には本変更を含むビルドが必要です。既存の v0.24.7 配布バイナリには +`chatgpt` サブコマンドがないため、同じバージョン表示だけで対応済みと判断しないでください。 + +```sh +zai chatgpt setup +zai chatgpt start +``` + +setup は OS/CPU、実行中の zai、ローカル Docker context/socket、workspace、既存設定を確認し、 +公式の**通常版** tunnel-client を検証して管理領域へ導入します。Tunnel ID、信頼する Agent image 名、 +Runtime API Key を対話入力します。image の SHA256 を手作業で調べる必要はありません。 +取得済み image を検査し、なければ確認して pull し、以後は immutable image ID で実行します。 +workspace は既定でカレントディレクトリです。絶対 canonical path に固定します。 + +**推論用の OpenAI API Key や別途の Responses / Chat Completions API 利用は不要です。** +Runtime API Key は Secure MCP Tunnel の認証専用です。モデル推論には使用しません。 +ChatGPT の通常 Chat が依頼の入口となり、既存 Bridge のコンテナ内 Agent が作業を行います。 +現在の Agent 実装には、下記契約を満たす **Qwen ACP・ローカルモデル同梱 image** が必要です。 +公式の本番 Agent image やモデル重みをこの CLI が提供するわけではありません。 +`tools/mcp-fixture.Dockerfile` は検証専用であり、一般のバグ修正を行うモデルではありません。 + +最後に ChatGPT の Settings → Plugins / Apps(Developer mode)で接続を作成します。 +名前を Zaivern、Connection を Tunnel、対象 Tunnel を選択し、Authentication は None とします。 +通常の新規 Chat で Zaivern を選び「このバグを直して」と依頼してください。 +ChatGPT UI の自動操作は行いません。アカウント・組織で Tunnel と Developer mode が利用可能である必要があります。 +Tunnel の作成・ID の確認先: 。 +一覧取得のために追加の Admin API Key を要求せず、Tunnel ID 入力を使用します。 + +### 日常の操作 + +| コマンド | 動作 | +| --- | --- | +| `zai chatgpt start` | 所有する supervisor と tunnel-client をバックグラウンド起動。二重起動を防止 | +| `zai chatgpt start --foreground` | 端末で実行。終了シグナルで所有する子を停止 | +| `zai chatgpt status` | 所有プロセス、health/ready、stdio channel、workspace/image/Tunnel を表示 | +| `zai chatgpt doctor` | 設定・鍵の存在・公式 doctor・実 stdio discovery を検査 | +| `zai chatgpt stop` | nonce で所有 supervisor を確認して停止。保存 PID だけを信用して kill しない | +| `zai chatgpt repair` | 未完了の所有Docker資源を再確認・回収後、client/profile/zai path を修復。image の再取得は確認付き | +| `zai chatgpt setup --reauth` | Runtime key を非表示入力で更新。起動中は先に stop | +| `zai chatgpt reset` | 削除範囲を表示して確認。設定・profile・Runtime key を削除 | +| `zai chatgpt test` | 一時 workspace と deterministic ACP fixture によるローカル E2E | + +setup の再実行では Verify / Repair / Reconfigure / Cancel を選べます。 +設定変更と reset は実行中の Bridge を勝手に止めません。先に `stop` してください。 +停止は MCP worker の終了記録も確認します。強制終了や後片付け未確認を成功表示しません。 +未確認の世代が残る場合は再起動・再設定・reset を止め、状態を保存します。 +`status` / `doctor` は未確認のcontainer/volume件数と `zai chatgpt repair` を案内します。 +reset は Platform の Tunnel、ソース、Docker image、検証済み client のインストールを削除しません。 +自動ログイン起動の登録は行わないため、PC 再起動後は `start` します。 + +### 保存先と秘密情報 + +管理領域は `~/.zaivern/chatgpt/`(`ZAIVERN_HOME` 設定時はその直下)です。 +0700 ディレクトリ、0600 設定/profile、所有者・symlink/hardlink 検査を使用します。 +既存の `~/.config/tunnel-client/` profile は上書きせず、専用の `profile.yaml` を使います。 +JSON は YAML のサブセットとして公式クライアントの strict decoder で読み込みます。 +profile 内には `env:CONTROL_PLANE_API_KEY` 参照だけを保存します。 + +macOS は Keychain、Linux は `secret-tool` 経由の Secret Service を使用します。 +Linux は `secret-tool` の存在だけで利用可能とは判定せず、実際に保存を試みます。 +DBus・keyring不在、SSH/headless環境、daemon停止などで保存に失敗した場合も、 +その後にユーザーへ確認し、承認された場合だけ専用0600 fileを使用します。拒否時は保存せず中止します。 +Keychain/keyring のアクセス拒否を理由に自動で平文保存へ切り替えることはありません。 +秘密保存先の変更は、先に保存先だけを記録した復旧 journal を永続化します。 +途中で設定保存が失敗しても `reset` が新旧の保存先を回収でき、旧キーは新設定の確定後に削除します。 +保存失敗でも応答喪失前にkeyringへ書かれた可能性があるため、失敗した保存先もjournalに残します。 +旧keyringが利用不能でも承認したfallback設定は確定します。旧保存先の回収は警告し、 +keyring復旧後の `reset` で再試行します。回収失敗を成功扱いしてjournalを消すことはありません。 +鍵は CLI 引数・shell history・workspace・image・Agent/Docker 環境へ渡しません。 +Tunnel 専用子プロセスだけに渡し、MCP 起動は通常初期化より前に環境を除去して再execします。 +生の子プロセス stdout/stderr は保存・表示せず、doctor の既知の検査名と状態だけを表示します。 +この版には `logs --export` はありません。診断には `status` / `doctor` を使用してください。 + +### ホスト実行ファイルと環境の境界 + +managed MCP と診断は、設定済みの canonical Docker executable と local Unix socket を直接使用します。 +タスクからPATHやDocker contextを再探索しません。setupのDocker検出は標準インストール先 +(`/usr/local/bin`、`/opt/homebrew/bin`、`/usr/bin`、`/bin`、Docker Desktop、`~/.docker/bin`)に限定し、 +workspace内の実行ファイル、hardlink、共有書込可能な実行ファイルを拒否します。 +LinuxのSecret Service helperはroot所有の `/usr/bin/secret-tool` を使用します。 +macOSの起動時言語検出も `/usr/bin/defaults` に固定し、workspaceのPATHを実行権限にしません。 +標準外のインストール先をユーザーPATHから自動採用することはありません。 + +snapshotのtarはRustで検証済みの凍結バイト列から生成します。ホストの `tar` は起動せず、 +symlink/hardlink、絶対パス、親ディレクトリ参照をarchiveへ追加しません。 +タスク用Docker CLIには空の専用設定ディレクトリを渡し、ホストDocker設定のproxy認証情報が +containerの環境へ自動注入される経路も遮断します。imageの事前取得はsetup側の処理です。 + +| 段階 | 環境と実行権限 | +| --- | --- | +| setup / secret helper | 環境をallowlistで再構成。PATHはsystem directoryのみ。HOME、DBus等は鍵保管・Docker context検出のために使用 | +| tunnel-client | 検証済み管理バイナリ。Runtime keyをこの子だけへ明示設定。`OPENAI_API_KEY`は継承しない | +| managed MCP | 通常初期化前に再execしてkeyを除去。固定system PATH、設定済みDocker/socketを使用 | +| タスク用Docker CLI | `env_clear`。system PATHと空の専用`DOCKER_CONFIG`のみ。HOME、DBus、Docker context、Runtime/model keyを継承しない | +| Agent / verifier | Docker引数で定義したHOME・build用変数のみ。host credential、socket、workspace bind mount、networkなし | + +Docker credential helperがsystem PATH外にあるprivate imageは、事前に通常のDocker CLIで取得してください。 +setupが任意のworkspace helperを実行するためにPATHを広げることはありません。 + +### Docker cleanup失敗からの復旧 + +```sh +zai chatgpt status +zai chatgpt repair +zai chatgpt start +``` + +`cleanup-pending.json` は、世代・local Docker socket・資源ごとのランダム名・作成/削除状態を +Docker作成要求の**前**に永続化します。秘密鍵・prompt・Dockerの生出力は含めません。 +所有者、0600、通常ファイル、リンク数、JSONサイズ、未知field、世代一致を検証します。 +完了済みの記録も世代終了まで保持します。件数が0でもreceipt保存が未完了なら未確認です。 + +`repair` はoperation/runtime/MCP execution lockを取得し、稼働中のsupervisorやworkerと競合しません。 +MCP受付を閉じたうえで、記録したsocketにだけ接続し、Zaivern管理領域・世代・資源nonceの +Docker labelsを照合します。containerは照合した完全IDで削除し、volumeは照合した固有名で削除します。 +containerを先に回収し、成功した一覧問い合わせで不存在を確認します。 +途中失敗はjournalを残し、次の `repair` で続行します。既に手動削除された作成確認済み資源も再確認できます。 +全資源の確認後だけjournalをconfirmedにし、同世代の `mcp.done`、shutdown receiptを保存します。 +receipt保存だけ失敗した場合も再試行できます。journalを直接編集・削除して解除しないでください。 + +旧Tunnelの遅延起動が新世代へ混入しないよう、起動世代を非秘密の専用環境変数で固定し、 +MCP受付は世代ごとに一度だけ許可します。MCPだけ異常終了した場合も `stop → repair → start` を使用します。 +世代切替途中の停止は `cleanup-prepared.json` から、資源を作成せず復旧します。 + +supervisorは専用guardianのleaderを `waitid(WNOWAIT)` で観測し、process groupの回収前にはreapしません。 +guardianとMCPは同じgroupを維持します。supervisorが異常終了すると、単独所有するpipe writerが閉じ、 +guardianがEOFを検出して同じ終了処理を行います。guardianが先に終了した場合はsupervisorが回収します。 +鍵取得前から監視を開始し、guardianが起動するSecret Service helperも同じgroupで回収します。 +保存PID/PGIDを再起動後のkill権限には使いません。正常終了にはMCP lockの解放、同世代のcleanup証拠、 +子の正常終了が必要です。強制終了後は成功したstopと表示せず、残ったjournalを `repair` で照合します。 + +この猶予が必要なのは、[tunnel-client v0.0.14のstdio stop](https://github.com/openai/tunnel-client/blob/v0.0.14/pkg/mcpclient/stdio_command.go) +がstdin closeとTERMの後、[Fxの既定15秒](https://github.com/uber-go/fx/blob/v1.23.0/app.go)までしか待たないためです。 +Zaivernは最大45秒のcleanup猶予を持ち、それを超えた場合も未確認のjournalを残します。 + +**証明できない状態は解除しません。** 古い版のjournalがない未確認世代、所有ラベル不一致、 +破損stateは安全な自動削除の対象外です。また作成要求の応答を失い、作成完了を一度も確認できず、 +資源もまだ見つからない場合は、遅延したDocker作成を否定できないため未確認を維持します。 +この場合はローカルDockerの作成状況を調査してください。対象が出現すれば再repairで照合・回収できます。 +同じOSユーザーとDocker管理者は信頼境界です。Docker管理者が検査中に同名volumeを外部から +置換する攻撃は防げません(Docker volumeにはcontainerのような不変IDがありません)。 +通常のMCP要求・AgentからjournalやDocker socketへ到達する経路はありません。 + +### 配布物と互換性 + +- Intel Mac: `darwin-amd64`。Apple Silicon: `darwin-arm64`。 +- Linux x86_64: `linux-amd64`。Linux aarch64/arm64: `linux-arm64`。 +- Windows は既存 MCP 実行層の安全な filesystem 対応が未実装のため明示的に非対応です。 +- tested / supported / detected を分離し、現在は **検証対象 0.0.14 のみ**を許可します。 + pre-1.0 の将来版を semver だけで互換とみなしません。更新点は `src/chatgpt/install.rs` に集約しています。 +- `openai/tunnel-client` の release metadata、SHA256SUMS、提供される asset digest、サイズを検査し、 + ZIP 検証後に binary header の OS/CPU を検査します。HTTPS のみで取得します。 + `tunnel-client-runtime-cloudflared` は対象 asset として選択しません。 +- macOS で起動が拒否された場合は実行権限・quarantine・Keychain を確認します。 + Gatekeeper を無効化したり quarantine を自動削除したりしません。 + +### 診断と検証範囲 + +`doctor` は `initialize → notifications/initialized → server/discover → tools/list` を実 stdio で実行し、 +公開 tool がちょうど3個であることを確認します。公式 `doctor --explain --json` の結果も検査します。 +公式 doctor は起動前の設定検査であり、Runtime key のサーバ側認証成功を保証しません。 +停止中の health/ready/Control Plane は WARN です。`start` 後に再検査してください。 +認証に失敗する場合は Runtime key、Tunnel の組織、Tunnels Read/Use 権限を確認し、 +必要なら `zai chatgpt setup --reauth` を実行します。 +Control Plane metadata の取得と MCP channel の起動は、Connector 登録や task 完了とは別の検査です。 + +`test` は本番 workspace を編集せず、一時 Rust プロジェクトで run/status/edit/offline test/diff/cancel を実行します。 +設定前でも実行できます。この場合はローカル Docker 検証だけを行い、Tunnel 検査は未確認と表示します。 +fixture の初回 Docker build には base image 取得が必要な場合がありますが、LLM・推論 API Key は不要です。 +Runtime key が取得できないときは tunnel doctor を WARN にし、ローカル検証だけを行います。 +終了時に一時 workspace を削除します。fixture image/build cache は再利用のため残します。 +本番 Agent image 内の実モデルの品質・動作と、通常 Chat からの呼び出しは別途確認してください。 +**Remaining manual check: Open a normal ChatGPT chat and invoke Zaivern once.** + +## Advanced / Manual setup + +### 起動と workspace の許可 ```sh zai mcp serve --workspace /absolute/path/to/project --image sha256:IMAGE_ID @@ -23,7 +202,7 @@ Windows は安全なファイルハンドル実装が未対応のため、明示 image の通常の起動処理は、コンテナ内で OpenAI 互換のローカル推論サーバを起動し、 ACP を `docker exec` できる間、生存している必要があります。 Qwen とモデル重み・推論エンジン・GNU tar・ビルド用ツールチェーンは事前に image に含めます。 -ホストにも tar が必要です。 +ホスト側のtarインストールは不要です。 image に実際の API キー、ログイントークン、秘密鍵を含めないでください。 `HOME=/tmp/agent-home` となり、workspace と一時ディレクトリだけが書き込み可能です。 @@ -308,10 +487,11 @@ Agent 自身の直接アクセスを制限しません。そのため今回の a 実行時限と出力収集には既存 Cloud Execution の `run_child` / `CollectSink` を使います。 タスクの内容をログへ記録せず、task ID / tool / state / duration / error 有無を stderr に出します。 -MCP は `2024-11-05` の stdio / initialize / ping / tools の限定実装です。 +MCP は stdio / initialize / ping / tools と上記の discovery・版交渉を実装します。 [公式 rmcp](https://github.com/modelcontextprotocol/rust-sdk) は保守されている標準候補ですが、 Tokio / futures / schemars 等を要します。同期構成と3 toolsに限定した今回は既存 serde_json を使い、 -新規依存・Cargo.lock の変更を避けました。独自 protocol 実装の保守責任が残ります。 +MCP 層への非同期 runtime 依存を追加していません。setup は別途 SHA256・ZIP・秘密保存の依存を使用します。 +独自 protocol 実装の保守責任が残ります。 HTTP を追加する際は SDK への置き換えを優先して再評価してください。 [chat-on-steroids](https://github.com/totec448-spec/chat-on-steroids) は high-level task lifecycle の参考で、 コードをコピーしていません。 diff --git a/locales/en.json b/locales/en.json index 69a0c3a..9129f89 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,5 +1,21 @@ { "agent.rename.hint": "Double-click to rename, or right-click and choose Rename.", + "chatgpt.runtime_auth_only": "Runtime API Key authenticates the tunnel only. No OpenAI inference API is used.", + "chatgpt.existing_setup": "Existing setup detected.\n[1] Verify\n[2] Repair\n[3] Reconfigure\n[4] Cancel", + "chatgpt.select": "Select", + "chatgpt.use_workspace": "Use this workspace?", + "chatgpt.workspace_path": "Workspace directory", + "chatgpt.image_contract": "Use a trusted Linux Agent image with Qwen ACP and a bundled local model. The test fixture is not a production Agent.", + "chatgpt.image_name": "Agent image name", + "chatgpt.pull_confirm": "Image unavailable/incompatible. Pull this trusted image?", + "chatgpt.pulling": "Pulling Agent image (up to 10 minutes)...", + "chatgpt.tunnel_hint": "Create/select a Secure MCP Tunnel: https://platform.openai.com/settings/organization/tunnels", + "chatgpt.file_fallback": "Secret Service unavailable. Save the Runtime key in a private 0600 file outside the workspace?", + "chatgpt.reset_summary": "This removes the managed profile, ChatGPT configuration/runtime state and stored Runtime API Key.\nThe Platform Tunnel, source code, Docker images and verified client installation are retained.", + "chatgpt.continue": "Continue?", + "chatgpt.manual_check": "Remaining manual check: Open a normal ChatGPT chat and invoke Zaivern once.", + "chatgpt.secret_prompt": "OpenAI Runtime API Key (hidden): ", + "chatgpt.setup_ready": "Local setup is ready. Run: zai chatgpt start\n\nFinal ChatGPT step:\n1. Settings → Plugins / Apps (developer mode)\n2. Create: Zaivern\n3. Connection: Tunnel; select your Zaivern Tunnel\n4. Authentication: None\n5. Create and select Zaivern in a normal ChatGPT chat.\nKeep the bridge running. Registration and invocation still need this manual check.", "remote.detect_app": "Connect so Norton can detect this app", "remote.detect_app_hint": "This zai.exe checks release information over HTTPS itself. It does not install updates or change settings. Reopen Norton's program list afterwards.", "remote.detect_app_busy": "Checking HTTPS from this app…", diff --git a/locales/es.json b/locales/es.json index 4a8c7fb..ee10732 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1,5 +1,21 @@ { "agent.rename.hint": "Haz doble clic para cambiar el nombre, o clic derecho y elige Cambiar nombre.", + "chatgpt.runtime_auth_only": "La Runtime API Key solo autentica el túnel. No se usa la API de inferencia de OpenAI.", + "chatgpt.existing_setup": "Se detectó una configuración existente.\n[1] Verificar\n[2] Reparar\n[3] Reconfigurar\n[4] Cancelar", + "chatgpt.select": "Seleccionar", + "chatgpt.use_workspace": "¿Usar este espacio de trabajo?", + "chatgpt.workspace_path": "Directorio del espacio de trabajo", + "chatgpt.image_contract": "Use una imagen Linux Agent de confianza con Qwen ACP y un modelo local incluido. El fixture de prueba no es un Agent de producción.", + "chatgpt.image_name": "Nombre de la imagen Agent", + "chatgpt.pull_confirm": "La imagen no está disponible o es incompatible. ¿Descargar esta imagen de confianza?", + "chatgpt.pulling": "Descargando imagen Agent (hasta 10 minutos)…", + "chatgpt.tunnel_hint": "Crear o seleccionar Secure MCP Tunnel: https://platform.openai.com/settings/organization/tunnels", + "chatgpt.file_fallback": "Secret Service no está disponible. ¿Guardar la Runtime key en un archivo privado 0600 fuera del espacio de trabajo?", + "chatgpt.reset_summary": "Se eliminarán el perfil administrado, la configuración/estado de ChatGPT y la Runtime API Key guardada.\nSe conservarán el Tunnel de Platform, el código fuente, las imágenes Docker y el cliente verificado.", + "chatgpt.continue": "¿Continuar?", + "chatgpt.manual_check": "Comprobación manual pendiente: abra un chat normal de ChatGPT e invoque Zaivern una vez.", + "chatgpt.secret_prompt": "OpenAI Runtime API Key (oculta): ", + "chatgpt.setup_ready": "Configuración local lista. Ejecute: zai chatgpt start\n\nPaso final en ChatGPT:\n1. Ajustes → Plugins / Apps (modo desarrollador)\n2. Crear Zaivern\n3. Conexión: Tunnel; seleccione su Tunnel de Zaivern\n4. Autenticación: None\n5. Crear y seleccionar Zaivern en un chat normal\nMantenga el Bridge ejecutándose. El registro y la invocación requieren esta comprobación manual.", "remote.detect_app": "Conectar para que Norton detecte la aplicación", "remote.detect_app_hint": "Este zai.exe consulta directamente información de versiones por HTTPS. No instala actualizaciones ni cambia ajustes. Después, vuelva a abrir la lista de programas de Norton.", "remote.detect_app_busy": "Comprobando HTTPS desde esta aplicación…", diff --git a/locales/ja.json b/locales/ja.json index 4e465d0..11639ac 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1,5 +1,21 @@ { "agent.rename.hint": "ダブルクリックで名前を変更。右クリックの「名前を変更」からも編集できます。", + "chatgpt.runtime_auth_only": "Runtime API Key は Tunnel 認証専用です。OpenAI の推論 API は使用しません。", + "chatgpt.existing_setup": "既存設定が見つかりました。\n[1] 検証\n[2] 修復\n[3] 再設定\n[4] キャンセル", + "chatgpt.select": "操作を選択", + "chatgpt.use_workspace": "この workspace を使用しますか?", + "chatgpt.workspace_path": "workspace ディレクトリ", + "chatgpt.image_contract": "Qwen ACP とローカルモデルを同梱した信頼できる Linux Agent image を指定します。テスト用 fixture は本番 Agent ではありません。", + "chatgpt.image_name": "Agent image 名", + "chatgpt.pull_confirm": "image が存在しないか非互換です。この信頼済み image を取得しますか?", + "chatgpt.pulling": "Agent image を取得しています(最大10分)…", + "chatgpt.tunnel_hint": "Secure MCP Tunnel の作成・選択: https://platform.openai.com/settings/organization/tunnels", + "chatgpt.file_fallback": "Secret Service が利用できません。workspace 外の専用0600ファイルに Runtime key を保存しますか?", + "chatgpt.reset_summary": "専用 profile、ChatGPT 設定・実行状態、保存済み Runtime API Key を削除します。\nPlatform の Tunnel、ソース、Docker image、検証済み client は保持します。", + "chatgpt.continue": "続行しますか?", + "chatgpt.manual_check": "残る手動確認: ChatGPT の通常 Chat を開き、Zaivern を一度呼び出してください。", + "chatgpt.secret_prompt": "OpenAI Runtime API Key(非表示入力): ", + "chatgpt.setup_ready": "ローカル設定が完了しました。実行: zai chatgpt start\n\nChatGPT 側の最終操作:\n1. 設定 → Plugins / Apps(開発者モード)\n2. Zaivern を新規作成\n3. 接続: Tunnel、Zaivern の Tunnel を選択\n4. 認証: None\n5. 作成後、通常 Chat で Zaivern を選択\nBridge を起動したままにしてください。登録と呼び出しは手動確認が必要です。", "remote.detect_app": "Norton 検出用の通信を行う", "remote.detect_app_hint": "この zai.exe 自身が配布元へ HTTPS 接続して更新情報を確認します。設定変更や更新は行いません。通信後に Norton のプログラム一覧を開き直してください。", "remote.detect_app_busy": "このアプリから HTTPS 通信を確認中…", diff --git a/locales/ko.json b/locales/ko.json index 0096ed6..f36b75d 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1,5 +1,21 @@ { "agent.rename.hint": "두 번 클릭하여 이름을 변경하거나, 마우스 오른쪽 버튼을 클릭한 뒤 이름 변경을 선택하세요.", + "chatgpt.runtime_auth_only": "Runtime API Key는 터널 인증 전용입니다. OpenAI 추론 API를 사용하지 않습니다.", + "chatgpt.existing_setup": "기존 설정을 찾았습니다.\n[1] 검증\n[2] 복구\n[3] 재설정\n[4] 취소", + "chatgpt.select": "선택", + "chatgpt.use_workspace": "이 작업 공간을 사용하시겠습니까?", + "chatgpt.workspace_path": "작업 공간 디렉터리", + "chatgpt.image_contract": "Qwen ACP와 로컬 모델이 포함된 신뢰할 수 있는 Linux Agent 이미지를 사용하세요. 테스트 fixture는 운영 Agent가 아닙니다.", + "chatgpt.image_name": "Agent 이미지 이름", + "chatgpt.pull_confirm": "이미지가 없거나 호환되지 않습니다. 이 신뢰할 수 있는 이미지를 가져올까요?", + "chatgpt.pulling": "Agent 이미지 가져오는 중(최대 10분)…", + "chatgpt.tunnel_hint": "Secure MCP Tunnel 생성/선택: https://platform.openai.com/settings/organization/tunnels", + "chatgpt.file_fallback": "Secret Service를 사용할 수 없습니다. 작업 공간 외부의 비공개 0600 파일에 Runtime key를 저장할까요?", + "chatgpt.reset_summary": "전용 profile, ChatGPT 설정/실행 상태 및 저장된 Runtime API Key를 삭제합니다.\nPlatform Tunnel, 소스 코드, Docker 이미지 및 검증된 클라이언트는 유지합니다.", + "chatgpt.continue": "계속하시겠습니까?", + "chatgpt.manual_check": "남은 수동 확인: 일반 ChatGPT 채팅에서 Zaivern을 한 번 호출하세요.", + "chatgpt.secret_prompt": "OpenAI Runtime API Key(입력 숨김): ", + "chatgpt.setup_ready": "로컬 설정 완료. 실행: zai chatgpt start\n\nChatGPT에서 마지막 단계:\n1. 설정 → Plugins / Apps(개발자 모드)\n2. Zaivern 생성\n3. 연결: Tunnel, Zaivern Tunnel 선택\n4. 인증: None\n5. 생성 후 일반 채팅에서 Zaivern 선택\nBridge를 계속 실행하세요. 등록 및 호출은 수동 확인이 필요합니다.", "remote.detect_app": "Norton이 앱을 감지하도록 연결", "remote.detect_app_hint": "이 zai.exe가 직접 HTTPS로 릴리스 정보를 확인합니다. 업데이트를 설치하거나 설정을 변경하지 않습니다. 이후 Norton 프로그램 목록을 다시 여세요.", "remote.detect_app_busy": "이 앱에서 HTTPS 연결 확인 중…", diff --git a/locales/pt-BR.json b/locales/pt-BR.json index bfc21d3..5e232a2 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -1,5 +1,21 @@ { "agent.rename.hint": "Clique duas vezes para renomear ou clique com o botão direito e escolha Renomear.", + "chatgpt.runtime_auth_only": "A Runtime API Key autentica apenas o túnel. A API de inferência da OpenAI não é usada.", + "chatgpt.existing_setup": "Configuração existente encontrada.\n[1] Verificar\n[2] Reparar\n[3] Reconfigurar\n[4] Cancelar", + "chatgpt.select": "Selecionar", + "chatgpt.use_workspace": "Usar este espaço de trabalho?", + "chatgpt.workspace_path": "Diretório do espaço de trabalho", + "chatgpt.image_contract": "Use uma imagem Linux Agent confiável com Qwen ACP e modelo local incluído. O fixture de teste não é um Agent de produção.", + "chatgpt.image_name": "Nome da imagem Agent", + "chatgpt.pull_confirm": "Imagem indisponível ou incompatível. Baixar esta imagem confiável?", + "chatgpt.pulling": "Baixando imagem Agent (até 10 minutos)…", + "chatgpt.tunnel_hint": "Criar ou selecionar Secure MCP Tunnel: https://platform.openai.com/settings/organization/tunnels", + "chatgpt.file_fallback": "Secret Service indisponível. Salvar a Runtime key em arquivo privado 0600 fora do espaço de trabalho?", + "chatgpt.reset_summary": "Remove o perfil gerenciado, a configuração/estado do ChatGPT e a Runtime API Key salva.\nMantém o Tunnel da Platform, código-fonte, imagens Docker e cliente verificado.", + "chatgpt.continue": "Continuar?", + "chatgpt.manual_check": "Verificação manual restante: abra um chat normal do ChatGPT e invoque Zaivern uma vez.", + "chatgpt.secret_prompt": "OpenAI Runtime API Key (oculta): ", + "chatgpt.setup_ready": "Configuração local pronta. Execute: zai chatgpt start\n\nEtapa final no ChatGPT:\n1. Configurações → Plugins / Apps (modo desenvolvedor)\n2. Criar Zaivern\n3. Conexão: Tunnel; selecione seu Tunnel do Zaivern\n4. Autenticação: None\n5. Criar e selecionar Zaivern em um chat normal\nMantenha o Bridge em execução. Registro e chamada ainda exigem verificação manual.", "remote.detect_app": "Conectar para o Norton detectar o app", "remote.detect_app_hint": "Este zai.exe consulta informações de versões via HTTPS diretamente. Não instala atualizações nem altera configurações. Depois, reabra a lista de programas do Norton.", "remote.detect_app_busy": "Verificando HTTPS a partir deste app…", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 34f99c4..45abdd1 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1,5 +1,21 @@ { "agent.rename.hint": "双击可重命名,也可右键单击并选择“重命名”。", + "chatgpt.runtime_auth_only": "Runtime API Key 仅用于隧道认证,不调用 OpenAI 推理 API。", + "chatgpt.existing_setup": "检测到现有配置。\n[1] 验证\n[2] 修复\n[3] 重新配置\n[4] 取消", + "chatgpt.select": "选择", + "chatgpt.use_workspace": "使用此工作区?", + "chatgpt.workspace_path": "工作区目录", + "chatgpt.image_contract": "请使用包含 Qwen ACP 和本地模型的可信 Linux Agent 镜像。测试 fixture 不是生产 Agent。", + "chatgpt.image_name": "Agent 镜像名称", + "chatgpt.pull_confirm": "镜像不可用或不兼容。拉取此可信镜像?", + "chatgpt.pulling": "正在拉取 Agent 镜像(最多10分钟)…", + "chatgpt.tunnel_hint": "创建或选择 Secure MCP Tunnel:https://platform.openai.com/settings/organization/tunnels", + "chatgpt.file_fallback": "Secret Service 不可用。将 Runtime key 保存到工作区外的私有0600文件?", + "chatgpt.reset_summary": "将删除专用 profile、ChatGPT 配置和运行状态以及已保存的 Runtime API Key。\n保留 Platform Tunnel、源代码、Docker 镜像和已验证的客户端。", + "chatgpt.continue": "继续?", + "chatgpt.manual_check": "剩余手动检查:打开普通 ChatGPT 对话并调用一次 Zaivern。", + "chatgpt.secret_prompt": "OpenAI Runtime API Key(隐藏输入):", + "chatgpt.setup_ready": "本地配置已就绪。运行:zai chatgpt start\n\nChatGPT 最后一步:\n1. 设置 → Plugins / Apps(开发者模式)\n2. 创建 Zaivern\n3. 连接:Tunnel;选择 Zaivern Tunnel\n4. 认证:None\n5. 创建后在普通对话中选择 Zaivern\n请保持 Bridge 运行。注册和调用仍需手动确认。", "remote.detect_app": "发起通信以便 Norton 检测此应用", "remote.detect_app_hint": "此 zai.exe 将自行通过 HTTPS 查询发布信息,不安装更新或更改设置。通信后请重新打开 Norton 的程序列表。", "remote.detect_app_busy": "正在从此应用检查 HTTPS 通信…", diff --git a/src/chat_bridge/create.rs b/src/chat_bridge/create.rs new file mode 100644 index 0000000..6f25ae0 --- /dev/null +++ b/src/chat_bridge/create.rs @@ -0,0 +1,186 @@ +//! Classify create responses before generic user-facing error conversion. +use super::ResourceKind; +use crate::features::cloud_execution::{model::CollectSink, transport::run_child}; +use std::{process::Command, time::Duration}; + +pub(crate) fn run( + command: Command, + timeout: Duration, + kind: ResourceKind, + name: &str, +) -> CreateOutcome { + let mut sink = CollectSink::with_limit(64 * 1024); + let result = run_child(command, timeout, "docker", &mut sink); + create_outcome( + result.as_ref().ok().and_then(|r| r.exit_code), + result.is_ok(), + &sink, + kind, + name, + ) +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum CreateOutcome { + Created, + DefinitelyNotCreated, + Unknown, +} + +fn create_outcome( + code: Option, + completed: bool, + output: &CollectSink, + kind: ResourceKind, + name: &str, +) -> CreateOutcome { + if !completed || output.truncated { + return CreateOutcome::Unknown; + } + match code { + Some(0) => CreateOutcome::Created, + // Even a complete daemon error can follow partial filesystem creation. + // Only recognize rejections known to precede resource creation. + Some(code) + if code > 0 + && output.stdout.is_empty() + && pre_create_rejection(kind, name, &output.stderr) => + { + CreateOutcome::DefinitelyNotCreated + } + _ => CreateOutcome::Unknown, + } +} + +fn pre_create_rejection(kind: ResourceKind, name: &str, stderr: &[u8]) -> bool { + let Ok(text) = std::str::from_utf8(stderr) else { + return false; + }; + let Some(message) = text.trim_end().strip_prefix("Error response from daemon: ") else { + return false; + }; + // Moby v28.1.1: daemon/create.go resolves the image before newContainer; + // volume/local/local_unix.go validates options before Root.Create's mkdir. + // Do not broaden to filesystem/backend/context errors: an unlisted partial + // volume directory can become visible after daemon restart (local.go New). + match kind { + ResourceKind::Container => message + .strip_prefix("No such image: ") + .is_some_and(|image| !image.is_empty() && !image.chars().any(char::is_whitespace)), + ResourceKind::Volume => { + let Some(reason) = message.strip_prefix(&format!("create {name}: ")) else { + return false; + }; + let option = reason + .strip_prefix("invalid option: ") + .or_else(|| reason.strip_prefix("missing required option: ")); + option + .and_then(|value| serde_json::from_str::(value).ok()) + .is_some_and(|value| { + !value.is_empty() + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) + }) + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::process::CommandExt; + + #[test] + fn only_completed_daemon_rejection_is_definite() { + for (script, expected) in [ + ("exit 0", CreateOutcome::Created), + ( + "echo 'Error response from daemon: No such image: sha256:0000' >&2; exit 1", + CreateOutcome::DefinitelyNotCreated, + ), + ( + "echo 'error during connect: EOF' >&2; exit 1", + CreateOutcome::Unknown, + ), + ("exit 1", CreateOutcome::Unknown), + ( + "echo 'Error response from daemon: truncated' >&2; kill -KILL $$", + CreateOutcome::Unknown, + ), + ( + "echo 'Error response from daemon: delayed' >&2; exec sleep 30", + CreateOutcome::Unknown, + ), + ] { + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", script]).env_clear().process_group(0); + let timeout = if script.ends_with("sleep 30") { + Duration::from_millis(250) + } else { + Duration::from_secs(5) + }; + assert_eq!( + run(cmd, timeout, ResourceKind::Container, "fixture"), + expected, + "{script}" + ); + } + } + + #[test] + fn incomplete_or_truncated_output_never_proves_rejection() { + let mut output = CollectSink::default(); + output.stderr = b"Error response from daemon: No such image: sha256:0000\n".to_vec(); + assert_eq!( + create_outcome(Some(1), false, &output, ResourceKind::Container, "fixture"), + CreateOutcome::Unknown + ); + assert_eq!( + create_outcome(None, true, &output, ResourceKind::Container, "fixture"), + CreateOutcome::Unknown + ); + output.truncated = true; + assert_eq!( + create_outcome(Some(1), true, &output, ResourceKind::Container, "fixture"), + CreateOutcome::Unknown + ); + output.truncated = false; + output.stdout = b"possibly-created-id".to_vec(); + assert_eq!( + create_outcome(Some(1), true, &output, ResourceKind::Container, "fixture"), + CreateOutcome::Unknown + ); + } + + #[test] + fn daemon_system_errors_are_unknown_even_with_complete_nonzero_exit() { + for message in [ + "create fixture: error while creating volume data path '/var/lib/docker/volumes/fixture/_data': no space left on device", + "create fixture: error while creating volume root path '/var/lib/docker/volumes/fixture': permission denied", + "context deadline exceeded", + "rpc error: code = Unavailable desc = transport is closing", + "create fixture: error while persisting volume options: input/output error", + "create fixture: quota size requested but no quota support", + "create other: invalid option: \"bad-option\"", + ] { + let mut output = CollectSink::default(); + output.stderr = format!("Error response from daemon: {message}\n").into_bytes(); + for kind in [ResourceKind::Volume, ResourceKind::Container] { + assert_eq!(create_outcome(Some(1), true, &output, kind, "fixture"), CreateOutcome::Unknown, "{message}"); + } + } + for reason in [ + "invalid option: \"bad-option\"", + "missing required option: \"device\"", + ] { + let mut output = CollectSink::default(); + output.stderr = + format!("Error response from daemon: create fixture: {reason}\n").into_bytes(); + assert_eq!( + create_outcome(Some(1), true, &output, ResourceKind::Volume, "fixture"), + CreateOutcome::DefinitelyNotCreated + ); + } + } +} diff --git a/src/chat_bridge/e2e_tests.rs b/src/chat_bridge/e2e_tests.rs index 4597bfa..f1fe35b 100644 --- a/src/chat_bridge/e2e_tests.rs +++ b/src/chat_bridge/e2e_tests.rs @@ -180,7 +180,11 @@ fn real_stdio_container_agent_edit_test_diff_and_cancel() { .iter() .map(|tool| tool["name"].as_str().unwrap()) .collect::>(), - ["zaivern_run_task", "zaivern_task_status", "zaivern_cancel_task"] + [ + "zaivern_run_task", + "zaivern_task_status", + "zaivern_cancel_task" + ] ); for instruction in [ "Fix the failing test and show the diff", diff --git a/src/chat_bridge/host.rs b/src/chat_bridge/host.rs new file mode 100644 index 0000000..4e684dd --- /dev/null +++ b/src/chat_bridge/host.rs @@ -0,0 +1,32 @@ +//! Host executable trust shared by managed and manual MCP entry points. +use std::path::{Path, PathBuf}; + +/// Resolve installation symlinks, then reject Agent-writable executable paths. +/// Same-user concurrent replacement is outside this filesystem trust boundary. +pub(crate) fn validate_executable(path: &Path, workspace: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + if !path.is_absolute() || !workspace.is_absolute() { + return Err("Host executable and workspace paths must be absolute".into()); + } + let resolved = path.canonicalize().map_err(|_| "Executable unavailable")?; + // Cleanup must remain possible after the original workspace is removed. + let resolved_workspace = workspace.canonicalize().ok(); + if resolved.starts_with(workspace) + || resolved_workspace + .as_ref() + .is_some_and(|w| resolved.starts_with(w)) + { + return Err("Host executables must be outside the Agent workspace".into()); + } + let metadata = + std::fs::symlink_metadata(&resolved).map_err(|_| "Cannot inspect host executable")?; + if !metadata.is_file() + || metadata.nlink() != 1 + || ![0, unsafe { libc::geteuid() }].contains(&metadata.uid()) + || metadata.mode() & 0o022 != 0 + || metadata.mode() & 0o111 == 0 + { + return Err("Unsafe host executable ownership, permissions, type or links".into()); + } + Ok(resolved) +} diff --git a/src/chat_bridge/mod.rs b/src/chat_bridge/mod.rs index 09fd9c1..d762dce 100644 --- a/src/chat_bridge/mod.rs +++ b/src/chat_bridge/mod.rs @@ -1,8 +1,11 @@ //! MCP transport is independent of task storage and execution. mod cargo_graph; mod cargo_verification; +pub(crate) mod create; #[cfg(test)] mod e2e_tests; +#[cfg(unix)] +pub(crate) mod host; mod protocol; #[cfg(all(test, unix))] mod snapshot_tests; @@ -15,6 +18,56 @@ mod workspace; use std::path::PathBuf; use std::sync::Arc; +#[derive(Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ResourceKind { + Container, + Volume, +} + +/// Managed lifecycle persistence; not exposed over MCP or selected by requests. +pub(crate) trait CleanupTracker: Send + Sync { + fn register(&self, kind: ResourceKind) -> Result<(String, Vec), String>; + fn created(&self, kind: ResourceKind, name: &str) -> Result<(), String>; + /// A proven pre-create rejection, not a backend failure or lost response. Absence + /// must still be proved by cleanup before committing any receipt. + fn rejected(&self, kind: ResourceKind, name: &str) -> Result<(), String>; + fn remove(&self, kind: ResourceKind, name: &str) -> Result<(), String>; +} + +#[cfg(unix)] +pub(crate) fn serve_managed( + root: PathBuf, + image: String, + docker: PathBuf, + docker_endpoint: String, + cleanup: Arc, +) -> Result<(), String> { + let root = workspace::validate_root(&root)?; + let target = target::LocalExecutionTarget::new_managed( + image, + docker, + docker_endpoint, + Some(cleanup), + &root, + )?; + serve_target(root, target) +} + +/// Local diagnostics use the same injected executable boundary, without a +/// running managed generation. This is not an MCP method or a task option. +#[cfg(unix)] +pub(crate) fn serve_probe( + root: PathBuf, + image: String, + docker: PathBuf, + endpoint: String, +) -> Result<(), String> { + let root = workspace::validate_root(&root)?; + let target = target::LocalExecutionTarget::new_managed(image, docker, endpoint, None, &root)?; + serve_target(root, target) +} + pub const HELP: &str = "\nMCP (ChatGPT bridge):\n zai mcp serve --workspace ABSOLUTE_PATH --image IMAGE@sha256:DIGEST\n See docs/chatgpt.md for isolation, agent setup and connection requirements.\n"; pub fn cli_main(args: &[String]) -> i32 { @@ -48,8 +101,20 @@ fn serve(args: &[String]) -> Result<(), String> { } } let root = workspace::validate_root(&root.ok_or("--workspace is required")?)?; - let target = target::LocalExecutionTarget::new(image.ok_or("--image is required")?)?; - let bridge = task::ChatBridge::new(root, Arc::new(target)); - protocol::serve(std::io::stdin().lock(), std::io::stdout().lock(), &bridge) - .map_err(|e| e.to_string()) + let target = target::LocalExecutionTarget::new(image.ok_or("--image is required")?, &root)?; + serve_target(root, target) +} + +fn serve_target(root: PathBuf, target: target::LocalExecutionTarget) -> Result<(), String> { + let target = Arc::new(target); + let bridge = task::ChatBridge::new(root, target.clone()); + let result = protocol::serve(std::io::stdin().lock(), std::io::stdout().lock(), &bridge) + .map_err(|e| e.to_string()); + drop(bridge); // Cancel/join the worker before observing final Docker cleanup. + if !target.cleanup_confirmed() { + return Err( + "Docker cleanup is unconfirmed; inspect the local task containers/volumes".into(), + ); + } + result } diff --git a/src/chat_bridge/protocol.rs b/src/chat_bridge/protocol.rs index a3a057f..6c41212 100644 --- a/src/chat_bridge/protocol.rs +++ b/src/chat_bridge/protocol.rs @@ -12,12 +12,7 @@ const MAX_LINE: usize = 256 * 1024; // A modern revision cannot be negotiated through the legacy initialize RPC. const VERSION: &str = "2025-11-25"; const MODERN_VERSION: &str = "2026-07-28"; -const VERSIONS: &[&str] = &[ - MODERN_VERSION, - VERSION, - "2025-06-18", - "2024-11-05", -]; +const VERSIONS: &[&str] = &[MODERN_VERSION, VERSION, "2025-06-18", "2024-11-05"]; const META_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; const META_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; const META_CLIENT: &str = "io.modelcontextprotocol/clientInfo"; @@ -149,7 +144,8 @@ pub(super) fn serve( Ok(mut value) => { if modern { value["resultType"] = json!("complete"); - value["_meta"] = json!({"io.modelcontextprotocol/serverInfo":server_info()}); + value["_meta"] = + json!({"io.modelcontextprotocol/serverInfo":server_info()}); if method == "tools/list" { value["cacheScope"] = json!("private"); value["ttlMs"] = json!(0); @@ -202,7 +198,9 @@ fn request_mode(params: &Value, id: &Value) -> Result { return Ok(false); } if !meta.get(META_CAPABILITIES).is_some_and(Value::is_object) - || meta.get(META_CLIENT).is_some_and(|info| !valid_implementation(info)) + || meta + .get(META_CLIENT) + .is_some_and(|info| !valid_implementation(info)) { return Err(invalid()); } @@ -239,11 +237,18 @@ mod tests { std::path::PathBuf::from("unused"), std::sync::Arc::new(super::super::task::tests::Fake), ); - let input = requests.iter().map(Value::to_string).collect::>().join("\n"); + let input = requests + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"); let mut output = Vec::new(); serve(io::Cursor::new(input), &mut output, &bridge).unwrap(); - std::str::from_utf8(&output).unwrap().lines() - .map(|line| serde_json::from_str(line).unwrap()).collect() + std::str::from_utf8(&output) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() } fn modern_meta() -> Value { @@ -254,8 +259,17 @@ mod tests { fn assert_tools(result: &Value) { let tools = result["tools"].as_array().unwrap(); - assert_eq!(tools.iter().map(|tool| tool["name"].as_str().unwrap()).collect::>(), - ["zaivern_run_task", "zaivern_task_status", "zaivern_cancel_task"]); + assert_eq!( + tools + .iter() + .map(|tool| tool["name"].as_str().unwrap()) + .collect::>(), + [ + "zaivern_run_task", + "zaivern_task_status", + "zaivern_cancel_task" + ] + ); for (tool, argument) in tools.iter().zip(["instruction", "task_id", "task_id"]) { let schema = &tool["inputSchema"]; assert_eq!(schema["type"], "object"); @@ -294,7 +308,10 @@ mod tests { assert_eq!(row["result"]["protocolVersion"], expected); assert_eq!(row["result"]["capabilities"], json!({"tools":{}})); assert_eq!(row["result"]["serverInfo"]["name"], "zaivern-chat-bridge"); - assert_eq!(row["result"]["serverInfo"]["version"], env!("CARGO_PKG_VERSION")); + assert_eq!( + row["result"]["serverInfo"]["version"], + env!("CARGO_PKG_VERSION") + ); } assert_eq!(rows[2]["error"]["code"], -32000); assert_eq!(rows[3]["error"]["code"], -32000); @@ -323,18 +340,27 @@ mod tests { assert_eq!(rows[offset]["result"]["resultType"], "complete"); assert_eq!(rows[offset]["result"]["cacheScope"], "private"); assert_eq!(rows[offset]["result"]["ttlMs"], 0); - assert_eq!(rows[offset+1]["id"], u64::MAX); - assert_eq!(rows[offset+1]["result"]["resultType"], "complete"); - assert_eq!(rows[offset+1]["result"]["isError"], true); - assert_eq!(rows[offset+1]["result"]["content"][0]["type"], "text"); - assert_eq!(rows[offset+2]["error"]["code"], -32000); - assert_eq!(rows[offset+4]["error"]["code"], -32000); - let discovery = &rows[offset+3]["result"]; + assert_eq!(rows[offset + 1]["id"], u64::MAX); + assert_eq!(rows[offset + 1]["result"]["resultType"], "complete"); + assert_eq!(rows[offset + 1]["result"]["isError"], true); + assert_eq!(rows[offset + 1]["result"]["content"][0]["type"], "text"); + assert_eq!(rows[offset + 2]["error"]["code"], -32000); + assert_eq!(rows[offset + 4]["error"]["code"], -32000); + let discovery = &rows[offset + 3]["result"]; assert_eq!(discovery["cacheScope"], "private"); assert_eq!(discovery["ttlMs"], 0); - assert_eq!(discovery["supportedVersions"], json!(["2026-07-28","2025-11-25","2025-06-18","2024-11-05"])); - assert_eq!(discovery["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "zaivern-chat-bridge"); - assert!(discovery.get("serverInfo").is_none(), "use the published schema, not the draft SEP"); + assert_eq!( + discovery["supportedVersions"], + json!(["2026-07-28", "2025-11-25", "2025-06-18", "2024-11-05"]) + ); + assert_eq!( + discovery["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "zaivern-chat-bridge" + ); + assert!( + discovery.get("serverInfo").is_none(), + "use the published schema, not the draft SEP" + ); } } @@ -346,15 +372,23 @@ mod tests { json!({"jsonrpc":"2.0","id":"unsupported","method":"tools/call","params":{"_meta":unsupported,"name":"zaivern_run_task","arguments":{"instruction":"must not run"}}}), json!({"jsonrpc":"2.0","id":"retry","method":"server/discover","params":{"_meta":modern_meta()}}), ]); - assert_eq!(rows[0], json!({"jsonrpc":"2.0","id":"unsupported","error":{"code":-32022,"message":"Unsupported protocol version","data":{"supported":VERSIONS,"requested":"2099-01-01"}}})); + assert_eq!( + rows[0], + json!({"jsonrpc":"2.0","id":"unsupported","error":{"code":-32022,"message":"Unsupported protocol version","data":{"supported":VERSIONS,"requested":"2099-01-01"}}}) + ); assert!(rows[1].get("error").is_none()); - for meta in [json!(null), json!([]), json!({META_VERSION:42}), + for meta in [ + json!(null), + json!([]), + json!({META_VERSION:42}), json!({META_VERSION:MODERN_VERSION}), json!({META_CAPABILITIES:{}}), json!({META_VERSION:MODERN_VERSION,META_CAPABILITIES:[]}), json!({META_VERSION:MODERN_VERSION,META_CAPABILITIES:{},META_CLIENT:{"name":"missing version"}}), ] { - let rows = exchange(&[json!({"jsonrpc":"2.0","id":"bad","method":"tools/call","params":{"_meta":meta,"name":"zaivern_run_task","arguments":{"instruction":"must not run"}}})]); + let rows = exchange(&[ + json!({"jsonrpc":"2.0","id":"bad","method":"tools/call","params":{"_meta":meta,"name":"zaivern_run_task","arguments":{"instruction":"must not run"}}}), + ]); assert_eq!(rows[0]["error"]["code"], -32602, "{rows:?}"); assert_eq!(rows[0]["id"], "bad"); } @@ -362,11 +396,27 @@ mod tests { #[test] fn discovery_never_exposes_host_operations_or_unadvertised_capabilities() { - let mut requests = vec![json!({"jsonrpc":"2.0","id":0,"method":"server/discover","params":{"_meta":modern_meta()}})]; - for method in ["unknown", "resources/list", "resources/templates/list", "prompts/list"] { + let mut requests = vec![ + json!({"jsonrpc":"2.0","id":0,"method":"server/discover","params":{"_meta":modern_meta()}}), + ]; + for method in [ + "unknown", + "resources/list", + "resources/templates/list", + "prompts/list", + ] { requests.push(json!({"jsonrpc":"2.0","id":method,"method":method,"params":{"_meta":modern_meta()}})); } - for name in ["execute", "delete", "move", "fetch", "zaivern_execute", "zaivern_delete", "zaivern_move", "zaivern_fetch"] { + for name in [ + "execute", + "delete", + "move", + "fetch", + "zaivern_execute", + "zaivern_delete", + "zaivern_move", + "zaivern_fetch", + ] { requests.push(json!({"jsonrpc":"2.0","id":name,"method":"tools/call","params":{"_meta":modern_meta(),"name":name,"arguments":{}}})); } requests.push(json!({"jsonrpc":"2.0","id":"workspace","method":"tools/call","params":{"_meta":modern_meta(),"name":"zaivern_run_task","arguments":{"instruction":"edit","workspace":"/outside"}}})); @@ -380,7 +430,10 @@ mod tests { } assert_eq!(rows[13]["result"]["isError"], true); // Unknown methods have the standard error even before any handshake. - assert_eq!(exchange(&[json!({"jsonrpc":"2.0","id":1,"method":"unknown"})])[0]["error"]["code"], -32601); + assert_eq!( + exchange(&[json!({"jsonrpc":"2.0","id":1,"method":"unknown"})])[0]["error"]["code"], + -32601 + ); } #[test] @@ -395,11 +448,18 @@ mod tests { json!({"jsonrpc":"2.0","id":"discover","method":"server/discover"}), json!({"jsonrpc":"2.0","id":0,"method":"tools/list"}), ]; - let input = requests.iter().map(Value::to_string).collect::>().join("\n"); + let input = requests + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"); let mut output = Vec::new(); serve(io::Cursor::new(input), &mut output, &bridge).unwrap(); - let rows: Vec = std::str::from_utf8(&output).unwrap().lines() - .map(|line| serde_json::from_str(line).unwrap()).collect(); + let rows: Vec = std::str::from_utf8(&output) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); assert_eq!(rows.len(), 3, "notifications must not receive a response"); assert_eq!(rows[1]["id"], "discover"); assert!(rows[1].get("error").is_none(), "{rows:?}"); diff --git a/src/chat_bridge/target.rs b/src/chat_bridge/target.rs index 3d115af..7ac8dab 100644 --- a/src/chat_bridge/target.rs +++ b/src/chat_bridge/target.rs @@ -3,6 +3,10 @@ use super::cargo_verification::Coverage; use super::task::{Control, Outcome, State, TaskExecutor}; use super::workspace::{Snapshot, FILE_LIMIT, SNAPSHOT_LIMIT}; +use super::{ + create::{self, CreateOutcome}, + CleanupTracker, ResourceKind, +}; use crate::acp::{AcpClient, Phase}; use crate::agents::approvals::ApprovalQueue; use crate::features::cloud_execution::{ @@ -18,9 +22,12 @@ pub(super) struct LocalExecutionTarget { image: String, docker: PathBuf, endpoint: String, + docker_config: Staging, + cleanup_failed: std::sync::atomic::AtomicBool, + pub(super) cleanup: Option>, } impl LocalExecutionTarget { - pub fn new(image: String) -> Result { + pub fn new(image: String, _workspace: &Path) -> Result { if !valid_image(&image) { return Err("image must be an immutable IMAGE@sha256:DIGEST reference".into()); } @@ -28,7 +35,13 @@ impl LocalExecutionTarget { return Err("MCP execution is not yet supported on this OS".into()); #[cfg(unix)] { - let docker = crate::shellenv::which("docker").ok_or("Docker CLI is required")?; + // Manual CLI may select its installation from the supplied PATH, + // but never run a login shell or execute an Agent-writable wrapper. + let docker = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|directory| directory.join("docker")) + .find(|path| path.is_file()) + .ok_or("Docker CLI is required")?; + let docker = super::host::validate_executable(&docker, _workspace)?; let endpoint = if let Ok(host) = std::env::var("DOCKER_HOST") { host } else { @@ -59,14 +72,55 @@ impl LocalExecutionTarget { image, docker, endpoint, + docker_config: Staging::new()?, + cleanup_failed: std::sync::atomic::AtomicBool::new(false), + cleanup: None, }) } } + /// The managed caller supplies its validated, canonical executable and + /// local endpoint. Never rediscover either through PATH or Docker contexts. + #[cfg(unix)] + pub(super) fn new_managed( + image: String, + docker: PathBuf, + endpoint: String, + cleanup: Option>, + workspace: &Path, + ) -> Result { + use std::os::unix::fs::FileTypeExt; + if !valid_image(&image) || super::host::validate_executable(&docker, workspace)? != docker { + return Err("invalid managed executable/image identity".into()); + } + let socket = endpoint + .strip_prefix("unix://") + .filter(|path| Path::new(path).is_absolute()) + .ok_or("managed execution requires a local Unix Docker socket")?; + if !std::fs::metadata(socket).is_ok_and(|m| m.file_type().is_socket()) { + return Err("local Docker socket is unavailable".into()); + } + Ok(Self { + image, + docker, + endpoint, + docker_config: Staging::new()?, + cleanup_failed: std::sync::atomic::AtomicBool::new(false), + cleanup, + }) + } + pub(super) fn cleanup_confirmed(&self) -> bool { + !self + .cleanup_failed + .load(std::sync::atomic::Ordering::Acquire) + } fn command(&self, args: &[String]) -> std::process::Command { let mut command = crate::procx::hidden_command_raw(&self.docker); command - .env_remove("DOCKER_HOST") - .env_remove("DOCKER_CONTEXT") + .env_clear() + .env("PATH", "/usr/bin:/bin") + // Docker otherwise injects host proxy credentials into create's + // container environment, even with --network=none. + .env("DOCKER_CONFIG", &self.docker_config.0) .args(["--host", &self.endpoint]) .args(args); #[cfg(unix)] @@ -86,29 +140,47 @@ impl LocalExecutionTarget { Ok(sink.stdout) } + fn create( + &self, + kind: ResourceKind, + name: &str, + args: &[String], + timeout: Duration, + ) -> Result<(), String> { + match create::run(self.command(args), timeout, kind, name) { + CreateOutcome::Created => { + if let Some(cleanup) = &self.cleanup { + cleanup.created(kind, name)?; + } + Ok(()) + } + CreateOutcome::DefinitelyNotCreated => { + if let Some(cleanup) = &self.cleanup { + cleanup.rejected(kind, name)?; + } + Err("Docker create was rejected".into()) + } + CreateOutcome::Unknown => { + Err("Docker create outcome is unknown; cleanup evidence retained".into()) + } + } + } + fn upload( &self, container: &Container<'_>, - files: &Path, + files: &BTreeMap>, started: Instant, ) -> Result<(), String> { let staging = Staging::new()?; let archive = staging.0.join("snapshot.tar"); - let tar = crate::shellenv::which("tar").ok_or("tar is required for snapshot transfer")?; - let mut command = crate::procx::hidden_command_raw(&tar); - command - .arg("-cf") - .arg(&archive) - .arg("-C") - .arg(files) - .arg("."); - // A private directory populated only from validated regular text files. + // Only frozen, policy-validated regular file bytes enter the archive. + // No host executable or second traversal of a mutable staging tree. + snapshot_archive( + std::fs::File::create(&archive).map_err(|_| "cannot create snapshot archive")?, + files, + )?; let mut sink = CollectSink::with_limit(64 * 1024); - let result = run_child(command, budget(started, 30)?, "tar", &mut sink) - .map_err(|_| "cannot prepare snapshot archive")?; - if !result.ok() { - return Err("cannot prepare snapshot archive".into()); - } let input = std::fs::File::open(archive).map_err(|_| "cannot open snapshot archive")?; let command = self.command(&strings(&[ "exec", @@ -151,14 +223,21 @@ impl LocalExecutionTarget { volume: Rc>, readonly: bool, ) -> Result, String> { + // No create can be issued when the task budget has already expired. + // Reject before the write-ahead intent rather than leaving false debt. + let create_timeout = budget(started, 30)?; let mount = format!( "type=volume,source={},target=/workspace,volume-nocopy{}", volume.name, if readonly { ",readonly" } else { "" } ); + let (name, labels) = match &self.cleanup { + Some(cleanup) => cleanup.register(ResourceKind::Container)?, + None => (ids::new_id("zaivern-mcp-"), Vec::new()), + }; let container = Container { target: self, - id: ids::new_id("zaivern-mcp-"), + id: name, removed: std::cell::Cell::new(false), volume, }; @@ -187,6 +266,7 @@ impl LocalExecutionTarget { "--env=CARGO_TARGET_DIR=/target", ])); } + args.extend(labels); args.push(self.image.clone()); if verifier { args.push("1800".into()); @@ -194,7 +274,12 @@ impl LocalExecutionTarget { // Track our unique name before create: the daemon can create a container // even if its response is lost or the CLI times out. Drop still removes it. let launch = self - .run(&args, budget(started, 30)?) + .create( + ResourceKind::Container, + &container.id, + &args, + create_timeout, + ) .and_then(|_| self.run(&strings(&["start", &container.id]), budget(started, 30)?)); if let Err(error) = launch { return Err(match container.shutdown() { @@ -232,7 +317,11 @@ impl LocalExecutionTarget { // Populate with a trusted sleeping preparer, then destroy that writable // handle before any candidate code runs in a readonly-mounted verifier. - fn prepare_verifier(&self, files: &Path, started: Instant) -> Result, String> { + fn prepare_verifier( + &self, + files: &BTreeMap>, + started: Instant, + ) -> Result, String> { let preparer = self.start_container(started, true)?; let result = self .upload(&preparer, files, started) @@ -289,7 +378,7 @@ impl LocalExecutionTarget { } // Shared inputs only; never discover host inputs from the candidate. // The entire input volume is readonly. - let verifier = self.prepare_verifier(&staging.0, started)?; + let verifier = self.prepare_verifier(changes, started)?; let (metadata_ok, metadata) = self.cargo( &verifier, &["metadata", "--format-version=1", "--frozen"], @@ -423,6 +512,41 @@ fn strings(args: &[&str]) -> Vec { args.iter().map(|s| s.to_string()).collect() } +fn snapshot_archive( + output: impl std::io::Write, + files: &BTreeMap>, +) -> Result<(), String> { + if files.len() > 8192 + || files.values().map(Vec::len).sum::() > super::workspace::VERIFICATION_LIMIT + { + return Err("snapshot archive limit exceeded".into()); + } + let mut archive = tar::Builder::new(output); + for (path, bytes) in files { + if path.as_os_str().is_empty() + || !super::workspace::allowed_verification(path) + || bytes.len() > FILE_LIMIT + || std::str::from_utf8(bytes).is_err() + || bytes.contains(&0) + { + return Err("invalid snapshot archive input".into()); + } + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Regular); + header.set_size(bytes.len() as u64); + header.set_mode(0o600); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + archive + .append_data(&mut header, path, bytes.as_slice()) + .map_err(|_| "cannot prepare snapshot archive")?; + } + archive + .finish() + .map_err(|_| "cannot finish snapshot archive".into()) +} + struct Container<'a> { target: &'a LocalExecutionTarget, id: String, @@ -439,17 +563,21 @@ impl Container<'_> { fn shutdown(&self) -> Result<(), String> { if !self.removed.get() { - self.target - .run( - &strings(&["rm", "--force", &self.id]), - Duration::from_secs(20), - ) - .map_err(|_| { - format!( - "container cleanup unconfirmed: {}; inspect Docker locally", - self.id + if let Some(cleanup) = &self.target.cleanup { + cleanup.remove(ResourceKind::Container, &self.id)?; + } else { + self.target + .run( + &strings(&["rm", "--force", &self.id]), + Duration::from_secs(20), ) - })?; + .map_err(|_| { + format!( + "container cleanup unconfirmed: {}; inspect Docker locally", + self.id + ) + })?; + } self.removed.set(true); } if Rc::strong_count(&self.volume) == 1 { @@ -462,6 +590,9 @@ impl Container<'_> { impl Drop for Container<'_> { fn drop(&mut self) { if let Err(error) = self.shutdown() { + self.target + .cleanup_failed + .store(true, std::sync::atomic::Ordering::Release); eprintln!("mcp {error}"); } } @@ -473,44 +604,52 @@ struct Volume<'a> { } impl<'a> Volume<'a> { fn new(target: &'a LocalExecutionTarget, started: Instant) -> Result { + let create_timeout = budget(started, 30)?; + let (name, labels) = match &target.cleanup { + Some(cleanup) => cleanup.register(ResourceKind::Volume)?, + None => (ids::new_id("zaivern-mcp-"), Vec::new()), + }; let volume = Self { target, - name: ids::new_id("zaivern-mcp-"), + name, removed: std::cell::Cell::new(false), }; - target.run( - &strings(&[ - "volume", - "create", - "--driver", - "local", - "--opt", - "type=tmpfs", - "--opt", - "device=tmpfs", - "--opt", - "o=size=256m,exec,nosuid,nodev", - &volume.name, - ]), - budget(started, 30)?, - )?; + let mut args = strings(&[ + "volume", + "create", + "--driver", + "local", + "--opt", + "type=tmpfs", + "--opt", + "device=tmpfs", + "--opt", + "o=size=256m,exec,nosuid,nodev", + ]); + args.extend(labels); + args.push(volume.name.clone()); + target.create(ResourceKind::Volume, &volume.name, &args, create_timeout)?; Ok(volume) } fn shutdown(&self) -> Result<(), String> { if self.removed.get() { return Ok(()); } - self.target - .run( - &strings(&["volume", "rm", &self.name]), - Duration::from_secs(20), - ) - .map_err(|_| { - format!( - "workspace volume cleanup unconfirmed: {}; inspect Docker locally", - self.name + if let Some(cleanup) = &self.target.cleanup { + cleanup.remove(ResourceKind::Volume, &self.name)?; + } else { + self.target + .run( + &strings(&["volume", "rm", &self.name]), + Duration::from_secs(20), ) - })?; + .map_err(|_| { + format!( + "workspace volume cleanup unconfirmed: {}; inspect Docker locally", + self.name + ) + })?; + } self.removed.set(true); Ok(()) } @@ -518,6 +657,9 @@ impl<'a> Volume<'a> { impl Drop for Volume<'_> { fn drop(&mut self) { if let Err(error) = self.shutdown() { + self.target + .cleanup_failed + .store(true, std::sync::atomic::Ordering::Release); eprintln!("mcp {error}"); } } @@ -527,13 +669,17 @@ struct Staging(PathBuf); impl Staging { fn new() -> Result { let path = std::env::temp_dir().join(ids::new_id("zaivern-mcp-")); - std::fs::create_dir(&path).map_err(|_| "cannot create task staging directory")?; + let builder = std::fs::DirBuilder::new(); #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)) - .map_err(|_| "cannot protect staging directory")?; - } + let builder = { + use std::os::unix::fs::DirBuilderExt; + let mut builder = builder; + builder.mode(0o700); + builder + }; + builder + .create(&path) + .map_err(|_| "cannot create private task staging directory")?; Ok(Self(path)) } } @@ -556,14 +702,11 @@ impl TaskExecutor for LocalExecutionTarget { return Err("cancelled".into()); } let staging = Staging::new()?; - let files = staging.0.join("source"); - std::fs::create_dir(&files).map_err(|_| "cannot create source staging directory")?; - snapshot.stage(&files)?; // The image must already exist. Pulling, provisioning and forwarding // host auth/environment are deliberately outside task execution. let container = self.start_container(started, false)?; let result = (|| { - self.upload(&container, &files, started)?; + self.upload(&container, &snapshot.files, started)?; // Reuse the existing agent catalog; no dynamically supplied command. let entry = crate::agents::ACP_CATALOG .iter() @@ -853,6 +996,320 @@ fn regular_tar_payload(tar: &[u8]) -> Result, String> { #[cfg(test)] mod tests { use super::*; + + #[test] + fn archive_contains_only_validated_regular_bytes_and_relative_names() { + let files = BTreeMap::from([ + ( + PathBuf::from("src/空白 ' file.rs"), + b"fn main() {}\n".to_vec(), + ), + ( + PathBuf::from(format!("{}/file.rs", "long-name".repeat(20))), + b"long\n".to_vec(), + ), + ]); + let mut bytes = Vec::new(); + snapshot_archive(&mut bytes, &files).unwrap(); + let mut decoded = BTreeMap::new(); + for entry in tar::Archive::new(bytes.as_slice()).entries().unwrap() { + use std::io::Read; + let mut entry = entry.unwrap(); + assert!(entry.header().entry_type().is_file()); + assert_eq!(entry.header().mode().unwrap(), 0o600); + let path = entry.path().unwrap().into_owned(); + let mut content = Vec::new(); + entry.read_to_end(&mut content).unwrap(); + decoded.insert(path, content); + } + assert_eq!(decoded, files); + for path in ["", "../escape", "/absolute", "a/../../b", ".env", "config/key"] { + assert!(snapshot_archive( + Vec::new(), + &BTreeMap::from([(PathBuf::from(path), b"x".to_vec())]) + ) + .is_err()); + } + #[cfg(not(windows))] + assert!(snapshot_archive( + Vec::new(), + &BTreeMap::from([(PathBuf::from("a\\b"), b"x".to_vec())]) + ) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn managed_upload_ignores_malicious_path_executables() { + use std::os::unix::fs::PermissionsExt; + const CHILD: &str = "ZAIVERN_PATH_ATTACK_FIXTURE"; + if std::env::var_os(CHILD).is_none() { + let root = Staging::new().unwrap(); + let attacker = root.0.join("workspace/bin"); + std::fs::create_dir_all(&attacker).unwrap(); + for name in ["tar", "docker"] { + let path = attacker.join(name); + std::fs::write( + &path, + "#!/bin/sh\nprintf ATTACK > \"$0.SENTINEL\"\nexit 99\n", + ) + .unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let mut cmd = std::process::Command::new(std::env::current_exe().unwrap()); + cmd.args(["--exact", "features::chat_bridge::imp::target::tests::managed_upload_ignores_malicious_path_executables", "--nocapture"]) + .env(CHILD, &root.0) + .env("PATH", std::env::join_paths([attacker.clone(), PathBuf::from("/tmp"), PathBuf::from("/usr/bin"), PathBuf::from("/bin")]).unwrap()) + .env("CONTROL_PLANE_API_KEY", "runtime-key-must-not-reach-docker") + .env("OPENAI_API_KEY", "model-key-must-not-reach-docker") + .env("DOCKER_CONFIG", &attacker) + .env("DOCKER_HOST", "tcp://attacker:2375"); + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + for name in ["tar", "docker"] { + assert!(!attacker.join(format!("{name}.SENTINEL")).exists()); + // Positive control: these exact executables really would leave + // evidence if the old PATH-based implementation selected them. + assert!(!std::process::Command::new(attacker.join(name)) + .status() + .unwrap() + .success()); + assert!(attacker.join(format!("{name}.SENTINEL")).exists()); + } + return; + } + let root = PathBuf::from(std::env::var_os(CHILD).unwrap()) + .canonicalize() + .unwrap(); + crate::shellenv::initialize_test_user_path(std::env::var_os("PATH").unwrap()); + assert!( + LocalExecutionTarget::new( + format!("sha256:{}", "a".repeat(64)), + &root.join("workspace") + ) + .is_err(), + "manual startup must reject the workspace Docker wrapper before executing it" + ); + let docker = root.join("trusted-docker"); + std::fs::write(&docker, "#!/bin/sh\n[ \"$1\" = --host ] || exit 2\nprintf '%s' \"$2\" > \"$0.endpoint\"\n[ -z \"${CONTROL_PLANE_API_KEY+x}${OPENAI_API_KEY+x}${HOME+x}${DOCKER_HOST+x}\" ] || exit 3\n[ -d \"$DOCKER_CONFIG\" ] && [ ! -e \"$DOCKER_CONFIG/config.json\" ] || exit 4\n/bin/cat > \"$0.archive\"\n").unwrap(); + std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o700)).unwrap(); + let socket = root.join("docker.sock"); + let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + let endpoint = format!("unix://{}", socket.display()); + let target = LocalExecutionTarget::new_managed( + format!("sha256:{}", "a".repeat(64)), + docker.clone(), + endpoint.clone(), + None, + &root.join("workspace"), + ) + .unwrap(); + let container = Container { + target: &target, + id: "fixture".into(), + removed: std::cell::Cell::new(true), + volume: Rc::new(Volume { + target: &target, + name: "fixture".into(), + removed: std::cell::Cell::new(true), + }), + }; + let files = BTreeMap::from([(PathBuf::from("bin/tar"), b"untrusted text\n".to_vec())]); + target.upload(&container, &files, Instant::now()).unwrap(); + assert_eq!( + std::fs::read_to_string(docker.with_extension("endpoint")).unwrap(), + endpoint + ); + let archive = std::fs::read(docker.with_extension("archive")).unwrap(); + let mut entries = tar::Archive::new(archive.as_slice()); + assert_eq!(entries.entries().unwrap().count(), 1); + } + + #[cfg(unix)] + #[test] + #[ignore = "requires local Docker and immutable ACP fixture image"] + fn real_managed_task_ignores_path_and_docker_proxy_credentials() { + use std::os::unix::fs::PermissionsExt; + use std::sync::Arc; + const CHILD: &str = "ZAIVERN_MANAGED_ATTACK_FIXTURE"; + let image = std::env::var("ZAIVERN_MCP_TEST_IMAGE").unwrap(); + if std::env::var_os(CHILD).is_none() { + let root = Staging::new().unwrap(); + let workspace = root.0.join("workspace"); + std::fs::create_dir_all(workspace.join("bin")).unwrap(); + std::fs::create_dir_all(root.0.join("home/.docker")).unwrap(); + std::fs::write(root.0.join("home/.docker/config.json"), br#"{"proxies":{"default":{"httpProxy":"http://user:PROXY_CREDENTIAL_SENTINEL@invalid:9","httpsProxy":"http://user:PROXY_CREDENTIAL_SENTINEL@invalid:9"}}}"#).unwrap(); + for name in ["tar", "docker"] { + let path = workspace.join("bin").join(name); + std::fs::write( + &path, + "#!/bin/sh\nprintf ATTACK > \"$0.SENTINEL\"\nexit 99\n", + ) + .unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let trusted = LocalExecutionTarget::new(image, &workspace).unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "features::chat_bridge::imp::target::tests::real_managed_task_ignores_path_and_docker_proxy_credentials", "--ignored", "--nocapture"]) + .env(CHILD, &root.0) + .env("ZAIVERN_TEST_DOCKER", trusted.docker.canonicalize().unwrap()) + .env("ZAIVERN_TEST_ENDPOINT", &trusted.endpoint) + .env("HOME", root.0.join("home")) + .env("PATH", std::env::join_paths([workspace.join("bin"), PathBuf::from("/tmp"), PathBuf::from("/usr/bin"), PathBuf::from("/bin")]).unwrap()) + .env("CONTROL_PLANE_API_KEY", "RUNTIME_SENTINEL") + .env("OPENAI_API_KEY", "INFERENCE_SENTINEL") + .output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + for name in ["tar", "docker"] { + assert!(!workspace + .join("bin") + .join(format!("{name}.SENTINEL")) + .exists()); + } + return; + } + let root = PathBuf::from(std::env::var_os(CHILD).unwrap()) + .canonicalize() + .unwrap(); + crate::shellenv::initialize_test_user_path(std::env::var_os("PATH").unwrap()); + let workspace = root.join("workspace"); + std::fs::create_dir(workspace.join("src")).unwrap(); + std::fs::write( + workspace.join("Cargo.toml"), + "[package]\nname='bridge_fixture'\nversion='0.1.0'\nedition='2021'\n", + ) + .unwrap(); + std::fs::write( + workspace.join("Cargo.lock"), + "version = 4\n[[package]]\nname='bridge_fixture'\nversion='0.1.0'\n", + ) + .unwrap(); + std::fs::write( + workspace.join("src/lib.rs"), + "#[test]\nfn arithmetic() { assert_eq!(2 + 2, 5); }\n", + ) + .unwrap(); + let target = Arc::new( + LocalExecutionTarget::new_managed( + image, + PathBuf::from(std::env::var_os("ZAIVERN_TEST_DOCKER").unwrap()), + std::env::var("ZAIVERN_TEST_ENDPOINT").unwrap(), + None, + &workspace, + ) + .unwrap(), + ); + { + let container = target.start_container(Instant::now(), false).unwrap(); + let bytes = target + .run( + &strings(&["inspect", "--format", "{{json .Config.Env}}", &container.id]), + Duration::from_secs(15), + ) + .unwrap(); + let text = String::from_utf8(bytes).unwrap(); + for forbidden in [ + "SENTINEL", + "CONTROL_PLANE_API_KEY", + "OPENAI_API_KEY", + "http_proxy", + "HTTP_PROXY", + "https_proxy", + "HTTPS_PROXY", + ] { + assert!(!text.contains(forbidden), "{forbidden} reached container"); + } + container.shutdown().unwrap(); + } + for instruction in ["Fix the failing test", "WAIT_FOREVER"] { + let bridge = super::super::task::ChatBridge::new(workspace.clone(), target.clone()); + let task = bridge + .call( + "zaivern_run_task", + serde_json::json!({"instruction":instruction}), + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(180); + loop { + let status = bridge.call("zaivern_task_status", task.clone()).unwrap(); + if instruction == "WAIT_FOREVER" && status["progress"] == "agent executing" { + bridge.call("zaivern_cancel_task", task.clone()).unwrap(); + } + if ["completed", "cancelled", "failed"].contains(&status["state"].as_str().unwrap()) + { + assert_eq!( + status["state"], + if instruction == "WAIT_FOREVER" { + "cancelled" + } else { + "completed" + }, + "{status}" + ); + if instruction != "WAIT_FOREVER" { + assert_eq!(status["test_status"], "passed"); + assert!(!status["diff_summary"].as_str().unwrap().is_empty()); + } + break; + } + assert!(Instant::now() < deadline, "managed task timed out"); + std::thread::sleep(Duration::from_millis(100)); + } + drop(bridge); // Join the worker before starting the next task. + } + assert!(target.cleanup_confirmed()); + assert!(std::fs::read_to_string(workspace.join("src/lib.rs")) + .unwrap() + .contains("2 + 2, 4")); + } + + #[cfg(unix)] + #[test] + fn expired_create_budget_never_registers_cleanup_intent() { + struct NoCreate; + impl CleanupTracker for NoCreate { + fn register(&self, _: ResourceKind) -> Result<(String, Vec), String> { + panic!("expired task must not register a create intent") + } + fn created(&self, _: ResourceKind, _: &str) -> Result<(), String> { + panic!("no create was issued") + } + fn rejected(&self, _: ResourceKind, _: &str) -> Result<(), String> { + panic!("no create was issued") + } + fn remove(&self, _: ResourceKind, _: &str) -> Result<(), String> { + panic!("no cleanup resource was registered") + } + } + let target = LocalExecutionTarget { + image: "unused".into(), + docker: PathBuf::from("unused"), + endpoint: "unused".into(), + docker_config: Staging::new().unwrap(), + cleanup_failed: std::sync::atomic::AtomicBool::new(false), + cleanup: Some(std::sync::Arc::new(NoCreate)), + }; + let expired = Instant::now() - Duration::from_secs(1801); + assert!(Volume::new(&target, expired).is_err()); + let volume = Rc::new(Volume { + target: &target, + name: "already-removed-fixture".into(), + removed: std::cell::Cell::new(true), + }); + assert!(target + .start_on_volume(expired, false, volume, false) + .is_err()); + assert!(target.cleanup_confirmed()); + } #[cfg(unix)] #[test] fn create_response_failure_still_cleans_owned_name_and_reports_failure() { @@ -878,6 +1335,9 @@ exit 1 image: "fixture".into(), docker: docker.clone(), endpoint: "fixture".into(), + docker_config: Staging::new().unwrap(), + cleanup_failed: std::sync::atomic::AtomicBool::new(false), + cleanup: None, }; let error = target .start_container(Instant::now(), false) @@ -887,9 +1347,13 @@ exit 1 let removed = std::fs::read_to_string(docker.with_extension("removed")).unwrap(); assert_eq!(created, removed); assert!(created.starts_with("zaivern-mcp-")); - assert!(error.contains("Docker operation failed"), "{error}"); + assert!( + error.contains("Docker create outcome is unknown"), + "{error}" + ); assert!(error.contains("container cleanup unconfirmed"), "{error}"); assert!(error.contains(&created), "{error}"); + assert!(!target.cleanup_confirmed()); std::fs::remove_dir_all(root).unwrap(); } @@ -929,6 +1393,9 @@ exit 1 image: "fixture".into(), docker, endpoint: "fixture".into(), + docker_config: Staging::new().unwrap(), + cleanup_failed: std::sync::atomic::AtomicBool::new(false), + cleanup: None, }; let container = Container { target: &target, @@ -951,6 +1418,10 @@ exit 1 "before" ); drop(container); + assert!( + !target.cleanup_confirmed(), + "{failure} cleanup must reach server exit status" + ); std::fs::remove_dir_all(root).unwrap(); } } @@ -982,6 +1453,9 @@ exit 1 image: "fixture".into(), docker: docker.clone(), endpoint: "fixture".into(), + docker_config: Staging::new().unwrap(), + cleanup_failed: std::sync::atomic::AtomicBool::new(false), + cleanup: None, }; let attack = br#"#[test] fn oracle() { let hidden = std::fs::read("vendor/local_dep/src/lib.rs").unwrap(); diff --git a/src/chat_bridge/workspace.rs b/src/chat_bridge/workspace.rs index 897a8c6..8e6cace 100644 --- a/src/chat_bridge/workspace.rs +++ b/src/chat_bridge/workspace.rs @@ -343,6 +343,7 @@ impl Snapshot { !self.verification_only.is_empty() } + #[cfg(all(test, unix))] pub fn stage(&self, destination: &Path) -> Result<(), String> { self.stage_changes(destination, &self.files) } diff --git a/src/chatgpt/cleanup.rs b/src/chatgpt/cleanup.rs new file mode 100644 index 0000000..372a2d2 --- /dev/null +++ b/src/chatgpt/cleanup.rs @@ -0,0 +1,1160 @@ +//! Write-ahead Docker ownership journal. A receipt never substitutes for missing +//! cleanup evidence. All callers hold mcp.lock; repair also holds runtime.lock. +use super::{ + config::Config, + docker, + private::{self, Result}, + process, +}; +use crate::features::chat_bridge::imp::{CleanupTracker, ResourceKind}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + sync::Mutex, + time::Duration, +}; + +const FILE: &str = "cleanup-pending.json"; +const PREPARED: &str = "cleanup-prepared.json"; +// 128 admitted tasks, at most 7 containers + 4 volumes per task. Retain the +// completed entries until the generation is retired; never silently evict debt. +const MAX_RESOURCES: usize = 2048; +const MAX_BYTES: usize = 1024 * 1024; +const OWNER: &str = "org.zaivern.chatgpt.owner"; +const GENERATION: &str = "org.zaivern.chatgpt.generation"; +const RESOURCE: &str = "org.zaivern.chatgpt.resource"; + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Phase { + Open, + Running, + Reconciling, + Confirmed, +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ResourceState { + Intent, + // Persist the completed rejection before querying Docker: a failed absence + // query must remain retryable without losing this evidence. + Rejected, + Created, + Removed, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Resource { + kind: ResourceKind, + token: String, + name: String, + state: ResourceState, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Journal { + version: u32, + generation: String, + owner: String, + endpoint: String, + phase: Phase, + resources: Vec, +} + +pub(super) struct Cleanup { + root: PathBuf, + config: Config, + journal: Mutex, +} + +pub(super) fn valid_nonce(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +fn owner(root: &Path) -> String { + super::install::sha256(root.as_os_str().as_encoded_bytes()) +} + +fn active(root: &Path) -> Result { + let value = String::from_utf8(private::read(&root.join("active-generation"), 64)?) + .map_err(|_| "Invalid active generation")?; + if !valid_nonce(&value) { + return Err("Invalid active generation".into()); + } + Ok(value) +} + +fn read(root: &Path) -> Result { + let journal = read_named(root, FILE)?; + if journal.generation != active(root)? { + return Err("Cleanup journal generation mismatch; no Docker resources were changed".into()); + } + Ok(journal) +} + +fn read_named(root: &Path, file: &str) -> Result { + private::directory(root)?; + let journal: Journal = serde_json::from_slice(&private::read(&root.join(file), MAX_BYTES)?) + .map_err(|_| "Invalid cleanup journal; no Docker resources were changed")?; + if journal.version != 1 + || !valid_nonce(&journal.generation) + || journal.owner != owner(root) + || journal.resources.len() > MAX_RESOURCES + || !journal + .endpoint + .strip_prefix("unix://") + .is_some_and(|p| Path::new(p).is_absolute()) + { + return Err("Cleanup journal identity mismatch; no Docker resources were changed".into()); + } + let mut names = std::collections::BTreeSet::new(); + for resource in &journal.resources { + if !valid_nonce(&resource.token) + || resource.name != format!("zaivern-mcp-{}-{}", journal.generation, resource.token) + || !names.insert(&resource.name) + { + return Err( + "Invalid cleanup resource identity; no Docker resources were changed".into(), + ); + } + } + if journal.phase == Phase::Confirmed + && journal + .resources + .iter() + .any(|r| r.state != ResourceState::Removed) + { + return Err("Invalid cleanup confirmation".into()); + } + Ok(journal) +} + +fn write(root: &Path, journal: &Journal) -> Result<()> { + let bytes = serde_json::to_vec(journal).map_err(|_| "Cannot encode cleanup journal")?; + if bytes.len() > MAX_BYTES { + return Err("Cleanup journal limit reached".into()); + } + private::write(&root.join(FILE), &bytes, false) +} + +impl Cleanup { + pub(super) fn prepare(root: &Path, config: &Config, generation: &str) -> Result<()> { + if !valid_nonce(generation) { + return Err("Invalid cleanup generation".into()); + } + let journal = Journal { + version: 1, + generation: generation.into(), + owner: owner(root), + endpoint: config.docker_endpoint.clone(), + phase: Phase::Open, + resources: Vec::new(), + }; + // Write-ahead activation: no client may spawn until all three commits + // finish. Repair can complete a crash between these writes safely. + private::write( + &root.join(PREPARED), + &serde_json::to_vec(&journal).map_err(|_| "Cannot encode prepared cleanup journal")?, + false, + )?; + private::write( + &root.join("active-generation"), + generation.as_bytes(), + false, + )?; + write(root, &journal)?; + private::remove(&root.join(PREPARED)) + } + + pub(super) fn load(root: &Path, config: &Config) -> Result { + let journal = read(root)?; + if journal.endpoint != config.docker_endpoint { + return Err("Cleanup Docker endpoint changed; restore the configured local socket before repair".into()); + } + Ok(Self { + root: root.into(), + config: config.clone(), + journal: Mutex::new(journal), + }) + } + + pub(super) fn admit(&self, generation: &str) -> Result<()> { + let mut journal = self + .journal + .lock() + .map_err(|_| "Cleanup journal unavailable")?; + if journal.generation != generation || journal.phase != Phase::Open { + return Err( + "Managed MCP generation is closed or already used; run stop, repair, then start" + .into(), + ); + } + journal.phase = Phase::Running; + write(&self.root, &journal) + } + + fn labels(journal: &Journal, resource: &Resource) -> BTreeMap { + BTreeMap::from([ + (OWNER.into(), journal.owner.clone()), + (GENERATION.into(), journal.generation.clone()), + (RESOURCE.into(), resource.token.clone()), + ]) + } + + fn run(&self, args: &[&str]) -> Result> { + if super::config::validate_executable(&self.config.docker, &self.config.workspace)? + != self.config.docker + { + return Err("Configured Docker executable changed; cleanup state preserved".into()); + } + docker::validate_endpoint(&self.config.docker_endpoint)?; + let mut command = docker::command(&self.config.docker, &self.config.docker_endpoint); + command.args(args); + process::capture(command, Duration::from_secs(20), MAX_BYTES).map_err(|_| { + "Docker cleanup query/removal failed; state preserved. Retry zai chatgpt repair".into() + }) + } + + fn exists(&self, resource: &Resource) -> Result { + // A failed inspect is not proof of absence (daemon down/permission error). + // Require a successful listing; compare complete names, never substrings. + let filter = format!("name={}", resource.name); + let args = match resource.kind { + ResourceKind::Container => vec![ + "container", + "ls", + "--all", + "--format", + "{{.Names}}", + "--filter", + &filter, + ], + ResourceKind::Volume => { + vec!["volume", "ls", "--format", "{{.Name}}", "--filter", &filter] + } + }; + let output = self.run(&args)?; + let names = std::str::from_utf8(&output).map_err(|_| "Invalid Docker resource listing")?; + Ok(names.lines().any(|name| name == resource.name)) + } + + fn inspect_owned(&self, journal: &Journal, resource: &Resource) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Identity { + id: String, + name: String, + labels: BTreeMap, + } + let (kind, format) = match resource.kind { + ResourceKind::Container => ( + "container", + r#"{"id":{{json .Id}},"name":{{json .Name}},"labels":{{json .Config.Labels}}}"#, + ), + ResourceKind::Volume => ( + "volume", + r#"{"id":{{json .Name}},"name":{{json .Name}},"labels":{{json .Labels}}}"#, + ), + }; + let bytes = self.run(&[kind, "inspect", "--format", format, &resource.name])?; + let identity: Identity = + serde_json::from_slice(&bytes).map_err(|_| "Invalid Docker ownership metadata")?; + if identity.name.trim_start_matches('/') != resource.name + || Self::labels(journal, resource) + .iter() + .any(|(key, value)| identity.labels.get(key) != Some(value)) + || match resource.kind { + ResourceKind::Container => !valid_nonce(&identity.id), + ResourceKind::Volume => identity.id != resource.name, + } + { + return Err("Docker resource ownership mismatch; nothing was removed".into()); + } + Ok(identity.id) + } + + fn remove_one(&self, journal: &mut Journal, index: usize) -> Result<()> { + let resource = &journal.resources[index]; + if self.exists(resource)? { + let id = self.inspect_owned(journal, resource)?; + // Persist proof that create completed before attempting removal. + journal.resources[index].state = ResourceState::Created; + write(&self.root, journal)?; + match journal.resources[index].kind { + ResourceKind::Container => { + self.run(&["container", "rm", "--force", &id])?; + } + ResourceKind::Volume => { + self.run(&["volume", "rm", &id])?; + } + } + } else if resource.state == ResourceState::Intent { + // A lost create response can still be executing in Docker. Do not + // turn a single empty list into a fabricated cleanup receipt. + return Err("Docker creation outcome is unresolved; journal retained. Restore Docker and retry repair after the resource appears".into()); + } + if self.exists(&journal.resources[index])? { + return Err("Docker resource still exists; cleanup remains unconfirmed".into()); + } + journal.resources[index].state = ResourceState::Removed; + write(&self.root, journal) + } + + pub(super) fn finish(&self, recovery: bool) -> Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(120); + let mut progress = std::time::Instant::now(); + let mut journal = self + .journal + .lock() + .map_err(|_| "Cleanup journal unavailable")?; + journal.phase = Phase::Reconciling; + write(&self.root, &journal)?; // Revoke admission before any Docker action. + // Containers first: a failed volume removal must not lose the already + // removed container's evidence. Every entry remains available on retry. + for kind in [ResourceKind::Container, ResourceKind::Volume] { + for index in 0..journal.resources.len() { + if journal.resources[index].kind == kind + && journal.resources[index].state != ResourceState::Removed + { + if std::time::Instant::now() >= deadline { + return Err("Cleanup reconciliation reached its two-minute budget; progress saved. Retry zai chatgpt repair".into()); + } + if recovery && progress.elapsed() >= Duration::from_secs(10) { + println!("Verifying owned Docker cleanup; progress is saved..."); + progress = std::time::Instant::now(); + } + self.remove_one(&mut journal, index)?; + } + } + } + journal.phase = Phase::Confirmed; + write(&self.root, &journal)?; + private::write( + &self.root.join("mcp.done"), + journal.generation.as_bytes(), + false, + )?; + if recovery { + super::daemon::write_shutdown(&self.root, &journal.generation, true)?; + } + Ok(()) + } +} + +impl CleanupTracker for Cleanup { + fn register(&self, kind: ResourceKind) -> Result<(String, Vec)> { + let mut journal = self + .journal + .lock() + .map_err(|_| "Cleanup journal unavailable")?; + if journal.phase != Phase::Running || journal.resources.len() >= MAX_RESOURCES { + return Err("Cleanup journal closed or resource limit reached; task rejected".into()); + } + let token = private::nonce()?; + let name = format!("zaivern-mcp-{}-{token}", journal.generation); + let resource = Resource { + kind, + token, + name: name.clone(), + state: ResourceState::Intent, + }; + let labels = Self::labels(&journal, &resource) + .into_iter() + .flat_map(|(key, value)| ["--label".into(), format!("{key}={value}")]) + .collect(); + journal.resources.push(resource); + write(&self.root, &journal)?; + Ok((name, labels)) + } + + fn created(&self, kind: ResourceKind, name: &str) -> Result<()> { + let mut journal = self + .journal + .lock() + .map_err(|_| "Cleanup journal unavailable")?; + let index = journal + .resources + .iter() + .position(|r| r.kind == kind && r.name == name) + .ok_or("Unregistered Docker resource")?; + self.inspect_owned(&journal, &journal.resources[index])?; + journal.resources[index].state = ResourceState::Created; + write(&self.root, &journal) + } + + fn rejected(&self, kind: ResourceKind, name: &str) -> Result<()> { + let mut journal = self + .journal + .lock() + .map_err(|_| "Cleanup journal unavailable")?; + if journal.phase != Phase::Running { + return Err("Cleanup generation is closed".into()); + } + let resource = journal + .resources + .iter_mut() + .find(|r| r.kind == kind && r.name == name) + .ok_or("Unregistered Docker resource")?; + if resource.state != ResourceState::Intent { + return Err("Docker create outcome already recorded".into()); + } + resource.state = ResourceState::Rejected; + write(&self.root, &journal) + } + + fn remove(&self, kind: ResourceKind, name: &str) -> Result<()> { + let mut journal = self + .journal + .lock() + .map_err(|_| "Cleanup journal unavailable")?; + let index = journal + .resources + .iter() + .position(|r| r.kind == kind && r.name == name) + .ok_or("Unregistered Docker resource")?; + self.remove_one(&mut journal, index) + } +} + +pub(super) fn ensure_confirmed(root: &Path) -> Result<()> { + if private::exists_checked(&root.join(PREPARED))? { + return Err("Generation activation interrupted; run zai chatgpt repair".into()); + } + if private::exists_checked(&root.join(FILE))? && read(root)?.phase != Phase::Confirmed { + return Err("Previous cleanup: UNCONFIRMED. Run zai chatgpt repair; start/setup/reset remain blocked".into()); + } + Ok(()) +} + +pub(super) fn report(root: &Path) -> Result { + if private::exists_checked(&root.join(PREPARED))? { + read_named(root, PREPARED)?; + return Ok("Previous cleanup: UNCONFIRMED (activation interrupted)\nRecovery: zai chatgpt repair\n".into()); + } + if !private::exists_checked(&root.join(FILE))? { + return Ok(if super::daemon::ensure_clean(root).is_ok() { + "Previous cleanup: confirmed / no generation\n".into() + } else { + "Previous cleanup: UNCONFIRMED (legacy state without resource journal). Recovery: local Docker ownership audit required; state preserved\n".into() + }); + } + let journal = read(root)?; + let count = |kind| { + journal + .resources + .iter() + .filter(|r| r.kind == kind && r.state != ResourceState::Removed) + .count() + }; + let (status, recovery) = if matches!(journal.phase, Phase::Open | Phase::Running) + && super::daemon::generation_running(root, &journal.generation) + { + ("Current generation: RUNNING\nCleanup tracking: active", "") + } else if journal.phase == Phase::Confirmed && super::daemon::ensure_clean(root).is_ok() { + ("Previous cleanup: CONFIRMED", "") + } else { + ( + "Previous cleanup: UNCONFIRMED", + "Recovery: zai chatgpt repair\n", + ) + }; + Ok(format!( + "{status}\nPending containers: {}\nPending volumes: {}\n{recovery}", + count(ResourceKind::Container), + count(ResourceKind::Volume) + )) +} + +pub(super) fn reconcile(root: &Path, config: &Config) -> Result<()> { + // Caller holds operation/runtime locks. Never inspect/kill a saved PID. + let _execution = private::Lock::acquire(root, "mcp.lock") + .map_err(|_| "MCP cleanup is still running; wait, then retry zai chatgpt repair")?; + if private::exists_checked(&root.join(PREPARED))? { + let prepared = read_named(root, PREPARED)?; + if prepared.phase != Phase::Open + || !prepared.resources.is_empty() + || prepared.endpoint != config.docker_endpoint + { + return Err("Invalid prepared generation; state preserved".into()); + } + if private::exists_checked(&root.join(FILE))? { + let previous = read_named(root, FILE)?; + if !(previous.phase == Phase::Confirmed + || previous.generation == prepared.generation + && previous.phase == Phase::Open + && previous.resources.is_empty()) + { + return Err( + "Prepared generation conflicts with outstanding cleanup; state preserved" + .into(), + ); + } + } else if private::exists_checked(&root.join("active-generation"))? + && active(root)? != prepared.generation + && private::read(&root.join("mcp.done"), 64)? != active(root)?.as_bytes() + { + return Err("Previous generation cleanup is unconfirmed".into()); + } + private::write( + &root.join("active-generation"), + prepared.generation.as_bytes(), + false, + )?; + write(root, &prepared)?; + private::remove(&root.join(PREPARED))?; + } + if super::daemon::ensure_clean(root).is_ok() && !private::exists_checked(&root.join(FILE))? { + return Ok(()); + } + Cleanup::load(root, config)?.finish(true)?; + super::daemon::ensure_clean(root) +} + +#[cfg(test)] +mod tests { + use super::super::{ + daemon, + tests::{fixture, Temp}, + }; + use super::*; + use std::os::unix::{ + fs::{symlink, PermissionsExt}, + net::UnixListener, + }; + + struct Harness { + temp: Temp, + _socket: UnixListener, + config: Config, + generation: String, + } + impl Harness { + fn new() -> Self { + let temp = Temp::new(); + // macOS Unix socket paths are limited to 104 bytes. + let socket_path = + std::env::temp_dir().join(format!("zc-{}", &private::nonce().unwrap()[..12])); + let socket = UnixListener::bind(&socket_path).unwrap(); + let mut config = fixture(&temp.0); + config.docker_endpoint = format!("unix://{}", socket_path.display()); + let script = br#"#!/bin/sh +base="${0%/*}" +shift 2 +kind="$1" +verb="$2" +shift 2 +for arg in "$@"; do name="$arg"; done +printf '%s %s %s\n' "$kind" "$verb" "$name" >> "$base/commands" +case "$verb" in +create) + if [ "$kind" = volume ]; then + printf 'Error response from daemon: create %s: invalid option: "invalid-option"\n' "$name" >&2 + else + printf '%s\n' 'Error response from daemon: No such image: sha256:0000' >&2 + fi + exit 1 ;; +ls) + [ ! -f "$base/daemon-down" ] || exit 1 + name="${name#name=}" + [ ! -f "$base/$name.present" ] || printf '%s\n' "$name" + exit 0 ;; +inspect) cat "$base/$name.identity"; exit $? ;; +rm) + [ ! -f "$base/fail-$kind" ] || exit 1 + if [ "$kind" = container ]; then name=$(cat "$base/$name.name") || exit 1; fi + rm -f "$base/$name.present" + exit 0 ;; +esac +exit 1 +"#; + private::write(&config.docker, script, true).unwrap(); + // The production setup persists the canonical Docker executable. + // Canonicalize the fixture as well so macOS temporary-directory + // symlink prefixes do not make identity validation fail. + config.docker = config.docker.canonicalize().unwrap(); + let generation = private::nonce().unwrap(); + private::write( + &temp.0.join("active-generation"), + generation.as_bytes(), + false, + ) + .unwrap(); + Cleanup::prepare(&temp.0, &config, &generation).unwrap(); + Self { + temp, + _socket: socket, + config, + generation, + } + } + fn cleanup(&self) -> Cleanup { + Cleanup::load(&self.temp.0, &self.config).unwrap() + } + fn resource(&self, cleanup: &Cleanup, kind: ResourceKind) -> String { + let (name, _) = cleanup.register(kind).unwrap(); + let journal = cleanup.journal.lock().unwrap(); + let resource = journal.resources.iter().find(|r| r.name == name).unwrap(); + let id = if kind == ResourceKind::Container { + private::nonce().unwrap() + } else { + name.clone() + }; + private::write(&self.temp.0.join(format!("{name}.present")), b"", false).unwrap(); + private::write(&self.temp.0.join(format!("{name}.identity")), + &serde_json::to_vec(&serde_json::json!({"id":id,"name":name,"labels":Cleanup::labels(&journal, resource)})).unwrap(), false).unwrap(); + private::write( + &self.temp.0.join(format!("{id}.name")), + name.as_bytes(), + false, + ) + .unwrap(); + drop(journal); + cleanup.created(kind, &name).unwrap(); + name + } + fn fail(&self, kind: &str) { + private::write(&self.temp.0.join(format!("fail-{kind}")), b"", false).unwrap(); + } + fn reconcile(&self) -> Result<()> { + let _operation = private::Lock::acquire(&self.temp.0, "operation.lock")?; + let _runtime = private::Lock::acquire(&self.temp.0, "runtime.lock")?; + reconcile(&self.temp.0, &self.config) + } + } + impl Drop for Harness { + fn drop(&mut self) { + let _ = + std::fs::remove_file(self.config.docker_endpoint.strip_prefix("unix://").unwrap()); + } + } + + // Exercise the real nonce/identity IPC, not an injected "running" boolean. + fn report_with_supervisor( + h: &Harness, + generation: &str, + valid_reply: bool, + hold_lock: bool, + ) -> String { + use std::io::{Read, Write}; + let runtime = private::Lock::acquire(&h.temp.0, "runtime.lock").unwrap(); + let _runtime = hold_lock.then_some(runtime); + let listener = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let pid = std::process::id(); + private::write( + &h.temp.0.join("runtime.json"), + &serde_json::to_vec(&serde_json::json!({ + "version":1, "pid":pid, "child_pid":pid, + "port":listener.local_addr().unwrap().port(), "generation":generation + })) + .unwrap(), + false, + ) + .unwrap(); + if !hold_lock { + let text = report(&h.temp.0).unwrap(); + assert!( + matches!(listener.accept(), Err(e) if e.kind() == std::io::ErrorKind::WouldBlock) + ); + return text; + } + let generation = generation.to_owned(); + let server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "missing status request" + ); + std::thread::sleep(Duration::from_millis(10)); + } + Err(e) => panic!("{e}"), + } + }; + // Darwin can inherit O_NONBLOCK from the listening socket; use + // the bounded blocking read consistently on both Unix platforms. + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = String::new(); + (&mut stream) + .take(256) + .read_to_string(&mut request) + .unwrap(); + assert_eq!(request, format!("{generation} status\n")); + if valid_reply { + writeln!(stream, "{generation} {pid} {pid}").unwrap(); + } else { + writeln!(stream, "wrong identity").unwrap(); + } + }); + let report = report(&h.temp.0).unwrap(); + server.join().unwrap(); + report + } + + #[test] + fn report_active_generation_requires_authenticated_matching_supervisor() { + let h = Harness::new(); + for phase in [Phase::Open, Phase::Running] { + if phase == Phase::Running { + let cleanup = h.cleanup(); + cleanup.admit(&h.generation).unwrap(); + cleanup.register(ResourceKind::Container).unwrap(); + cleanup.register(ResourceKind::Volume).unwrap(); + } + let text = report_with_supervisor(&h, &h.generation, true, true); + assert!(text.contains("Current generation: RUNNING\nCleanup tracking: active")); + assert!(!text.contains("UNCONFIRMED")); + assert!(!text.contains("repair")); + let count = usize::from(phase == Phase::Running); + assert!(text.contains(&format!( + "Pending containers: {count}\nPending volumes: {count}" + ))); + // A display observation never produces a cleanup receipt/admission. + assert!(daemon::ensure_clean(&h.temp.0).is_err()); + assert!(!h.temp.0.join("mcp.done").exists()); + for text in [ + report_with_supervisor(&h, &private::nonce().unwrap(), true, true), + report_with_supervisor(&h, &h.generation, false, true), + report_with_supervisor(&h, &h.generation, true, false), + report(&h.temp.0).unwrap(), // saved identity, no live listener + ] { + assert!(text.contains("UNCONFIRMED")); + assert!(text.contains("Recovery: zai chatgpt repair")); + } + } + } + + #[test] + fn report_stopped_phases_and_legacy_remain_fail_closed() { + let h = Harness::new(); + for phase in [Phase::Running, Phase::Reconciling] { + let mut journal = read(&h.temp.0).unwrap(); + journal.phase = phase; + write(&h.temp.0, &journal).unwrap(); + let text = report(&h.temp.0).unwrap(); + assert!(text.contains("UNCONFIRMED")); + assert!(text.contains("Recovery: zai chatgpt repair")); + } + h.reconcile().unwrap(); + let text = report(&h.temp.0).unwrap(); + assert!(text.contains("Previous cleanup: CONFIRMED")); + assert!(!text.contains("repair")); + assert!(text.contains("Pending containers: 0\nPending volumes: 0")); + private::remove(&h.temp.0.join(FILE)).unwrap(); + private::remove(&h.temp.0.join("mcp.done")).unwrap(); + let text = report(&h.temp.0).unwrap(); + assert!(text.contains("UNCONFIRMED (legacy state without resource journal)")); + assert!(text.contains("ownership audit required; state preserved")); + assert!(daemon::ensure_clean(&h.temp.0).is_err()); + } + + #[test] + fn cleanup_failure_blocks_start_reset_and_repair_reconciles() { + let h = Harness::new(); + let cleanup = h.cleanup(); + cleanup.admit(&h.generation).unwrap(); + let container = h.resource(&cleanup, ResourceKind::Container); + let volume = h.resource(&cleanup, ResourceKind::Volume); + h.fail("container"); + assert!(cleanup.remove(ResourceKind::Container, &container).is_err()); + assert!(!h.temp.0.join("mcp.done").exists()); + assert!(daemon::start(&h.temp.0, false).is_err()); + assert!(super::super::reset(&h.temp.0).is_err()); + assert!(report(&h.temp.0) + .unwrap() + .contains("Pending containers: 1\nPending volumes: 1")); + assert!(h.reconcile().is_err()); + assert!(!h.temp.0.join("mcp.done").exists()); + private::remove(&h.temp.0.join("fail-container")).unwrap(); + h.reconcile().unwrap(); + daemon::ensure_clean(&h.temp.0).unwrap(); + for name in [container, volume] { + assert!(!h.temp.0.join(format!("{name}.present")).exists()); + } + assert_eq!( + private::read(&h.temp.0.join("mcp.done"), 64).unwrap(), + h.generation.as_bytes() + ); + assert!(report(&h.temp.0) + .unwrap() + .contains("Previous cleanup: CONFIRMED\nPending containers: 0\nPending volumes: 0")); + assert!(h.cleanup().admit(&h.generation).is_err()); + h.reconcile().unwrap(); + } + + #[test] + fn partial_reconciliation_preserves_debt_and_retries() { + let h = Harness::new(); + let cleanup = h.cleanup(); + cleanup.admit(&h.generation).unwrap(); + let container = h.resource(&cleanup, ResourceKind::Container); + let volume = h.resource(&cleanup, ResourceKind::Volume); + h.fail("volume"); + assert!(h.reconcile().is_err()); + assert!(!h.temp.0.join(format!("{container}.present")).exists()); + assert!(h.temp.0.join(format!("{volume}.present")).exists()); + assert!(!h.temp.0.join("mcp.done").exists()); + assert!(report(&h.temp.0) + .unwrap() + .contains("Pending containers: 0\nPending volumes: 1")); + private::remove(&h.temp.0.join("fail-volume")).unwrap(); + h.reconcile().unwrap(); + daemon::ensure_clean(&h.temp.0).unwrap(); + } + + #[test] + fn missing_resources_require_successful_absence_query() { + let h = Harness::new(); + let cleanup = h.cleanup(); + cleanup.admit(&h.generation).unwrap(); + let name = h.resource(&cleanup, ResourceKind::Volume); + private::remove(&h.temp.0.join(format!("{name}.present"))).unwrap(); + private::write(&h.temp.0.join("daemon-down"), b"", false).unwrap(); + assert!(h.reconcile().is_err()); + assert!(!h.temp.0.join("mcp.done").exists()); + private::remove(&h.temp.0.join("daemon-down")).unwrap(); + h.reconcile().unwrap(); // Manual removal is recoverable, too. + assert!(!std::fs::read_to_string(h.temp.0.join("commands")) + .unwrap() + .contains(" rm ")); + } + + #[test] + fn forged_journal_and_unsafe_files_cannot_reach_docker() { + for mutation in [ + "id", + "unknown", + "generation", + "symlink", + "hardlink", + "mode", + "endpoint", + "oversized", + ] { + let h = Harness::new(); + let cleanup = h.cleanup(); + cleanup.admit(&h.generation).unwrap(); + cleanup.register(ResourceKind::Volume).unwrap(); + let path = h.temp.0.join(FILE); + let mut json: serde_json::Value = + serde_json::from_slice(&private::read(&path, MAX_BYTES).unwrap()).unwrap(); + match mutation { + "id" => { + json["resources"][0]["name"] = "unrelated-production-volume".into(); + } + "unknown" => { + json["resources"][0]["execute"] = "bad".into(); + } + "generation" => { + json["generation"] = private::nonce().unwrap().into(); + } + "endpoint" => { + json["endpoint"] = "tcp://remote:2375".into(); + } + "symlink" => { + std::fs::rename(&path, h.temp.0.join("original")).unwrap(); + symlink(h.temp.0.join("original"), &path).unwrap(); + } + "hardlink" => { + std::fs::hard_link(&path, h.temp.0.join("alias")).unwrap(); + } + "mode" => { + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) + .unwrap(); + } + "oversized" => { + private::write(&path, &vec![b' '; MAX_BYTES + 1], false).unwrap(); + } + _ => unreachable!(), + } + if ["id", "unknown", "generation", "endpoint"].contains(&mutation) { + private::write(&path, &serde_json::to_vec(&json).unwrap(), false).unwrap(); + } + assert!(h.reconcile().is_err(), "{mutation}"); + assert!(!h.temp.0.join("commands").exists(), "{mutation}"); + assert!(!h.temp.0.join("mcp.done").exists()); + } + } + + #[test] + fn foreign_labels_reject_use_and_deletion_even_with_valid_name() { + for kind in [ResourceKind::Container, ResourceKind::Volume] { + let h = Harness::new(); + let cleanup = h.cleanup(); + cleanup.admit(&h.generation).unwrap(); + let name = h.resource(&cleanup, kind); + let path = h.temp.0.join(format!("{name}.identity")); + let mut json: serde_json::Value = + serde_json::from_slice(&private::read(&path, MAX_BYTES).unwrap()).unwrap(); + json["labels"][OWNER] = "foreign-owner".into(); + private::write(&path, &serde_json::to_vec(&json).unwrap(), false).unwrap(); + assert!(cleanup.created(kind, &name).is_err()); + assert!(h.reconcile().is_err()); + assert!(!std::fs::read_to_string(h.temp.0.join("commands")) + .unwrap() + .contains(" rm ")); + assert!(h.temp.0.join(format!("{name}.present")).exists()); + } + } + + #[test] + fn orphan_mcp_lock_and_closed_generation_prevent_reentry() { + let h = Harness::new(); + let lock = private::Lock::acquire(&h.temp.0, "mcp.lock").unwrap(); + assert!(h.reconcile().is_err()); + assert!(!h.temp.0.join("mcp.done").exists()); + drop(lock); + h.reconcile().unwrap(); + assert!(h.cleanup().admit(&h.generation).is_err()); + let next = private::nonce().unwrap(); + private::write(&h.temp.0.join("active-generation"), next.as_bytes(), false).unwrap(); + Cleanup::prepare(&h.temp.0, &h.config, &next).unwrap(); + assert!(h.cleanup().admit(&h.generation).is_err()); + h.cleanup().admit(&next).unwrap(); + assert!(h.cleanup().admit(&next).is_err()); + } + + #[test] + fn receipt_commit_failure_is_retryable_without_losing_resources() { + let h = Harness::new(); + let cleanup = h.cleanup(); + cleanup.admit(&h.generation).unwrap(); + h.resource(&cleanup, ResourceKind::Volume); + symlink(h.temp.0.join("unrelated"), h.temp.0.join("mcp.done")).unwrap(); + assert!(h.reconcile().is_err()); + assert!(daemon::ensure_clean(&h.temp.0).is_err()); + assert!(!h.temp.0.join("unrelated").exists()); + std::fs::remove_file(h.temp.0.join("mcp.done")).unwrap(); + h.reconcile().unwrap(); + daemon::ensure_clean(&h.temp.0).unwrap(); + } + + #[test] + fn final_shutdown_receipt_failure_is_retried() { + let h = Harness::new(); + symlink(h.temp.0.join("unrelated"), h.temp.0.join("shutdown.json")).unwrap(); + assert!(h.reconcile().is_err()); + assert!(!h.temp.0.join("unrelated").exists()); + std::fs::remove_file(h.temp.0.join("shutdown.json")).unwrap(); + h.reconcile().unwrap(); + let receipt: serde_json::Value = + serde_json::from_slice(&private::read(&h.temp.0.join("shutdown.json"), 4096).unwrap()) + .unwrap(); + assert_eq!(receipt["generation"], h.generation); + assert_eq!(receipt["success"], true); + } + + #[test] + fn definite_create_rejection_is_durable_and_repairable_for_both_resource_kinds() { + use crate::features::chat_bridge::imp::create::{self, CreateOutcome}; + for kind in [ResourceKind::Container, ResourceKind::Volume] { + let h = Harness::new(); + let cleanup = h.cleanup(); + cleanup.admit(&h.generation).unwrap(); + let (name, _) = cleanup.register(kind).unwrap(); + let mut command = docker::command(&h.config.docker, &h.config.docker_endpoint); + command.args([ + if kind == ResourceKind::Container { + "container" + } else { + "volume" + }, + "create", + &name, + ]); + assert_eq!( + create::run(command, Duration::from_secs(5), kind, &name), + CreateOutcome::DefinitelyNotCreated + ); + cleanup.rejected(kind, &name).unwrap(); + assert!(read(&h.temp.0).unwrap().resources[0].state == ResourceState::Rejected); + // Losing Docker after the rejection must retain the proof for retry. + private::write(&h.temp.0.join("daemon-down"), b"", false).unwrap(); + assert!(h.reconcile().is_err()); + let journal = read(&h.temp.0).unwrap(); + assert!(journal.resources[0].state == ResourceState::Rejected); + assert!(journal.phase == Phase::Reconciling); + assert!(!h.temp.0.join("mcp.done").exists()); + assert!(!h.temp.0.join("shutdown.json").exists()); + assert!(daemon::ensure_clean(&h.temp.0).is_err()); + let pending = report(&h.temp.0).unwrap(); + assert!(pending.contains(if kind == ResourceKind::Container { + "Pending containers: 1" + } else { + "Pending volumes: 1" + })); + private::remove(&h.temp.0.join("daemon-down")).unwrap(); + h.reconcile().unwrap(); + let journal = read(&h.temp.0).unwrap(); + assert!(journal.resources[0].state == ResourceState::Removed); + assert!(journal.phase == Phase::Confirmed); + assert_eq!( + private::read(&h.temp.0.join("mcp.done"), 64).unwrap(), + h.generation.as_bytes() + ); + let receipt: serde_json::Value = serde_json::from_slice( + &private::read(&h.temp.0.join("shutdown.json"), 4096).unwrap(), + ) + .unwrap(); + assert_eq!(receipt["generation"], h.generation); + assert_eq!(receipt["success"], true); + daemon::ensure_clean(&h.temp.0).unwrap(); + let report = report(&h.temp.0).unwrap(); + assert!(report.contains("Pending containers: 0")); + assert!(report.contains("Pending volumes: 0")); + let commands = std::fs::read_to_string(h.temp.0.join("commands")).unwrap(); + assert!(!commands.lines().any(|line| line.contains(" rm "))); + } + } + + #[test] + fn rejected_create_still_requires_ownership_when_resource_exists() { + for kind in [ResourceKind::Container, ResourceKind::Volume] { + let h = Harness::new(); + let cleanup = h.cleanup(); + cleanup.admit(&h.generation).unwrap(); + let name = h.resource(&cleanup, kind); + // Model a daemon that created the resource before returning an error. + let mut journal = read(&h.temp.0).unwrap(); + journal.resources[0].state = ResourceState::Rejected; + write(&h.temp.0, &journal).unwrap(); + let identity_path = h.temp.0.join(format!("{name}.identity")); + let valid = private::read(&identity_path, 4096).unwrap(); + let mut foreign: serde_json::Value = serde_json::from_slice(&valid).unwrap(); + foreign["labels"][RESOURCE] = serde_json::json!(private::nonce().unwrap()); + private::write( + &identity_path, + &serde_json::to_vec(&foreign).unwrap(), + false, + ) + .unwrap(); + assert!(h.reconcile().is_err()); + assert!(!h.temp.0.join("mcp.done").exists()); + assert!(!std::fs::read_to_string(h.temp.0.join("commands")) + .unwrap() + .contains(" rm ")); + private::write(&identity_path, &valid, false).unwrap(); + h.reconcile().unwrap(); + assert!(!h.temp.0.join(format!("{name}.present")).exists()); + daemon::ensure_clean(&h.temp.0).unwrap(); + } + } + + #[test] + fn lost_create_response_retains_intent_and_blocks_receipts_for_both_kinds() { + use crate::features::chat_bridge::imp::create::{self, CreateOutcome}; + for kind in [ResourceKind::Container, ResourceKind::Volume] { + let h = Harness::new(); + let cleanup = h.cleanup(); + cleanup.admit(&h.generation).unwrap(); + let (name, _) = cleanup.register(kind).unwrap(); + let mut command = process::command(Path::new("/bin/sh")); + command.args(["-c", "printf 'error during connect: EOF\\n' >&2; exit 1"]); + assert_eq!( + create::run(command, Duration::from_secs(5), kind, &name), + CreateOutcome::Unknown + ); + for _ in 0..2 { + assert!(h.reconcile().is_err()); + let journal = read(&h.temp.0).unwrap(); + assert!(journal.phase == Phase::Reconciling); + assert!(journal.resources[0].state == ResourceState::Intent); + assert!(!h.temp.0.join("mcp.done").exists()); + assert!(!h.temp.0.join("shutdown.json").exists()); + assert!(daemon::ensure_clean(&h.temp.0).is_err()); + } + assert!(!std::fs::read_to_string(h.temp.0.join("commands")) + .unwrap() + .contains(" rm ")); + } + } + + #[test] + fn daemon_partial_volume_creation_cannot_commit_an_absence_receipt() { + use crate::features::chat_bridge::imp::create::{self, CreateOutcome}; + let h = Harness::new(); + let cleanup = h.cleanup(); + cleanup.admit(&h.generation).unwrap(); + let (name, _) = cleanup.register(ResourceKind::Volume).unwrap(); + // Moby local Root.Create can leave an unlisted directory on data-mkdir + // failure. It can reappear as a volume after daemon restart. + let message = format!("Error response from daemon: create {name}: error while creating volume data path: no space left on device"); + let mut command = process::command(Path::new("/bin/sh")); + command.args([ + "-c", + "printf '%s\\n' \"$1\" >&2; exit 1", + "fixture", + &message, + ]); + assert_eq!( + create::run(command, Duration::from_secs(5), ResourceKind::Volume, &name), + CreateOutcome::Unknown + ); + assert!(h.reconcile().is_err()); + assert!(read(&h.temp.0).unwrap().resources[0].state == ResourceState::Intent); + assert!(!h.temp.0.join("mcp.done").exists()); + assert!(!h.temp.0.join("shutdown.json").exists()); + assert!(daemon::ensure_clean(&h.temp.0).is_err()); + assert!(report(&h.temp.0).unwrap().contains("Pending volumes: 1")); + assert!(!std::fs::read_to_string(h.temp.0.join("commands")) + .unwrap() + .contains(" rm ")); + } + + #[test] + fn uncertain_create_is_not_mistaken_for_absence() { + let h = Harness::new(); + let cleanup = h.cleanup(); + cleanup.admit(&h.generation).unwrap(); + cleanup.register(ResourceKind::Volume).unwrap(); + assert!(h.reconcile().is_err()); + assert!(!h.temp.0.join("mcp.done").exists()); + } + + #[test] + fn interrupted_activation_is_recovered_before_any_child_can_start() { + for step in ["prepared", "active", "journal"] { + let h = Harness::new(); + h.reconcile().unwrap(); + let next = private::nonce().unwrap(); + let mut prepared = read(&h.temp.0).unwrap(); + prepared.generation = next.clone(); + prepared.phase = Phase::Open; + private::write( + &h.temp.0.join(PREPARED), + &serde_json::to_vec(&prepared).unwrap(), + false, + ) + .unwrap(); + if step != "prepared" { + private::write(&h.temp.0.join("active-generation"), next.as_bytes(), false) + .unwrap(); + } + if step == "journal" { + write(&h.temp.0, &prepared).unwrap(); + } + assert!(daemon::ensure_clean(&h.temp.0).is_err()); + let text = report(&h.temp.0).unwrap(); + assert!(text.contains("activation interrupted")); + assert!(text.contains("Recovery: zai chatgpt repair")); + h.reconcile().unwrap(); + daemon::ensure_clean(&h.temp.0).unwrap(); + assert_eq!(active(&h.temp.0).unwrap(), next); + assert!(!h.temp.0.join(PREPARED).exists()); + assert!(!h.temp.0.join("commands").exists()); + } + } +} diff --git a/src/chatgpt/config.rs b/src/chatgpt/config.rs new file mode 100644 index 0000000..6f313ad --- /dev/null +++ b/src/chatgpt/config.rs @@ -0,0 +1,185 @@ +use super::private::{self, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub(crate) use crate::features::chat_bridge::imp::host::validate_executable; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Config { + pub(super) version: u32, + pub(super) workspace: PathBuf, + pub(super) executable: PathBuf, + pub(super) image: String, + pub(super) image_source: String, + pub(super) docker: PathBuf, + pub(super) docker_endpoint: String, + pub(super) tunnel_id: String, + pub(super) secret_store: String, + pub(super) client_version: String, +} +impl Config { + pub(super) fn validate(&self) -> Result<()> { + if self.version != 1 + || !super::install::SUPPORTED_VERSIONS.contains(&self.client_version.as_str()) + { + return Err("Unsupported ChatGPT configuration/client version".into()); + } + if !self.workspace.is_absolute() + || !self.executable.is_absolute() + || !self.docker.is_absolute() + { + return Err("ChatGPT paths must be absolute".into()); + } + if [&self.workspace, &self.executable, &self.docker] + .iter() + .any(|path| { + path.components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + }) + || self.executable.starts_with(&self.workspace) + || self.docker.starts_with(&self.workspace) + { + return Err( + "Host executable paths must be outside the workspace without parent traversal" + .into(), + ); + } + // v0.0.14 runtimeconfig.ValidateTunnelID: tunnel_<32 lowercase letters or digits>. + if !self.tunnel_id.strip_prefix("tunnel_").is_some_and(|id| { + id.len() == 32 + && id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) { + return Err("Invalid Tunnel ID; copy it from OpenAI Platform Tunnels".into()); + } + if !self + .image + .strip_prefix("sha256:") + .is_some_and(|v| v.len() == 64 && v.bytes().all(|b| b.is_ascii_hexdigit())) + { + return Err("Agent image must resolve to an immutable Docker image ID".into()); + } + let socket = self + .docker_endpoint + .strip_prefix("unix://") + .ok_or("A local Unix Docker socket is required")?; + if !Path::new(socket).is_absolute() { + return Err("Docker socket must be absolute".into()); + } + if !["keychain", "secret-service", "private-file"].contains(&self.secret_store.as_str()) { + return Err("Unknown secret store".into()); + } + Ok(()) + } + pub(super) fn validate_runtime_paths(&self) -> Result<()> { + if validate_executable(&self.executable, &self.workspace)? != self.executable + || validate_executable(&self.docker, &self.workspace)? != self.docker + { + return Err("Configured executable path changed; run zai chatgpt repair".into()); + } + Ok(()) + } + pub(super) fn load(root: &Path) -> Result { + let value: Self = + serde_json::from_slice(&private::read(&root.join("config.json"), 64 * 1024)?) + .map_err(|_| "Invalid ChatGPT configuration; run zai chatgpt setup")?; + value.validate()?; + Ok(value) + } + pub(super) fn save(&self, root: &Path) -> Result<()> { + self.validate()?; + private::write( + &root.join("config.json"), + &serde_json::to_vec_pretty(self).map_err(|_| "Cannot encode configuration")?, + false, + ) + } +} + +// Quote each argument in the command string accepted by tunnel-client v0.0.14, +// then JSON-encode the complete string for its YAML profile. +pub(super) fn quote(value: &str) -> Result { + if value.contains(['\0', '\n', '\r']) { + return Err("MCP command paths cannot contain NUL/newlines".into()); + } + Ok(format!("'{}'", value.replace('\'', "'\"'\"'"))) +} + +pub(super) fn profile(config: &Config, root: &Path) -> Result> { + config.validate()?; + let command = [ + config + .executable + .to_str() + .ok_or("Executable path must be UTF-8")?, + "chatgpt", + "__mcp", + root.to_str().ok_or("State path must be UTF-8")?, + ] + .into_iter() + .map(quote) + .collect::>>()? + .join(" "); + // JSON is a YAML subset accepted by the client's strict yaml.v3 decoder. + // Only the secret reference is serialized, never the resolved value. + serde_json::to_vec_pretty(&serde_json::json!({ + "config_version":1, + "control_plane":{"tunnel_id":config.tunnel_id,"api_key":"env:CONTROL_PLANE_API_KEY"}, + "mcp":{"commands":[{"channel":"main","command":command}],"stdio_send_initialized_notification":true}, + "health":{"listen_addr":"127.0.0.1:0","url_file":root.join("health-url")}, + "admin_ui":{"allow_remote":false,"open_browser":false}, + "log":{"level":"info","format":"json","http_raw_unsafe":false} + })).map_err(|_| "Cannot encode tunnel profile".into()) +} + +pub(super) fn verify_profile(config: &Config, root: &Path) -> Result<()> { + let actual = private::read(&root.join("profile.yaml"), 64 * 1024)?; + if actual != profile(config, root)? { + return Err("Managed tunnel profile changed; run zai chatgpt repair".into()); + } + Ok(()) +} + +#[cfg(test)] +mod executable_tests { + use super::*; + use std::os::unix::fs::{symlink, PermissionsExt}; + + #[test] + fn executable_trust_rejects_workspace_links_and_shared_write() { + let root = crate::test_util::unique_temp_dir("chatgpt", "executable-trust"); + let workspace = root.join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let binary = root.join("tool"); + std::fs::write(&binary, b"#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + validate_executable(&binary, &workspace).unwrap(), + binary.canonicalize().unwrap() + ); + let unsafe_tool = workspace.join("docker"); + std::fs::copy(&binary, &unsafe_tool).unwrap(); + assert!(validate_executable(&unsafe_tool, &workspace).is_err()); + let link = root.join("installation-link"); + symlink(&unsafe_tool, &link).unwrap(); + assert!(validate_executable(&link, &workspace).is_err()); + std::fs::remove_file(&link).unwrap(); + symlink(&binary, &link).unwrap(); + assert_eq!( + validate_executable(&link, &workspace).unwrap(), + binary.canonicalize().unwrap() + ); + let hardlink = root.join("hardlink"); + std::fs::hard_link(&binary, &hardlink).unwrap(); + assert!(validate_executable(&binary, &workspace).is_err()); + std::fs::remove_file(&hardlink).unwrap(); + for mode in [0o770, 0o707, 0o600] { + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(mode)).unwrap(); + assert!(validate_executable(&binary, &workspace).is_err()); + } + assert!(validate_executable(&workspace, &workspace).is_err()); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/chatgpt/daemon.rs b/src/chatgpt/daemon.rs new file mode 100644 index 0000000..80344f7 --- /dev/null +++ b/src/chatgpt/daemon.rs @@ -0,0 +1,851 @@ +//! The supervisor owns the Child; remote commands never signal a saved PID. +use super::{ + config::{self, Config}, + private::{self, Result}, + process, secret, +}; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +static STOP: AtomicBool = AtomicBool::new(false); +extern "C" fn stop_signal(_: libc::c_int) { + STOP.store(true, Ordering::Relaxed); +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct State { + version: u32, + pid: u32, + child_pid: u32, + port: u16, + generation: String, +} + +fn read_state(root: &Path) -> Result { + let state: State = serde_json::from_slice(&private::read(&root.join("runtime.json"), 4096)?) + .map_err(|_| "Invalid runtime state")?; + if state.version != 1 + || state.pid == 0 + || state.child_pid == 0 + || state.port == 0 + || state.generation.len() != 64 + || !state.generation.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err("Invalid runtime identity".into()); + } + Ok(state) +} + +pub(super) fn request(root: &Path, action: &str) -> Result { + Ok(request_state(root, action)?.child_pid) +} + +pub(super) fn generation_running(root: &Path, generation: &str) -> bool { + // The authenticated supervisor holds runtime.lock throughout its lifetime. + // Neither a journal phase nor the PID saved in runtime.json proves liveness. + private::Lock::held(root, "runtime.lock") == Ok(true) + && request_state(root, "status").is_ok_and(|state| state.generation == generation) + && private::Lock::held(root, "runtime.lock") == Ok(true) +} + +fn request_state(root: &Path, action: &str) -> Result { + if !["status", "stop"].contains(&action) { + return Err("Unknown supervisor action".into()); + } + let state = read_state(root)?; + let mut stream = TcpStream::connect_timeout( + &SocketAddr::from((Ipv4Addr::LOCALHOST, state.port)), + Duration::from_secs(2), + ) + .map_err(|_| "Bridge stopped or supervisor unavailable")?; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .map_err(|_| "Cannot set IPC timeout")?; + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .map_err(|_| "Cannot set IPC timeout")?; + writeln!(stream, "{} {}", state.generation, action).map_err(|_| "Cannot contact supervisor")?; + stream + .shutdown(std::net::Shutdown::Write) + .map_err(|_| "Cannot finish IPC request")?; + let mut response = String::new(); + stream + .take(256) + .read_to_string(&mut response) + .map_err(|_| "Supervisor response unavailable")?; + if response != format!("{} {} {}\n", state.generation, state.pid, state.child_pid) { + return Err("Supervisor identity mismatch; no process was signalled".into()); + } + Ok(state) +} + +fn read_supervisor_request(stream: &mut TcpStream, timeout: Duration) -> std::io::Result> { + // Darwin inherits the nonblocking listener flag. A read timeout alone + // does not override it. Bound the whole frame, not each arriving byte. + stream.set_nonblocking(false)?; + stream.set_write_timeout(Some(timeout))?; + let deadline = Instant::now() + timeout; + let mut input = vec![0; 256]; + let mut len = 0; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(std::io::ErrorKind::TimedOut.into()); + } + stream.set_read_timeout(Some(remaining))?; + match stream.read(&mut input[len..]) { + Ok(0) => { + input.truncate(len); + return Ok(input); + } + Ok(n) => { + len += n; + if len == input.len() { + return Ok(input); // Oversized frames cannot match an action. + } + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + return Err(std::io::ErrorKind::TimedOut.into()); + } + Err(error) => return Err(error), + } + } +} + +pub(super) fn tunnel_command( + root: &Path, + config: &Config, + secret: &secret::Secret, + verb: &str, +) -> Result { + config::verify_profile(config, root)?; + let mut command = process::command(&root.join("tunnel-client")); + command + .args([verb, "--profile-file"]) + .arg(root.join("profile.yaml")) + .env("CONTROL_PLANE_API_KEY", secret.text()) + .env("DOCKER_HOST", &config.docker_endpoint) + .env("TUNNEL_CLIENT_STATE_DIR", root.join("client-state")); + Ok(command) +} + +pub(super) fn supervise(root: &Path) -> Result<()> { + let _lock = private::Lock::acquire(root, "runtime.lock")?; + ensure_clean(root)?; + let config = Config::load(root)?; + super::install::verify_installed(root)?; + for file in ["health-url", "runtime.json"] { + let path = root.join(file); + if private::exists_checked(&path)? { + private::open(&path, false)?; + std::fs::remove_file(path).map_err(|_| "Cannot clear stopped runtime state")?; + } + } + // The client creates health-url; umask also protects all client-owned state. + unsafe { + libc::umask(0o077); + libc::signal( + libc::SIGTERM, + stop_signal as *const () as libc::sighandler_t, + ); + libc::signal(libc::SIGINT, stop_signal as *const () as libc::sighandler_t); + } + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .map_err(|_| "Cannot bind private supervisor listener")?; + listener + .set_nonblocking(true) + .map_err(|_| "Cannot configure supervisor listener")?; + let mut cmd = process::command(&config.executable); + cmd.args(["chatgpt", "__tunnel"]) + .arg(root) + .stdin(std::process::Stdio::piped()); + // Raw client output can contain prompts or credentials. Discard it; status + // and doctor expose structured checks only. No raw log file is persisted. + let generation = private::nonce()?; + { + let _execution = private::Lock::acquire(root, "mcp.lock")?; + super::cleanup::Cleanup::prepare(root, &config, &generation)?; + } + cmd.env("ZAIVERN_CHATGPT_GENERATION", &generation); + let mut child = match process::OwnedProcessGroup::spawn(&mut cmd) { + Ok(child) => child, + Err(error) => { + // No child was spawned, so no task can require cleanup. + super::cleanup::Cleanup::load(root, &config)?.finish(false)?; + return Err(error); + } + }; + // Only this process owns the writer. SIGKILL/crash closes it in the kernel, + // waking the guardian even when no Rust destructor can run here. + let _lease = child.take_lease()?; + let state = State { + version: 1, + pid: std::process::id(), + child_pid: child.id(), + port: listener + .local_addr() + .map_err(|_| "Cannot inspect supervisor listener")? + .port(), + generation, + }; + private::write( + &root.join("runtime.json"), + &serde_json::to_vec(&state).map_err(|_| "Cannot encode runtime state")?, + false, + )?; + let result = loop { + if STOP.load(Ordering::Relaxed) { + break Ok(()); + } + if child.exited()? { + break Err("Tunnel client exited; run zai chatgpt doctor".into()); + } + match listener.accept() { + Ok((mut stream, _)) => { + // A failed/slow connection never terminates the supervisor. + // Reading the entire frame and writing the reply each have a + // 250ms budget, so STOP and the next status remain responsive. + if let Ok(input) = read_supervisor_request(&mut stream, Duration::from_millis(250)) + { + let status = format!("{} status\n", state.generation); + let stop = format!("{} stop\n", state.generation); + if input == status.as_bytes() || input == stop.as_bytes() { + let _ = writeln!( + stream, + "{} {} {}", + state.generation, state.pid, state.child_pid + ); + if input == stop.as_bytes() { + break Ok(()); + } + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(25)) + } + Err(_) => break Err("Supervisor listener failed".into()), + } + }; + let shutdown = child.stop_gracefully(Duration::from_secs(45), || { + let lock = private::Lock::acquire(root, "mcp.lock").ok()?; + ensure_clean(root).ok()?; + Some(lock) + }); + let deadline = Instant::now() + Duration::from_secs(45); + while ensure_clean(root).is_err() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(50)); + } + let cleanup = ensure_clean(root); + let success = result.is_ok() && shutdown.is_ok() && cleanup.is_ok(); + write_shutdown(root, &state.generation, success)?; + // Still hold the runtime lock, so another generation cannot exist yet. + for file in ["runtime.json", "health-url"] { + let _ = std::fs::remove_file(root.join(file)); + } + result.and(shutdown).and(cleanup) +} + +pub(super) fn tunnel_guardian(root: &Path) -> Result<()> { + process::disable_core_dumps()?; + unsafe { + libc::signal( + libc::SIGTERM, + stop_signal as *const () as libc::sighandler_t, + ); + libc::signal(libc::SIGINT, stop_signal as *const () as libc::sighandler_t); + } + process::guard_tunnel( + |scope| { + let config = Config::load(root)?; + super::install::verify_installed(root)?; + let generation = std::env::var("ZAIVERN_CHATGPT_GENERATION") + .map_err(|_| "Missing managed generation")?; + if !super::cleanup::valid_nonce(&generation) + || private::read(&root.join("active-generation"), 64)? != generation.as_bytes() + { + return Err("Guardian generation mismatch".into()); + } + let key = secret::load_guarded(root, &config.secret_store, scope)?; + let mut command = tunnel_command(root, &config, &key, "run")?; + command.env("ZAIVERN_CHATGPT_GENERATION", generation); + Ok(command) + }, + Duration::from_secs(45), + &STOP, + || { + let lock = private::Lock::acquire(root, "mcp.lock").ok()?; + ensure_clean(root).ok()?; + Some(lock) + }, + ) +} + +pub(super) fn start(root: &Path, foreground: bool) -> Result<()> { + if request(root, "status").is_ok() { + println!("ChatGPT Bridge is already running."); + return Ok(()); + } + // Never remove a lock file or decide a PID is stale by probing kill(0). + drop(private::Lock::acquire(root, "runtime.lock")?); + ensure_clean(root)?; + if foreground { + return supervise(root); + } + let config = Config::load(root)?; + let mut cmd = process::command(&config.executable); + cmd.args(["chatgpt", "__supervise"]).arg(root); + let mut child = cmd.spawn().map_err(|_| "Cannot start ChatGPT supervisor")?; + wait_for_start( + root, + &mut child, + Duration::from_secs(65), + Duration::from_secs(15), + ) +} + +fn wait_for_start( + root: &Path, + child: &mut std::process::Child, + startup_timeout: Duration, + shutdown_timeout: Duration, +) -> Result<()> { + let started = Instant::now(); + loop { + if request(root, "status").is_ok() { + println!("ChatGPT Bridge started. Use zai chatgpt status."); + return Ok(()); + } + if child + .try_wait() + .map_err(|_| "Cannot inspect supervisor")? + .is_some() + { + return Err( + "Supervisor failed to start; run zai chatgpt doctor (Keychain may be locked)" + .into(), + ); + } + if started.elapsed() >= startup_timeout { + // Allow a short graceful wait here; the supervisor retains its + // existing 45s shutdown/cleanup budgets if it needs longer. + if !process::terminate_and_wait(child, shutdown_timeout)? { + return Err("Supervisor startup timed out and shutdown is still pending; state preserved. Wait, then run zai chatgpt status or doctor.".into()); + } + return Err( + "Supervisor startup timed out; unlock your secret store and run doctor".into(), + ); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +pub(super) fn stop(root: &Path) -> Result<()> { + stop_with_timeout(root, Duration::from_secs(75)) +} + +fn stop_with_timeout(root: &Path, operation_timeout: Duration) -> Result<()> { + // Keep lifecycle authority until the supervisor has completed shutdown and + // its receipt has been authenticated. In the foreground-start case the + // command itself owns operation.lock for the duration of supervise(), so + // send the stop request first to let that owner exit, then acquire the + // lock before inspecting any shutdown state. No other lifecycle operation + // can mutate state while the supervisor still owns runtime.lock. + let _operation = match private::Lock::acquire(root, "operation.lock") { + Ok(lock) => lock, + Err(_) => { + let _ = request(root, "stop"); + acquire_operation_for_stop(root, operation_timeout)? + } + }; + match request(root, "stop") { + Ok(_) => wait_for_stop(root), + Err(_) => { + // With lifecycle authority held, a failed request means no + // supervisor can start concurrently. Only the runtime lock and + // cleanup evidence decide whether the bridge is already stopped. + let _runtime = private::Lock::acquire(root, "runtime.lock")?; + ensure_clean(root)?; + println!("ChatGPT Bridge is stopped. No saved PID was signalled."); + Ok(()) + } + } +} + +fn acquire_operation_for_stop(root: &Path, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + loop { + match private::Lock::acquire(root, "operation.lock") { + Ok(lock) => return Ok(lock), + Err(acquire_error) => match private::Lock::held(root, "operation.lock") { + Ok(true) | Ok(false) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + Ok(true) | Ok(false) => { + return Err("Another ChatGPT lifecycle operation is still running; stop was not confirmed".into()) + } + Err(_) => return Err(acquire_error), + }, + } + } +} + +fn wait_for_stop(root: &Path) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(100); + let mut progress = Instant::now(); + loop { + if let Ok(_lock) = private::Lock::acquire(root, "runtime.lock") { + ensure_clean(root)?; + let bytes = private::read(&root.join("shutdown.json"), 4096)?; + let receipt: ShutdownReceipt = + serde_json::from_slice(&bytes).map_err(|_| "Invalid shutdown receipt")?; + let active = private::read(&root.join("active-generation"), 64)?; + if !receipt.success || receipt.generation.as_bytes() != active.as_slice() { + return Err("Bridge exited abnormally or required forced termination; cleanup was checked, but stop was not successful. Run doctor.".into()); + } + println!("ChatGPT Bridge stopped."); + return Ok(()); + } + if Instant::now() >= deadline { + return Err("Supervisor is still stopping; state was preserved".into()); + } + if progress.elapsed() >= Duration::from_secs(10) { + println!("Waiting for MCP task cancellation and cleanup..."); + progress = Instant::now(); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +pub(super) fn ensure_clean(root: &Path) -> Result<()> { + super::cleanup::ensure_confirmed(root)?; + if !private::exists_checked(&root.join("active-generation"))? { + return Ok(()); + } + let active = private::read(&root.join("active-generation"), 64)?; + if active.len() != 64 + || !active.iter().all(u8::is_ascii_hexdigit) + || private::read(&root.join("mcp.done"), 64).ok().as_ref() != Some(&active) + { + return Err("Previous MCP cleanup is unconfirmed. State preserved; start/setup/reset blocked. Run zai chatgpt status and zai chatgpt repair.".into()); + } + Ok(()) +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ShutdownReceipt { + generation: String, + success: bool, +} + +pub(super) fn write_shutdown(root: &Path, generation: &str, success: bool) -> Result<()> { + private::write( + &root.join("shutdown.json"), + &serde_json::to_vec(&ShutdownReceipt { + generation: generation.into(), + success, + }) + .map_err(|_| "Cannot encode shutdown receipt")?, + false, + ) +} + +pub(super) fn health(root: &Path, endpoint: &str) -> Result { + Ok(local_get(root, endpoint)?.0) +} + +fn local_get(root: &Path, endpoint: &str) -> Result<(bool, Vec)> { + request(root, "status")?; + let bytes = private::read(&root.join("health-url"), 256)?; + let url = std::str::from_utf8(&bytes) + .map_err(|_| "Invalid health URL")? + .trim(); + let port = url + .strip_prefix("http://127.0.0.1:") + .and_then(|p| p.trim_end_matches('/').parse::().ok()) + .filter(|p| *p != 0) + .ok_or("Health URL must point to IPv4 loopback")?; + if !["healthz", "readyz", "api/status"].contains(&endpoint) { + return Err("Unknown health endpoint".into()); + } + let agent: ureq::Agent = ureq::Agent::config_builder() + .proxy(None) + .max_redirects(0) + .timeout_global(Some(Duration::from_secs(2))) + .http_status_as_error(false) + .build() + .into(); + let mut response = agent + .get(format!("http://127.0.0.1:{port}/{endpoint}")) + .call() + .map_err(|_| "Health listener unavailable")?; + let success = response.status().is_success(); + let mut bytes = Vec::new(); + response + .body_mut() + .as_reader() + .take(256 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Cannot read local status")?; + if bytes.len() > 256 * 1024 { + return Err("Local status exceeds size limit".into()); + } + Ok((success, bytes)) +} + +pub(super) fn live_status(root: &Path, tunnel: &str) -> Result<(bool, bool)> { + let (success, bytes) = local_get(root, "api/status")?; + if !success { + return Err("Local status request failed".into()); + } + parse_live_status(&bytes, tunnel) +} + +fn parse_live_status(bytes: &[u8], tunnel: &str) -> Result<(bool, bool)> { + let value: serde_json::Value = + serde_json::from_slice(bytes).map_err(|_| "Invalid local status")?; + if value["control_plane_tunnel_id"] != tunnel || value["raw_http_logging_enabled"] != false { + return Err("Live tunnel identity/security settings mismatch".into()); + } + let control = value["tunnel_metadata"].is_object() + && value["tunnel_metadata_error"] + .as_str() + .is_none_or(str::is_empty); + let mcp = value["channels"].as_array().is_some_and(|channels| { + channels.iter().any(|c| { + c["name"] == "main" + && c["enabled"] == true + && c["transport_kind"] == "stdio" + && c["details"].as_array().is_some_and(|details| { + details.iter().any(|d| { + d["key"] == "pid" + && d["value"] + .as_str() + .is_some_and(|p| p.parse::().is_ok_and(|p| p > 0)) + }) + }) + }) + }); + Ok((control, mcp)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn supervisor_accepts_split_frames_and_bounds_slow_or_unfinished_requests() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let accept = || { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "loopback accept timed out"); + std::thread::sleep(Duration::from_millis(1)); + } + Err(error) => panic!("{error}"), + } + } + }; + for slow in [false, true] { + let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let mut server = accept(); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let reader = std::thread::spawn(move || { + let result = read_supervisor_request(&mut server, Duration::from_millis(100)); + tx.send(result).unwrap(); + }); + // Keep the connection open: the request must expire even when + // bytes keep arriving more frequently than the read timeout. + let writer = if slow { + Some(std::thread::spawn(move || { + for _ in 0..100 { + if client.write_all(b"x").is_err() { + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + })) + } else { + None + }; + let error = rx + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + reader.join().unwrap(); + if let Some(writer) = writer { + writer.join().unwrap(); + } + } + // A later legitimate, split request still works on the same listener. + let generation = private::nonce().unwrap(); + let expected = format!("{generation} status\n"); + let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let mut server = accept(); + let reader = std::thread::spawn(move || { + read_supervisor_request(&mut server, Duration::from_secs(2)) + }); + client.write_all(generation.as_bytes()).unwrap(); + std::thread::sleep(Duration::from_millis(25)); + client.write_all(b" status\n").unwrap(); + client.shutdown(std::net::Shutdown::Write).unwrap(); + assert_eq!(reader.join().unwrap().unwrap(), expected.as_bytes()); + } + + #[test] + fn startup_timeout_wait_preserves_unconfirmed_generation() { + use super::super::{ + cleanup::Cleanup, + tests::{fixture, Temp}, + }; + let temp = Temp::new(); + let generation = private::nonce().unwrap(); + Cleanup::prepare(&temp.0, &fixture(&temp.0), &generation).unwrap(); + let _runtime = private::Lock::acquire(&temp.0, "runtime.lock").unwrap(); + for (script, finishes) in [ + ( + "trap 'sleep 0.2; exit 0' TERM; printf 'ready\\n'; while :; do sleep 1; done", + true, + ), + ("trap '' TERM; printf 'ready\\n'; exec sleep 30", false), + ] { + let mut child = process::tests::ready_child(script); + let started = Instant::now(); + let result = wait_for_start( + &temp.0, + &mut child.child, + Duration::ZERO, + if finishes { + Duration::from_secs(5) + } else { + Duration::from_millis(100) + }, + ); + let exited = child.exited().unwrap(); + let error = result.unwrap_err(); + assert!(error.contains("startup timed out")); + assert_eq!(error.contains("shutdown is still pending"), !finishes); + assert_eq!(exited.is_some(), finishes); + assert!(started.elapsed() < Duration::from_secs(6)); + assert!(private::Lock::acquire(&temp.0, "runtime.lock").is_err()); + assert!(ensure_clean(&temp.0).is_err()); + assert_eq!( + private::read(&temp.0.join("active-generation"), 64).unwrap(), + generation.as_bytes() + ); + assert!(!temp.0.join("mcp.done").exists()); + assert!(!temp.0.join("shutdown.json").exists()); + } + } + + #[test] + fn stop_does_not_claim_stopped_while_lifecycle_operation_is_active() { + use super::super::tests::Temp; + + let temp = Temp::new(); + let _operation = private::Lock::acquire(&temp.0, "operation.lock").unwrap(); + let error = stop_with_timeout(&temp.0, Duration::from_millis(50)).unwrap_err(); + assert!(error.contains("lifecycle operation")); + assert!(private::Lock::held(&temp.0, "operation.lock").unwrap()); + assert!(private::Lock::acquire(&temp.0, "runtime.lock").is_ok()); + + let stopped = Temp::new(); + stop_with_timeout(&stopped.0, Duration::from_millis(50)).unwrap(); + assert!(private::Lock::acquire(&stopped.0, "runtime.lock").is_ok()); + } + + #[test] + fn stop_holds_operation_lock_until_shutdown_receipt_is_verified() { + use super::super::tests::Temp; + + let temp = Temp::new(); + let generation = "a".repeat(64); + let pid = std::process::id(); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + private::write( + &temp.0.join("runtime.json"), + &serde_json::to_vec(&State { + version: 1, + pid, + child_pid: pid, + port, + generation: generation.clone(), + }) + .unwrap(), + false, + ) + .unwrap(); + private::write( + &temp.0.join("active-generation"), + generation.as_bytes(), + false, + ) + .unwrap(); + private::write(&temp.0.join("mcp.done"), generation.as_bytes(), false).unwrap(); + + // Keep the supervisor's runtime lease held while the accepted stop + // request is waiting for shutdown. This makes receipt verification a + // real, observable phase rather than a timing assumption. + let runtime = private::Lock::acquire(&temp.0, "runtime.lock").unwrap(); + let (accepted_tx, accepted_rx) = std::sync::mpsc::sync_channel(1); + let expected = format!("{generation} stop\n"); + let response_generation = generation.clone(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = String::new(); + stream.read_to_string(&mut request).unwrap(); + assert_eq!(request, expected); + accepted_tx.send(()).unwrap(); + writeln!(stream, "{response_generation} {pid} {pid}").unwrap(); + }); + + let root = temp.0.clone(); + let stopper = std::thread::spawn(move || stop_with_timeout(&root, Duration::from_secs(2))); + accepted_rx + .recv_timeout(Duration::from_secs(1)) + .expect("stop request was not accepted"); + assert!(private::Lock::acquire(&temp.0, "operation.lock").is_err()); + + write_shutdown(&temp.0, &generation, true).unwrap(); + drop(runtime); + server.join().unwrap(); + stopper.join().unwrap().unwrap(); + assert!(private::Lock::acquire(&temp.0, "operation.lock").is_ok()); + } + + #[test] + fn live_status_checks_identity_stdio_and_unsafe_logging() { + let mut value = serde_json::json!({"control_plane_tunnel_id":"tunnel_fixture","raw_http_logging_enabled":false,"tunnel_metadata":{},"channels":[{"name":"main","enabled":true,"transport_kind":"stdio","details":[{"key":"pid","value":"1234"}]}]}); + assert_eq!( + parse_live_status(value.to_string().as_bytes(), "tunnel_fixture").unwrap(), + (true, true) + ); + assert!(parse_live_status(value.to_string().as_bytes(), "tunnel_other").is_err()); + value["channels"][0]["transport_kind"] = serde_json::json!("http-streamable"); + assert_eq!( + parse_live_status(value.to_string().as_bytes(), "tunnel_fixture").unwrap(), + (true, false) + ); + value["raw_http_logging_enabled"] = serde_json::json!(true); + assert!(parse_live_status(value.to_string().as_bytes(), "tunnel_fixture").is_err()); + } + + #[test] + fn fake_health_ready_and_supervisor_identity() { + let root = crate::test_util::unique_temp_dir("chatgpt", "health"); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + let ipc = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let http = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let state = State { + version: 1, + pid: std::process::id(), + child_pid: std::process::id(), + port: ipc.local_addr().unwrap().port(), + generation: private::nonce().unwrap(), + }; + private::write( + &root.join("runtime.json"), + &serde_json::to_vec(&state).unwrap(), + false, + ) + .unwrap(); + private::write( + &root.join("health-url"), + format!("http://{}", http.local_addr().unwrap()).as_bytes(), + false, + ) + .unwrap(); + let replies = std::thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = ipc.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut bytes = Vec::new(); + (&mut stream).take(256).read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, format!("{} status\n", state.generation).as_bytes()); + writeln!( + stream, + "{} {} {}", + state.generation, state.pid, state.child_pid + ) + .unwrap(); + } + }); + let health = std::thread::spawn(move || { + for status in ["200 OK", "503 Service Unavailable"] { + let (mut stream, _) = http.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut bytes = [0u8; 4096]; + assert!(stream.read(&mut bytes).unwrap() > 0); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}" + ) + .unwrap(); + } + }); + assert!(super::health(&root, "healthz").unwrap()); + assert!(!super::health(&root, "readyz").unwrap()); + replies.join().unwrap(); + health.join().unwrap(); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn forged_pid_cannot_signal_an_unrelated_process() { + let root = crate::test_util::unique_temp_dir("chatgpt", "forged-pid"); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let state = State { + version: 1, + pid: std::process::id(), + child_pid: std::process::id(), + port: listener.local_addr().unwrap().port(), + generation: private::nonce().unwrap(), + }; + private::write( + &root.join("runtime.json"), + &serde_json::to_vec(&state).unwrap(), + false, + ) + .unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut bytes = Vec::new(); + (&mut stream).take(256).read_to_end(&mut bytes).unwrap(); + stream + .write_all(b"wrong generation and identity\n") + .unwrap(); + }); + assert!(request(&root, "stop").is_err()); + server.join().unwrap(); + // Reaching this assertion proves the forged saved PID was not killed. + assert!(std::process::id() > 0); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/chatgpt/docker.rs b/src/chatgpt/docker.rs new file mode 100644 index 0000000..3027aac --- /dev/null +++ b/src/chatgpt/docker.rs @@ -0,0 +1,122 @@ +use super::private::Result; +use super::process; +use std::os::unix::fs::FileTypeExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +fn executable(workspace: &Path) -> Result { + let mut candidates: Vec = [ + "/usr/local/bin/docker", + "/opt/homebrew/bin/docker", + "/usr/bin/docker", + "/bin/docker", + "/Applications/Docker.app/Contents/Resources/bin/docker", + ] + .into_iter() + .map(PathBuf::from) + .collect(); + if let Some(home) = std::env::var_os("HOME") { + candidates.push(PathBuf::from(home).join(".docker/bin/docker")); + } + for candidate in candidates { + if let Ok(path) = super::config::validate_executable(&candidate, workspace) { + return Ok(path); + } + } + Err("Trusted Docker CLI missing; install Docker outside the workspace".into()) +} + +pub(super) fn detect(workspace: &Path) -> Result<(PathBuf, String)> { + let binary = executable(workspace)?; + let endpoint = if let Ok(host) = std::env::var("DOCKER_HOST") { + host + } else { + let mut cmd = process::command(&binary); + cmd.args(["context", "inspect"]); + let data = process::capture(cmd, Duration::from_secs(10), 65536).map_err(|_| { + "Docker context lookup failed or timed out; check docker context ls, then retry setup" + })?; + let value: serde_json::Value = + serde_json::from_slice(&data).map_err(|_| "Invalid Docker context")?; + value[0]["Endpoints"]["docker"]["Host"] + .as_str() + .ok_or("Docker context has no endpoint")? + .to_owned() + }; + validate_endpoint(&endpoint)?; + let mut cmd = command(&binary, &endpoint); + cmd.args(["info", "--format", "{{.OSType}}"]); + if process::capture(cmd, Duration::from_secs(15), 1024).map_err(|_| { + "Docker daemon unavailable or timed out; start Docker and retry zai chatgpt doctor" + })? != b"linux\n" + { + return Err("Docker must run Linux containers".into()); + } + Ok((binary, endpoint)) +} + +pub(super) fn validate_endpoint(endpoint: &str) -> Result<()> { + let socket = endpoint + .strip_prefix("unix://") + .filter(|p| Path::new(p).is_absolute()) + .ok_or( + "Only local Unix Docker sockets are supported; remote Docker contexts are rejected", + )?; + if !std::fs::metadata(socket).is_ok_and(|m| m.file_type().is_socket()) { + return Err("Local Docker socket unavailable; start Docker and retry".into()); + } + Ok(()) +} + +pub(super) fn command(binary: &Path, endpoint: &str) -> std::process::Command { + let mut cmd = process::command(binary); + cmd.env_remove("DOCKER_HOST") + .env_remove("DOCKER_CONTEXT") + .args(["--host", endpoint]); + cmd +} + +pub(super) fn image(binary: &Path, endpoint: &str, source: &str) -> Result { + if source.is_empty() + || source.starts_with('-') + || source.len() > 512 + || source.chars().any(char::is_whitespace) + { + return Err("Invalid Docker image name".into()); + } + let mut cmd = command(binary, endpoint); + cmd.args(["image", "inspect", source]); + let data = process::capture(cmd, Duration::from_secs(15), 256 * 1024)?; + let value: serde_json::Value = + serde_json::from_slice(&data).map_err(|_| "Invalid Docker image metadata")?; + let arch = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + v => v, + }; + if value[0]["Os"] != "linux" || value[0]["Architecture"] != arch { + return Err( + "Agent image architecture mismatch; build/pull the native Linux architecture".into(), + ); + } + let id = value[0]["Id"] + .as_str() + .ok_or("Missing immutable image ID")?; + if !id + .strip_prefix("sha256:") + .is_some_and(|v| v.len() == 64 && v.bytes().all(|b| b.is_ascii_hexdigit())) + { + return Err("Docker did not return an immutable image ID".into()); + } + Ok(id.to_owned()) +} + +pub(super) fn pull(binary: &Path, endpoint: &str, source: &str) -> Result { + if source.is_empty() || source.starts_with('-') || source.chars().any(char::is_whitespace) { + return Err("Invalid Docker image name".into()); + } + let mut cmd = command(binary, endpoint); + cmd.args(["pull", source]); + process::capture(cmd, Duration::from_secs(600), 4 * 1024 * 1024)?; + image(binary, endpoint, source) +} diff --git a/src/chatgpt/install.rs b/src/chatgpt/install.rs new file mode 100644 index 0000000..6a5a555 --- /dev/null +++ b/src/chatgpt/install.rs @@ -0,0 +1,334 @@ +//! Official full-client installation. Verify before parsing or executing bytes. +use super::private::{self, Result}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::io::{Cursor, Read}; +use std::path::Path; +use std::time::Duration; + +pub(super) const TESTED_VERSION: &str = "0.0.14"; +// Compatibility is established by tests, not inferred from pre-1.0 semver. +pub(super) const SUPPORTED_VERSIONS: &[&str] = &[TESTED_VERSION]; +const DOWNLOAD_LIMIT: usize = 256 * 1024 * 1024; +const BINARY_LIMIT: usize = 512 * 1024 * 1024; + +pub(super) fn platform(os: &str, arch: &str) -> Result { + let os = match os { + "macos" => "darwin", + "linux" => "linux", + _ => { + return Err( + "ChatGPT Bridge supports macOS and Linux; Windows is not yet supported".into(), + ) + } + }; + let arch = match arch { + "x86_64" => "amd64", + "aarch64" | "arm64" => "arm64", + _ => return Err("Unsupported CPU architecture".into()), + }; + Ok(format!("{os}-{arch}")) +} + +pub(super) fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +pub(super) fn verify_sha(bytes: &[u8], expected: &str) -> Result<()> { + if expected.len() != 64 + || !expected.bytes().all(|b| b.is_ascii_hexdigit()) + || sha256(bytes) != expected.to_ascii_lowercase() + { + return Err("Tunnel client SHA256 mismatch; previous installation was preserved".into()); + } + Ok(()) +} + +#[derive(Deserialize)] +struct Release { + tag_name: String, + draft: bool, + prerelease: bool, + assets: Vec, +} +#[derive(Deserialize)] +struct Asset { + name: String, + browser_download_url: String, + digest: Option, + size: u64, +} + +fn select<'a>(release: &'a Release, name: &str) -> Result<&'a Asset> { + let mut matches = release.assets.iter().filter(|a| a.name == name); + let asset = matches.next().ok_or("Official release asset not found")?; + if matches.next().is_some() || asset.browser_download_url != format!("https://github.com/openai/tunnel-client/releases/download/v{TESTED_VERSION}/{name}") { + return Err("Unexpected or ambiguous release asset".into()); + } + Ok(asset) +} + +fn download(url: &str, limit: usize) -> Result> { + let agent: ureq::Agent = ureq::Agent::config_builder() + .https_only(true) + .timeout_global(Some(Duration::from_secs(180))) + .max_redirects(5) + .user_agent("Zaivern-ChatGPT-Setup") + .build() + .into(); + let mut response = agent + .get(url) + .call() + .map_err(|_| "Official download failed; check HTTPS connectivity and GitHub rate limits")?; + let mut bytes = Vec::new(); + response + .body_mut() + .as_reader() + .take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Download interrupted")?; + if bytes.len() > limit { + return Err("Release asset exceeds download limit".into()); + } + Ok(bytes) +} + +fn asset_bytes(asset: &Asset, limit: usize) -> Result> { + if asset.size > limit as u64 { + return Err("Release asset exceeds download limit".into()); + } + let bytes = download(&asset.browser_download_url, limit)?; + if bytes.len() as u64 != asset.size { + return Err("Release asset size mismatch".into()); + } + if let Some(digest) = &asset.digest { + verify_sha( + &bytes, + digest + .strip_prefix("sha256:") + .ok_or("Unsupported release digest")?, + )?; + } + Ok(bytes) +} + +fn checksum<'a>(sums: &'a str, name: &str) -> Result<&'a str> { + let mut found = None; + for line in sums.lines() { + let parts: Vec<_> = line.split_whitespace().collect(); + if parts.len() == 2 && parts[1].trim_start_matches('*') == name { + if found.is_some() { + return Err("Duplicate checksum entry".into()); + } + found = Some(parts[0]); + } + } + found.ok_or_else(|| "Missing asset SHA256SUMS entry".into()) +} + +fn extract(bytes: &[u8]) -> Result> { + let mut archive = + zip::ZipArchive::new(Cursor::new(bytes)).map_err(|_| "Invalid release ZIP")?; + if archive.len() > 256 { + return Err("Too many ZIP entries".into()); + } + let mut names = std::collections::HashSet::new(); + let mut binary = None; + let mut total = 0u64; + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|_| "Invalid ZIP entry")?; + let name = entry.name().to_string(); + if entry.enclosed_name().is_none() || name.contains('\\') || !names.insert(name.clone()) { + return Err("Unsafe ZIP path or duplicate entry".into()); + } + if let Some(mode) = entry.unix_mode() { + let kind = mode & 0o170000; + if kind != 0 && kind != 0o100000 && kind != 0o040000 { + return Err("ZIP links/special files are forbidden".into()); + } + } + total = total.checked_add(entry.size()).ok_or("ZIP size overflow")?; + if total > BINARY_LIMIT as u64 { + return Err("ZIP exceeds expanded size limit".into()); + } + if !entry.is_dir() + && Path::new(&name) + .file_name() + .is_some_and(|n| n == "tunnel-client") + { + if binary.is_some() { + return Err("Ambiguous tunnel-client binary".into()); + } + let mut data = Vec::new(); + entry + .by_ref() + .take(BINARY_LIMIT as u64 + 1) + .read_to_end(&mut data) + .map_err(|_| "ZIP CRC/read failure")?; + if data.len() > BINARY_LIMIT { + return Err("Binary exceeds size limit".into()); + } + binary = Some(data); + } + } + binary.ok_or_else(|| "Full tunnel-client binary missing from release".into()) +} + +pub(super) fn verify_arch(bytes: &[u8], target: &str) -> Result<()> { + let good = match target { + "darwin-amd64" => bytes.get(..8) == Some(&[0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1]), + "darwin-arm64" => bytes.get(..8) == Some(&[0xcf, 0xfa, 0xed, 0xfe, 12, 0, 0, 1]), + "linux-amd64" | "linux-arm64" => { + bytes.get(..6) == Some(&[0x7f, b'E', b'L', b'F', 2, 1]) + && bytes.get(18..20) + == Some(if target == "linux-amd64" { + &[62, 0] + } else { + &[183, 0] + }) + } + _ => false, + }; + if good { + Ok(()) + } else { + Err("Tunnel client binary OS/architecture mismatch".into()) + } +} + +pub(super) fn verify_installed(root: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let file = private::open(&root.join("tunnel-client"), true)?; + if file + .metadata() + .map_err(|_| "Cannot inspect tunnel-client permissions")? + .permissions() + .mode() + & 0o100 + == 0 + { + return Err("Tunnel client is not executable; run zai chatgpt repair".into()); + } + let mut bytes = Vec::new(); + file.take(BINARY_LIMIT as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Cannot read installed tunnel-client")?; + if bytes.len() > BINARY_LIMIT { + return Err("Installed binary exceeds size limit".into()); + } + verify_arch( + &bytes, + &platform(std::env::consts::OS, std::env::consts::ARCH)?, + )?; + let expected = private::read(&root.join("tunnel-client.sha256"), 128)?; + verify_sha( + &bytes, + std::str::from_utf8(&expected).map_err(|_| "Invalid installed checksum")?, + ) +} + +pub(super) fn install(root: &Path) -> Result<()> { + if verify_installed(root).is_ok() { + return Ok(()); + } + let target = platform(std::env::consts::OS, std::env::consts::ARCH)?; + let raw = download( + &format!( + "https://api.github.com/repos/openai/tunnel-client/releases/tags/v{TESTED_VERSION}" + ), + 2 * 1024 * 1024, + )?; + let release: Release = + serde_json::from_slice(&raw).map_err(|_| "Invalid official release metadata")?; + if release.tag_name != format!("v{TESTED_VERSION}") || release.draft || release.prerelease { + return Err("Unexpected release version/state".into()); + } + let name = format!("tunnel-client-v{TESTED_VERSION}-{target}.zip"); + let sums = asset_bytes(select(&release, "SHA256SUMS.txt")?, 1024 * 1024)?; + let sums = std::str::from_utf8(&sums).map_err(|_| "Invalid SHA256SUMS encoding")?; + let archive = asset_bytes(select(&release, &name)?, DOWNLOAD_LIMIT)?; + verify_sha(&archive, checksum(sums, &name)?)?; + let bytes = extract(&archive)?; + verify_arch(&bytes, &target)?; + private::write(&root.join("tunnel-client"), &bytes, true)?; + private::write( + &root.join("tunnel-client.sha256"), + sha256(&bytes).as_bytes(), + false, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn zip_rejects_traversal_symlinks_and_ambiguous_binaries() { + use std::io::Write; + use zip::write::SimpleFileOptions; + for names in [ + vec!["../tunnel-client"], + vec!["/tunnel-client"], + vec!["a/tunnel-client", "b/tunnel-client"], + vec!["a\\tunnel-client"], + ] { + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + for name in names { + writer + .start_file(name, SimpleFileOptions::default()) + .unwrap(); + writer.write_all(b"binary").unwrap(); + } + assert!(extract(&writer.finish().unwrap().into_inner()).is_err()); + } + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer + .add_symlink("tunnel-client", "/etc/passwd", SimpleFileOptions::default()) + .unwrap(); + assert!(extract(&writer.finish().unwrap().into_inner()).is_err()); + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer + .start_file("release/tunnel-client", SimpleFileOptions::default()) + .unwrap(); + writer.write_all(b"verified binary").unwrap(); + assert_eq!( + extract(&writer.finish().unwrap().into_inner()).unwrap(), + b"verified binary" + ); + } + #[test] + fn platforms_and_runtime_asset_rejection() { + for (os, arch, expected) in [ + ("macos", "x86_64", "darwin-amd64"), + ("macos", "aarch64", "darwin-arm64"), + ("linux", "arm64", "linux-arm64"), + ("linux", "x86_64", "linux-amd64"), + ] { + assert_eq!(platform(os, arch).unwrap(), expected); + } + assert!(platform("windows", "x86_64").is_err()); + assert!(platform("linux", "riscv64").is_err()); + let r = Release { + tag_name: "v0.0.14".into(), + draft: false, + prerelease: false, + assets: vec![Asset { + name: "tunnel-client-runtime-cloudflared-v0.0.14-darwin-amd64.zip".into(), + browser_download_url: String::new(), + digest: None, + size: 0, + }], + }; + assert!(select(&r, "tunnel-client-v0.0.14-darwin-amd64.zip").is_err()); + } + #[test] + fn checksum_and_arch_fail_closed() { + let digest = sha256(b"verified"); + verify_sha(b"verified", &digest).unwrap(); + assert!(verify_sha(b"tampered", &digest).is_err()); + assert!(checksum("abcd a.zip\nabcd a.zip", "a.zip").is_err()); + assert!(checksum("abcd b.zip", "a.zip").is_err()); + verify_arch(&[0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1], "darwin-amd64").unwrap(); + assert!(verify_arch(&[0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1], "darwin-arm64").is_err()); + assert!(verify_arch(b"#!/bin/sh", "linux-amd64").is_err()); + } +} diff --git a/src/chatgpt/mod.rs b/src/chatgpt/mod.rs new file mode 100644 index 0000000..4b53eec --- /dev/null +++ b/src/chatgpt/mod.rs @@ -0,0 +1,603 @@ +//! Managed Secure MCP Tunnel setup. No model inference HTTP client exists here. +mod cleanup; +mod config; +mod daemon; +mod docker; +mod install; +mod private; +mod probe; +mod process; +mod secret; +#[cfg(test)] +mod tests; + +use crate::i18n::tr; +use config::Config; +use private::Result; +use std::io::{BufRead, IsTerminal, Write}; +use std::path::Path; +use std::time::Duration; + +pub fn cli_main(args: &[String]) -> i32 { + if args.is_empty() || args == ["--help"] || args == ["-h"] { + println!("{}", crate::features::chatgpt::HELP); + return 0; + } + match dispatch(args) { + Ok(()) => 0, + Err(error) => { + eprintln!("ChatGPT: {error}"); + 1 + } + } +} + +fn dispatch(args: &[String]) -> Result<()> { + install::platform(std::env::consts::OS, std::env::consts::ARCH)?; + match args.first().map(String::as_str) { + Some("__probe") if args.len() == 5 => { + let workspace = Path::new(&args[1]); + let docker = Path::new(&args[3]); + config::validate_executable(docker, workspace)?; + crate::features::chat_bridge::imp::serve_probe(workspace.into(), args[2].clone(), docker.into(), args[4].clone()) + } + Some("__mcp" | "__serve" | "__supervise" | "__tunnel") if args.len() == 2 => { + let root = Path::new(&args[1]); + private::directory(root)?; + match args[0].as_str() { "__mcp" => mcp_exec(root), "__serve" => managed_serve(root), "__tunnel" => daemon::tunnel_guardian(root), _ => daemon::supervise(root) } + } + Some("setup") if args[1..].iter().all(|a| a == "--reauth" || a == "--test") => { + let root = private::root()?; + setup(&root,args.iter().any(|a| a == "--reauth"))?; + if args.iter().any(|a| a == "--test") { test(&root)?; } + Ok(()) + } + Some("start") if args.len() == 1 || args.get(1).is_some_and(|a| a == "--foreground") && args.len() == 2 => { + let root = private::root()?; + let _operation = private::Lock::acquire(&root,"operation.lock")?; + let config = Config::load(&root)?; + preflight(&root,&config)?; + daemon::start(&root,args.len() == 2) + } + Some(action @ ("status"|"doctor"|"stop"|"reset"|"repair"|"test")) if args.len() == 1 => { + let root = private::root()?; + match action { + "status" => status(&root), + "doctor" => doctor(&root,&Config::load(&root)?), + "stop" => daemon::stop(&root), + "reset" => reset(&root), + "repair" => repair(&root), + "test" => test(&root), + _ => unreachable!(), + } + } + _ => Err("Unknown command/options. Use zai chatgpt --help. Runtime keys are never accepted as arguments.".into()), + } +} + +fn prompt(label: &str, default: &str) -> Result { + if !std::io::stdin().is_terminal() { + return Err("Setup/reset requires an interactive terminal".into()); + } + print!("{label}"); + if !default.is_empty() { + print!(" [{default}]"); + } + print!(": "); + std::io::stdout() + .flush() + .map_err(|_| "Cannot flush prompt")?; + let mut input = Vec::new(); + use std::io::Read; + std::io::stdin() + .lock() + .take(8193) + .read_until(b'\n', &mut input) + .map_err(|_| "Cannot read input")?; + if input.len() > 8192 || input.is_empty() { + return Err("Input cancelled or too long".into()); + } + let value = std::str::from_utf8(&input) + .map_err(|_| "Input must be UTF-8")? + .trim(); + Ok(if value.is_empty() { + default.to_owned() + } else { + value.to_owned() + }) +} +fn confirm(label: &str, default_yes: bool) -> Result { + let response = prompt(label, if default_yes { "Y/n" } else { "y/N" })?; + Ok(response.eq_ignore_ascii_case("y") + || response.eq_ignore_ascii_case("yes") + || default_yes && response == "Y/n") +} + +fn setup(root: &Path, reauth: bool) -> Result<()> { + println!( + "Zaivern {} — {}", + env!("CARGO_PKG_VERSION"), + install::platform(std::env::consts::OS, std::env::consts::ARCH)? + ); + println!("{}", tr("chatgpt.runtime_auth_only")); + let existing = if private::exists_checked(&root.join("config.json"))? { + Some(Config::load(root)?) + } else { + None + }; + if let Some(config) = &existing { + if !reauth { + println!("{}", tr("chatgpt.existing_setup")); + match prompt(&tr("chatgpt.select"), "1")?.as_str() { + "1" => return doctor(root, config), + "2" => return repair(root), + "3" => {} + "4" => return Ok(()), + _ => return Err("Invalid selection".into()), + } + } + } + let _operation = private::Lock::acquire(root, "operation.lock")?; + let _runtime = private::Lock::acquire(root, "runtime.lock") + .map_err(|_| "Stop the running bridge before reconfiguring: zai chatgpt stop")?; + daemon::ensure_clean(root)?; + let executable = std::env::current_exe() + .and_then(|p| p.canonicalize()) + .map_err(|_| "Cannot locate zai executable")?; + println!("Zaivern executable: {}", executable.display()); + let workspace = existing + .as_ref() + .map(|c| c.workspace.clone()) + .unwrap_or(std::env::current_dir().map_err(|_| "Cannot locate current directory")?); + println!("Workspace: {}", workspace.display()); + let workspace = if confirm(&tr("chatgpt.use_workspace"), true)? { + workspace + } else { + std::path::PathBuf::from(prompt(&tr("chatgpt.workspace_path"), "")?) + }; + let workspace = workspace + .canonicalize() + .map_err(|_| "Workspace does not exist")?; + if !workspace.is_dir() + || workspace.parent().is_none() + || root.starts_with(&workspace) + || workspace.starts_with(root) + { + return Err( + "Workspace must be a project directory outside ChatGPT credential storage".into(), + ); + } + let (docker, docker_endpoint) = docker::detect(&workspace)?; + println!("[PASS] Docker — {}", docker_endpoint); + println!("{}", tr("chatgpt.image_contract")); + let image_source = prompt( + &tr("chatgpt.image_name"), + existing + .as_ref() + .map(|c| c.image_source.as_str()) + .unwrap_or(""), + )?; + let image = match docker::image(&docker, &docker_endpoint, &image_source) { + Ok(image) => image, + Err(_) => { + if !confirm(&tr("chatgpt.pull_confirm"), false)? { + return Err( + "Provide/build a compatible Agent image, then rerun setup; see docs/chatgpt.md" + .into(), + ); + } + println!("{}", tr("chatgpt.pulling")); + docker::pull(&docker, &docker_endpoint, &image_source)? + } + }; + println!("[PASS] Agent image: {image}"); + println!( + "Installing/verifying official full tunnel-client {}...", + install::TESTED_VERSION + ); + install::install(root)?; + client_version(root)?; + println!("{}", tr("chatgpt.tunnel_hint")); + let tunnel_id = prompt( + "Tunnel ID", + existing + .as_ref() + .map(|c| c.tunnel_id.as_str()) + .unwrap_or(""), + )?; + let mut config = Config { + version: 1, + workspace, + executable, + image, + image_source, + docker, + docker_endpoint, + tunnel_id, + secret_store: existing + .as_ref() + .map(|c| c.secret_store.clone()) + .unwrap_or_else(|| "private-file".into()), + client_version: install::TESTED_VERSION.into(), + }; + config.validate()?; + config.validate_runtime_paths()?; + if reauth || existing.is_none() || secret::load(root, &config.secret_store).is_err() { + if let Some(old) = &existing { + secret::remember_store(root, &old.secret_store)?; + } + let key = secret::prompt()?; + config.secret_store = + secret::save(root, &key, || confirm(&tr("chatgpt.file_fallback"), false))?; + } + private::write( + &root.join("profile.yaml"), + &config::profile(&config, root)?, + false, + )?; + config.save(root)?; + if let Some(old) = &existing { + if old.secret_store != config.secret_store { + if secret::remove(root, &old.secret_store).is_ok() { + secret::forget_store(root, &old.secret_store)?; + } else { + // The new credential/config is committed. An unavailable old + // keyring must not disable the explicitly approved fallback. + println!("[WARN] Previous secret store unavailable; its recovery journal was retained. Restore the keyring before reset."); + } + } + } + doctor(root, &config)?; + println!("{}", tr("chatgpt.setup_ready")); + Ok(()) +} + +fn client_version(root: &Path) -> Result<()> { + install::verify_installed(root)?; + let mut cmd = process::command(&root.join("tunnel-client")); + cmd.arg("--version"); + let output = process::capture(cmd, Duration::from_secs(10), 4096)?; + let output = + std::str::from_utf8(&output).map_err(|_| "Invalid tunnel-client version output")?; + if !output + .split_whitespace() + .any(|s| s.trim_start_matches('v').split('+').next() == Some(install::TESTED_VERSION)) + { + return Err("Unsupported tunnel-client version; run zai chatgpt repair".into()); + } + println!( + "[PASS] Tunnel client (tested/supported: {}; detected: {})", + install::TESTED_VERSION, + install::TESTED_VERSION + ); + Ok(()) +} + +fn preflight(root: &Path, config: &Config) -> Result<()> { + config.validate()?; + config.validate_runtime_paths()?; + config::verify_profile(config, root)?; + install::verify_installed(root)?; + if !config.executable.is_file() || !config.workspace.is_dir() { + return Err("MCP executable/workspace missing; run zai chatgpt repair".into()); + } + if config + .workspace + .canonicalize() + .map_err(|_| "Workspace unavailable")? + != config.workspace + || root.starts_with(&config.workspace) + || config.workspace.starts_with(root) + { + return Err("Workspace path changed or includes credentials; rerun setup".into()); + } + docker::validate_endpoint(&config.docker_endpoint)?; + if docker::image(&config.docker, &config.docker_endpoint, &config.image)? != config.image { + return Err("Agent image identity changed".into()); + } + Ok(()) +} + +fn doctor(root: &Path, config: &Config) -> Result<()> { + println!("Zaivern ChatGPT doctor"); + private::directory(root)?; + print!("{}", cleanup::report(root)?); + daemon::ensure_clean(root).or_else(|error| { + if daemon::request(root, "status").is_ok() { + Ok(()) + } else { + Err(error) + } + })?; + preflight(root, config)?; + println!("[PASS] configuration / filesystem permissions\n[PASS] workspace\n[PASS] Docker local socket\n[PASS] immutable Agent image\n[PASS] tunnel ID / profile / MCP executable"); + client_version(root)?; + let key = secret::load(root, &config.secret_store)?; + println!("[PASS] Runtime key available (value hidden)"); + let mut cmd = daemon::tunnel_command(root, config, &key, "doctor")?; + cmd.args(["--explain", "--json"]); + let (success, bytes) = process::capture_status(cmd, Duration::from_secs(60), 1024 * 1024)?; + let bytes = zeroize::Zeroizing::new(bytes); + let report = doctor_report(&bytes)?; + print!("{report}"); + if !success { + return Err("Tunnel doctor failed. Check profile/MCP executable/health listener. Missing Runtime key: zai chatgpt setup --reauth. Live authentication is checked after start.".into()); + } + println!("[PASS] tunnel-client doctor"); + probe::discovery(config)?; + for endpoint in ["healthz", "readyz"] { + match daemon::health(root, endpoint) { + Ok(true) => println!("[PASS] {endpoint}"), + Ok(false) => { + return Err(format!( + "{endpoint} failed; check Runtime key, Tunnel permission and outbound HTTPS" + )) + } + Err(_) => { + println!("[WARN] {endpoint}: bridge stopped/unavailable; run zai chatgpt start") + } + } + } + live_status(root, config); + println!("[WARN] ChatGPT Connector: invoke Zaivern once in a normal ChatGPT chat."); + Ok(()) +} + +fn status(root: &Path) -> Result<()> { + let config = Config::load(root)?; + println!("ChatGPT Bridge"); + print!("{}", cleanup::report(root)?); + match daemon::request(root, "status") { + Ok(pid) => println!("Tunnel group running (owned leader PID {pid})"), + Err(_) => println!("Tunnel group stopped/unknown"), + } + println!( + "Health {}", + if daemon::health(root, "healthz").unwrap_or(false) { + "live" + } else { + "unknown" + } + ); + println!( + "Ready {}", + if daemon::health(root, "readyz").unwrap_or(false) { + "ready" + } else { + "unknown" + } + ); + live_status(root, &config); + println!("Workspace {}\nAgent image {}\nTunnel {}\nConnector unknown (manual ChatGPT check)",config.workspace.display(),config.image,config.tunnel_id); + Ok(()) +} + +fn repair(root: &Path) -> Result<()> { + let _operation = private::Lock::acquire(root, "operation.lock")?; + let _runtime = private::Lock::acquire(root, "runtime.lock") + .map_err(|_| "Stop the bridge before repair")?; + let mut config = Config::load(root)?; + cleanup::reconcile(root, &config)?; + install::install(root)?; + config.executable = std::env::current_exe() + .and_then(|p| p.canonicalize()) + .map_err(|_| "Cannot resolve zai")?; + let (docker, endpoint) = docker::detect(&config.workspace)?; + config.docker = docker; + config.docker_endpoint = endpoint; + if docker::image(&config.docker, &config.docker_endpoint, &config.image).is_err() { + if !confirm("Configured immutable image missing. Pull its original image name and update the image ID?",false)? { return Err("Repair cancelled; original config preserved".into()); } + config.image = docker::pull( + &config.docker, + &config.docker_endpoint, + &config.image_source, + )?; + } + private::write( + &root.join("profile.yaml"), + &config::profile(&config, root)?, + false, + )?; + config.save(root)?; + doctor(root, &config) +} + +fn reset(root: &Path) -> Result<()> { + let _operation = private::Lock::acquire(root, "operation.lock")?; + let _runtime = private::Lock::acquire(root, "runtime.lock") + .map_err(|_| "Stop the bridge before reset: zai chatgpt stop")?; + daemon::ensure_clean(root)?; + let mut stores = secret::stores(root)?; + if let Ok(config) = Config::load(root) { + if !stores.contains(&config.secret_store) { + stores.push(config.secret_store); + } + } + println!("{}", tr("chatgpt.reset_summary")); + if !confirm(&tr("chatgpt.continue"), false)? { + return Ok(()); + } + for backend in stores { + secret::remove(root, &backend)?; + secret::forget_store(root, &backend)?; + } + for name in [ + "profile.yaml", + "config.json", + "runtime.json", + "health-url", + "secret-stores.json", + "cleanup-pending.json", + "active-generation", + "mcp.done", + "shutdown.json", + ] { + private::remove(&root.join(name))?; + } + println!("ChatGPT setup removed."); + Ok(()) +} + +fn test(root: &Path) -> Result<()> { + let config = if private::exists_checked(&root.join("config.json"))? { + let config = Config::load(root)?; + preflight(root, &config)?; + if secret::load(root, &config.secret_store).is_ok() { + doctor(root, &config)?; + } else { + println!("[WARN] Runtime key unavailable: tunnel doctor skipped; local E2E requires no API key."); + } + config + } else { + println!("[WARN] Setup not present: running local E2E only, without tunnel credentials."); + let (docker, docker_endpoint) = + docker::detect(&std::env::current_dir().map_err(|_| "Cannot locate workspace")?)?; + Config { + version: 1, + workspace: std::env::temp_dir(), + executable: std::env::current_exe().map_err(|_| "Cannot locate zai")?, + image: String::new(), + image_source: String::new(), + docker, + docker_endpoint, + tunnel_id: String::new(), + secret_store: String::new(), + client_version: install::TESTED_VERSION.into(), + } + }; + probe::local_test(&config)?; + for endpoint in ["healthz", "readyz"] { + println!( + "[{}] Tunnel {endpoint}", + if daemon::health(root, endpoint).unwrap_or(false) { + "PASS" + } else { + "WARN: not confirmed" + } + ); + } + println!("{}", tr("chatgpt.manual_check")); + Ok(()) +} + +fn mcp_exec(root: &Path) -> Result<()> { + use std::os::unix::process::CommandExt; + process::disable_core_dumps()?; + let config = Config::load(root)?; + if config::validate_executable(&config.executable, &config.workspace)? != config.executable { + return Err("Configured MCP executable changed; run zai chatgpt repair".into()); + } + let generation = std::env::var("ZAIVERN_CHATGPT_GENERATION") + .ok() + .filter(|s| cleanup::valid_nonce(s)) + .ok_or("Missing managed MCP launch generation")?; + let mut command = process::command(&config.executable); + command + .env("PATH", "/usr/bin:/bin") + .env_remove("DOCKER_HOST") + .env_remove("DOCKER_CONTEXT") + // exec must retain the tunnel's owned group, not become a new leader. + .process_group(unsafe { libc::getpgrp() }); + command + .args(["chatgpt", "__serve"]) + .arg(root) + .env("ZAIVERN_CHATGPT_GENERATION", generation) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::null()); + // The full client closes stdin and also sends TERM. Preserve EOF-driven + // ChatBridge::drop cancellation instead of killing Rust before cleanup. + unsafe { + command.pre_exec(|| { + libc::signal(libc::SIGTERM, libc::SIG_IGN); + libc::signal(libc::SIGINT, libc::SIG_IGN); + Ok(()) + }); + } + let _ = command.exec(); + Err("Cannot execute sanitized MCP target".into()) +} + +fn managed_serve(root: &Path) -> Result<()> { + if std::env::var_os("CONTROL_PLANE_API_KEY").is_some() + || std::env::var_os("OPENAI_API_KEY").is_some() + { + return Err("Managed MCP must be launched through its sanitized entry point".into()); + } + let _execution = private::Lock::acquire(root, "mcp.lock")?; + let generation = std::env::var("ZAIVERN_CHATGPT_GENERATION") + .ok() + .filter(|s| cleanup::valid_nonce(s)) + .ok_or("Missing managed MCP launch generation")?; + let config = Config::load(root)?; + config.validate_runtime_paths()?; + let cleanup = std::sync::Arc::new(cleanup::Cleanup::load(root, &config)?); + // The child can exec before its supervisor has committed runtime.json. + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if daemon::generation_running(root, &generation) { + break; + } + if std::time::Instant::now() >= deadline { + return Err("Managed supervisor unavailable; MCP admission denied".into()); + } + std::thread::sleep(Duration::from_millis(25)); + } + cleanup.admit(&generation)?; + crate::features::chat_bridge::imp::serve_managed( + config.workspace, + config.image, + config.docker, + config.docker_endpoint, + cleanup.clone(), + )?; + // The bridge has joined its worker. Only verified resource absence can + // commit a receipt; a failed server leaves its journal for repair. + cleanup.finish(false) +} + +fn doctor_report(bytes: &[u8]) -> Result { + let value: serde_json::Value = + serde_json::from_slice(bytes).map_err(|_| "Invalid tunnel-client doctor report")?; + if !matches!(value["result"].as_str(), Some("ok" | "fail")) { + return Err("Missing tunnel doctor result".into()); + } + let checks = value["checks"] + .as_array() + .ok_or("Missing tunnel doctor checks")?; + let mut result = String::new(); + // Summaries/evidence/next are untrusted and may contain credentials. Only + // fixed check names and fixed status words are allowed into diagnostics. + for id in [ + "config_source", + "profile_load", + "tunnel_id", + "control_plane_api_key", + "mcp_target", + "mcp_command_executable", + "health_listener", + ] { + if let Some(check) = checks.iter().find(|c| c["id"] == id) { + let state = match check["status"].as_str() { + Some("PASS") => "PASS", + Some("FAIL") => "FAIL", + _ => "WARN", + }; + result.push_str(&format!("[{state}] {id}\n")); + } + } + if result.is_empty() { + return Err("Unrecognized doctor checks".into()); + } + Ok(result) +} + +fn live_status(root: &Path, config: &Config) { + match daemon::live_status(root, &config.tunnel_id) { + Ok((control,mcp)) => { + println!("[{}] Control Plane metadata", if control {"PASS"} else {"WARN: unavailable; check Runtime key, organization and Tunnels Read/Use permission"}); + println!("[{}] MCP main channel: stdio",if mcp {"PASS"} else {"WARN: not connected"}); + } + Err(_) => println!("[WARN] Control Plane / MCP live channel unavailable; start the bridge and rerun doctor"), + } +} diff --git a/src/chatgpt/private.rs b/src/chatgpt/private.rs new file mode 100644 index 0000000..a1530e7 --- /dev/null +++ b/src/chatgpt/private.rs @@ -0,0 +1,259 @@ +//! Private, bounded, link-resistant storage. Never adopt or chmod unsafe files. +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; + +pub(super) type Result = std::result::Result; + +#[cfg(test)] +thread_local! { + static METADATA_FAILURE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +fn metadata(path: &Path) -> std::io::Result { + #[cfg(test)] + if METADATA_FAILURE.with(|slot| slot.borrow().as_deref() == Some(path)) { + return Err(std::io::Error::from_raw_os_error(libc::EIO)); + } + fs::symlink_metadata(path) +} + +/// A link is present; only ENOENT proves absence. IO/permission failures cannot +/// authorize replacing a journal or treating an old generation as clean. +pub(super) fn exists_checked(path: &Path) -> Result { + match metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err("Cannot inspect managed state; existing evidence preserved".into()), + } +} + +#[cfg(test)] +pub(super) fn with_metadata_failure(path: &Path, action: impl FnOnce() -> T) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + METADATA_FAILURE.with(|slot| *slot.borrow_mut() = self.0.take()); + } + } + let previous = METADATA_FAILURE.with(|slot| slot.replace(Some(path.into()))); + let _restore = Restore(previous); + action() +} + +pub(super) fn directory(path: &Path) -> Result<()> { + if !path.is_absolute() { + return Err("ChatGPT state directory must be absolute".into()); + } + if !exists_checked(path)? { + fs::DirBuilder::new() + .mode(0o700) + .create(path) + .map_err(|_| "Cannot create private ChatGPT directory")?; + } + let m = fs::symlink_metadata(path).map_err(|_| "Cannot inspect ChatGPT directory")?; + if !m.is_dir() || m.uid() != unsafe { libc::geteuid() } || m.mode() & 0o077 != 0 { + return Err("ChatGPT directory must be owned by you, mode 0700, and not a symlink".into()); + } + Ok(()) +} + +fn validate(file: &File, executable: bool) -> Result<()> { + let m = file.metadata().map_err(|_| "Cannot inspect private file")?; + let forbidden = if executable { 0o022 } else { 0o077 }; + if !m.is_file() + || m.nlink() != 1 + || m.uid() != unsafe { libc::geteuid() } + || m.mode() & forbidden != 0 + { + return Err( + "Unsafe file: expected an owned regular file without links or shared write access" + .into(), + ); + } + Ok(()) +} + +pub(super) fn open(path: &Path, executable: bool) -> Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path) + .map_err(|_| "Cannot open private file (missing or symlink)")?; + validate(&file, executable)?; + Ok(file) +} + +pub(super) fn read(path: &Path, limit: usize) -> Result> { + let mut bytes = Vec::new(); + open(path, false)? + .take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Cannot read private file")?; + if bytes.len() > limit { + return Err("Private file exceeds size limit".into()); + } + Ok(bytes) +} + +pub(super) fn remove(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(_) => { + open(path, false)?; + fs::remove_file(path).map_err(|_| "Cannot remove managed private file".into()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("Cannot inspect managed file before removal".into()), + } +} + +pub(super) fn write(path: &Path, bytes: &[u8], executable: bool) -> Result<()> { + directory(path.parent().ok_or("Missing private parent")?)?; + if exists_checked(path)? { + open(path, executable)?; + } + let tmp = path.with_extension(format!("{}.tmp", nonce()?)); + let outcome = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(if executable { 0o700 } else { 0o600 }) + .open(&tmp) + .map_err(|_| "Cannot create private temporary file")?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|_| "Cannot write private file")?; + fs::rename(&tmp, path).map_err(|_| "Cannot install private file")?; + File::open(path.parent().ok_or("Missing private parent")?) + .and_then(|file| file.sync_all()) + .map_err(|_| "Cannot persist private directory update")?; + Ok(()) + })(); + if outcome.is_err() { + let _ = fs::remove_file(tmp); + } + outcome +} + +pub(super) fn nonce() -> Result { + let mut bytes = [0u8; 32]; + File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut bytes)) + .map_err(|_| "OS randomness unavailable")?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} + +pub(super) struct Lock(File); +impl Lock { + /// Observe an existing lock without adopting an unsafe file or conflating + /// permission/IO errors with contention. This grants no lifecycle authority. + pub(super) fn held(root: &Path, name: &str) -> Result { + directory(root)?; + let file = open(&root.join(name), false)?; + let fd = std::os::fd::AsRawFd::as_raw_fd(&file); + if unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) } == 0 { + // File close releases this briefly acquired observational lock. + return Ok(false); + } + if std::io::Error::last_os_error().kind() == std::io::ErrorKind::WouldBlock { + Ok(true) + } else { + Err("Cannot inspect ChatGPT runtime lock".into()) + } + } + + pub(super) fn acquire(root: &Path, name: &str) -> Result { + directory(root)?; + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(root.join(name)) + .map_err(|_| "Cannot open ChatGPT lock")?; + validate(&file, false)?; + if unsafe { + libc::flock( + std::os::fd::AsRawFd::as_raw_fd(&file), + libc::LOCK_EX | libc::LOCK_NB, + ) + } != 0 + { + return Err("ChatGPT operation already running; use zai chatgpt status".into()); + } + Ok(Self(file)) + } +} +impl Drop for Lock { + fn drop(&mut self) { + unsafe { + libc::flock(std::os::fd::AsRawFd::as_raw_fd(&self.0), libc::LOCK_UN); + } + } +} + +pub(super) fn root() -> Result { + let base = crate::config::zaivern_dir(); + if !base.is_absolute() { + return Err("ZAIVERN_HOME must be absolute".into()); + } + if !exists_checked(&base)? { + fs::DirBuilder::new() + .mode(0o700) + .create(&base) + .map_err(|_| "Cannot create Zaivern directory")?; + } + let m = fs::symlink_metadata(&base).map_err(|_| "Cannot inspect Zaivern directory")?; + if !m.is_dir() || m.uid() != unsafe { libc::geteuid() } || m.mode() & 0o022 != 0 { + return Err( + "Zaivern directory must be owned by you and not writable by others or a symlink".into(), + ); + } + let root = base + .canonicalize() + .map_err(|_| "Cannot resolve Zaivern directory")? + .join("chatgpt"); + directory(&root)?; + Ok(root) +} + +#[cfg(test)] +mod metadata_tests { + use super::*; + use std::os::unix::fs::{symlink, PermissionsExt}; + + #[test] + fn metadata_errors_never_prove_clean_state_or_authorize_overwrite() { + let root = crate::test_util::unique_temp_dir("chatgpt", "metadata-failure"); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(super::super::daemon::ensure_clean(&root).is_ok()); + for name in [ + "cleanup-prepared.json", + "cleanup-pending.json", + "active-generation", + ] { + with_metadata_failure(&root.join(name), || { + assert!(super::super::daemon::ensure_clean(&root).is_err(), "{name}"); + }); + assert!(!root.join("mcp.done").exists()); + assert!(!root.join("shutdown.json").exists()); + } + let journal = root.join("secret-stores.json"); + write(&journal, b"[\"private-file\"]", false).unwrap(); + with_metadata_failure(&journal, || { + assert!(super::super::secret::stores(&root).is_err()); + assert!(super::super::secret::remember_store(&root, "secret-service").is_err()); + assert!(write(&journal, b"replacement", false).is_err()); + }); + assert_eq!(read(&journal, 1024).unwrap(), b"[\"private-file\"]"); + let dangling = root.join("dangling"); + symlink(root.join("absent"), &dangling).unwrap(); + assert!(exists_checked(&dangling).unwrap()); + assert!(write(&dangling, b"must not follow", false).is_err()); + assert!(!exists_checked(&root.join("absent")).unwrap()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/chatgpt/probe.rs b/src/chatgpt/probe.rs new file mode 100644 index 0000000..60a2866 --- /dev/null +++ b/src/chatgpt/probe.rs @@ -0,0 +1,313 @@ +use super::{ + config::Config, + private::{self, Result}, + process, +}; +use serde_json::{json, Value}; +use std::io::{BufRead, Read, Write}; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +struct Mcp { + child: process::OwnedProcessGroup, + input: Option, + finished: Option>, + replies: std::sync::mpsc::Receiver>, + id: u64, +} +impl Mcp { + fn start(config: &Config) -> Result { + config.validate_runtime_paths()?; + let mut cmd = process::command(&config.executable); + cmd.env("PATH", "/usr/bin:/bin"); + cmd.args(["chatgpt", "__probe"]) + .arg(&config.workspace) + .arg(&config.image) + .arg(&config.docker) + .arg(&config.docker_endpoint) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + let mut child = process::OwnedProcessGroup::spawn(&mut cmd)?; + let output = child.take_stdout()?; + let input = Some(child.take_lease()?); + let (tx, replies) = std::sync::mpsc::sync_channel(8); + std::thread::spawn(move || { + let mut reader = std::io::BufReader::new(output); + loop { + let mut line = Vec::new(); + match (&mut reader) + .take(256 * 1024 + 1) + .read_until(b'\n', &mut line) + { + Ok(n) if n > 0 && n <= 256 * 1024 => { + if tx.send(line).is_err() { + break; + } + } + _ => break, + } + } + }); + Ok(Self { + child, + input, + finished: None, + replies, + id: 0, + }) + } + fn send(&mut self, value: Value) -> Result<()> { + let input = self.input.as_mut().ok_or("MCP stdin closed")?; + writeln!(input, "{value}") + .and_then(|_| input.flush()) + .map_err(|_| "MCP request failed".into()) + } + fn call(&mut self, method: &str, params: Value) -> Result { + self.id += 1; + self.send(json!({"jsonrpc":"2.0","id":self.id,"method":method,"params":params}))?; + let raw = self + .replies + .recv_timeout(Duration::from_secs(15)) + .map_err(|_| "MCP response timeout; check Docker and workspace")?; + let value: Value = serde_json::from_slice(&raw).map_err(|_| "Invalid MCP JSON response")?; + if value["jsonrpc"] != "2.0" || value["id"] != self.id || value.get("error").is_some() { + return Err("MCP response failed protocol validation".into()); + } + value + .get("result") + .cloned() + .ok_or_else(|| "MCP result missing".into()) + } + fn discover(&mut self) -> Result<()> { + let init = self.call("initialize",json!({"protocolVersion":"2025-11-25","clientInfo":{"name":"zaivern-doctor","version":env!("CARGO_PKG_VERSION")},"capabilities":{}}))?; + if init["protocolVersion"] != "2025-11-25" || init["capabilities"].get("tools").is_none() { + return Err("MCP initialize capability/version mismatch".into()); + } + println!("[PASS] MCP initialize"); + self.send(json!({"jsonrpc":"2.0","method":"notifications/initialized"}))?; + let discovery = self.call("server/discover", json!({}))?; + if discovery["resultType"] != "complete" || discovery["capabilities"].get("tools").is_none() + { + return Err("MCP discovery failed".into()); + } + println!("[PASS] server/discover"); + let tools = self.call("tools/list", json!({}))?; + validate_tools(&tools)?; + println!("[PASS] tools/list (exactly 3 public tools)"); + Ok(()) + } + fn tool(&mut self, name: &str, args: Value) -> Result { + let value = self.call("tools/call", json!({"name":name,"arguments":args}))?; + if value["isError"] != false { + return Err("MCP tool reported an error".into()); + } + serde_json::from_str( + value["content"][0]["text"] + .as_str() + .ok_or("Missing MCP tool content")?, + ) + .map_err(|_| "Invalid MCP tool content".into()) + } + fn finish(&mut self) -> Result<()> { + if let Some(result) = &self.finished { + return result.clone(); + } + let result = self.finish_inner(); + self.finished = Some(result.clone()); + result + } + fn finish_inner(&mut self) -> Result<()> { + self.input.take(); + let deadline = Instant::now() + Duration::from_secs(35); + while !self.child.exited()? { + if Instant::now() >= deadline { + self.child.reclaim()?; + return Err("MCP probe shutdown timed out; Docker cleanup is not confirmed".into()); + } + std::thread::sleep(Duration::from_millis(25)); + } + if !self.child.reclaim()?.success() { + return Err("MCP probe failed or Docker cleanup is unconfirmed".into()); + } + Ok(()) + } +} +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.finish(); + } +} + +fn validate_tools(value: &Value) -> Result<()> { + let tools = value["tools"].as_array().ok_or("Missing MCP tools list")?; + let mut names = tools + .iter() + .filter_map(|t| t["name"].as_str()) + .collect::>(); + names.sort_unstable(); + if names + != [ + "zaivern_cancel_task", + "zaivern_run_task", + "zaivern_task_status", + ] + || tools.len() != 3 + || tools.iter().any(|t| t["inputSchema"]["type"] != "object") + { + return Err("MCP public tool boundary/schema mismatch".into()); + } + Ok(()) +} + +pub(super) fn discovery(config: &Config) -> Result<()> { + let mut mcp = Mcp::start(config)?; + mcp.discover()?; + mcp.finish() +} + +struct Temporary(PathBuf); +impl Temporary { + fn new() -> Result { + let path = std::env::temp_dir().join(format!("zai-chatgpt-{}", private::nonce()?)); + private::directory(&path)?; + Ok(Self(path)) + } +} +impl Drop for Temporary { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +pub(super) fn local_test(config: &Config) -> Result<()> { + let temporary = Temporary::new()?; + let build = temporary.0.join("fixture"); + private::directory(&build)?; + private::write( + &build.join("Dockerfile"), + include_bytes!("../../tools/mcp-fixture.Dockerfile"), + false, + )?; + private::write( + &build.join("mcp-fixture.rs"), + include_bytes!("../../tools/mcp-fixture.rs"), + false, + )?; + println!("Building isolated deterministic ACP fixture (no model/API key)..."); + let mut cmd = super::docker::command(&config.docker, &config.docker_endpoint); + cmd.args(["build", "--quiet"]).arg(&build); + let bytes = process::capture(cmd, Duration::from_secs(600), 4 * 1024 * 1024)?; + let image = std::str::from_utf8(&bytes) + .map_err(|_| "Invalid fixture build output")? + .lines() + .last() + .ok_or("Missing fixture image ID")?; + let image = super::docker::image(&config.docker, &config.docker_endpoint, image)?; + let workspace = temporary.0.join("workspace"); + private::directory(&workspace)?; + private::directory(&workspace.join("src"))?; + private::write( + &workspace.join("Cargo.toml"), + b"[package]\nname='bridge_fixture'\nversion='0.1.0'\nedition='2021'\n", + false, + )?; + private::write( + &workspace.join("Cargo.lock"), + b"version = 4\n[[package]]\nname='bridge_fixture'\nversion='0.1.0'\n", + false, + )?; + private::write( + &workspace.join("src/lib.rs"), + b"#[test]\nfn arithmetic() { assert_eq!(2 + 2, 5); }\n", + false, + )?; + private::write(&workspace.join(".env"), b"SENTINEL=not-for-agent\n", false)?; + let mut isolated = config.clone(); + isolated.image = image; + isolated.workspace = workspace.clone(); + let mut mcp = Mcp::start(&isolated)?; + mcp.discover()?; + let task = mcp.tool( + "zaivern_run_task", + json!({"instruction":"Fix the failing test and show the diff"}), + )?; + println!("[PASS] zaivern_run_task"); + let status = wait(&mut mcp, &task, false)?; + if status["state"] != "completed" + || status["test_status"] != "passed" + || status["diff_summary"].as_str().is_none_or(str::is_empty) + || std::fs::read_to_string(workspace.join("src/lib.rs")) + .map_err(|_| "Cannot read fixture result")? + .contains("2 + 2, 5") + { + return Err("Fixture edit/test/diff verification failed".into()); + } + println!("[PASS] zaivern_task_status\n[PASS] file edit\n[PASS] offline test\n[PASS] diff"); + let task = mcp.tool("zaivern_run_task", json!({"instruction":"WAIT_FOREVER"}))?; + wait(&mut mcp, &task, true)?; + mcp.tool("zaivern_cancel_task", task.clone())?; + if wait(&mut mcp, &task, false)?["state"] != "cancelled" { + return Err("Fixture cancel failed".into()); + } + println!("[PASS] cancel"); + mcp.finish()?; + drop(mcp); + let path = temporary.0.clone(); + drop(temporary); + if super::private::exists_checked(&path)? { + return Err("Temporary workspace cleanup failed".into()); + } + println!("[PASS] temporary workspace cleanup\nLocal E2E: PASS"); + Ok(()) +} + +fn wait(mcp: &mut Mcp, task: &Value, running: bool) -> Result { + let deadline = Instant::now() + Duration::from_secs(180); + let mut reported = Instant::now(); + loop { + let status = mcp.tool("zaivern_task_status", task.clone())?; + let terminal = matches!( + status["state"].as_str(), + Some("completed" | "cancelled" | "failed") + ); + if (running && status["progress"] == "agent executing") || (!running && terminal) { + return Ok(status); + } + if terminal || Instant::now() >= deadline { + return Err("Fixture task failed or timed out".into()); + } + if reported.elapsed() >= Duration::from_secs(10) { + println!("Waiting for isolated fixture..."); + reported = Instant::now(); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn probe_cannot_report_cleanup_success_after_mcp_failure() { + for code in [0, 1] { + let mut cmd = process::command(Path::new("/bin/sh")); + cmd.args(["-c", &format!("exit {code}")]) + .stdin(Stdio::piped()); + let mut child = process::OwnedProcessGroup::spawn(&mut cmd).unwrap(); + let input = Some(child.take_lease().unwrap()); + let (_tx, replies) = std::sync::mpsc::sync_channel(1); + let mut mcp = Mcp { + child, + input, + replies, + id: 0, + finished: None, + }; + assert_eq!(mcp.finish().is_ok(), code == 0); + assert_eq!(mcp.finish().is_ok(), code == 0); + } + } +} diff --git a/src/chatgpt/process.rs b/src/chatgpt/process.rs new file mode 100644 index 0000000..562cca7 --- /dev/null +++ b/src/chatgpt/process.rs @@ -0,0 +1,1188 @@ +//! Child ownership, bounded output, and an explicit environment allowlist. +use super::private::Result; +use std::io::Read; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +pub(super) fn command(path: &Path) -> Command { + let mut command = Command::new(path); + command + .env_clear() + .env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin"); + for name in [ + "HOME", + "TMPDIR", + "LANG", + "LC_ALL", + "ZAIVERN_HOME", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + ] { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0); + command +} + +#[cfg(test)] +pub(super) struct OwnedChild { + pub(super) child: Child, + reaped: bool, +} +#[cfg(test)] +impl OwnedChild { + pub(super) fn spawn(command: &mut Command) -> Result { + Ok(Self { + child: command.spawn().map_err(|_| { + "Cannot start child process; check executable permissions and macOS quarantine" + })?, + reaped: false, + }) + } + pub(super) fn exited(&mut self) -> Result> { + let status = self + .child + .try_wait() + .map_err(|_| "Cannot inspect owned child")?; + if status.is_some() { + self.reaped = true; + } + Ok(status) + } + pub(super) fn stop(&mut self) { + // The unreaped Child reserves the PID. Never signal an arbitrary saved PID. + if !self.reaped { + crate::procx::kill_tree(self.child.id()); + let _ = self.child.wait(); + self.reaped = true; + } + } +} +#[cfg(test)] +impl Drop for OwnedChild { + fn drop(&mut self) { + self.stop(); + } +} + +/// Own the tunnel's process group until its descendants have been reclaimed. +/// Unlike `OwnedChild`, exit observation MUST NOT reap the leader: its reserved +/// PID also reserves the PGID, including while the leader is a zombie. +pub(super) struct OwnedProcessGroup { + child: Child, + reserved: bool, + status: Option, + forced: bool, +} + +impl OwnedProcessGroup { + pub(super) fn spawn(command: &mut Command) -> Result { + command.process_group(0); + let child = command + .spawn() + .map_err(|_| "Cannot start owned tunnel process group")?; + Ok(Self { + child, + reserved: true, + status: None, + forced: false, + }) + } + + pub(super) fn id(&self) -> u32 { + self.child.id() + } + + pub(super) fn take_lease(&mut self) -> Result { + self.child + .stdin + .take() + .ok_or_else(|| "Missing guardian lifetime lease".into()) + } + + pub(super) fn take_stdout(&mut self) -> Result { + self.child + .stdout + .take() + .ok_or_else(|| "Missing child output".into()) + } + + #[cfg(any(not(target_os = "macos"), test))] + pub(super) fn take_stderr(&mut self) -> Result { + self.child + .stderr + .take() + .ok_or_else(|| "Missing child error output".into()) + } + + pub(super) fn exited(&self) -> Result { + if !self.reserved { + return Ok(true); + } + let mut info = std::mem::MaybeUninit::::zeroed(); + loop { + let result = unsafe { + libc::waitid( + libc::P_PID, + self.child.id() as libc::id_t, + info.as_mut_ptr(), + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result == 0 { + break; + } + if std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted { + return Err( + "Cannot inspect reserved tunnel leader; cleanup state preserved".into(), + ); + } + } + Ok(unsafe { info.assume_init().si_pid() } != 0) + } + + pub(super) fn reclaim(&mut self) -> Result { + if !self.reserved { + return self + .status + .ok_or_else(|| "Missing owned process exit status".into()); + } + // Verify that wait ownership still exists immediately before signalling. + // ECHILD is NOT permission to use a possibly recycled PID/PGID. + self.exited()?; + if unsafe { libc::killpg(self.id() as libc::pid_t, libc::SIGKILL) } != 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::ESRCH) + && !(error.raw_os_error() == Some(libc::EPERM) && self.zombie_is_only_member()?) + { + return Err("Cannot reclaim owned tunnel group; cleanup state preserved".into()); + } + } + let deadline = Instant::now() + Duration::from_secs(5); + while !self.exited()? { + if Instant::now() >= deadline { + return Err( + "Owned tunnel termination is still pending; cleanup state preserved".into(), + ); + } + std::thread::sleep(Duration::from_millis(25)); + } + let status = self + .child + .wait() + .map_err(|_| "Cannot reap owned tunnel leader")?; + self.reserved = false; + self.status = Some(status); + Ok(status) + } + + fn zombie_is_only_member(&self) -> Result { + #[cfg(target_os = "macos")] + { + // Darwin killpg returns EPERM for a zombie-only group. Do not treat + // arbitrary EPERM as absence: WNOWAIT must prove our leader exited, + // and libproc must list exactly that reserved leader, nobody else. + if !self.exited()? { + return Ok(false); + } + const PROC_PGRP_ONLY: u32 = 2; // Darwin sys/proc_info.h. + let mut members = [0 as libc::pid_t; 2]; + let bytes = unsafe { + libc::proc_listpids( + PROC_PGRP_ONLY, + self.id(), + members.as_mut_ptr().cast(), + std::mem::size_of_val(&members) as libc::c_int, + ) + }; + Ok(bytes == std::mem::size_of::() as libc::c_int + && members[0] == self.id() as libc::pid_t) + } + #[cfg(not(target_os = "macos"))] + { + Ok(false) + } + } + + pub(super) fn stop_gracefully( + &mut self, + timeout: Duration, + mut cleanup_guard: impl FnMut() -> Option, + ) -> Result<()> { + if !self.reserved { + return self.clean_exit(); + } + if !self.exited()? + && unsafe { libc::kill(self.id() as libc::pid_t, libc::SIGTERM) } != 0 + && std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) + { + return Err("Cannot request tunnel shutdown; cleanup state preserved".into()); + } + let deadline = Instant::now() + timeout; + loop { + if self.exited()? { + if let Some(_guard) = cleanup_guard() { + // Hold mcp.lock and verified cleanup evidence across the + // final sweep. A late, unadmitted MCP cannot start work. + self.reclaim()?; + return self.clean_exit(); + } + } + if Instant::now() >= deadline { + self.forced = true; + self.reclaim()?; + return Err("Tunnel shutdown timed out; forced termination required. Cleanup state preserved; run zai chatgpt repair.".into()); + } + std::thread::sleep(Duration::from_millis(25)); + } + } + + fn clean_exit(&self) -> Result<()> { + if !self.forced && self.status.is_some_and(|status| status.success()) { + Ok(()) + } else { + Err("Tunnel exited abnormally or required forced termination; cleanup evidence does not prove successful shutdown".into()) + } + } +} + +impl Drop for OwnedProcessGroup { + fn drop(&mut self) { + // Error and unwind paths retain the same unreaped-leader authority. + let _ = self.reclaim(); + } +} + +/// A second owner remains inside the group while the supervisor owns its +/// unreaped leader. Either owner's death is observed by the surviving owner. +/// The stdin lease has exactly one writer, held by the supervisor, and is never +/// inherited by tunnel-client or MCP. This function runs only in __tunnel. +pub(super) fn guard_tunnel( + command: impl FnOnce(&GuardianScope) -> Result, + timeout: Duration, + stop: &std::sync::atomic::AtomicBool, + mut cleanup_guard: impl FnMut() -> Option, +) -> Result<()> { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + let group = unsafe { libc::getpgrp() }; + if group != unsafe { libc::getpid() } { + return Err("Tunnel guardian must own its process group".into()); + } + struct GroupGuard(bool); + impl Drop for GroupGuard { + fn drop(&mut self) { + if self.0 { + // Membership itself pins the group identity, even if our parent + // died and was reaped. Never use a saved parent PID or PGID. + unsafe { + if libc::getpgrp() == libc::getpid() { + libc::killpg(libc::getpgrp(), libc::SIGKILL); + } + libc::_exit(1); + } + } + } + } + let mut owner = GroupGuard(true); + let parent_gone = Arc::new(AtomicBool::new(false)); + let observed = parent_gone.clone(); + // FD 0 is replaced with /dev/null for the client below; CLOEXEC also + // protects the lease from any other future exec in this helper. + if unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 { + return Err("Cannot protect guardian lifetime lease".into()); + } + std::thread::Builder::new() + .name("tunnel-lifetime".into()) + .spawn(move || { + let mut byte = [0u8; 1]; + loop { + match std::io::stdin().read(&mut byte) { + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + _ => break, + } + } + observed.store(true, Ordering::Release); + // The main thread can be blocked by Keychain/Secret Service or + // filesystem IO before the client exists. The lease monitor owns + // its own deadline and can reclaim its group without that thread. + std::thread::sleep(timeout); + drop(GroupGuard(true)); + }) + .map_err(|_| "Cannot monitor supervisor lifetime")?; + let mut command = command(&GuardianScope { group })?; + if parent_gone.load(Ordering::Acquire) { + return Err("Supervisor lifetime ended before tunnel startup".into()); + } + command.process_group(group).stdin(Stdio::null()); + let mut child = command + .spawn() + .map_err(|_| "Cannot start guarded tunnel client")?; + let mut deadline = None; + let mut status = None; + let mut requested = false; + loop { + if status.is_none() { + status = child + .try_wait() + .map_err(|_| "Cannot inspect guarded tunnel client")?; + } + let stopping = stop.load(Ordering::Relaxed) || parent_gone.load(Ordering::Acquire); + if stopping && !requested { + requested = true; + if status.is_none() { + // The directly owned, unreaped child reserves this PID. + if unsafe { libc::kill(child.id() as libc::pid_t, libc::SIGTERM) } != 0 + && std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) + { + return Err("Cannot request guarded tunnel shutdown".into()); + } + } + } + if stopping || status.is_some() { + let deadline = deadline.get_or_insert_with(|| Instant::now() + timeout); + if let Some(status) = status { + if let Some(_cleanup) = cleanup_guard() { + if parent_gone.load(Ordering::Acquire) { + // No supervisor remains to sweep the group. Drop kills + // our group including this helper; no receipt is forged. + return Err("Supervisor lifetime ended".into()); + } + owner.0 = false; + return if requested && status.success() { + Ok(()) + } else { + Err("Tunnel client exited unexpectedly or unsuccessfully".into()) + }; + } + } + if Instant::now() >= *deadline { + return Err("Guarded tunnel cleanup timed out; journal preserved".into()); + } + } + std::thread::sleep(Duration::from_millis(25)); + } +} + +/// Constructed only after guard_tunnel installed both lifetime owners. Helpers +/// in this scope must remain in that group, including during credential lookup. +pub(super) struct GuardianScope { + group: libc::pid_t, +} + +struct GuardianMember<'a> { + child: Child, + status: Option, + _scope: &'a GuardianScope, +} + +impl<'a> GuardianMember<'a> { + fn spawn(command: &mut Command, scope: &'a GuardianScope) -> Result { + if scope.group != unsafe { libc::getpid() } || scope.group != unsafe { libc::getpgrp() } { + return Err("Guardian helper ownership changed".into()); + } + command.process_group(scope.group); + let child = command + .spawn() + .map_err(|_| "Cannot start guardian helper")?; + Ok(Self { + child, + status: None, + _scope: scope, + }) + } + + fn exited(&mut self) -> Result { + if self.status.is_none() { + self.status = self + .child + .try_wait() + .map_err(|_| "Cannot inspect guardian helper")?; + } + Ok(self.status.is_some()) + } + + fn reclaim(&mut self) -> Result { + if !self.exited()? { + // This member does not own its process group. Only signal the + // unreaped Child; descendants remain under the guardian's sweep. + self.child + .kill() + .map_err(|_| "Cannot terminate owned guardian helper")?; + let deadline = Instant::now() + Duration::from_secs(5); + while !self.exited()? { + if Instant::now() >= deadline { + return Err("Guardian helper termination remains pending".into()); + } + std::thread::sleep(Duration::from_millis(25)); + } + } + self.status + .ok_or_else(|| "Missing guardian helper exit status".into()) + } +} + +impl Drop for GuardianMember<'_> { + fn drop(&mut self) { + let _ = self.reclaim(); + } +} + +enum CaptureChild<'a> { + Independent(OwnedProcessGroup), + Guarded(GuardianMember<'a>), +} + +impl CaptureChild<'_> { + fn take_stdout(&mut self) -> Result { + match self { + Self::Independent(child) => child.take_stdout(), + Self::Guarded(child) => child + .child + .stdout + .take() + .ok_or_else(|| "Missing helper output".into()), + } + } + fn exited(&mut self) -> Result { + match self { + Self::Independent(child) => child.exited(), + Self::Guarded(child) => child.exited(), + } + } + fn reclaim(&mut self) -> Result { + match self { + Self::Independent(child) => child.reclaim(), + Self::Guarded(child) => child.reclaim(), + } + } +} + +/// Request graceful shutdown of a directly owned child without forcing cleanup +/// to stop. `false` leaves the live Child with the caller; no saved PID is used. +pub(super) fn terminate_and_wait(child: &mut Child, timeout: Duration) -> Result { + // try_wait also handles a Child that was already reaped: never signal its + // potentially recycled PID. Otherwise the unreaped child reserves the PID. + if child + .try_wait() + .map_err(|_| "Cannot inspect owned child")? + .is_some() + { + return Ok(true); + } + if unsafe { libc::kill(child.id() as i32, libc::SIGTERM) } != 0 + && std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) + { + return Err("Cannot request supervisor shutdown; state preserved".into()); + } + let deadline = Instant::now() + timeout; + loop { + if child + .try_wait() + .map_err(|_| "Cannot inspect owned child")? + .is_some() + { + return Ok(true); // Reaped, but this is not a cleanup receipt. + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(false); + } + std::thread::sleep(remaining.min(Duration::from_millis(25))); + } +} + +pub(super) fn capture(mut command: Command, timeout: Duration, limit: usize) -> Result> { + command.stdout(Stdio::piped()).stderr(Stdio::null()); + let (success, bytes) = capture_status(command, timeout, limit)?; + if !success { + return Err("Child command failed; run zai chatgpt doctor".into()); + } + Ok(bytes) +} + +pub(super) fn capture_status( + command: Command, + timeout: Duration, + limit: usize, +) -> Result<(bool, Vec)> { + capture_status_scoped(command, timeout, limit, None) +} + +/// Capture bounded stderr while retaining the same owned-process-group +/// lifetime and timeout guarantees as the normal capture path. Callers must +/// classify the returned bytes locally; they must never surface them because +/// helper diagnostics can contain sensitive arguments or environment details. +#[cfg(any(not(target_os = "macos"), test))] +pub(super) fn capture_status_stderr( + mut command: Command, + timeout: Duration, + limit: usize, +) -> Result<(bool, Vec)> { + command.stdout(Stdio::null()).stderr(Stdio::piped()); + let mut child = OwnedProcessGroup::spawn(&mut command)?; + let stderr = child.take_stderr()?; + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let result = stderr.take(limit as u64 + 1).read_to_end(&mut bytes); + let _ = tx.send((result, bytes)); + }); + let start = Instant::now(); + loop { + if child.exited()? { + let (read, bytes) = rx + .recv_timeout(Duration::from_secs(1)) + .map_err(|_| "Child error output unavailable")?; + if read.is_err() || bytes.len() > limit { + return Err("Child error output exceeded limit or could not be read".into()); + } + let status = child.reclaim()?; + return Ok((status.success(), bytes)); + } + if start.elapsed() >= timeout { + return Err("Child command timed out".into()); + } + std::thread::sleep(Duration::from_millis(25)); + } +} + +#[cfg(any(not(target_os = "macos"), test))] +pub(super) fn capture_guarded( + scope: &GuardianScope, + command: Command, + timeout: Duration, + limit: usize, +) -> Result> { + let (success, bytes) = capture_status_scoped(command, timeout, limit, Some(scope))?; + if !success { + return Err("Guardian helper failed".into()); + } + Ok(bytes) +} + +fn capture_status_scoped( + mut command: Command, + timeout: Duration, + limit: usize, + scope: Option<&GuardianScope>, +) -> Result<(bool, Vec)> { + command.stdout(Stdio::piped()).stderr(Stdio::null()); + let mut child = match scope { + Some(scope) => CaptureChild::Guarded(GuardianMember::spawn(&mut command, scope)?), + None => CaptureChild::Independent(OwnedProcessGroup::spawn(&mut command)?), + }; + let stdout = child.take_stdout()?; + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let result = stdout.take(limit as u64 + 1).read_to_end(&mut bytes); + let _ = tx.send((result, bytes)); + }); + let start = Instant::now(); + let mut output = None; + loop { + if output.is_none() { + if let Ok((read, bytes)) = rx.try_recv() { + if read.is_err() || bytes.len() > limit { + return Err("Child output exceeded limit or could not be read".into()); + } + output = Some(bytes); + } + } + if child.exited()? { + // Only natural EOF proves complete output. Killing a pipe-holding + // descendant first could fabricate successful, truncated Docker + // listings. Independent captures keep their leader reserved; + // guarded captures remain inside the live guardian's group. + let output = output.or_else(|| { + rx.recv_timeout(Duration::from_secs(1)) + .ok() + .and_then(|(r, b)| (r.is_ok() && b.len() <= limit).then_some(b)) + }); + let status = child.reclaim()?; + return output + .map(|bytes| (status.success(), bytes)) + .ok_or_else(|| "Child output unavailable".into()); + } + if start.elapsed() >= timeout { + return Err("Child command timed out".into()); + } + std::thread::sleep(Duration::from_millis(25)); + } +} + +pub(super) fn disable_core_dumps() -> Result<()> { + let limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + if unsafe { libc::setrlimit(libc::RLIMIT_CORE, &limit) } != 0 { + return Err("Cannot disable core dumps before loading credentials".into()); + } + Ok(()) +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + use std::io::BufRead; + + const GROUP_FIXTURE: &str = "features::chatgpt::imp::process::tests::owned_group_fixture"; + static FIXTURE_STOP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + extern "C" fn fixture_stop(_: libc::c_int) { + FIXTURE_STOP.store(true, std::sync::atomic::Ordering::Relaxed); + } + + fn wait_until(mut predicate: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !predicate() { + assert!(Instant::now() < deadline, "process fixture timed out"); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn owned_group_fixture() { + use super::super::{cleanup::Cleanup, private, tests::fixture}; + let Some(root) = std::env::var_os("ZAIVERN_GROUP_FIXTURE_ROOT") else { + return; + }; + let root = std::path::PathBuf::from(root); + let role = std::env::var("ZAIVERN_GROUP_FIXTURE_ROLE").unwrap_or_default(); + if role == "credential-helper" { + let _lock = private::Lock::acquire(&root, "factory.lock").unwrap(); + private::write( + &root.join("helper-group"), + unsafe { libc::getpgrp() }.to_string().as_bytes(), + false, + ) + .unwrap(); + private::write(&root.join("factory-ready"), b"ready", false).unwrap(); + std::thread::sleep(Duration::from_secs(60)); + return; + } + if role == "capture" { + let mut cmd = command(&std::env::current_exe().unwrap()); + cmd.args(["--exact", GROUP_FIXTURE]) + .env("ZAIVERN_GROUP_FIXTURE_ROOT", &root) + .env("ZAIVERN_GROUP_FIXTURE_WORKER", "1") + .process_group(unsafe { libc::getpgrp() }) + .stdout(Stdio::inherit()); + let _worker = cmd.spawn().unwrap(); + wait_until(|| root.join("worker-ready").exists()); + println!("capture-payload"); + std::process::exit(0); // Worker still owns stdout and mcp.lock. + } + if role == "supervisor" { + let mut cmd = guardian_fixture_command(&root, true); + let blocked = std::env::var_os("ZAIVERN_GROUP_FIXTURE_BLOCK_FACTORY").is_some(); + if blocked { + cmd.env("ZAIVERN_GROUP_FIXTURE_ROLE", "blocked-factory"); + } + if std::env::var_os("ZAIVERN_GROUP_FIXTURE_BLOCK_HELPER").is_some() { + cmd.env("ZAIVERN_GROUP_FIXTURE_BLOCK_HELPER", "1"); + } + let mut guardian = OwnedProcessGroup::spawn(&mut cmd).unwrap(); + let _lease = guardian.take_lease().unwrap(); + wait_until(|| { + root.join(if blocked { + "factory-ready" + } else { + "leader-ready" + }) + .exists() + }); + private::write(&root.join("supervisor-ready"), b"ready", false).unwrap(); + wait_until(|| false); // Parent test SIGKILLs this directly owned fixture. + return; + } + if role == "blocked-factory" { + guard_tunnel( + |scope| { + if std::env::var_os("ZAIVERN_GROUP_FIXTURE_BLOCK_HELPER").is_some() { + private::write( + &root.join("guardian-group"), + scope.group.to_string().as_bytes(), + false, + ) + .unwrap(); + let mut cmd = command(&std::env::current_exe().unwrap()); + cmd.args(["--exact", GROUP_FIXTURE]) + .env("ZAIVERN_GROUP_FIXTURE_ROOT", &root) + .env("ZAIVERN_GROUP_FIXTURE_ROLE", "credential-helper"); + capture_guarded(scope, cmd, Duration::from_secs(60), 4096)?; + return Err("blocked helper unexpectedly resumed".into()); + } + let _lock = private::Lock::acquire(&root, "factory.lock").unwrap(); + private::write(&root.join("factory-ready"), b"ready", false).unwrap(); + // Simulate a blocked OS credential API before client creation. + std::thread::sleep(Duration::from_secs(60)); + Err("blocked credential fixture unexpectedly resumed".into()) + }, + Duration::from_millis(200), + &FIXTURE_STOP, + || None::<()>, + ) + .unwrap(); + return; + } + if role == "guardian" { + unsafe { + libc::signal( + libc::SIGTERM, + fixture_stop as *const () as libc::sighandler_t, + ); + } + let mut cmd = command(&std::env::current_exe().unwrap()); + cmd.args(["--exact", GROUP_FIXTURE]) + .env("ZAIVERN_GROUP_FIXTURE_ROOT", &root); + if std::env::var_os("ZAIVERN_GROUP_FIXTURE_BLOCK").is_some() { + cmd.env("ZAIVERN_GROUP_FIXTURE_BLOCK", "1"); + } + if let Some(outcome) = std::env::var_os("ZAIVERN_GROUP_FIXTURE_EXIT") { + cmd.env("ZAIVERN_GROUP_FIXTURE_EXIT", outcome); + } + let timeout = if std::env::var_os("ZAIVERN_GROUP_FIXTURE_BLOCK").is_some() { + Duration::from_millis(200) + } else { + Duration::from_secs(5) + }; + guard_tunnel( + |_| Ok(cmd), + timeout, + &FIXTURE_STOP, + || { + let lock = private::Lock::acquire(&root, "mcp.lock").ok()?; + super::super::daemon::ensure_clean(&root).ok()?; + Some(lock) + }, + ) + .unwrap(); + return; + } + if std::env::var_os("ZAIVERN_GROUP_FIXTURE_WORKER").is_some() { + unsafe { + libc::signal(libc::SIGTERM, libc::SIG_IGN); + } + let _lock = private::Lock::acquire(&root, "mcp.lock").unwrap(); + private::write(&root.join("worker-ready"), b"ready", false).unwrap(); + wait_until(|| root.join("release-worker").exists()); + Cleanup::load(&root, &fixture(&root)) + .unwrap() + .finish(false) + .unwrap(); + return; + } + unsafe { + libc::signal( + libc::SIGTERM, + fixture_stop as *const () as libc::sighandler_t, + ); + } + // Inherit the leader's group exactly as tunnel-client's stdio child does. + let mut worker = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", GROUP_FIXTURE]) + .env("ZAIVERN_GROUP_FIXTURE_WORKER", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + wait_until(|| root.join("worker-ready").exists()); + private::write(&root.join("leader-ready"), b"ready", false).unwrap(); + wait_until(|| { + root.join("exit-leader").exists() + || (std::env::var_os("ZAIVERN_GROUP_FIXTURE_BLOCK").is_none() + && FIXTURE_STOP.load(std::sync::atomic::Ordering::Relaxed)) + }); + if root.join("exit-leader").exists() { + // Simulate Fx stopping before its TERM-ignoring MCP child completed. + std::process::exit(0); + } + private::write(&root.join("release-worker"), b"release", false).unwrap(); + assert!(worker.wait().unwrap().success()); + match std::env::var("ZAIVERN_GROUP_FIXTURE_EXIT").as_deref() { + Ok("nonzero") => std::process::exit(1), + Ok("signal") => unsafe { + libc::kill(libc::getpid(), libc::SIGKILL); + }, + _ => {} + } + } + + fn guardian_fixture_command(root: &Path, block: bool) -> Command { + let mut cmd = command(&std::env::current_exe().unwrap()); + cmd.args(["--exact", GROUP_FIXTURE]) + .env("ZAIVERN_GROUP_FIXTURE_ROOT", root) + .env("ZAIVERN_GROUP_FIXTURE_ROLE", "guardian") + .stdin(Stdio::piped()); + if block { + cmd.env("ZAIVERN_GROUP_FIXTURE_BLOCK", "1"); + } + cmd + } + + #[test] + fn supervisor_crash_reclaims_guardian_blocked_before_client_creation() { + blocked_factory_crash(false); + } + + #[test] + fn supervisor_crash_also_reclaims_blocked_credential_helper() { + blocked_factory_crash(true); + } + + fn blocked_factory_crash(helper: bool) { + use super::super::{ + cleanup::{self, Cleanup}, + daemon, private, + tests::{fixture, Temp}, + }; + let temp = Temp::new(); + let config = fixture(&temp.0); + Cleanup::prepare(&temp.0, &config, &private::nonce().unwrap()).unwrap(); + let mut unrelated = ready_child("printf 'ready\\n'; exec sleep 30"); + let mut cmd = command(&std::env::current_exe().unwrap()); + cmd.args(["--exact", GROUP_FIXTURE]) + .env("ZAIVERN_GROUP_FIXTURE_ROOT", &temp.0) + .env("ZAIVERN_GROUP_FIXTURE_ROLE", "supervisor") + .env("ZAIVERN_GROUP_FIXTURE_BLOCK_FACTORY", "1"); + if helper { + cmd.env("ZAIVERN_GROUP_FIXTURE_BLOCK_HELPER", "1"); + } + let mut supervisor = OwnedChild::spawn(&mut cmd).unwrap(); + wait_until(|| temp.0.join("supervisor-ready").exists()); + assert_eq!(private::Lock::held(&temp.0, "factory.lock"), Ok(true)); + if helper { + assert_eq!( + private::read(&temp.0.join("helper-group"), 32).unwrap(), + private::read(&temp.0.join("guardian-group"), 32).unwrap(), + "credential helper must remain in the guardian's owned group" + ); + } + assert_eq!( + unsafe { libc::kill(supervisor.child.id() as libc::pid_t, libc::SIGKILL) }, + 0 + ); + wait_until(|| supervisor.exited().unwrap().is_some()); + wait_until(|| private::Lock::held(&temp.0, "factory.lock") == Ok(false)); + assert!(unrelated.exited().unwrap().is_none()); + assert!(!temp.0.join("worker-ready").exists()); + assert!(!temp.0.join("mcp.done").exists()); + assert!(!temp.0.join("shutdown.json").exists()); + assert!(daemon::ensure_clean(&temp.0).is_err()); + let _operation = private::Lock::acquire(&temp.0, "operation.lock").unwrap(); + let _runtime = private::Lock::acquire(&temp.0, "runtime.lock").unwrap(); + cleanup::reconcile(&temp.0, &config).unwrap(); + daemon::ensure_clean(&temp.0).unwrap(); + } + + #[test] + fn capture_reclaims_pipe_holding_descendant_after_leader_exit() { + use super::super::{private, tests::Temp}; + let temp = Temp::new(); + let mut unrelated = ready_child("printf 'ready\\n'; exec sleep 30"); + let mut cmd = command(&std::env::current_exe().unwrap()); + cmd.args(["--exact", GROUP_FIXTURE, "--nocapture"]) + .env("ZAIVERN_GROUP_FIXTURE_ROOT", &temp.0) + .env("ZAIVERN_GROUP_FIXTURE_ROLE", "capture"); + assert!(capture_status(cmd, Duration::from_secs(5), 4096).is_err()); + wait_until(|| private::Lock::held(&temp.0, "mcp.lock") == Ok(false)); + assert!(unrelated.exited().unwrap().is_none()); + } + + #[test] + fn cleanup_receipt_never_turns_nonzero_or_signalled_exit_into_success() { + use super::super::{ + cleanup::Cleanup, + daemon, private, + tests::{fixture, Temp}, + }; + for killed in [false, true] { + let temp = Temp::new(); + let config = fixture(&temp.0); + let generation = private::nonce().unwrap(); + Cleanup::prepare(&temp.0, &config, &generation).unwrap(); + Cleanup::load(&temp.0, &config) + .unwrap() + .finish(false) + .unwrap(); + daemon::ensure_clean(&temp.0).unwrap(); + let mut cmd = command(Path::new("/bin/sh")); + cmd.args(["-c", if killed { "exec sleep 30" } else { "exit 1" }]); + let mut group = OwnedProcessGroup::spawn(&mut cmd).unwrap(); + if killed { + assert_eq!( + unsafe { libc::kill(group.id() as libc::pid_t, libc::SIGKILL) }, + 0 + ); + } + wait_until(|| group.exited().unwrap()); + #[cfg(target_os = "macos")] + assert!(group.zombie_is_only_member().unwrap()); + let stopped = group.stop_gracefully(Duration::from_secs(1), || { + let lock = private::Lock::acquire(&temp.0, "mcp.lock").ok()?; + daemon::ensure_clean(&temp.0).ok()?; + Some(lock) + }); + assert!(stopped.is_err()); + assert!(!group.reserved); + assert!(!group.status.unwrap().success()); + assert!(group.stop_gracefully(Duration::ZERO, || Some(())).is_err()); + assert!(!temp.0.join("shutdown.json").exists()); + daemon::ensure_clean(&temp.0).unwrap(); + } + } + + #[test] + fn guardian_preserves_tunnel_failure_even_after_mcp_cleanup_completed() { + use super::super::{ + cleanup::Cleanup, + daemon, private, + tests::{fixture, Temp}, + }; + for outcome in ["nonzero", "signal"] { + let temp = Temp::new(); + let config = fixture(&temp.0); + let generation = private::nonce().unwrap(); + Cleanup::prepare(&temp.0, &config, &generation).unwrap(); + Cleanup::load(&temp.0, &config) + .unwrap() + .admit(&generation) + .unwrap(); + let mut cmd = guardian_fixture_command(&temp.0, false); + cmd.env("ZAIVERN_GROUP_FIXTURE_EXIT", outcome); + let mut guardian = OwnedProcessGroup::spawn(&mut cmd).unwrap(); + let _lease = guardian.take_lease().unwrap(); + wait_until(|| temp.0.join("leader-ready").exists()); + assert!(guardian + .stop_gracefully(Duration::from_secs(5), || { + let lock = private::Lock::acquire(&temp.0, "mcp.lock").ok()?; + daemon::ensure_clean(&temp.0).ok()?; + Some(lock) + }) + .is_err()); + daemon::ensure_clean(&temp.0).unwrap(); + assert!(!temp.0.join("shutdown.json").exists()); + } + } + + #[test] + fn supervisor_or_guardian_crash_releases_mcp_lock_without_losing_cleanup_debt() { + use super::super::{ + cleanup::{self, Cleanup}, + daemon, private, + tests::{fixture, Temp}, + }; + for crash in ["supervisor", "guardian", "neither"] { + let temp = Temp::new(); + let config = fixture(&temp.0); + let generation = private::nonce().unwrap(); + Cleanup::prepare(&temp.0, &config, &generation).unwrap(); + Cleanup::load(&temp.0, &config) + .unwrap() + .admit(&generation) + .unwrap(); + let mut unrelated = ready_child("printf 'ready\\n'; exec sleep 30"); + if crash == "supervisor" { + let mut cmd = command(&std::env::current_exe().unwrap()); + cmd.args(["--exact", GROUP_FIXTURE]) + .env("ZAIVERN_GROUP_FIXTURE_ROOT", &temp.0) + .env("ZAIVERN_GROUP_FIXTURE_ROLE", "supervisor"); + let mut supervisor = OwnedChild::spawn(&mut cmd).unwrap(); + wait_until(|| temp.0.join("supervisor-ready").exists()); + // An actual SIGKILL closes the lease without running Drop. + assert_eq!( + unsafe { libc::kill(supervisor.child.id() as libc::pid_t, libc::SIGKILL) }, + 0 + ); + wait_until(|| supervisor.exited().unwrap().is_some()); + } else { + let mut guardian = OwnedProcessGroup::spawn(&mut guardian_fixture_command( + &temp.0, + crash == "guardian", + )) + .unwrap(); + let _lease = guardian.take_lease().unwrap(); + wait_until(|| temp.0.join("leader-ready").exists()); + if crash == "guardian" { + assert_eq!( + unsafe { libc::kill(guardian.id() as libc::pid_t, libc::SIGKILL) }, + 0 + ); + wait_until(|| guardian.exited().unwrap()); + } + let result = guardian.stop_gracefully(Duration::from_secs(5), || { + let lock = private::Lock::acquire(&temp.0, "mcp.lock").ok()?; + daemon::ensure_clean(&temp.0).ok()?; + Some(lock) + }); + assert_eq!(result.is_ok(), crash == "neither"); + } + wait_until(|| private::Lock::held(&temp.0, "mcp.lock") == Ok(false)); + assert!(unrelated.exited().unwrap().is_none()); + if crash != "neither" { + assert!(!temp.0.join("mcp.done").exists()); + assert!(!temp.0.join("shutdown.json").exists()); + assert!(temp.0.join("cleanup-pending.json").exists()); + assert!(daemon::ensure_clean(&temp.0).is_err()); + let _operation = private::Lock::acquire(&temp.0, "operation.lock").unwrap(); + let _runtime = private::Lock::acquire(&temp.0, "runtime.lock").unwrap(); + cleanup::reconcile(&temp.0, &config).unwrap(); + } + daemon::ensure_clean(&temp.0).unwrap(); + } + } + + #[test] + fn leader_exit_retains_group_authority_until_lock_released_and_repairable() { + use super::super::{ + cleanup::{self, Cleanup}, + daemon, private, + tests::{fixture, Temp}, + }; + for graceful in [false, true] { + let temp = Temp::new(); + let config = fixture(&temp.0); + let generation = private::nonce().unwrap(); + Cleanup::prepare(&temp.0, &config, &generation).unwrap(); + Cleanup::load(&temp.0, &config) + .unwrap() + .admit(&generation) + .unwrap(); + let mut cmd = command(&std::env::current_exe().unwrap()); + cmd.args(["--exact", GROUP_FIXTURE]) + .env("ZAIVERN_GROUP_FIXTURE_ROOT", &temp.0); + let mut group = OwnedProcessGroup::spawn(&mut cmd).unwrap(); + let mut unrelated = ready_child("printf 'ready\\n'; exec sleep 30"); + wait_until(|| temp.0.join("leader-ready").exists()); + assert_eq!(private::Lock::held(&temp.0, "mcp.lock"), Ok(true)); + if !graceful { + private::write(&temp.0.join("exit-leader"), b"exit", false).unwrap(); + wait_until(|| group.exited().unwrap()); + // Repeated observation preserves wait ownership and the PGID. + assert!(group.exited().unwrap()); + #[cfg(target_os = "macos")] + assert!(!group.zombie_is_only_member().unwrap()); + assert_eq!(private::Lock::held(&temp.0, "mcp.lock"), Ok(true)); + } + let result = group.stop_gracefully( + if graceful { + Duration::from_secs(5) + } else { + Duration::from_millis(100) + }, + || { + let lock = private::Lock::acquire(&temp.0, "mcp.lock").ok()?; + daemon::ensure_clean(&temp.0).ok()?; + Some(lock) + }, + ); + assert_eq!(result.is_ok(), graceful); + wait_until(|| private::Lock::held(&temp.0, "mcp.lock") == Ok(false)); + assert!(unrelated.exited().unwrap().is_none()); + assert_eq!( + unsafe { libc::waitpid(group.id() as i32, std::ptr::null_mut(), libc::WNOHANG) }, + -1 + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ECHILD) + ); + assert_eq!(daemon::ensure_clean(&temp.0).is_ok(), graceful); + if !graceful { + assert!(!temp.0.join("mcp.done").exists()); + assert!(!temp.0.join("shutdown.json").exists()); + assert!(temp.0.join("cleanup-pending.json").exists()); + let _operation = private::Lock::acquire(&temp.0, "operation.lock").unwrap(); + let _runtime = private::Lock::acquire(&temp.0, "runtime.lock").unwrap(); + cleanup::reconcile(&temp.0, &config).unwrap(); + daemon::ensure_clean(&temp.0).unwrap(); + } + // A second stop cannot use the reaped identity to signal anything. + assert_eq!( + group.stop_gracefully(Duration::ZERO, || Some(())).is_ok(), + graceful + ); + assert!(unrelated.exited().unwrap().is_none()); + } + } + + pub(in super::super) fn ready_child(script: &str) -> OwnedChild { + let mut cmd = command(Path::new("/bin/sh")); + cmd.args(["-c", script]).stdout(Stdio::piped()); + let mut child = OwnedChild::spawn(&mut cmd).unwrap(); + let output = child.child.stdout.take().unwrap(); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let mut line = String::new(); + let result = std::io::BufReader::new(output).read_line(&mut line); + let _ = tx.send((result, line)); + }); + let (result, line) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + result.unwrap(); + assert_eq!(line, "ready\n"); + child + } + + #[test] + fn termination_waits_for_graceful_exit_and_reaps_owned_child() { + let mut child = ready_child( + "trap 'sleep 0.2; exit 0' TERM; printf 'ready\\n'; while :; do sleep 1; done", + ); + let started = Instant::now(); + let terminated = terminate_and_wait(&mut child.child, Duration::from_secs(5)); + let status = child.exited().unwrap(); // Update the guard before assertions can unwind. + assert!(terminated.unwrap()); + assert!(started.elapsed() >= Duration::from_millis(150)); + assert!(status.unwrap().success()); + // The OS has no waitable child left, rather than just a dead zombie. + assert_eq!( + unsafe { libc::waitpid(child.child.id() as i32, std::ptr::null_mut(), libc::WNOHANG) }, + -1 + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ECHILD) + ); + } + + #[test] + fn termination_is_bounded_and_retains_live_child_ownership() { + let mut child = ready_child("trap '' TERM; printf 'ready\\n'; exec sleep 30"); + let mut unrelated = ready_child("printf 'ready\\n'; exec sleep 30"); + let started = Instant::now(); + assert!(!terminate_and_wait(&mut child.child, Duration::from_millis(100)).unwrap()); + assert!(started.elapsed() < Duration::from_secs(5)); + assert!(child.exited().unwrap().is_none()); + assert!(unrelated.exited().unwrap().is_none()); + // Only the fixture owner terminates these children after the assertion. + child.stop(); + unrelated.stop(); + } + + #[test] + fn termination_of_already_reaped_child_returns_without_signalling() { + let mut cmd = command(Path::new("/bin/sh")); + cmd.args(["-c", "exit 0"]); + let mut child = OwnedChild::spawn(&mut cmd).unwrap(); + assert!(child.child.wait().unwrap().success()); + assert!(child.exited().unwrap().is_some()); + assert!(terminate_and_wait(&mut child.child, Duration::ZERO).unwrap()); + assert!(child.exited().unwrap().is_some()); + } +} diff --git a/src/chatgpt/secret.rs b/src/chatgpt/secret.rs new file mode 100644 index 0000000..b1ce075 --- /dev/null +++ b/src/chatgpt/secret.rs @@ -0,0 +1,442 @@ +//! Runtime authentication only. Secrets never implement Debug/Display. +use super::private::{self, Result}; +use std::io::{Read, Write}; +use std::os::fd::AsRawFd; +use std::path::Path; +use zeroize::Zeroizing; + +pub(super) struct Secret(pub(super) Zeroizing>); +impl Secret { + pub(super) fn from_bytes(bytes: Vec) -> Result { + let bytes = Zeroizing::new(bytes); + if bytes.len() < 16 || bytes.len() > 4096 || !bytes.iter().all(|b| (33..=126).contains(b)) { + return Err( + "Invalid Runtime API Key format (expected 16–4096 printable ASCII characters)" + .into(), + ); + } + Ok(Self(bytes)) + } + pub(super) fn text(&self) -> &str { + std::str::from_utf8(&self.0).unwrap_or_default() + } +} + +pub(super) fn prompt() -> Result { + super::process::disable_core_dumps()?; + let mut tty = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .map_err(|_| { + "Runtime API Key requires an interactive terminal; never pass it as an argument" + })?; + let fd = tty.as_raw_fd(); + let mut old = std::mem::MaybeUninit::::uninit(); + if unsafe { libc::tcgetattr(fd, old.as_mut_ptr()) } != 0 { + return Err("Cannot read terminal mode".into()); + } + let old = unsafe { old.assume_init() }; + struct Restore(i32, libc::termios); + impl Drop for Restore { + fn drop(&mut self) { + unsafe { + libc::tcsetattr(self.0, libc::TCSAFLUSH, &self.1); + } + } + } + let mut mode = old; + // Handle Ctrl-C ourselves so normal cancellation also restores echo. + mode.c_lflag &= !(libc::ECHO | libc::ECHONL | libc::ICANON | libc::ISIG); + mode.c_cc[libc::VMIN] = 1; + mode.c_cc[libc::VTIME] = 0; + tty.write_all(crate::i18n::tr("chatgpt.secret_prompt").as_bytes()) + .map_err(|_| "Cannot write terminal")?; + if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &mode) } != 0 { + return Err("Cannot disable terminal echo".into()); + } + let _restore = Restore(fd, old); + let mut bytes = Zeroizing::new(Vec::new()); + loop { + let mut byte = [0u8]; + tty.read_exact(&mut byte) + .map_err(|_| "Secret input interrupted")?; + match byte[0] { + b'\n' | b'\r' => break, + 3 | 4 => { + let _ = tty.write_all(b"\n"); + return Err("Cancelled".into()); + } + 8 | 127 => { + bytes.pop(); + } + b if bytes.len() < 4096 => bytes.push(b), + _ => return Err("Runtime key exceeds input limit".into()), + } + } + let _ = tty.write_all(b"\n"); + Secret::from_bytes(std::mem::take(&mut *bytes)) +} + +fn account(root: &Path) -> String { + super::install::sha256(root.as_os_str().as_encoded_bytes()) +} + +#[cfg(not(target_os = "macos"))] +fn service_executable() -> Result { + use std::os::unix::fs::MetadataExt; + let path = Path::new("/usr/bin/secret-tool") + .canonicalize() + .map_err(|_| "System Secret Service tool unavailable")?; + // Do not let PATH or an Agent-edited helper receive the runtime key. + let metadata = + std::fs::symlink_metadata(&path).map_err(|_| "Cannot inspect Secret Service executable")?; + if !metadata.is_file() + || metadata.uid() != 0 + || metadata.nlink() != 1 + || metadata.mode() & 0o022 != 0 + || metadata.mode() & 0o111 == 0 + { + return Err("Secret Service executable must be a root-owned, non-shared executable".into()); + } + Ok(path) +} + +pub(super) fn save( + root: &Path, + secret: &Secret, + approve_file: impl FnOnce() -> Result, +) -> Result { + #[cfg(target_os = "macos")] + { + let _ = approve_file; + remember_store(root, "keychain")?; + security_framework::passwords::set_generic_password( + "org.zaivern.chatgpt.runtime", + &account(root), + &secret.0, + ) + .map_err(|_| { + "macOS Keychain denied access; unlock Keychain and retry zai chatgpt setup --reauth" + })?; + Ok("keychain".into()) + } + #[cfg(not(target_os = "macos"))] + { + save_with_service( + root, + secret, + service_executable().ok().as_deref(), + approve_file, + ) + } +} + +// Inject the executable, not global PATH/DBus state, in deterministic tests. +#[cfg(any(not(target_os = "macos"), test))] +fn save_with_service( + root: &Path, + secret: &Secret, + executable: Option<&Path>, + approve_file: impl FnOnce() -> Result, +) -> Result { + if let Some(bin) = executable { + // Keep this entry even on failure: the service may have committed the + // key before its reply was lost. Reset must retain that recovery debt. + if try_save_service(root, secret, bin).is_ok() { + return Ok("secret-service".into()); + } + } + if !approve_file()? { + return Err( + "Secret Service unavailable; private-file fallback declined. Setup cancelled.".into(), + ); + } + remember_store(root, "private-file")?; + private::write(&root.join("runtime-key"), &secret.0, false)?; + Ok("private-file".into()) +} + +#[cfg(any(not(target_os = "macos"), test))] +fn try_save_service(root: &Path, secret: &Secret, bin: &Path) -> Result<()> { + remember_store(root, "secret-service")?; + let mut command = super::process::command(bin); + command + .args([ + "store", + "--label=Zaivern ChatGPT Runtime", + "application", + "zaivern-chatgpt", + "account", + &account(root), + ]) + .stdin(std::process::Stdio::piped()); + let mut child = super::process::OwnedProcessGroup::spawn(&mut command)?; + let mut input = child.take_lease()?; + input + .write_all(&secret.0) + .and_then(|_| input.write_all(b"\n")) + .map_err(|_| "Secret Service write failed")?; + drop(input); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + loop { + if child.exited()? { + let status = child.reclaim()?; + return if status.success() { + Ok(()) + } else { + Err("Secret Service denied access; unlock your keyring and retry".into()) + }; + } + if std::time::Instant::now() >= deadline { + return Err("Secret Service timed out".into()); + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } +} + +pub(super) fn load(root: &Path, backend: &str) -> Result { + load_scoped(root, backend, None) +} + +pub(super) fn load_guarded( + root: &Path, + backend: &str, + scope: &super::process::GuardianScope, +) -> Result { + load_scoped(root, backend, Some(scope)) +} + +fn load_scoped( + root: &Path, + backend: &str, + _scope: Option<&super::process::GuardianScope>, +) -> Result { + super::process::disable_core_dumps()?; + let bytes = match backend { + #[cfg(target_os = "macos")] + "keychain" => security_framework::passwords::get_generic_password( + "org.zaivern.chatgpt.runtime", + &account(root), + ) + .map_err(|_| "Runtime key unavailable in Keychain; run zai chatgpt setup --reauth")?, + #[cfg(not(target_os = "macos"))] + "secret-service" => { + let bin = service_executable()?; + let mut command = super::process::command(&bin); + command.args([ + "lookup", + "application", + "zaivern-chatgpt", + "account", + &account(root), + ]); + let mut bytes = match _scope { + Some(scope) => super::process::capture_guarded( + scope, + command, + std::time::Duration::from_secs(60), + 4097, + )?, + None => super::process::capture(command, std::time::Duration::from_secs(60), 4097)?, + }; + if bytes.last() == Some(&b'\n') { + bytes.pop(); + } + bytes + } + "private-file" => private::read(&root.join("runtime-key"), 4096)?, + _ => return Err("Unsupported secret store; run zai chatgpt setup --reauth".into()), + }; + Secret::from_bytes(bytes) +} + +pub(super) fn remove(root: &Path, backend: &str) -> Result<()> { + match backend { + #[cfg(target_os = "macos")] + "keychain" => match security_framework::passwords::delete_generic_password( + "org.zaivern.chatgpt.runtime", + &account(root), + ) { + Ok(()) => {} + Err(error) if error.code() == -25300 => {} // errSecItemNotFound: idempotent deletion + Err(_) => return Err("Cannot remove Runtime key from Keychain".into()), + }, + #[cfg(not(target_os = "macos"))] + "secret-service" => { + remove_with_service(root, &service_executable()?)?; + } + "private-file" => { + private::remove(&root.join("runtime-key"))?; + } + _ => return Err("Unknown secret store".into()), + } + Ok(()) +} + +#[cfg(any(not(target_os = "macos"), test))] +fn remove_with_service(root: &Path, bin: &Path) -> Result<()> { + let mut cmd = super::process::command(bin); + cmd.args([ + "clear", + "application", + "zaivern-chatgpt", + "account", + &account(root), + ]); + let (success, error) = super::process::capture_status_stderr( + cmd, + std::time::Duration::from_secs(30), + 4096, + )?; + if !success && !secret_service_absent(&error) { + return Err( + "Secret Service cleanup could not confirm credential absence; recovery state preserved" + .into(), + ); + } + Ok(()) +} + +#[cfg(any(not(target_os = "macos"), test))] +fn secret_service_absent(stderr: &[u8]) -> bool { + let text = String::from_utf8_lossy(stderr).to_ascii_lowercase(); + text.contains("no matching secret") || text.contains("no secret found") +} + +pub(super) fn stores(root: &Path) -> Result> { + if !private::exists_checked(&root.join("secret-stores.json"))? { + return Ok(Vec::new()); + } + let stores: Vec = + serde_json::from_slice(&private::read(&root.join("secret-stores.json"), 1024)?) + .map_err(|_| "Invalid secret store recovery journal")?; + if stores.len() > 3 + || stores + .iter() + .any(|s| !["keychain", "secret-service", "private-file"].contains(&s.as_str())) + { + return Err("Invalid secret store recovery journal".into()); + } + Ok(stores) +} + +pub(super) fn remember_store(root: &Path, backend: &str) -> Result<()> { + if !["keychain", "secret-service", "private-file"].contains(&backend) { + return Err("Unknown secret store".into()); + } + let mut stores = stores(root)?; + if !stores.iter().any(|s| s == backend) { + stores.push(backend.into()); + } + private::write( + &root.join("secret-stores.json"), + &serde_json::to_vec(&stores).map_err(|_| "Cannot encode secret store journal")?, + false, + ) +} + +pub(super) fn forget_store(root: &Path, backend: &str) -> Result<()> { + let mut stores = stores(root)?; + stores.retain(|s| s != backend); + private::write( + &root.join("secret-stores.json"), + &serde_json::to_vec(&stores).map_err(|_| "Cannot encode secret store journal")?, + false, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn exercise(success: bool, approve: bool) { + let root = crate::test_util::unique_temp_dir("chatgpt", "secret-fallback"); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + let bin = root.join("secret-tool"); + private::write(&bin, format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$0.args\"\nIFS= read -r key\nprintf '%s' \"$key\" >&2\nprintf '%s' \"$key\"\nexit {}\n", + if success { 0 } else { 1 }).as_bytes(), true).unwrap(); + let sentinel = "sk-runtime-fallback-sentinel-123456789"; + let secret = Secret::from_bytes(sentinel.as_bytes().to_vec()).unwrap(); + let asked = std::cell::Cell::new(false); + let result = save_with_service(&root, &secret, Some(&bin), || { + asked.set(true); + Ok(approve) + }); + assert_eq!(asked.get(), !success); + if success { + assert_eq!(result.unwrap(), "secret-service"); + assert_eq!(stores(&root).unwrap(), ["secret-service"]); + assert!(!root.join("runtime-key").exists()); + } else if approve { + assert_eq!(result.unwrap(), "private-file"); + assert_eq!(stores(&root).unwrap(), ["secret-service", "private-file"]); + let path = root.join("runtime-key"); + assert_eq!(private::read(&path, 4096).unwrap(), sentinel.as_bytes()); + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } else { + assert!(!result.unwrap_err().contains(sentinel)); + assert!(!root.join("runtime-key").exists()); + assert_eq!(stores(&root).unwrap(), ["secret-service"]); + } + for file in ["secret-tool.args", "secret-stores.json"] { + assert!(!std::fs::read_to_string(root.join(file)) + .unwrap() + .contains(sentinel)); + } + assert!(!root.join("config.json").exists()); + assert!(!root.join("profile.yaml").exists()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn secret_service_unavailable_accepted_private_file() { + exercise(false, true); + } + #[test] + fn secret_service_unavailable_declined_does_not_save_file() { + exercise(false, false); + } + #[test] + fn secret_service_success_never_prompts_for_fallback() { + exercise(true, false); + } + + #[cfg(any(not(target_os = "macos"), test))] + fn remove_fixture(mode: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let root = crate::test_util::unique_temp_dir("chatgpt", "secret-remove"); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + let bin = root.join("secret-tool"); + let script = format!( + "#!/bin/sh\nif [ \"$1\" = clear ]; then printf '%s' '{}' >&2; exit 1; fi\nexit 0\n", + mode + ); + private::write(&bin, script.as_bytes(), true).unwrap(); + remember_store(&root, "secret-service").unwrap(); + (root, bin) + } + + #[cfg(any(not(target_os = "macos"), test))] + #[test] + fn secret_service_absent_is_idempotent_and_clears_debt() { + let (root, bin) = remove_fixture("No matching secret found"); + remove_with_service(&root, &bin).unwrap(); + forget_store(&root, "secret-service").unwrap(); + assert!(stores(&root).unwrap().is_empty()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(any(not(target_os = "macos"), test))] + #[test] + fn secret_service_backend_failure_preserves_debt() { + let (root, bin) = remove_fixture("Cannot connect to the D-Bus session bus"); + assert!(remove_with_service(&root, &bin).is_err()); + assert_eq!(stores(&root).unwrap(), ["secret-service"]); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/chatgpt/tests.rs b/src/chatgpt/tests.rs new file mode 100644 index 0000000..f8460c0 --- /dev/null +++ b/src/chatgpt/tests.rs @@ -0,0 +1,474 @@ +use super::*; +use std::os::unix::fs::{symlink, PermissionsExt}; + +pub(super) struct Temp(pub(super) std::path::PathBuf); +impl Temp { + pub(super) fn new() -> Self { + let path = crate::test_util::unique_temp_dir("chatgpt", "private"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + Self(path) + } +} +impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +pub(super) fn fixture(root: &Path) -> Config { + Config { + version: 1, + workspace: root.join("project ' 引用 $()"), + executable: root.join("zai ' Unicode 空白"), + image: format!("sha256:{}", "a".repeat(64)), + image_source: "local-agent:trusted".into(), + docker: root.join("docker"), + docker_endpoint: "unix:///tmp/test-docker.sock".into(), + tunnel_id: "tunnel_0123456789abcdef0123456789abcdef".into(), + secret_store: "private-file".into(), + client_version: install::TESTED_VERSION.into(), + } +} + +#[test] +fn private_files_reject_symlink_hardlink_and_permissions() { + let temp = Temp::new(); + let path = temp.0.join("key"); + private::write(&path, b"secret", false).unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + symlink(&path, temp.0.join("link")).unwrap(); + assert!(private::read(&temp.0.join("link"), 100).is_err()); + assert!(private::write(&temp.0.join("link"), b"overwrite", false).is_err()); + std::fs::hard_link(&path, temp.0.join("hard")).unwrap(); + assert!(private::read(&path, 100).is_err()); + assert!(private::write(&path, b"overwrite", false).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"secret"); + std::fs::remove_file(temp.0.join("hard")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(private::read(&path, 100).is_err()); +} + +#[test] +fn config_profile_roundtrip_and_repeat_are_secret_free() { + let temp = Temp::new(); + let config = fixture(&temp.0); + for _ in 0..2 { + config.save(&temp.0).unwrap(); + private::write( + &temp.0.join("profile.yaml"), + &config::profile(&config, &temp.0).unwrap(), + false, + ) + .unwrap(); + let loaded = Config::load(&temp.0).unwrap(); + config::verify_profile(&loaded, &temp.0).unwrap(); + assert_eq!(loaded.workspace, config.workspace); + } + let json: serde_json::Value = + serde_json::from_slice(&private::read(&temp.0.join("profile.yaml"), 65536).unwrap()) + .unwrap(); + assert_eq!( + json["control_plane"]["api_key"], + "env:CONTROL_PLANE_API_KEY" + ); + assert!(json["mcp"]["commands"][0]["command"] + .as_str() + .unwrap() + .contains("'\"'\"'")); + assert!(config::quote("line\nbreak").is_err()); + private::write(&temp.0.join("profile.yaml"), b"{}", false).unwrap(); + assert!(config::verify_profile(&config, &temp.0).is_err()); +} + +#[test] +fn exclusive_lock_survives_file_reuse_and_blocks_duplicates() { + let temp = Temp::new(); + let first = private::Lock::acquire(&temp.0, "runtime.lock").unwrap(); + assert_eq!(private::Lock::held(&temp.0, "runtime.lock"), Ok(true)); + assert!(private::Lock::acquire(&temp.0, "runtime.lock").is_err()); + drop(first); + assert_eq!(private::Lock::held(&temp.0, "runtime.lock"), Ok(false)); + assert!(private::Lock::acquire(&temp.0, "runtime.lock").is_ok()); + std::fs::set_permissions( + temp.0.join("runtime.lock"), + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + assert!(private::Lock::held(&temp.0, "runtime.lock").is_err()); +} + +#[test] +fn child_environment_and_profile_never_contain_runtime_key() { + let temp = Temp::new(); + let config = fixture(&temp.0); + let sentinel = "sk-runtime-secret-sentinel-1234567890"; + let key = secret::Secret::from_bytes(sentinel.as_bytes().to_vec()).unwrap(); + private::write( + &temp.0.join("profile.yaml"), + &config::profile(&config, &temp.0).unwrap(), + false, + ) + .unwrap(); + let tunnel = daemon::tunnel_command(&temp.0, &config, &key, "run").unwrap(); + assert!(tunnel + .get_envs() + .any(|(k, v)| k == "CONTROL_PLANE_API_KEY" && v.is_some_and(|v| v == sentinel))); + assert!(tunnel + .get_args() + .all(|a| !a.to_string_lossy().contains(sentinel))); + let mcp = process::command(&config.executable); + assert!(mcp.get_envs().all(|(k, v)| k != "CONTROL_PLANE_API_KEY" + && k != "OPENAI_API_KEY" + && !v.is_some_and(|v| v == sentinel))); + assert!( + !String::from_utf8(config::profile(&config, &temp.0).unwrap()) + .unwrap() + .contains(sentinel) + ); + assert!(secret::Secret::from_bytes(b"short".to_vec()).is_err()); + assert!(secret::Secret::from_bytes(format!("{sentinel}\n").into_bytes()).is_err()); +} + +#[test] +fn fake_child_pass_fail_and_output_limit() { + let mut cmd = process::command(Path::new("/bin/sh")); + cmd.args([ + "-c", + "printf 'RESULT ok'; printf 'sk-runtime-secret-sentinel-1234567890' >&2", + ]); + assert_eq!( + process::capture(cmd, Duration::from_secs(2), 100).unwrap(), + b"RESULT ok" + ); + let mut cmd = process::command(Path::new("/bin/sh")); + cmd.args([ + "-c", + "printf 'sk-runtime-secret-sentinel-1234567890' >&2; exit 1", + ]); + let error = process::capture(cmd, Duration::from_secs(2), 100).unwrap_err(); + assert!(!error.contains("sentinel")); + let mut cmd = process::command(Path::new("/bin/sh")); + cmd.args(["-c", "printf 'too much output'"]); + assert!(process::capture(cmd, Duration::from_secs(2), 3).is_err()); +} + +#[test] +fn missing_key_client_docker_image_and_bad_state_fail_closed() { + let temp = Temp::new(); + assert!(secret::load(&temp.0, "private-file").is_err()); + assert!(install::verify_installed(&temp.0).is_err()); + assert!(docker::validate_endpoint("tcp://127.0.0.1:2375").is_err()); + assert!(docker::validate_endpoint("unix:///nonexistent/zaivern-test.sock").is_err()); + assert!(docker::image( + Path::new("/nonexistent/docker"), + "unix:///nonexistent/socket", + "image" + ) + .is_err()); + private::write( + &temp.0.join("runtime.json"), + br#"{"pid":1,"port":80}"#, + false, + ) + .unwrap(); + assert!(daemon::request(&temp.0, "stop").is_err()); + let mut config = fixture(&temp.0); + config.image = "latest".into(); + assert!(config.validate().is_err()); + config = fixture(&temp.0); + config.tunnel_id = "tunnel_good\napi_key: secret".into(); + assert!(config.validate().is_err()); + config.tunnel_id = "tunnel_short".into(); + assert!(config.validate().is_err()); + config.tunnel_id = "tunnel_0123456789ABCDEF0123456789abcdef".into(); + assert!(config.validate().is_err()); +} + +#[test] +fn fifo_is_rejected_without_waiting_for_a_writer() { + let temp = Temp::new(); + let path = temp.0.join("runtime-key"); + let c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0); + assert!(private::read(&path, 4096).is_err()); +} + +#[test] +fn doctor_diagnostics_redact_untrusted_fields_and_ids() { + let secret = "sk-runtime-secret-sentinel-1234567890"; + for result in ["ok", "fail"] { + let input = serde_json::json!({"result":result,"next":secret,"checks":[ + {"id":"control_plane_api_key","status":"PASS","summary":secret,"evidence":[secret]}, + {"id":"health_listener","status":"FAIL","why":secret}, + {"id":secret,"status":secret,"summary":secret} + ]}); + let text = doctor_report(input.to_string().as_bytes()).unwrap(); + assert!(!text.contains(secret)); + assert_eq!( + text, + "[PASS] control_plane_api_key\n[FAIL] health_listener\n" + ); + } + assert!(doctor_report(b"{}").is_err()); +} + +#[test] +fn failed_config_commit_keeps_secret_recovery_journal() { + let temp = Temp::new(); + secret::remember_store(&temp.0, "private-file").unwrap(); + private::write( + &temp.0.join("runtime-key"), + b"sk-old-runtime-key-123456789", + false, + ) + .unwrap(); + secret::remember_store(&temp.0, "keychain").unwrap(); + // Fault injection: the destination is a symlink, so config commit fails. + symlink(temp.0.join("untouched"), temp.0.join("config.json")).unwrap(); + assert!(fixture(&temp.0).save(&temp.0).is_err()); + assert_eq!( + secret::stores(&temp.0).unwrap(), + ["private-file", "keychain"] + ); + secret::remove(&temp.0, "private-file").unwrap(); + secret::forget_store(&temp.0, "private-file").unwrap(); + assert_eq!(secret::stores(&temp.0).unwrap(), ["keychain"]); + assert!(!temp.0.join("runtime-key").exists()); + assert!(!temp.0.join("untouched").exists()); +} + +#[test] +fn shutdown_requires_matching_mcp_cleanup_receipt() { + let temp = Temp::new(); + daemon::ensure_clean(&temp.0).unwrap(); + let generation = private::nonce().unwrap(); + private::write( + &temp.0.join("active-generation"), + generation.as_bytes(), + false, + ) + .unwrap(); + assert!(daemon::ensure_clean(&temp.0).is_err()); + private::write( + &temp.0.join("mcp.done"), + private::nonce().unwrap().as_bytes(), + false, + ) + .unwrap(); + assert!(daemon::ensure_clean(&temp.0).is_err()); + private::write(&temp.0.join("mcp.done"), generation.as_bytes(), false).unwrap(); + daemon::ensure_clean(&temp.0).unwrap(); +} + +#[test] +#[ignore = "requires explicitly built zai binary; no Docker or API key"] +fn real_mcp_reexec_strips_key_before_any_startup_child() { + let temp = Temp::new(); + let bin = std::env::current_exe() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .join("zai"); + assert_eq!( + crate::test_util::zai_gate_at( + &bin, + &Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), + env!("CARGO_PKG_VERSION") + ), + crate::test_util::ZaiVerdict::Usable + ); + let mut config = fixture(&temp.0); + // This trusted test target exposes only whether a credential was inherited. + let fake = b"#!/bin/sh\nif [ -n \"${CONTROL_PLANE_API_KEY+x}\" ] || [ -n \"${OPENAI_API_KEY+x}\" ]; then echo LEAK >&2; exit 9; fi\nprintf 'clean\\n'\n"; + private::write(&config.executable, fake, true).unwrap(); + config.workspace = temp.0.join("unused-project"); + config.save(&temp.0).unwrap(); + let mut cmd = process::command(&bin); + cmd.args(["chatgpt", "__mcp"]) + .arg(&temp.0) + .env("CONTROL_PLANE_API_KEY", "sk-runtime-sentinel-123456789") + .env("OPENAI_API_KEY", "sk-inference-must-not-inherit") + .env("ZAIVERN_CHATGPT_GENERATION", private::nonce().unwrap()) + .env("ZAIVERN_HOME", temp.0.join("home")) + .env_remove("LANG") + .env_remove("LC_ALL"); + assert_eq!( + process::capture(cmd, Duration::from_secs(15), 1024).unwrap(), + b"clean\n" + ); + assert!(!temp.0.join("home/panic.log").exists()); + assert!(!temp.0.join("runtime-key").exists()); +} + +#[test] +#[ignore = "downloads the pinned official release over HTTPS into a private temporary directory"] +fn official_release_install_and_profile_doctor() { + let temp = Temp::new(); + install::install(&temp.0).unwrap(); + install::verify_installed(&temp.0).unwrap(); + install::install(&temp.0).unwrap(); + client_version(&temp.0).unwrap(); + let config = fixture(&temp.0); + private::write(&config.executable, b"#!/bin/sh\nexit 0\n", true).unwrap(); + private::write( + &temp.0.join("profile.yaml"), + &config::profile(&config, &temp.0).unwrap(), + false, + ) + .unwrap(); + let key = secret::Secret::from_bytes(b"sk-runtime-test-only-not-real-12345".to_vec()).unwrap(); + let mut cmd = daemon::tunnel_command(&temp.0, &config, &key, "doctor").unwrap(); + cmd.args(["--explain", "--json"]); + let (success, raw) = process::capture_status(cmd, Duration::from_secs(30), 65536).unwrap(); + assert!(success, "{}", doctor_report(&raw).unwrap()); + let value: serde_json::Value = serde_json::from_slice(&raw).unwrap(); + assert_eq!(value["result"], "ok"); + assert!(!String::from_utf8(raw).unwrap().contains(key.text())); +} + +#[test] +#[ignore = "requires built zai, local Docker and official client download; uses only a dummy runtime key"] +fn official_client_managed_lifecycle_and_cleanup() { + let temp = Temp::new(); + let root = temp.0.join("bridge ' 日本語"); + private::directory(&root).unwrap(); + let bin = std::env::current_exe() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .join("zai"); + assert_eq!( + crate::test_util::zai_gate_at( + &bin, + &Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), + env!("CARGO_PKG_VERSION") + ), + crate::test_util::ZaiVerdict::Usable + ); + let (docker, endpoint) = docker::detect(&fixture(&temp.0).workspace).unwrap(); + let image = std::env::var("ZAIVERN_MCP_TEST_IMAGE").expect("set immutable fixture image"); + let mut config = fixture(&temp.0); + config.executable = bin; + config.docker = docker; + config.docker_endpoint = endpoint; + config.image = image; + std::fs::create_dir(&config.workspace).unwrap(); + config.save(&root).unwrap(); + private::write( + &root.join("profile.yaml"), + &config::profile(&config, &root).unwrap(), + false, + ) + .unwrap(); + private::write( + &root.join("runtime-key"), + b"sk-runtime-test-only-not-real-12345", + false, + ) + .unwrap(); + install::install(&root).unwrap(); + real_cleanup_failure_and_recovery(&root, &config); + // Never use an actual account credential or a production workspace. The + // control plane may reject this key; local MCP and health still must work. + for _ in 0..2 { + daemon::start(&root, false).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(30); + while daemon::health(&root, "healthz").is_err() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + let health = daemon::health(&root, "healthz"); + let running_report = cleanup::report(&root).unwrap(); + let stopped = daemon::stop(&root); + assert_eq!(health, Ok(true), "local health listener must start"); + stopped.unwrap(); + assert!( + running_report.contains("Current generation: RUNNING"), + "{running_report}" + ); + assert!(!running_report.contains("UNCONFIRMED"), "{running_report}"); + assert!(!running_report.contains("repair"), "{running_report}"); + daemon::ensure_clean(&root).unwrap(); + let stopped_report = cleanup::report(&root).unwrap(); + assert!(stopped_report.contains("Previous cleanup: CONFIRMED")); + assert!(!stopped_report.contains("repair")); + assert!(daemon::request(&root, "status").is_err()); + } + assert_eq!(std::fs::read_dir(&config.workspace).unwrap().count(), 0); +} + +fn real_cleanup_failure_and_recovery(root: &Path, config: &Config) { + use crate::features::chat_bridge::imp::{CleanupTracker, ResourceKind}; + let generation = private::nonce().unwrap(); + cleanup::Cleanup::prepare(root, config, &generation).unwrap(); + let tracker = cleanup::Cleanup::load(root, config).unwrap(); + tracker.admit(&generation).unwrap(); + let (volume, labels) = tracker.register(ResourceKind::Volume).unwrap(); + let mut cmd = docker::command(&config.docker, &config.docker_endpoint); + cmd.args(["volume", "create", "--driver", "local"]) + .args(labels) + .arg(&volume); + process::capture(cmd, Duration::from_secs(30), 4096).unwrap(); + tracker.created(ResourceKind::Volume, &volume).unwrap(); + // The only resource this guard can remove was registered, labelled and + // verified above. Keep cleanup ownership even if an assertion unwinds. + struct CleanupGuard<'a>(&'a cleanup::Cleanup); + impl Drop for CleanupGuard<'_> { + fn drop(&mut self) { + let _ = self.0.finish(true); + } + } + let _guard = CleanupGuard(&tracker); + let (container, labels) = tracker.register(ResourceKind::Container).unwrap(); + let mut cmd = docker::command(&config.docker, &config.docker_endpoint); + cmd.args([ + "container", + "create", + "--name", + &container, + "--pull=never", + "--network=none", + "--read-only", + "--cap-drop=ALL", + "--security-opt=no-new-privileges", + "--pids-limit=128", + "--memory=4g", + "--cpus=2", + "--entrypoint=sleep", + ]) + .args(labels) + .arg("--mount") + .arg(format!( + "type=volume,source={volume},target=/workspace,volume-nocopy" + )) + .arg(&config.image) + .arg("1800"); + process::capture(cmd, Duration::from_secs(30), 4096).unwrap(); + tracker + .created(ResourceKind::Container, &container) + .unwrap(); + let mut failing = config.clone(); + failing.docker = root.join("docker-fault-fixture"); + let script = format!( + "#!/bin/sh\nif [ \"$3\" = volume ] && [ \"$4\" = rm ]; then exit 19; fi\nexec {} \"$@\"\n", + config::quote(config.docker.to_str().unwrap()).unwrap() + ); + private::write(&failing.docker, script.as_bytes(), true).unwrap(); + let _operation = private::Lock::acquire(root, "operation.lock").unwrap(); + let _runtime = private::Lock::acquire(root, "runtime.lock").unwrap(); + assert!(cleanup::reconcile(root, &failing).is_err()); + assert!(!root.join("mcp.done").exists()); + assert!(daemon::ensure_clean(root).is_err()); + assert!(cleanup::report(root) + .unwrap() + .contains("Pending containers: 0\nPending volumes: 1")); + cleanup::reconcile(root, config).unwrap(); + daemon::ensure_clean(root).unwrap(); + private::remove(&failing.docker).unwrap(); +} diff --git a/src/cli.rs b/src/cli.rs index 1c3fc9a..2ad0b97 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -218,7 +218,7 @@ fn call(inst: &Instance, method: &str, path: &str, body: Option) -> Resu pub fn is_cli_subcommand(word: &str) -> bool { matches!( word, - "mcp" + "mcp" | "chatgpt" | "open" | "notify" | "prompt" @@ -305,7 +305,7 @@ fn yields_to_directory(word: &str) -> bool { pub fn help_text() -> String { format!( "{HELP_HEAD}{HELP_WORKTREE}{HELP_SESSION}{HELP_AGENT}{HELP_LEASE}{HELP_GUARD}\n\ - {HELP_CZERO}{HELP_TRAIN_SPLIT}{HELP_CONTEXT}{HELP_CLOUD}{HELP_TEAM}{HELP_MCP}{HELP_UPDATE}{HELP_UNINSTALL}{HELP_TAIL}" + {HELP_CZERO}{HELP_TRAIN_SPLIT}{HELP_CONTEXT}{HELP_CLOUD}{HELP_TEAM}{HELP_MCP}{HELP_CHATGPT}{HELP_UPDATE}{HELP_UNINSTALL}{HELP_TAIL}" ) } @@ -438,6 +438,7 @@ pub const HELP_CLOUD: &str = crate::features::cloud_execution::HELP; /// (`HELP_GUARD` と同じ方針 — 写経すると必ず食い違う)。 pub const HELP_TEAM: &str = crate::features::team::HELP; pub const HELP_MCP: &str = crate::features::chat_bridge::HELP; +pub const HELP_CHATGPT: &str = crate::features::chatgpt::HELP; /// 競合ゼロの導入・証明・プロセスメッシュ・交渉。 /// @@ -572,6 +573,7 @@ pub fn try_run_cli(args: &[String]) -> Option { 0 } "mcp" => crate::features::chat_bridge::cli_main(rest), + "chatgpt" => crate::features::chatgpt::cli_main(rest), // git フック (pre-commit 等) と CI がここを呼ぶ。実体は src/guard.rs。 "guard" => crate::features::guard::cli_main(rest), // 順次統合。実体は src/train.rs。 diff --git a/src/features/chatgpt.rs b/src/features/chatgpt.rs new file mode 100644 index 0000000..337c774 --- /dev/null +++ b/src/features/chatgpt.rs @@ -0,0 +1,21 @@ +//! Managed ChatGPT Secure MCP Tunnel lifecycle (no inference API). +#[cfg(unix)] +#[path = "../chatgpt/mod.rs"] +pub mod imp; +#[cfg(unix)] +pub use imp::cli_main; + +pub const HELP: &str = "\nChatGPT Secure MCP Tunnel:\n zai chatgpt setup [--reauth]\n zai chatgpt start [--foreground]\n zai chatgpt status|doctor|stop|reset|repair|test\n Runtime API Key authenticates the tunnel only; no OpenAI inference API is used.\n"; + +#[cfg(not(unix))] +pub fn cli_main(_args: &[String]) -> i32 { + eprintln!( + "ChatGPT Bridge is supported on macOS/Linux. Windows execution is not yet supported." + ); + 1 +} + +pub const FEATURE: crate::feature::Feature = crate::feature::Feature { + module: "chatgpt", + ..crate::feature::Feature::DEFAULT +}; diff --git a/src/features/cloud_execution/transport/mod.rs b/src/features/cloud_execution/transport/mod.rs index c763891..a61d6f4 100644 --- a/src/features/cloud_execution/transport/mod.rs +++ b/src/features/cloud_execution/transport/mod.rs @@ -63,10 +63,7 @@ pub trait ExecutionTransport: Send + Sync { /// 実行先に合う Transport を返す。**ここが Provider 名で分岐しない唯一の理由** /// — 見ているのは [`ExecutionTarget::transport`] だけで、その値を誰が作ったか /// (Hetzner か静的 SSH か) は 1 バイトも見ていない。 -pub fn for_target( - target: &ExecutionTarget, - timeout: Duration, -) -> Box { +pub fn for_target(target: &ExecutionTarget, timeout: Duration) -> Box { match target.transport { TransportKind::Local => Box::new(LocalTransport::new(timeout)), TransportKind::Ssh => Box::new(SshTransport::new(timeout)), @@ -79,6 +76,7 @@ enum Chunk { Err(Vec), OutEof, ErrEof, + ReadFailed, } /// 子プロセスを走らせ、出力を流しながら上限つきで待つ。 @@ -142,8 +140,12 @@ pub(crate) fn run_child_with_stdin( } } Err(mpsc::RecvTimeoutError::Timeout) => continue, - // 両方の読み手が落ちた (相手が消えた)。終了状態の確認へ進む。 - Err(mpsc::RecvTimeoutError::Disconnected) => break, + Ok(Chunk::ReadFailed) | Err(mpsc::RecvTimeoutError::Disconnected) => { + // A missing/read-failed response is not a completed command, + // even when the process subsequently returns a nonzero status. + let _ = kill_and_timeout(child, label, timeout); + return Err(CloudError::io(format!("{label} output unavailable"))); + } } } @@ -155,7 +157,7 @@ pub(crate) fn run_child_with_stdin( return Ok(ExecResult { exit_code: status.code(), duration_ms, - }) + }); } Ok(None) => { if started.elapsed() >= timeout { @@ -186,14 +188,19 @@ fn spawn_reader( is_stdout: bool, ) { let Some(mut stream) = stream else { - let _ = tx.send(if is_stdout { Chunk::OutEof } else { Chunk::ErrEof }); + let _ = tx.send(Chunk::ReadFailed); return; }; std::thread::spawn(move || { let mut buf = [0u8; 8192]; loop { match stream.read(&mut buf) { - Ok(0) | Err(_) => break, + Ok(0) => break, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => { + let _ = tx.send(Chunk::ReadFailed); + return; + } Ok(n) => { let chunk = buf[..n].to_vec(); let sent = tx.send(if is_stdout { @@ -207,7 +214,11 @@ fn spawn_reader( } } } - let _ = tx.send(if is_stdout { Chunk::OutEof } else { Chunk::ErrEof }); + let _ = tx.send(if is_stdout { + Chunk::OutEof + } else { + Chunk::ErrEof + }); }); } @@ -369,16 +380,42 @@ mod tests { let mut sink = CollectSink::default(); let r = run_child(cmd, Duration::from_secs(30), "test", &mut sink).expect("走る"); assert_eq!(r.exit_code, Some(3)); - assert!(sink.stdout_text().contains("hello"), "{}", sink.stdout_text()); + assert!( + sink.stdout_text().contains("hello"), + "{}", + sink.stdout_text() + ); } #[test] fn 無い道具は設定の誤りとして返る() { let cmd = Command::new("zaivern-no-such-program-xyz"); let mut sink = CollectSink::default(); - let e = run_child(cmd, Duration::from_secs(5), "zaivern-no-such-program-xyz", &mut sink) - .expect_err("失敗する"); + let e = run_child( + cmd, + Duration::from_secs(5), + "zaivern-no-such-program-xyz", + &mut sink, + ) + .expect_err("失敗する"); // 「実行時エラー」ではなく「設定の誤り」= 終了コード 3 assert_eq!(e.exit_code(), 3, "{e:?}"); } + + #[test] + fn reader_failure_is_not_reported_as_eof() { + struct Broken; + impl std::io::Read for Broken { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("injected read failure")) + } + } + let (tx, rx) = mpsc::channel(); + spawn_reader(Some(Broken), tx, true); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(5)).unwrap(), + Chunk::ReadFailed + )); + assert!(rx.recv_timeout(Duration::from_secs(5)).is_err()); + } } diff --git a/src/locale.rs b/src/locale.rs index 5a0c4c3..7bf9976 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -598,7 +598,9 @@ fn detect_windows() -> Option { /// 待つわけにはいかない** ので裏のスレッドで 1 度だけ引く。 #[cfg(target_os = "macos")] fn detect_macos() -> Option { - let out = std::process::Command::new("defaults") + // PATH may contain an Agent-editable project directory. Locale detection + // runs before CLI dispatch, so only the system utility is authoritative. + let out = std::process::Command::new("/usr/bin/defaults") .args(["read", "-g", "AppleLocale"]) .stdin(std::process::Stdio::null()) .output() @@ -687,6 +689,81 @@ pub const SOURCE_LANG: &str = "ja"; #[cfg(test)] mod tests { + #[cfg(target_os = "macos")] + #[test] + fn macos_locale_never_executes_defaults_from_path() { + use std::os::unix::fs::PermissionsExt; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + const CHILD: &str = "ZAIVERN_LOCALE_PATH_FIXTURE"; + if let Some(root) = std::env::var_os(CHILD) { + // Call the OS probe directly: LANG must not accidentally bypass it. + let _ = super::detect_macos(); + std::fs::write( + std::path::PathBuf::from(root).join("probe.completed"), + b"ok", + ) + .unwrap(); + return; + } + let root = crate::test_util::unique_temp_dir("locale", "defaults-path"); + let helper = root.join("defaults"); + let sentinel = root.join("defaults.SENTINEL"); + std::fs::write( + &helper, + b"#!/bin/sh\nprintf attacked > \"$0.SENTINEL\"\nprintf 'en_US\\n'\n", + ) + .unwrap(); + std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o700)).unwrap(); + let home = root.join("home"); + std::fs::create_dir(&home).unwrap(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "locale::tests::macos_locale_never_executes_defaults_from_path", + ]) + .env_clear() + .env(CHILD, &root) + .env("PATH", &root) + .env("HOME", &home) + .env("CFFIXED_USER_HOME", &home) + .env("ZAIVERN_HOME", root.join("zaivern")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(15); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("locale child exceeded deadline"); + } + std::thread::sleep(Duration::from_millis(25)); + }; + assert!(status.success()); + assert!( + root.join("probe.completed").exists(), + "child probe did not run" + ); + assert!(!sentinel.exists(), "PATH defaults was executed"); + // Positive control proves the exact poisoned PATH executable can run. + assert!(Command::new("defaults") + .env_clear() + .env("PATH", &root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success()); + assert!(sentinel.exists()); + std::fs::remove_dir_all(root).unwrap(); + } use super::*; #[test] diff --git a/src/main.rs b/src/main.rs index 377460a..81c429d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -146,6 +146,19 @@ fn load_icon() -> Option { fn main() -> eframe::Result<()> { let args: Vec = std::env::args().skip(1).collect(); + // tunnel-client inherits its authentication environment into the stdio + // target. Sanitize before locale detection can spawn any helper process. + #[cfg(unix)] + if args.first().is_some_and(|arg| arg == "chatgpt") + && args.get(1).is_some_and(|arg| { + matches!( + arg.as_str(), + "__mcp" | "__serve" | "__probe" | "__tunnel" | "__supervise" + ) + }) + { + std::process::exit(features::chatgpt::cli_main(&args[1..])); + } #[cfg(windows)] let (gui_child, args) = windows_startup::take_gui_marker(args); #[cfg(any(target_os = "linux", target_os = "macos"))] diff --git a/tools/mcp-fixture.rs b/tools/mcp-fixture.rs index b406631..c1cabf4 100644 --- a/tools/mcp-fixture.rs +++ b/tools/mcp-fixture.rs @@ -8,6 +8,9 @@ fn send(message: &str) { } fn main() { + // Authentication for the host tunnel must never enter the Agent container. + assert!(std::env::var_os("CONTROL_PLANE_API_KEY").is_none()); + assert!(std::env::var_os("CONTROL_PLANE_TUNNEL_ID").is_none()); let mut prompt_id = String::new(); let mut helper_only = false; let mut mixed = false;