diff --git a/Cargo.lock b/Cargo.lock index ff6f442b..c0f2cefe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -900,6 +909,17 @@ dependencies = [ "serde_core", ] +[[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.117", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -5131,6 +5151,7 @@ dependencies = [ "webcodex-persistent-shell", "webcodex-sandbox", "webcodex-workspace", + "zip", "zstd", ] @@ -6054,12 +6075,41 @@ dependencies = [ "syn 2.0.117", ] +[[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 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[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 = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index a9879487..d6fa142f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ url = "2" sha2 = "0.10" tempfile = "3" zstd = "0.13" +zip = { version = "2", default-features = false, features = ["deflate"] } # Custom QUIC agent transport. Quinn reuses the # rustls 0.23 + aws-lc-rs provider already present transitively (via # tokio-tungstenite / salvo). `rustls-pemfile` loads PEM cert/key on the diff --git a/README.md b/README.md index d04993ea..1a40d5f3 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,24 @@ A default `share` URL and credential are temporary and stop working when the command exits. `webcodex share --tunnel none` is available for local-only MCP debugging and does not require `cloudflared`. +### Optional: OpenAI Secure MCP Tunnel + +If the repository should be reachable only from a supported OpenAI product, use +`webcodex share --tunnel openai`. Create/select a Secure MCP Tunnel in the OpenAI +Platform first, then export `CONTROL_PLANE_TUNNEL_ID` and a Restricted +`CONTROL_PLANE_API_KEY` with **Tunnels Read + Use**. WebCodex reuses a matching +`tunnel-client` from `WEBCODEX_TUNNEL_CLIENT_BIN` or `PATH`, or downloads and +verifies pinned OpenAI `tunnel-client` v0.0.12 for Linux/macOS amd64/arm64. + +This provider keeps the temporary WebCodex Bearer credential in private local +share state and gives `tunnel-client` a file-backed `Authorization` header for +the loopback MCP hop. In ChatGPT choose **Connection: Tunnel**, select/paste the +Tunnel ID, and choose **No authentication**; do not paste the local WebCodex +credential into ChatGPT. `--tunnel openai` currently supports the default +`--auth bearer` path only. Ctrl-C stops the local runtime and `tunnel-client` and +removes the temporary WebCodex credential; the Platform Tunnel identity remains +operator-managed for later reuse. + ## What happens after the first connection WebCodex can read/search files, prepare guarded edits, run commands and focused diff --git a/README.zh-CN.md b/README.zh-CN.md index 926813b0..06e30aba 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -63,6 +63,22 @@ credential,以 CLI 成功输出为准。 默认 `share` 的 URL 与 credential 都是临时的,命令退出后失效。仅做本地 MCP 调试时可用 `webcodex share --tunnel none`,此模式不需要 `cloudflared`。 +### 可选:OpenAI Secure MCP Tunnel + +如果只希望受支持的 OpenAI 产品访问本机仓库,可以使用 +`webcodex share --tunnel openai`。先在 OpenAI Platform 创建/选择 Secure MCP Tunnel, +然后导出 `CONTROL_PLANE_TUNNEL_ID` 与只授予 **Tunnels Read + Use** 的 Restricted +`CONTROL_PLANE_API_KEY`。WebCodex 会依次复用 `WEBCODEX_TUNNEL_CLIENT_BIN`、`PATH` +中匹配的 `tunnel-client`,否则为 Linux/macOS amd64/arm64 下载并校验固定的 OpenAI +`tunnel-client` v0.0.12。 + +这条路径把临时 WebCodex Bearer credential 留在私有本地 share state,只通过 file-backed +`Authorization` header 交给 `tunnel-client` 访问 loopback MCP。在 ChatGPT 里选择 +**Connection: Tunnel**,选择/粘贴 Tunnel ID,并把认证选择为 **No authentication**;不要把 +本地 WebCodex credential 粘贴到 ChatGPT。`--tunnel openai` 当前只支持默认的 +`--auth bearer`。Ctrl-C 会停止本地 runtime 与 `tunnel-client` 并删除临时 WebCodex +credential;Platform Tunnel identity 仍由 operator 管理,可以以后继续复用。 + ## 第一次连接以后 WebCodex 可以读取/搜索文件、准备受保护的修改、运行命令与聚焦校验、查看 Git,并让长时间 diff --git a/docs/AI_ONBOARDING.md b/docs/AI_ONBOARDING.md index 85ba9d42..2225c050 100644 --- a/docs/AI_ONBOARDING.md +++ b/docs/AI_ONBOARDING.md @@ -30,9 +30,9 @@ Do not invent `https://your-server.example` as a prerequisite for a new user. ## First-time ChatGPT path: `share` -Verify Git and the target repository, then verify that `cloudflared` is installed -for the default public share. If it is absent, point the user to Cloudflare's -official downloads rather than installing third-party executables silently. +Verify Git and the target repository. For the default public share, WebCodex +reuses `WEBCODEX_CLOUDFLARED_BIN` / `PATH` or automatically downloads its pinned, +verified managed `cloudflared`; do not ask a first-time user to install it first. ```bash npm install -g @yyjeqhc/webcodex @@ -59,6 +59,14 @@ client-side permissions are separate from WebCodex authorization: For local-only debugging, `webcodex share --tunnel none` avoids the Cloudflare Quick Tunnel and does not require `cloudflared`. +When the user explicitly wants OpenAI-only private reachability and already has +an OpenAI Secure MCP Tunnel, use `webcodex share --tunnel openai`. Require +`CONTROL_PLANE_TUNNEL_ID` and a Restricted `CONTROL_PLANE_API_KEY` with Tunnels +Read + Use. In ChatGPT choose Connection: Tunnel and No authentication; WebCodex +keeps its temporary Bearer local and injects it through `tunnel-client`. Do not +ask the human to paste that local credential into ChatGPT. Keep Cloudflare Quick +as the ordinary zero-Platform-setup default. + ## Existing shared-key Server path: `connect` Use this path only when a hosted Server is already configured for shared-key diff --git a/docs/AI_ONBOARDING.zh-CN.md b/docs/AI_ONBOARDING.zh-CN.md index 0dc04a91..12af91c9 100644 --- a/docs/AI_ONBOARDING.zh-CN.md +++ b/docs/AI_ONBOARDING.zh-CN.md @@ -28,8 +28,8 @@ Server。不要在 Windows 上推荐 `share`。 ## 第一次 ChatGPT 接入:`share` -先确认 Git 和目标仓库;默认公网 share 还要确认 `cloudflared` 已安装。如果缺少它,告诉用户 -使用 Cloudflare 官方下载,不要静默增加“自动下载第三方 executable”的行为。 +先确认 Git 和目标仓库。默认公网 share 会复用 `WEBCODEX_CLOUDFLARED_BIN` / `PATH`, +没有时由 WebCodex 自动下载并校验固定的 managed `cloudflared`;不要要求第一次用户先手动安装。 ```bash npm install -g @yyjeqhc/webcodex @@ -55,6 +55,12 @@ ChatGPT 套餐、workspace 和管理员设置控制,这些客户端侧权限 仅做本地调试时可用 `webcodex share --tunnel none`,不启动 Cloudflare Quick Tunnel,也不 需要 `cloudflared`。 +如果用户明确只需要 OpenAI 产品的私有可达性,并且已经创建 OpenAI Secure MCP Tunnel, +使用 `webcodex share --tunnel openai`。要求 `CONTROL_PLANE_TUNNEL_ID` 和只授予 Tunnels +Read + Use 的 Restricted `CONTROL_PLANE_API_KEY`。ChatGPT 侧选择 Connection: Tunnel + +No authentication;WebCodex 临时 Bearer 留在本机,由 `tunnel-client` 注入,不要让人类把它 +粘贴到 ChatGPT。普通零 Platform 配置的 first-run 默认仍然是 Cloudflare Quick。 + ## 已有 shared-key Server:`connect` 只有 hosted Server 已明确配置 shared-key client,并且 operator 已提供该 client credential diff --git a/docs/CLI.md b/docs/CLI.md index 49942a85..3fe367a2 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -50,6 +50,8 @@ These commands work on the current Git project. Quick Tunnel origins remain temporary. For an operator-managed stable HTTPS origin, use `--tunnel none --public-url https://share.example` and route that origin to the loopback WebCodex Server yourself; `--public-url` advertises the external origin/issuer and does not create a proxy or tunnel. +`webcodex share --tunnel openai` is the explicit OpenAI Secure MCP Tunnel provider. It requires `CONTROL_PLANE_TUNNEL_ID` plus a Restricted `CONTROL_PLANE_API_KEY` with Tunnels Read + Use and currently supports only `--auth bearer`. WebCodex resolves pinned OpenAI `tunnel-client` v0.0.12 from `WEBCODEX_TUNNEL_CLIENT_BIN`, `PATH`, or a verified managed download; it runs `doctor` before the daemon and waits for `/readyz`. The temporary WebCodex Bearer is written only to the private share directory and referenced by `tunnel-client` through a file-backed MCP `Authorization` header. ChatGPT therefore uses Connection: Tunnel + No authentication. `OPENAI_ADMIN_KEY` and `OPENAI_API_KEY` are explicitly removed from the long-lived daemon environment; the Runtime API key remains the control-plane authority. + For public `share`, WebCodex best-effort copies only the MCP URL to the clipboard; it never copies the temporary credential. Interactive Linux/macOS terminals also offer an Enter shortcut to open ChatGPT App settings. Clipboard/browser integration is convenience-only and never gates runtime readiness. Use `--no-copy-url` to suppress clipboard access. `webcodex connect --auth oauth --oauth-redirect-uri ` is the ordinary ChatGPT OAuth path for hosted connect. It uses the same `wck_*` shared-key identity as the Runner and keeps the direct shared-key baseline unchanged: `runtime:read`, `project:read`, `project:write`, `job:run`, `computer:read`, and `computer:control`. A fresh OAuth client starts with that full baseline, while an existing protected client may carry a valid narrower baseline subset. Adding `--oauth-computer-permissions` is an explicit client-ceiling opt-in that appends only `computer:launch`, `computer:display_read`, `computer:pointer_control`, `computer:clipboard_read`, and `computer:clipboard_write` to the existing baseline subset; it never restores baseline scopes that were previously absent and does not grant the optional scopes by itself. The WebCodex authorize page presents eligible additional Computer permissions unchecked, and only the selected permissions enter the authorization code/access/refresh grant. Launch consent requires the OAuth request to contain both `computer:read` and `computer:launch`; missing prerequisites are disabled rather than filled in by WebCodex. A real ceiling expansion revokes existing grants and requires reauthorization; ordinary reconnect never widens a baseline client. `account:manage`, `admin`, `job:detach`, every `agent:*` scope, and future scopes are never part of this picker. Runner capability shown on the consent page is current backend availability, not a guarantee that native/OS permission will succeed; runtime calls recheck current capability and native preflight. OAuth access tokens remain invalid on Agent transport. diff --git a/docs/CLI.zh-CN.md b/docs/CLI.zh-CN.md index bf9aedda..b5b210de 100644 --- a/docs/CLI.zh-CN.md +++ b/docs/CLI.zh-CN.md @@ -45,6 +45,8 @@ MCP endpoint。默认 Quick Tunnel 会依次优先使用 `WEBCODEX_CLOUDFLARED_B Cloudflare Quick Tunnel 的公网 origin 仍然是临时的。如需稳定 HTTPS origin,可使用 `--tunnel none --public-url https://share.example`,并由 operator 自己把该 origin 反向代理/隧道到 loopback WebCodex Server;`--public-url` 只声明外部 origin/issuer,不会创建代理或 tunnel。 +`webcodex share --tunnel openai` 是显式 opt-in 的 OpenAI Secure MCP Tunnel provider。它要求 `CONTROL_PLANE_TUNNEL_ID` 与只授予 Tunnels Read + Use 的 Restricted `CONTROL_PLANE_API_KEY`,当前只支持 `--auth bearer`。WebCodex 会从 `WEBCODEX_TUNNEL_CLIENT_BIN`、`PATH` 或经过校验的 managed 下载解析固定 OpenAI `tunnel-client` v0.0.12;启动 daemon 前运行 `doctor`,并等待 `/readyz`。临时 WebCodex Bearer 只写入私有 share 目录,通过 file-backed MCP `Authorization` header 交给 `tunnel-client`,因此 ChatGPT 使用 Connection: Tunnel + No authentication。长驻 daemon 环境会显式移除 `OPENAI_ADMIN_KEY` 与 `OPENAI_API_KEY`;Runtime API key 仍只承担 control-plane authority。 + 公网 `share` 会 best-effort 只把 MCP URL 复制到剪贴板,绝不会自动复制临时 credential;Linux/macOS 交互式终端还会提供按 Enter 打开 ChatGPT App 设置的快捷入口。剪贴板/浏览器集成都只是 convenience,失败不会影响已经 ready 的 runtime。使用 `--no-copy-url` 可关闭剪贴板访问。 `webcodex connect --auth oauth --oauth-redirect-uri <精确回调地址>` 是普通 hosted connect 面向 ChatGPT 的 OAuth 路径。它继续使用 Runner 的同一个 `wck_*` shared-key 身份,并保持 direct shared-key baseline 不变:`runtime:read`、`project:read`、`project:write`、`job:run`、`computer:read`、`computer:control`。fresh OAuth client 从完整 baseline 开始,但已有受保护 client 可以合法持有更窄的 baseline subset。只有显式增加 `--oauth-computer-permissions`,client ceiling 才在**现有 baseline subset**上追加固定的 `computer:launch`、`computer:display_read`、`computer:pointer_control`、`computer:clipboard_read`、`computer:clipboard_write`;不会恢复此前缺失的 baseline scope,该 flag 本身也不会 grant optional scope。WebCodex authorize 页面只把当前合法且可用的 Additional Computer permissions 以默认未勾选 checkbox 展示,真正进入 authorization code/access/refresh grant 的只有用户选择项。Launch consent 要求本次 OAuth request 同时包含 `computer:read` 与 `computer:launch`;缺失 prerequisite 时页面会禁用,而不是由 Server 偷偷补 scope。client ceiling 真正扩大时会撤销旧 grant 并要求重新授权;普通 reconnect 不会静默扩大 baseline client。`account:manage`、`admin`、`job:detach`、任何 `agent:*` 与未来新增 scope 永远不进入 picker。授权页显示的 Runner capability 只表示当前 backend support,不保证 OS/native permission 一定成功;runtime 调用仍会实时重新检查 capability 与 native preflight。OAuth access token 仍不能用于 Agent transport。 diff --git a/docs/MCP.md b/docs/MCP.md index c7f09f46..886d5a61 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -13,8 +13,8 @@ The self-contained `share` path below requires a local WebCodex Server and is supported on Linux/macOS. Windows users should connect the Runner to an existing remote Linux Server with `webcodex connect ` instead. -For the default temporary public path, install -[`cloudflared`](https://developers.cloudflare.com/tunnel/downloads/) and run: +For the default temporary public path, WebCodex reuses an explicit/PATH +`cloudflared` or downloads its pinned verified managed copy automatically, then run: ```bash npm install -g @yyjeqhc/webcodex @@ -47,6 +47,12 @@ with the same endpoint and the authentication mechanism reported by the CLI. For local-only clients, `webcodex share --tunnel none` exposes the loopback MCP endpoint without `cloudflared`. +For an OpenAI-only private transport, create/select a Secure MCP Tunnel, export +`CONTROL_PLANE_TUNNEL_ID` plus a Restricted `CONTROL_PLANE_API_KEY` with Tunnels +Read + Use, and run `webcodex share --tunnel openai`. ChatGPT uses Connection: +Tunnel + No authentication; the temporary WebCodex Bearer stays local and is +injected by the pinned verified OpenAI `tunnel-client`. + ## Existing Server For an existing hosted Server intentionally configured for shared-key clients, diff --git a/docs/MCP.zh-CN.md b/docs/MCP.zh-CN.md index fe81e3a4..18cdac5d 100644 --- a/docs/MCP.zh-CN.md +++ b/docs/MCP.zh-CN.md @@ -12,8 +12,8 @@ reference,不是 onboarding 前置知识。 Windows 用户应改用 `webcodex connect `,把 Runner 连接到已有的远程 Linux Server。 -默认临时公网路径先安装 -[`cloudflared`](https://developers.cloudflare.com/tunnel/downloads/),然后执行: +默认临时公网路径会复用显式指定/`PATH` 中的 `cloudflared`,否则由 WebCodex 自动下载并校验 +固定的 managed 副本,然后执行: ```bash npm install -g @yyjeqhc/webcodex @@ -42,6 +42,12 @@ WebCodex scope 不会扩大这些客户端侧权限。 其他 MCP client 同样使用 CLI 报告的 endpoint 与认证方式。仅本地 client 可用 `webcodex share --tunnel none`,不需要 `cloudflared`。 +如果只需要 OpenAI 产品的私有 transport,创建/选择 Secure MCP Tunnel,导出 +`CONTROL_PLANE_TUNNEL_ID` 与只授予 Tunnels Read + Use 的 Restricted +`CONTROL_PLANE_API_KEY`,然后运行 `webcodex share --tunnel openai`。ChatGPT 使用 +Connection: Tunnel + No authentication;临时 WebCodex Bearer 留在本机,由固定且经过校验的 +OpenAI `tunnel-client` 注入。 + ## 已有 Server 对于已经明确配置为 shared-key client 接入的 hosted Server,使用 operator 提供的 credential diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index ddd6f5c6..2807c40e 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -85,6 +85,20 @@ terminal, press Enter to open ChatGPT App settings. Then: Use `webcodex share --no-copy-url` to suppress the clipboard attempt. +For the opt-in OpenAI-only transport, configure an OpenAI Secure MCP Tunnel and +a Restricted Runtime API key with Tunnels Read + Use, export +`CONTROL_PLANE_TUNNEL_ID` and `CONTROL_PLANE_API_KEY`, then run: + +```bash +webcodex share --tunnel openai +``` + +WebCodex auto-resolves pinned verified OpenAI `tunnel-client` v0.0.12 (or a +matching `WEBCODEX_TUNNEL_CLIENT_BIN` / `PATH` binary), runs its doctor/readiness +checks, and keeps the temporary WebCodex Bearer credential local. In ChatGPT use +**Connection: Tunnel** and **No authentication**. The default Cloudflare Quick +Tunnel path remains unchanged and client-agnostic. + The Console intentionally does not display that credential. If you later open `/console`, get connection credentials from the successful CLI output, not from the browser page. ChatGPT Developer Mode, custom MCP apps, and write/modify diff --git a/docs/QUICK_START.zh-CN.md b/docs/QUICK_START.zh-CN.md index 99276f1c..ad424ca4 100644 --- a/docs/QUICK_START.zh-CN.md +++ b/docs/QUICK_START.zh-CN.md @@ -75,6 +75,19 @@ Enter 打开 ChatGPT App 设置。然后: 如不希望访问剪贴板,使用 `webcodex share --no-copy-url`。 +如果只需要 OpenAI 产品访问,可以先创建 OpenAI Secure MCP Tunnel 与仅授予 Tunnels +Read + Use 的 Restricted Runtime API key,导出 `CONTROL_PLANE_TUNNEL_ID` 和 +`CONTROL_PLANE_API_KEY`,然后运行: + +```bash +webcodex share --tunnel openai +``` + +WebCodex 会自动解析固定且经过校验的 OpenAI `tunnel-client` v0.0.12(也可使用匹配的 +`WEBCODEX_TUNNEL_CLIENT_BIN` / `PATH` binary),运行 doctor/readiness,并把临时 +WebCodex Bearer credential 留在本机。ChatGPT 侧使用 **Connection: Tunnel** 与 +**No authentication**。默认 Cloudflare Quick Tunnel 路径保持不变,仍然不绑定特定 MCP client。 + Console 故意不显示 credential。以后即使打开 `/console`,认证值也应来自成功的 CLI 首次 输出,而不是浏览器页面。ChatGPT Developer Mode、custom MCP app 与 write/modify action 还分别受 ChatGPT 套餐、workspace 和管理员设置控制;客户端 workspace 没有允许的 action, diff --git a/npm/webcodex/README.md b/npm/webcodex/README.md index fbc0531f..123d5e97 100644 --- a/npm/webcodex/README.md +++ b/npm/webcodex/README.md @@ -62,6 +62,12 @@ Use `webcodex share --no-copy-url` to disable clipboard access. The default share is temporary and ends when the command exits. For local-only MCP debugging, `webcodex share --tunnel none` does not require `cloudflared`. +For OpenAI-only private reachability, users who already have an OpenAI Secure MCP +Tunnel can export `CONTROL_PLANE_TUNNEL_ID` plus a Restricted +`CONTROL_PLANE_API_KEY` with Tunnels Read + Use and run +`webcodex share --tunnel openai`. WebCodex resolves pinned verified OpenAI +`tunnel-client` v0.0.12 and keeps the temporary WebCodex Bearer local; ChatGPT +uses Connection: Tunnel + No authentication. ChatGPT Developer Mode, custom MCP apps, and write/modify actions are controlled by the ChatGPT plan, workspace, and admin settings; WebCodex cannot widen those client-side permissions. @@ -154,6 +160,12 @@ credential;交互式终端可以按 Enter 打开 ChatGPT App 设置。然后 MCP app 与 write/modify action 受 ChatGPT 套餐、workspace 和管理员设置控制;WebCodex 不能扩大这些客户端侧权限。 +如果只需要 OpenAI 产品的私有可达性,已经创建 Secure MCP Tunnel 的用户可以导出 +`CONTROL_PLANE_TUNNEL_ID` 与只授予 Tunnels Read + Use 的 Restricted +`CONTROL_PLANE_API_KEY`,然后运行 `webcodex share --tunnel openai`。WebCodex 会解析固定且 +经过校验的 OpenAI `tunnel-client` v0.0.12,并把临时 WebCodex Bearer 留在本机;ChatGPT +使用 Connection: Tunnel + No authentication。 + 如果你已经运营一个明确支持 shared-key client 的 hosted WebCodex Server,使用 operator 提供的 client key 走长期路径: diff --git a/src/project_entry.rs b/src/project_entry.rs index 4bdf613a..640548bf 100644 --- a/src/project_entry.rs +++ b/src/project_entry.rs @@ -8,6 +8,8 @@ mod client_handoff_service; #[path = "project_entry_cloudflared.rs"] mod cloudflared_service; +#[path = "project_entry_openai_tunnel.rs"] +mod openai_tunnel_service; #[path = "project_entry_setup.rs"] mod setup_service; #[path = "project_entry_share.rs"] @@ -224,7 +226,7 @@ pub(crate) fn parse_options( pub(crate) fn usage() -> &'static str { "Usage: webcodex share [--root PATH] [--profile NAME] [--state-dir PATH]\n\ - [--tunnel cloudflare|none] [--auth bearer|oauth]\n\ + [--tunnel cloudflare|openai|none] [--auth bearer|oauth]\n\ [--oauth-redirect-uri URL] [--public-url URL] [--no-copy-url]\n\ webcodex status [--root PATH] [--profile NAME] [--state-dir PATH] [--json]\n\ webcodex doctor [--root PATH] [--profile NAME] [--state-dir PATH] [--json]\n\ @@ -233,8 +235,10 @@ pub(crate) fn usage() -> &'static str { [--console-assets-dir ABSOLUTE_PATH]\n\n\ `share` is the first-run path for ChatGPT/remote MCP: it performs project setup,\n\ starts the local Server + Runner, and exposes a temporary credential. The default\n\ -Cloudflare Quick Tunnel reuses or auto-manages a verified `cloudflared`. Public\n\ -share best-effort copies only the MCP URL; use `--no-copy-url` to disable that.\n\ +Cloudflare Quick Tunnel reuses or auto-manages a verified `cloudflared`. The opt-in\n\ +OpenAI Secure MCP Tunnel provider uses a pinned verified `tunnel-client` and keeps\n\ +the temporary WebCodex Bearer credential local. Public URL sharing best-effort\n\ +copies only the MCP URL; use `--no-copy-url` to disable that.\n\ `setup`, `doctor`, and `run` remain the local/manual workflow; setup writes private state without\n\ starting services. `run` is the explicit foreground local runtime step. Its optional\n\ `--console-assets-dir` enables loopback-only development assets for that run.\n\ @@ -638,6 +642,7 @@ pub(super) struct LocalRuntimeOptions { pub(super) public_url: Option, pub(super) connector_credential_file: Option, pub(super) project_share_oauth: Option, + pub(super) child_environment_remove: Vec<&'static str>, pub(super) port_conflict_action: &'static str, } @@ -647,6 +652,7 @@ impl Default for LocalRuntimeOptions { public_url: None, connector_credential_file: None, project_share_oauth: None, + child_environment_remove: Vec::new(), port_conflict_action: "Stop the conflicting process, then run webcodex run.", } } @@ -753,6 +759,9 @@ pub(super) async fn start_local_runtime( let server_error = server_log.try_clone().map_err(io_error)?; let mut server_command = Command::new(server_binary); remove_npm_wrapper_network_environment(&mut server_command); + for name in &runtime_options.child_environment_remove { + server_command.env_remove(name); + } server_command .current_dir(&paths.state) .env_remove("WEBCODEX_ENV_FILE") @@ -821,6 +830,9 @@ pub(super) async fn start_local_runtime( let agent_error = agent_log.try_clone().map_err(io_error)?; let mut agent_command = Command::new(agent_binary); remove_npm_wrapper_network_environment(&mut agent_command); + for name in &runtime_options.child_environment_remove { + agent_command.env_remove(name); + } agent_command .arg("--config") .arg(&paths.agent_config) diff --git a/src/project_entry_cloudflared.rs b/src/project_entry_cloudflared.rs index bf995116..85326971 100644 --- a/src/project_entry_cloudflared.rs +++ b/src/project_entry_cloudflared.rs @@ -33,7 +33,7 @@ struct CloudflaredAsset { } #[derive(Debug, Clone, Default, PartialEq, Eq)] -struct NpmNetworkSettings { +pub(super) struct NpmNetworkSettings { https_proxy: Option, proxy: Option, noproxy: Option, @@ -43,7 +43,7 @@ struct NpmNetworkSettings { } #[derive(Debug, Clone, Default, PartialEq, Eq)] -struct DownloadNetworkConfig { +pub(super) struct DownloadNetworkConfig { proxy: Option, no_proxy: Option, ca_pem: Option>, @@ -86,7 +86,7 @@ where } } -async fn effective_npm_network_settings() -> NpmNetworkSettings { +pub(super) async fn effective_npm_network_settings() -> NpmNetworkSettings { let environment = npm_network_settings_from_env_with(|name| std::env::var(name).ok()); if std::env::var(NPM_WRAPPER_MARKER).ok().as_deref() != Some("1") { return environment; @@ -168,7 +168,7 @@ async fn query_npm_config_value_with(program: &OsStr, key: &str) -> Option( +pub(super) fn resolve_download_network_config_with( url: &str, npm: &NpmNetworkSettings, get: F, @@ -257,7 +257,7 @@ fn read_npm_ca_file(path: &Path) -> Result, ProductError> { Ok(bytes) } -fn build_cloudflared_download_client( +pub(super) fn build_managed_download_client( network: &DownloadNetworkConfig, ) -> Result { let mut builder = reqwest::Client::builder() @@ -524,7 +524,7 @@ async fn download_cloudflared_asset_with_network( destination: &Path, network: &DownloadNetworkConfig, ) -> Result<(), ProductError> { - let client = build_cloudflared_download_client(network)?; + let client = build_managed_download_client(network)?; let mut response = client .get(url) .header( diff --git a/src/project_entry_cloudflared_tests.rs b/src/project_entry_cloudflared_tests.rs index 2079ed96..d4316f19 100644 --- a/src/project_entry_cloudflared_tests.rs +++ b/src/project_entry_cloudflared_tests.rs @@ -246,7 +246,7 @@ fn npm_cafile_is_bounded_and_errors_do_not_expose_paths_or_ca_content() { proxy: Some("http://user:proxy-secret@[invalid".to_string()), ..DownloadNetworkConfig::default() }; - let error = build_cloudflared_download_client(&invalid_proxy).unwrap_err(); + let error = build_managed_download_client(&invalid_proxy).unwrap_err(); assert!(error.message.contains("proxy URL")); assert!(!error.message.contains("proxy-secret")); @@ -256,7 +256,7 @@ fn npm_cafile_is_bounded_and_errors_do_not_expose_paths_or_ca_content() { ), ..DownloadNetworkConfig::default() }; - let error = build_cloudflared_download_client(&invalid_ca).unwrap_err(); + let error = build_managed_download_client(&invalid_ca).unwrap_err(); assert!(error.message.contains("CA bundle")); assert!(!error.message.contains("private-ca-secret")); } diff --git a/src/project_entry_openai_tunnel.rs b/src/project_entry_openai_tunnel.rs new file mode 100644 index 00000000..e7405d4a --- /dev/null +++ b/src/project_entry_openai_tunnel.rs @@ -0,0 +1,647 @@ +use super::{ + cloudflared_service, executable_name, remove_npm_wrapper_network_environment, ProductError, +}; +use reqwest::header::USER_AGENT; +use sha2::{Digest, Sha256}; +use std::ffi::OsStr; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; +use tokio::process::{Child, Command}; + +const TUNNEL_CLIENT_VERSION: &str = "0.0.12"; +const TUNNEL_CLIENT_RELEASE_BASE: &str = + "https://github.com/openai/tunnel-client/releases/download/v0.0.12"; +const TUNNEL_CLIENT_MAX_DOWNLOAD_BYTES: usize = 64 * 1024 * 1024; +const TUNNEL_CLIENT_MAX_BINARY_BYTES: u64 = 64 * 1024 * 1024; +const TUNNEL_CLIENT_VERIFY_TIMEOUT: Duration = Duration::from_secs(10); +const TUNNEL_CLIENT_DOCTOR_TIMEOUT: Duration = Duration::from_secs(30); +const TUNNEL_CLIENT_READY_TIMEOUT: Duration = Duration::from_secs(45); +const TUNNEL_CLIENT_READY_PROBE_TIMEOUT: Duration = Duration::from_secs(2); +const TUNNEL_CLIENT_HEALTH_URL_BYTES: usize = 512; +const TUNNEL_CLIENT_OVERRIDE: &str = "WEBCODEX_TUNNEL_CLIENT_BIN"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TunnelClientAsset { + target: &'static str, + file_name: &'static str, + archive_sha256: &'static str, + binary_sha256: &'static str, +} + +#[derive(Debug)] +pub(super) struct OpenAiTunnelPrerequisites { + pub(super) binary: PathBuf, + pub(super) tunnel_id: String, +} + +#[derive(Debug)] +pub(super) struct OpenAiTunnel { + child: Child, +} + +impl OpenAiTunnel { + pub(super) async fn wait_for_exit(&mut self) -> Result<(), ProductError> { + let status = + self.child.wait().await.map_err(|_| { + tunnel_runtime_error("OpenAI tunnel-client could not be supervised") + })?; + Err(ProductError::new( + "tunnel_unavailable", + format!("OpenAI Secure MCP Tunnel stopped unexpectedly ({status})"), + Some("Check the OpenAI tunnel-client and network connectivity, then retry webcodex share --tunnel openai."), + )) + } + + pub(super) async fn stop(&mut self) { + let _ = self.child.start_kill(); + let _ = self.child.wait().await; + } +} + +pub(super) async fn prepare_openai_tunnel() -> Result { + let tunnel_id = required_tunnel_id()?; + require_runtime_api_key()?; + let binary = resolve_tunnel_client().await?; + Ok(OpenAiTunnelPrerequisites { binary, tunnel_id }) +} + +pub(super) async fn start_openai_tunnel( + prerequisites: &OpenAiTunnelPrerequisites, + mcp_url: &str, + authorization_file: &Path, + session_dir: &Path, +) -> Result { + run_doctor(prerequisites, mcp_url, authorization_file).await?; + + let health_url_file = session_dir.join("openai-tunnel-health-url"); + let log_file = session_dir.join("openai-tunnel.log"); + let mut command = Command::new(&prerequisites.binary); + command.arg("run"); + configure_runtime_command(&mut command, prerequisites, mcp_url, authorization_file); + command + .arg("--health.listen-addr") + .arg("127.0.0.1:0") + .arg("--health.url-file") + .arg(&health_url_file) + .arg("--log.file") + .arg(&log_file) + .arg("--log.format") + .arg("json") + .arg("--log.level") + .arg("info") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let mut child = command + .spawn() + .map_err(|_| tunnel_runtime_error("OpenAI tunnel-client could not start"))?; + + if let Err(error) = wait_until_ready(&mut child, &health_url_file).await { + let _ = child.start_kill(); + let _ = child.wait().await; + return Err(error); + } + Ok(OpenAiTunnel { child }) +} + +fn configure_runtime_command( + command: &mut Command, + prerequisites: &OpenAiTunnelPrerequisites, + mcp_url: &str, + authorization_file: &Path, +) { + remove_npm_wrapper_network_environment(command); + command + .env("CONTROL_PLANE_TUNNEL_ID", &prerequisites.tunnel_id) + // The runtime key remains in the inherited environment. Explicitly keep + // broader OpenAI authority out of the long-lived tunnel daemon. + .env_remove("OPENAI_ADMIN_KEY") + .env_remove("OPENAI_API_KEY") + .arg("--mcp.server-url") + .arg(format!("url={mcp_url},channel=main")) + .arg("--mcp.extra-headers") + .arg(format!( + "Authorization: file:{}", + authorization_file.to_string_lossy() + )); +} + +async fn run_doctor( + prerequisites: &OpenAiTunnelPrerequisites, + mcp_url: &str, + authorization_file: &Path, +) -> Result<(), ProductError> { + let mut command = Command::new(&prerequisites.binary); + command.arg("doctor"); + configure_runtime_command(&mut command, prerequisites, mcp_url, authorization_file); + command + .arg("--health.listen-addr") + .arg("127.0.0.1:0") + .arg("--json") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let status = tokio::time::timeout(TUNNEL_CLIENT_DOCTOR_TIMEOUT, command.status()) + .await + .map_err(|_| { + tunnel_runtime_error( + "OpenAI tunnel-client doctor timed out before validating the connection", + ) + })? + .map_err(|_| tunnel_runtime_error("OpenAI tunnel-client doctor could not start"))?; + if !status.success() { + return Err(ProductError::new( + "tunnel_unavailable", + "OpenAI tunnel-client doctor rejected the Secure MCP Tunnel configuration", + Some("Check CONTROL_PLANE_TUNNEL_ID, CONTROL_PLANE_API_KEY, Tunnel workspace scope, and network access, then retry."), + )); + } + Ok(()) +} + +async fn wait_until_ready(child: &mut Child, health_url_file: &Path) -> Result<(), ProductError> { + let client = reqwest::Client::builder() + .connect_timeout(TUNNEL_CLIENT_READY_PROBE_TIMEOUT) + .timeout(TUNNEL_CLIENT_READY_PROBE_TIMEOUT) + .no_proxy() + .build() + .map_err(|_| { + tunnel_runtime_error("WebCodex could not initialize the local tunnel readiness probe") + })?; + let deadline = Instant::now() + TUNNEL_CLIENT_READY_TIMEOUT; + let mut health_base = None; + + loop { + if let Some(status) = child + .try_wait() + .map_err(|_| tunnel_runtime_error("OpenAI tunnel-client could not be supervised"))? + { + return Err(ProductError::new( + "tunnel_unavailable", + format!("OpenAI tunnel-client exited before becoming ready ({status})"), + Some("Check the Tunnel ID, runtime API key permissions, local WebCodex authentication, and network access, then retry."), + )); + } + + if health_base.is_none() && health_url_file.is_file() { + health_base = read_loopback_health_url(health_url_file).ok(); + } + if let Some(base) = health_base.as_ref() { + if let Ok(response) = client.get(format!("{base}/readyz")).send().await { + if response.status().is_success() { + return Ok(()); + } + } + } + + if Instant::now() >= deadline { + return Err(ProductError::new( + "tunnel_unavailable", + "OpenAI Secure MCP Tunnel did not become ready before the startup timeout", + Some("Check CONTROL_PLANE_TUNNEL_ID, CONTROL_PLANE_API_KEY, Tunnel workspace scope, and local MCP reachability, then retry."), + )); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +fn read_loopback_health_url(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path) + .map_err(|_| tunnel_runtime_error("OpenAI tunnel-client health URL could not be read"))?; + if !metadata.file_type().is_file() || metadata.len() > TUNNEL_CLIENT_HEALTH_URL_BYTES as u64 { + return Err(tunnel_runtime_error( + "OpenAI tunnel-client health URL file is invalid", + )); + } + let value = fs::read_to_string(path) + .map_err(|_| tunnel_runtime_error("OpenAI tunnel-client health URL could not be read"))?; + let value = value.trim(); + let parsed = url::Url::parse(value) + .map_err(|_| tunnel_runtime_error("OpenAI tunnel-client health URL is invalid"))?; + let host = parsed.host_str().unwrap_or(""); + if parsed.scheme() != "http" + || !matches!(host, "127.0.0.1" | "localhost" | "::1" | "[::1]") + || parsed.port().is_none() + || !matches!(parsed.path(), "" | "/") + || parsed.username() != "" + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(tunnel_runtime_error( + "OpenAI tunnel-client health URL is not a loopback HTTP origin", + )); + } + Ok(value.trim_end_matches('/').to_string()) +} + +fn required_tunnel_id() -> Result { + let value = std::env::var("CONTROL_PLANE_TUNNEL_ID") + .map_err(|_| missing_tunnel_configuration("CONTROL_PLANE_TUNNEL_ID is not set"))?; + if !valid_tunnel_id(&value) { + return Err(missing_tunnel_configuration( + "CONTROL_PLANE_TUNNEL_ID must be tunnel_ followed by 32 lowercase hexadecimal characters", + )); + } + Ok(value) +} + +fn require_runtime_api_key() -> Result<(), ProductError> { + let value = std::env::var_os("CONTROL_PLANE_API_KEY") + .ok_or_else(|| missing_tunnel_configuration("CONTROL_PLANE_API_KEY is not set"))?; + if value.is_empty() { + return Err(missing_tunnel_configuration( + "CONTROL_PLANE_API_KEY is empty", + )); + } + Ok(()) +} + +fn valid_tunnel_id(value: &str) -> bool { + value.strip_prefix("tunnel_").is_some_and(|suffix| { + suffix.len() == 32 + && suffix + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +async fn resolve_tunnel_client() -> Result { + let override_bin = std::env::var_os(TUNNEL_CLIENT_OVERRIDE).map(PathBuf::from); + if let Some(binary) = override_bin.as_deref() { + if !binary.is_file() { + return Err(ProductError::new( + "tunnel_unavailable", + "WEBCODEX_TUNNEL_CLIENT_BIN does not point to a tunnel-client file", + Some("Fix or unset WEBCODEX_TUNNEL_CLIENT_BIN, then retry webcodex share --tunnel openai."), + )); + } + verify_tunnel_client_version(binary).await?; + return Ok(binary.to_path_buf()); + } + + if let Some(path) = std::env::var_os("PATH") { + for directory in std::env::split_paths(&path) { + let candidate = directory.join(executable_name("tunnel-client")); + if candidate.is_file() && verify_tunnel_client_version(&candidate).await.is_ok() { + return Ok(candidate); + } + } + } + + let asset = tunnel_client_asset_for(std::env::consts::OS, std::env::consts::ARCH)?; + let root = managed_tunnel_client_root()?; + ensure_managed_tunnel_client_at(&root, asset).await +} + +fn tunnel_client_asset_for(os: &str, arch: &str) -> Result { + let asset = match (os, arch) { + ("linux", "x86_64") => TunnelClientAsset { + target: "linux-amd64", + file_name: "tunnel-client-v0.0.12-linux-amd64.zip", + archive_sha256: "2bb693bd7b5cd28da7ce09cd9e309529dbb33b7cc9dc0058e62a064688f92c81", + binary_sha256: "ee9d4a75bc0b42f36f345aa96231e0db1ab00488122f34ebc99d6db055b6603e", + }, + ("linux", "aarch64") => TunnelClientAsset { + target: "linux-arm64", + file_name: "tunnel-client-v0.0.12-linux-arm64.zip", + archive_sha256: "6813878a3edb82ebebb32fe5a859bc6327a81cce5bc7b635a2313174d26365d6", + binary_sha256: "0a48e6696de0df5951c013e40be81ce775e6644e209758c48795a0ecbda06406", + }, + ("macos", "x86_64") => TunnelClientAsset { + target: "darwin-amd64", + file_name: "tunnel-client-v0.0.12-darwin-amd64.zip", + archive_sha256: "33de53aec680faafedc795f8f8268d6861577bddb871cb2d49529c91f88c2009", + binary_sha256: "4133dab2575223252732a998210c34b7ed96a51765cf5ea835a8e24cf2be1272", + }, + ("macos", "aarch64") => TunnelClientAsset { + target: "darwin-arm64", + file_name: "tunnel-client-v0.0.12-darwin-arm64.zip", + archive_sha256: "42fb3138dc9c081d5777cb7e8bd1e041cc48b67c4978dbab3c5167ca1aabca02", + binary_sha256: "b1757220cf4722cec9085ee4a908cf0ee4c1a499a33bd99979b9a9c7669e29b1", + }, + _ => { + return Err(ProductError::new( + "tunnel_unavailable", + format!("automatic OpenAI tunnel-client installation is unsupported on {os}/{arch}"), + Some("Install the pinned OpenAI tunnel-client and set WEBCODEX_TUNNEL_CLIENT_BIN, or use another WebCodex tunnel provider."), + )) + } + }; + Ok(asset) +} + +fn managed_tunnel_client_root() -> Result { + managed_tunnel_client_root_from( + std::env::var_os("XDG_STATE_HOME").as_deref(), + std::env::var_os("HOME").as_deref(), + ) +} + +fn managed_tunnel_client_root_from( + xdg_state_home: Option<&OsStr>, + home: Option<&OsStr>, +) -> Result { + if let Some(path) = xdg_state_home.filter(|value| !value.is_empty()) { + let path = PathBuf::from(path); + if !path.is_absolute() { + return Err(managed_user_root_error("XDG_STATE_HOME")); + } + return Ok(path.join("webcodex/tools/tunnel-client")); + } + if let Some(path) = home.filter(|value| !value.is_empty()) { + let path = PathBuf::from(path); + if !path.is_absolute() { + return Err(managed_user_root_error("HOME")); + } + return Ok(path.join(".local/state/webcodex/tools/tunnel-client")); + } + Err(ProductError::new( + "tunnel_unavailable", + "WebCodex cannot choose a private user directory for managed OpenAI tunnel-client", + Some("Set HOME or XDG_STATE_HOME, set WEBCODEX_TUNNEL_CLIENT_BIN, or use another WebCodex tunnel provider."), + )) +} + +fn managed_binary_path(root: &Path, asset: TunnelClientAsset) -> PathBuf { + root.join(TUNNEL_CLIENT_VERSION) + .join(asset.target) + .join(executable_name("tunnel-client")) +} + +async fn ensure_managed_tunnel_client_at( + root: &Path, + asset: TunnelClientAsset, +) -> Result { + let destination = managed_binary_path(root, asset); + if managed_binary_is_valid(&destination, asset).await { + return Ok(destination); + } + + eprintln!( + "WebCodex: tunnel-client was not found; downloading verified OpenAI tunnel-client {TUNNEL_CLIENT_VERSION}..." + ); + let install_dir = destination.parent().ok_or_else(managed_tool_path_error)?; + create_private_tool_dir(install_dir)?; + let temporary = install_dir.join(format!(".install-{}", uuid::Uuid::new_v4().simple())); + create_private_tool_dir(&temporary)?; + let result = async { + let archive = temporary.join(asset.file_name); + let url = format!("{TUNNEL_CLIENT_RELEASE_BASE}/{}", asset.file_name); + download_tunnel_client_asset(&url, &archive).await?; + verify_sha256(&archive, asset.archive_sha256, "downloaded tunnel-client archive")?; + let candidate = temporary.join(executable_name("tunnel-client")); + extract_tunnel_client(&archive, &candidate)?; + verify_sha256(&candidate, asset.binary_sha256, "downloaded tunnel-client binary")?; + make_private_executable(&candidate)?; + verify_tunnel_client_version(&candidate).await?; + fs::rename(&candidate, &destination).map_err(|_| { + ProductError::new( + "tunnel_unavailable", + "WebCodex could not install its managed OpenAI tunnel-client atomically", + Some("Check user-state filesystem permissions, then retry webcodex share --tunnel openai."), + ) + })?; + if !managed_binary_is_valid(&destination, asset).await { + return Err(verification_error()); + } + Ok(destination.clone()) + } + .await; + let _ = fs::remove_dir_all(&temporary); + result +} + +async fn managed_binary_is_valid(path: &Path, asset: TunnelClientAsset) -> bool { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(_) => return false, + }; + if !metadata.file_type().is_file() { + return false; + } + if sha256_file(path).ok().as_deref() != Some(asset.binary_sha256) { + return false; + } + verify_tunnel_client_version(path).await.is_ok() +} + +async fn download_tunnel_client_asset(url: &str, destination: &Path) -> Result<(), ProductError> { + let npm = cloudflared_service::effective_npm_network_settings().await; + let network = cloudflared_service::resolve_download_network_config_with(url, &npm, |name| { + std::env::var(name).ok() + }) + .map_err(|_| download_error("network/proxy configuration is invalid"))?; + let client = cloudflared_service::build_managed_download_client(&network) + .map_err(|_| download_error("could not initialize the download client"))?; + let mut response = client + .get(url) + .header( + USER_AGENT, + format!("webcodex/{}", env!("CARGO_PKG_VERSION")), + ) + .send() + .await + .map_err(|error| { + if error.is_timeout() { + download_error("request timed out") + } else if error.is_connect() { + download_error("connection failed") + } else { + download_error("request failed") + } + })?; + if !response.status().is_success() { + return Err(download_error(&format!( + "server returned HTTP {}", + response.status() + ))); + } + if response + .content_length() + .is_some_and(|length| length > TUNNEL_CLIENT_MAX_DOWNLOAD_BYTES as u64) + { + return Err(download_error("artifact exceeds the download size limit")); + } + let mut bytes = Vec::with_capacity( + response + .content_length() + .unwrap_or(0) + .min(TUNNEL_CLIENT_MAX_DOWNLOAD_BYTES as u64) as usize, + ); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| download_error("response body failed"))? + { + if bytes.len().saturating_add(chunk.len()) > TUNNEL_CLIENT_MAX_DOWNLOAD_BYTES { + return Err(download_error("artifact exceeds the download size limit")); + } + bytes.extend_from_slice(&chunk); + } + write_private_file(destination, &bytes) +} + +fn extract_tunnel_client(archive: &Path, destination: &Path) -> Result<(), ProductError> { + let file = File::open(archive).map_err(|_| extraction_error("archive could not be opened"))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|_| extraction_error("verified archive is not a valid ZIP file"))?; + let mut entry = archive + .by_name("tunnel-client") + .map_err(|_| extraction_error("verified archive does not contain tunnel-client"))?; + if !entry.is_file() || entry.size() > TUNNEL_CLIENT_MAX_BINARY_BYTES { + return Err(extraction_error("tunnel-client archive member is invalid")); + } + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut bytes) + .map_err(|_| extraction_error("tunnel-client archive member could not be read"))?; + if bytes.len() as u64 != entry.size() { + return Err(extraction_error( + "tunnel-client archive member size changed while reading", + )); + } + write_private_file(destination, &bytes) +} + +fn create_private_tool_dir(path: &Path) -> Result<(), ProductError> { + fs::create_dir_all(path).map_err(|_| managed_tool_path_error())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|_| managed_tool_path_error())?; + } + Ok(()) +} + +fn write_private_file(path: &Path, bytes: &[u8]) -> Result<(), ProductError> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path).map_err(|_| managed_tool_path_error())?; + file.write_all(bytes).map_err(|_| managed_tool_path_error()) +} + +fn make_private_executable(path: &Path) -> Result<(), ProductError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|_| managed_tool_path_error())?; + } + Ok(()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path).map_err(|_| verification_error())?; + let mut hash = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer).map_err(|_| verification_error())?; + if read == 0 { + break; + } + hash.update(&buffer[..read]); + } + Ok(format!("{:x}", hash.finalize())) +} + +fn verify_sha256(path: &Path, expected: &str, label: &str) -> Result<(), ProductError> { + if sha256_file(path)? != expected { + return Err(ProductError::new( + "tunnel_unavailable", + format!("{label} failed SHA-256 verification"), + Some("Retry webcodex share --tunnel openai; if the failure persists, set WEBCODEX_TUNNEL_CLIENT_BIN to the pinned trusted binary."), + )); + } + Ok(()) +} + +async fn verify_tunnel_client_version(path: &Path) -> Result<(), ProductError> { + let output = tokio::time::timeout( + TUNNEL_CLIENT_VERIFY_TIMEOUT, + Command::new(path).arg("--version").output(), + ) + .await + .map_err(|_| verification_error())? + .map_err(|_| verification_error())?; + let version_text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + if !output.status.success() || !version_text.starts_with(TUNNEL_CLIENT_VERSION) { + return Err(verification_error()); + } + Ok(()) +} + +fn missing_tunnel_configuration(message: &'static str) -> ProductError { + ProductError::new( + "tunnel_unavailable", + message, + Some("Create or select an OpenAI Secure MCP Tunnel, export CONTROL_PLANE_TUNNEL_ID and a Restricted CONTROL_PLANE_API_KEY with Tunnels Read + Use, then retry."), + ) +} + +fn managed_user_root_error(name: &str) -> ProductError { + ProductError::new( + "tunnel_unavailable", + format!("{name} must be an absolute path for managed OpenAI tunnel-client"), + Some("Fix the user-state environment, set WEBCODEX_TUNNEL_CLIENT_BIN, or use another WebCodex tunnel provider."), + ) +} + +fn managed_tool_path_error() -> ProductError { + ProductError::new( + "tunnel_unavailable", + "WebCodex could not create or protect its managed OpenAI tunnel-client files", + Some("Check user-state filesystem permissions, then retry webcodex share --tunnel openai."), + ) +} + +fn download_error(detail: &str) -> ProductError { + ProductError::new( + "tunnel_unavailable", + format!("WebCodex could not download verified OpenAI tunnel-client: {detail}"), + Some("Check network/proxy connectivity and retry, or set WEBCODEX_TUNNEL_CLIENT_BIN to the pinned trusted binary."), + ) +} + +fn extraction_error(detail: &str) -> ProductError { + ProductError::new( + "tunnel_unavailable", + format!("WebCodex could not unpack verified OpenAI tunnel-client: {detail}"), + Some("Retry webcodex share --tunnel openai or set WEBCODEX_TUNNEL_CLIENT_BIN to the pinned trusted binary."), + ) +} + +fn verification_error() -> ProductError { + ProductError::new( + "tunnel_unavailable", + format!("OpenAI tunnel-client failed pinned {TUNNEL_CLIENT_VERSION} verification"), + Some("Remove the managed tunnel-client file and retry, or set WEBCODEX_TUNNEL_CLIENT_BIN to the pinned trusted binary."), + ) +} + +fn tunnel_runtime_error(message: &'static str) -> ProductError { + ProductError::new( + "tunnel_unavailable", + message, + Some("Check the OpenAI tunnel-client configuration and retry webcodex share --tunnel openai."), + ) +} + +#[cfg(test)] +#[path = "project_entry_openai_tunnel_tests.rs"] +mod tests; diff --git a/src/project_entry_openai_tunnel_tests.rs b/src/project_entry_openai_tunnel_tests.rs new file mode 100644 index 00000000..077c702e --- /dev/null +++ b/src/project_entry_openai_tunnel_tests.rs @@ -0,0 +1,140 @@ +use super::*; + +#[test] +fn tunnel_ids_are_strict_and_runtime_key_never_part_of_the_id_contract() { + assert!(valid_tunnel_id("tunnel_0123456789abcdef0123456789abcdef")); + for invalid in [ + "0123456789abcdef0123456789abcdef", + "tunnel_0123456789ABCDEF0123456789ABCDEF", + "tunnel_0123456789abcdef", + "tunnel_0123456789abcdef0123456789abcdef0", + "tunnel_0123456789abcdef0123456789abcdeg", + ] { + assert!( + !valid_tunnel_id(invalid), + "accepted invalid tunnel id {invalid}" + ); + } +} + +#[test] +fn official_release_assets_and_extracted_binaries_are_pinned_per_supported_platform() { + let linux_amd64 = tunnel_client_asset_for("linux", "x86_64").unwrap(); + assert_eq!( + linux_amd64.file_name, + "tunnel-client-v0.0.12-linux-amd64.zip" + ); + assert_eq!( + linux_amd64.archive_sha256, + "2bb693bd7b5cd28da7ce09cd9e309529dbb33b7cc9dc0058e62a064688f92c81" + ); + assert_eq!( + linux_amd64.binary_sha256, + "ee9d4a75bc0b42f36f345aa96231e0db1ab00488122f34ebc99d6db055b6603e" + ); + + let linux_arm64 = tunnel_client_asset_for("linux", "aarch64").unwrap(); + assert_eq!( + linux_arm64.binary_sha256, + "0a48e6696de0df5951c013e40be81ce775e6644e209758c48795a0ecbda06406" + ); + let darwin_amd64 = tunnel_client_asset_for("macos", "x86_64").unwrap(); + assert_eq!( + darwin_amd64.binary_sha256, + "4133dab2575223252732a998210c34b7ed96a51765cf5ea835a8e24cf2be1272" + ); + let darwin_arm64 = tunnel_client_asset_for("macos", "aarch64").unwrap(); + assert_eq!( + darwin_arm64.binary_sha256, + "b1757220cf4722cec9085ee4a908cf0ee4c1a499a33bd99979b9a9c7669e29b1" + ); + assert_eq!(darwin_arm64.target, "darwin-arm64"); + + let error = tunnel_client_asset_for("windows", "x86_64").unwrap_err(); + assert_eq!(error.code, "tunnel_unavailable"); + assert!(error.message.contains("unsupported")); +} + +#[test] +fn managed_root_prefers_private_xdg_then_home() { + assert_eq!( + managed_tunnel_client_root_from(Some(OsStr::new("/state")), Some(OsStr::new("/home/user"))) + .unwrap(), + PathBuf::from("/state/webcodex/tools/tunnel-client") + ); + assert_eq!( + managed_tunnel_client_root_from(None, Some(OsStr::new("/home/user"))).unwrap(), + PathBuf::from("/home/user/.local/state/webcodex/tools/tunnel-client") + ); + assert!(managed_tunnel_client_root_from(None, None).is_err()); + assert!(managed_tunnel_client_root_from( + Some(OsStr::new("relative")), + Some(OsStr::new("/home/user")) + ) + .is_err()); +} + +#[test] +fn health_url_accepts_only_bounded_loopback_http_origins() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("health.url"); + for valid in [ + "http://127.0.0.1:12345\n", + "http://localhost:43210/\n", + "http://[::1]:3000\n", + ] { + fs::write(&path, valid).unwrap(); + let result = read_loopback_health_url(&path).unwrap(); + assert!(result.starts_with("http://")); + } + for invalid in [ + "https://127.0.0.1:12345", + "http://example.com:12345", + "http://127.0.0.1", + "http://user:secret@127.0.0.1:12345", + "http://127.0.0.1:12345/readyz", + "http://127.0.0.1:12345?secret=value", + ] { + fs::write(&path, invalid).unwrap(); + assert!( + read_loopback_health_url(&path).is_err(), + "accepted {invalid}" + ); + } +} + +#[test] +fn zip_extraction_reads_only_the_exact_tunnel_client_member() { + let temp = tempfile::tempdir().unwrap(); + let archive_path = temp.path().join("client.zip"); + let file = File::create(&archive_path).unwrap(); + let mut archive = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default(); + archive.start_file("../tunnel-client", options).unwrap(); + archive.write_all(b"wrong").unwrap(); + archive.start_file("tunnel-client", options).unwrap(); + archive.write_all(b"expected-binary").unwrap(); + archive.finish().unwrap(); + + let destination = temp.path().join("extracted"); + extract_tunnel_client(&archive_path, &destination).unwrap(); + assert_eq!(fs::read(&destination).unwrap(), b"expected-binary"); + assert!(!temp.path().join("tunnel-client").exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn version_verification_requires_the_pinned_client_line() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let good = temp.path().join("good"); + fs::write(&good, "#!/bin/sh\necho '0.0.12+test (git sha: abc)'\n").unwrap(); + fs::set_permissions(&good, fs::Permissions::from_mode(0o700)).unwrap(); + verify_tunnel_client_version(&good).await.unwrap(); + + let wrong = temp.path().join("wrong"); + fs::write(&wrong, "#!/bin/sh\necho '0.0.13'\n").unwrap(); + fs::set_permissions(&wrong, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(verify_tunnel_client_version(&wrong).await.is_err()); +} diff --git a/src/project_entry_share.rs b/src/project_entry_share.rs index e610b7f5..bb21c4eb 100644 --- a/src/project_entry_share.rs +++ b/src/project_entry_share.rs @@ -30,6 +30,7 @@ const TUNNEL_LOG_DRAIN_TIMEOUT: Duration = Duration::from_secs(1); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TunnelProvider { CloudflareQuick, + OpenAiSecure, None, } @@ -66,11 +67,12 @@ pub(crate) fn parse_share_options(args: &[String]) -> Result TunnelProvider::CloudflareQuick, + "openai" => TunnelProvider::OpenAiSecure, "none" => TunnelProvider::None, _ => { return Err(format!( - "unknown tunnel provider '{value}'; expected cloudflare or none" - )) + "unknown tunnel provider '{value}'; expected cloudflare, openai, or none" + )) } }; } @@ -118,8 +120,11 @@ pub(crate) fn parse_share_options(args: &[String]) -> Result { @@ -196,6 +201,12 @@ impl ShareSession { } result } + + fn write_openai_authorization_file(&self) -> Result { + let path = self.directory.join("openai-mcp-authorization"); + write_new_private(&path, format!("Bearer {}", self.credential).as_bytes())?; + Ok(path) + } } impl Drop for ShareSession { @@ -416,13 +427,20 @@ fn tunnel_runtime_error() -> ProductError { } pub(crate) async fn share(options: &ShareCommandOptions) -> Result<(), ProductError> { - // Resolve or acquire the default public-share dependency before project setup/state creation. + // Resolve managed transport dependencies before project setup/state creation. let cloudflared_binary = match options.tunnel { TunnelProvider::CloudflareQuick => { Some(super::cloudflared_service::resolve_cloudflared().await?) } - TunnelProvider::None => None, + TunnelProvider::OpenAiSecure | TunnelProvider::None => None, + }; + let openai_prerequisites = match options.tunnel { + TunnelProvider::OpenAiSecure => { + Some(super::openai_tunnel_service::prepare_openai_tunnel().await?) + } + TunnelProvider::CloudflareQuick | TunnelProvider::None => None, }; + setup(&options.project)?; let (config, paths) = configured_project(&options.project)?; ensure_local_runtime_port_available( @@ -458,7 +476,7 @@ pub(crate) async fn share(options: &ShareCommandOptions) -> Result<(), ProductEr }); let local_url = config.server_url(); - let (public_url, mut tunnel) = match options.tunnel { + let (public_url, mut cloudflare_tunnel) = match options.tunnel { TunnelProvider::CloudflareQuick => { let binary = cloudflared_binary .as_deref() @@ -468,6 +486,7 @@ pub(crate) async fn share(options: &ShareCommandOptions) -> Result<(), ProductEr .await?; (url, Some(tunnel)) } + TunnelProvider::OpenAiSecure => (local_url.clone(), None), TunnelProvider::None => ( options .public_url @@ -483,34 +502,70 @@ pub(crate) async fn share(options: &ShareCommandOptions) -> Result<(), ProductEr public_url: Some(public_url.clone()), connector_credential_file: Some(session.credential_file.clone()), project_share_oauth, + child_environment_remove: if options.tunnel == TunnelProvider::OpenAiSecure { + vec![ + "CONTROL_PLANE_API_KEY", + "CONTROL_PLANE_TUNNEL_ID", + "OPENAI_ADMIN_KEY", + "OPENAI_API_KEY", + ] + } else { + Vec::new() + }, port_conflict_action: "Stop the conflicting process, then retry webcodex share.", }, ) .await?; + let mut openai_tunnel = if let Some(prerequisites) = openai_prerequisites.as_ref() { + let authorization_file = session.write_openai_authorization_file()?; + match super::openai_tunnel_service::start_openai_tunnel( + prerequisites, + &mcp_url(&runtime.local_url), + &authorization_file, + &session.directory, + ) + .await + { + Ok(tunnel) => Some(tunnel), + Err(error) => { + runtime.stop().await; + return Err(error); + } + } + } else { + None + }; + let externally_managed = options.tunnel == TunnelProvider::None && options.public_url.is_some(); - let ready = match oauth_client.as_ref() { - Some(oauth) => render_share_oauth_ready( - &runtime.project_name, - options.tunnel, - externally_managed, - &runtime.public_url, - &session.credential, - oauth, - ), - None => render_share_ready( - &runtime.project_name, - options.tunnel, - externally_managed, - &runtime.public_url, - &session.credential, - ), + let ready = if let Some(prerequisites) = openai_prerequisites.as_ref() { + render_openai_share_ready(&runtime.project_name, &prerequisites.tunnel_id) + } else { + match oauth_client.as_ref() { + Some(oauth) => render_share_oauth_ready( + &runtime.project_name, + options.tunnel, + externally_managed, + &runtime.public_url, + &session.credential, + oauth, + ), + None => render_share_ready( + &runtime.project_name, + options.tunnel, + externally_managed, + &runtime.public_url, + &session.credential, + ), + } }; println!("{ready}"); - let remote_client_handoff = + let copy_remote_mcp_url = options.tunnel == TunnelProvider::CloudflareQuick || externally_managed; - let clipboard_outcome = if remote_client_handoff { + let open_chatgpt_handoff = + copy_remote_mcp_url || options.tunnel == TunnelProvider::OpenAiSecure; + let clipboard_outcome = if copy_remote_mcp_url { copy_mcp_url(&mcp_url(&runtime.public_url), options.copy_url).await } else { ClipboardCopyOutcome::Disabled @@ -518,21 +573,26 @@ pub(crate) async fn share(options: &ShareCommandOptions) -> Result<(), ProductEr if let Some(status) = render_clipboard_status(clipboard_outcome) { println!("\n{status}"); } - let handoff_task = remote_client_handoff + let handoff_task = open_chatgpt_handoff .then(maybe_spawn_chatgpt_open_prompt) .flatten(); - let outcome = if let Some(tunnel) = tunnel.as_mut() { - tokio::select! { + let outcome = match (cloudflare_tunnel.as_mut(), openai_tunnel.as_mut()) { + (Some(tunnel), None) => tokio::select! { _ = tokio::signal::ctrl_c() => Ok(()), result = runtime.wait_for_exit() => result, result = tunnel.wait_for_exit() => result, - } - } else { - tokio::select! { + }, + (None, Some(tunnel)) => tokio::select! { _ = tokio::signal::ctrl_c() => Ok(()), result = runtime.wait_for_exit() => result, - } + result = tunnel.wait_for_exit() => result, + }, + (None, None) => tokio::select! { + _ = tokio::signal::ctrl_c() => Ok(()), + result = runtime.wait_for_exit() => result, + }, + (Some(_), Some(_)) => unreachable!("one share cannot own two tunnel providers"), }; if let Some(task) = handoff_task { @@ -540,7 +600,10 @@ pub(crate) async fn share(options: &ShareCommandOptions) -> Result<(), ProductEr let _ = task.await; } runtime.stop().await; - if let Some(tunnel) = tunnel.as_mut() { + if let Some(tunnel) = cloudflare_tunnel.as_mut() { + tunnel.stop().await; + } + if let Some(tunnel) = openai_tunnel.as_mut() { tunnel.stop().await; } outcome @@ -556,6 +619,11 @@ fn share_access_labels( "temporary", "Ready for ChatGPT or another remote MCP client.", ), + (TunnelProvider::OpenAiSecure, _) => ( + "OpenAI Secure MCP Tunnel", + "private through the selected OpenAI workspace Tunnel", + "Ready for ChatGPT through OpenAI Secure MCP Tunnel.", + ), (TunnelProvider::None, true) => ( "none (externally managed)", "operator managed", @@ -569,6 +637,12 @@ fn share_access_labels( } } +fn render_openai_share_ready(project_name: &str, tunnel_id: &str) -> String { + format!( + "WebCodex ready\n\nWhat to do next\n1. In ChatGPT Developer Mode, create a custom MCP app.\n2. Connection: Tunnel\n3. Tunnel: {tunnel_id}\n4. Authentication: No authentication\n5. Scan Tools.\n6. First prompt: \"Inspect this repository and summarize its structure. Do not make changes.\"\n\nReady for ChatGPT through OpenAI Secure MCP Tunnel.\n\nDetails\nProject: {project_name}\nRuntime: local\nTunnel: OpenAI Secure MCP Tunnel\nPublic access: no public WebCodex endpoint; outbound-only OpenAI Tunnel transport\nWebCodex authentication: the temporary Bearer credential stays local and is injected by tunnel-client into the private MCP hop. Do not paste it into ChatGPT.\nCredential lifetime: temporary; stopping this share removes the local credential and tunnel-client process. The Platform Tunnel identity remains operator managed.\nPress Ctrl-C to stop sharing." + ) +} + fn render_share_ready( project_name: &str, tunnel: TunnelProvider, @@ -579,19 +653,26 @@ fn render_share_ready( let (tunnel_name, public_access, ready_message) = share_access_labels(tunnel, externally_managed); let base = public_url.trim_end_matches('/'); - let next_steps = if tunnel == TunnelProvider::CloudflareQuick || externally_managed { - format!( + let next_steps = match tunnel { + TunnelProvider::CloudflareQuick if !externally_managed => format!( "What to do next\n1. In ChatGPT Developer Mode, create a custom MCP app.\n2. MCP URL: {base}/mcp\n3. Authentication: Bearer token\n4. Credential (this share only): {credential}\n5. Scan Tools.\n6. First prompt: \"Inspect this repository and summarize its structure. Do not make changes.\"" - ) - } else { - format!( + ), + TunnelProvider::None if externally_managed => format!( + "What to do next\n1. In ChatGPT Developer Mode, create a custom MCP app.\n2. MCP URL: {base}/mcp\n3. Authentication: Bearer token\n4. Credential (this share only): {credential}\n5. Scan Tools.\n6. First prompt: \"Inspect this repository and summarize its structure. Do not make changes.\"" + ), + TunnelProvider::None => format!( "What to do next\n1. Add this MCP endpoint to a local MCP client: {base}/mcp\n2. Authentication: Bearer token\n3. Credential (this share only): {credential}\n4. First prompt: \"Inspect this repository and summarize its structure. Do not make changes.\"" - ) + ), + TunnelProvider::OpenAiSecure | TunnelProvider::CloudflareQuick => { + "What to do next\nOpenAI Secure MCP Tunnel uses dedicated credential-free ChatGPT handoff output.".to_string() + } }; - let lifetime_message = if tunnel == TunnelProvider::CloudflareQuick { - "This credential and tunneled URL are temporary." - } else { - "This credential is temporary." + let lifetime_message = match tunnel { + TunnelProvider::CloudflareQuick => "This credential and tunneled URL are temporary.", + TunnelProvider::OpenAiSecure => { + "The temporary credential stays local and is never printed by this output path." + } + TunnelProvider::None => "This credential is temporary.", }; format!( "WebCodex ready\n\n{next_steps}\n\n{ready_message}\n\nDetails\nProject: {project_name}\nRuntime: local\nTunnel: {tunnel_name}\nPublic access: {public_access}\nCredential lifetime: {lifetime_message}\nPress Ctrl-C to stop sharing." @@ -845,7 +926,7 @@ mod tests { } #[test] - fn share_cli_defaults_to_cloudflare_and_accepts_none() { + fn share_cli_defaults_to_cloudflare_and_accepts_openai_and_none() { let default = parse_share_options(&[]).unwrap(); assert_eq!(default.tunnel, TunnelProvider::CloudflareQuick); assert_eq!(default.auth, ShareAuth::Bearer); @@ -855,6 +936,25 @@ mod tests { let explicit = parse_share_options(&["--tunnel".to_string(), "cloudflare".to_string()]).unwrap(); assert_eq!(explicit.tunnel, TunnelProvider::CloudflareQuick); + let openai = parse_share_options(&["--tunnel".to_string(), "openai".to_string()]).unwrap(); + assert_eq!(openai.tunnel, TunnelProvider::OpenAiSecure); + assert_eq!(openai.auth, ShareAuth::Bearer); + assert!(parse_share_options(&[ + "--tunnel".to_string(), + "openai".to_string(), + "--auth".to_string(), + "oauth".to_string(), + "--oauth-redirect-uri".to_string(), + "https://client.example/callback".to_string(), + ]) + .is_err()); + assert!(parse_share_options(&[ + "--tunnel".to_string(), + "openai".to_string(), + "--public-url".to_string(), + "https://share.example".to_string(), + ]) + .is_err()); let local = parse_share_options(&["--tunnel".to_string(), "none".to_string()]).unwrap(); assert_eq!(local.tunnel, TunnelProvider::None); assert!(parse_share_options(&["--tunnel".to_string(), "unknown".to_string()]).is_err()); @@ -931,6 +1031,18 @@ mod tests { assert!(!output.contains("tunneled URL")); } + #[test] + fn openai_share_output_keeps_webcodex_credential_local() { + let output = render_openai_share_ready("demo", "tunnel_0123456789abcdef0123456789abcdef"); + assert!(output.contains("Connection: Tunnel")); + assert!(output.contains("Authentication: No authentication")); + assert!(output.contains("tunnel_0123456789abcdef0123456789abcdef")); + assert!(output.contains("temporary Bearer credential stays local")); + assert!(output.contains("Do not paste it into ChatGPT")); + assert!(!output.contains("Credential (this share only)")); + assert!(!output.contains("MCP URL:")); + } + #[test] fn oauth_share_output_keeps_project_credential_separate_from_client_secret() { let oauth = ShareOAuthClient { @@ -1007,6 +1119,11 @@ mod tests { let session = ShareSession::create(&state).unwrap(); assert_ne!(session.credential, persistent_value); assert_eq!(fs::read_to_string(&persistent).unwrap(), persistent_before); + let authorization_file = session.write_openai_authorization_file().unwrap(); + assert_eq!( + fs::read_to_string(&authorization_file).unwrap(), + format!("Bearer {}", session.credential) + ); assert_eq!( crate::auth::read_protected_secret(&session.credential_file).unwrap(), session.credential @@ -1038,10 +1155,19 @@ mod tests { & 0o777, 0o600 ); + assert_eq!( + fs::metadata(&authorization_file) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); } let directory = session.directory.clone(); drop(session); assert!(!directory.exists()); + assert!(!authorization_file.exists()); assert!(persistent.is_file()); } diff --git a/src/startup.rs b/src/startup.rs index 0beb6428..e68e2801 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -246,6 +246,13 @@ mod tests { .. }) )); + assert!(matches!( + project_cli_action(["share", "--tunnel", "openai"]), + ProjectCliAction::Share(project_entry::ShareCommandOptions { + tunnel: project_entry::TunnelProvider::OpenAiSecure, + .. + }) + )); } #[test]