@moretea-labs/forge 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,14 @@ All notable public Forge changes are recorded here. Release entries are tied to
4
4
 
5
5
  ## Unreleased
6
6
 
7
- No unreleased public changes are currently recorded.
7
+ ## 1.5.1 - 2026-08-13
8
+
9
+ ### ChatGPT Secure Tunnel and package setup
10
+
11
+ - Make `--tunnel auto` prefer OpenAI Secure MCP Tunnel instead of silently selecting an installed public-ingress CLI. Cloudflare, Tailscale, existing HTTPS, and deferred remote access remain explicit fallbacks.
12
+ - Add a package-managed loopback OAuth Gateway for ChatGPT alongside the bearer-only Canonical Runtime, and persist the local OAuth endpoint in user-level MCP configuration so Secure Tunnel never targets the internal Runtime directly.
13
+ - Accept modern ChatGPT `server/discover` probing with the correct legacy fallback response, detect tunnel runtimes by `tunnel_id` rather than a hard-coded alias, and prefer an existing repository Controller Home during setup reconciliation.
14
+ - Clarify that the ChatGPT App/connector owns application/tool identity while Secure Tunnel owns transport, document migration from Cloudflare/HTTPS, and add the supported npm update flow (`npm install -g @moretea-labs/forge@latest` then `forge setup next`).
8
15
 
9
16
  ## 1.5.0 - 2026-08-13
10
17
 
package/README.md CHANGED
@@ -44,9 +44,9 @@ forge setup configure --controller chatgpt --tunnel auto
44
44
  forge setup next
45
45
  ```
46
46
 
47
- Setup persists progress and guides the selected path through the user-level Package Runtime, remote connectivity, controller registration, and connection verification. For ChatGPT, prefer **OpenAI Secure MCP Tunnel** when your organization supports it; Cloudflare Tunnel, Tailscale Funnel, an existing HTTPS `/mcp` URL, or deferred remote access remain alternatives. Local Codex/Claude controllers do not need a tunnel.
47
+ Setup persists progress and guides the selected path through the user-level Package Runtime, remote connectivity, controller registration, and connection verification. For ChatGPT, `--tunnel auto` now means **OpenAI Secure MCP Tunnel first**: Forge keeps the Canonical Runtime private, installs a loopback OAuth Gateway for ChatGPT, and lets the official `tunnel-client` carry MCP traffic outbound to OpenAI. Cloudflare Tunnel, Tailscale Funnel, an existing HTTPS `/mcp` URL, or deferred remote access remain explicit fallbacks when Secure Tunnel is unavailable. Local Codex/Claude controllers do not need a tunnel. The ChatGPT App/connector is the application identity; Secure Tunnel is only its network transport, so switching transport does not create a second Forge tool schema.
48
48
 
49
- Then follow [Install and start](docs/tutorials/01-install-and-start.md) and [Connect ChatGPT](docs/tutorials/02-connect-chatgpt.md). A repository is optional during initial setup; add one later with `forge adopt --repo /path/to/your-project`.
49
+ Then follow [Install and start](docs/tutorials/01-install-and-start.md) and [Connect ChatGPT](docs/tutorials/02-connect-chatgpt.md). A repository is optional during initial setup; add one later with `forge adopt --repo /path/to/your-project`. Upgrade an existing install with `npm install -g @moretea-labs/forge@latest`, verify with `forge --version`, then run `forge setup next` to reconcile newer Runtime/connector setup contracts.
50
50
 
51
51
  For a reviewed source checkout: `git clone https://github.com/moretea-labs/forge.git && cd forge && npm ci --ignore-scripts --no-audit --no-fund && npm install -g . --omit=optional --no-audit --no-fund`.
52
52
 
@@ -58,7 +58,7 @@ Forge ships typed local/browser/desktop/iOS/GitHub/Gmail/Calendar/Tasks/App Stor
58
58
 
59
59
  Local read/write grants are explicit and expiring; remote or destructive effects are distinguished from ordinary local work; high-impact actions require stronger confirmation. Full Access reduces repetitive prompts for normal work without removing the hard boundaries around secrets, destructive actions, and outside-workspace access. See [Security](SECURITY.md) and the [Security Model](docs/wiki/Security-Model.md).
60
60
 
61
- **Docs:** [Wiki](docs/wiki/Home.md) · [Support](SUPPORT.md) · [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md). Current stable release: `1.5.0`; stable installs use npm `latest`.
61
+ **Docs:** [Wiki](docs/wiki/Home.md) · [Support](SUPPORT.md) · [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md). Current stable release: `1.5.1`; stable installs use npm `latest`.
62
62
 
63
63
  ---
64
64
  <a id="zh-cn"></a>
@@ -101,12 +101,12 @@ forge setup configure --controller chatgpt --tunnel auto
101
101
  forge setup next # 按每次显示的 Next 动作继续
102
102
  ```
103
103
 
104
- 远程主控的 setup 会继续引导 Package Runtime、远程连接、主控配置和连接验证;ChatGPT 在组织支持时优先 **OpenAI Secure MCP Tunnel**,也可选 Cloudflare Tunnel、Tailscale Funnel、已有 HTTPS `/mcp`,或暂缓远程连接。本地 Codex/Claude 主控不需要 tunnel,也不会配置未选择的 CLI。
104
+ 远程主控的 setup 会继续引导 Package Runtime、远程连接、主控配置和连接验证;对 ChatGPT,`--tunnel auto` 现在明确表示 **优先 OpenAI Secure MCP Tunnel**。Forge 保持 Canonical Runtime 私有,并安装一个仅监听 loopback 的 OAuth Gateway,再由官方 `tunnel-client` 出站连接 OpenAI。只有 Secure Tunnel 不可用时才显式选择 Cloudflare Tunnel、Tailscale Funnel、已有 HTTPS `/mcp` 或暂缓远程连接。本地 Codex/Claude 主控不需要 tunnel。ChatGPT App/connector 是应用身份,Secure Tunnel 只是网络传输层,因此切换网络不会产生另一套 Forge 工具 schema。
105
105
 
106
- 继续阅读[安装与启动](docs/tutorials/01-install-and-start.zh-CN.md)和[连接 ChatGPT](docs/tutorials/02-connect-chatgpt.zh-CN.md)。仓库不是首次 setup 的前置条件,需要开发能力时再运行 `forge adopt --repo /path/to/your-project`。源码安装:`git clone https://github.com/moretea-labs/forge.git && cd forge && npm ci --ignore-scripts --no-audit --no-fund && npm install -g . --omit=optional --no-audit --no-fund`。
106
+ 继续阅读[安装与启动](docs/tutorials/01-install-and-start.zh-CN.md)和[连接 ChatGPT](docs/tutorials/02-connect-chatgpt.zh-CN.md)。仓库不是首次 setup 的前置条件,需要开发能力时再运行 `forge adopt --repo /path/to/your-project`。已安装用户升级:`npm install -g @moretea-labs/forge@latest`,确认 `forge --version` 后运行 `forge setup next` 让新版 setup 自动补齐 Runtime/connector 配置。源码安装:`git clone https://github.com/moretea-labs/forge.git && cd forge && npm ci --ignore-scripts --no-audit --no-fund && npm install -g . --omit=optional --no-audit --no-fund`。
107
107
 
108
108
  Forge 内置本机、Browser、Desktop、iOS、GitHub、Gmail、Calendar、Tasks、App Store Connect、Resend 等类型化能力;公开 Provider 目录还有 **Forge Desktop Operator / Forge Design / Personal Knowledge Assistant**。详见[功能清单](docs/operations/features.zh-CN.md)与[插件管理](docs/forge-plugin-management.md)。
109
109
 
110
110
  安全上,普通本地修改、远程影响、破坏性操作、密钥和工作区外访问是不同边界;Full Access 不会取消高风险确认。详见[安全说明](SECURITY.md)和[安全模型](docs/wiki/Security-Model.md)。
111
111
 
112
- **文档:** [Wiki](docs/wiki/Home.md) · [支持](SUPPORT.md) · [贡献](CONTRIBUTING.md) · [更新日志](CHANGELOG.md)。当前稳定版为 `1.5.0`,稳定安装使用 npm `latest`。
112
+ **文档:** [Wiki](docs/wiki/Home.md) · [支持](SUPPORT.md) · [贡献](CONTRIBUTING.md) · [更新日志](CHANGELOG.md)。当前稳定版为 `1.5.1`,稳定安装使用 npm `latest`。
package/README.zh-CN.md CHANGED
@@ -52,10 +52,12 @@ forge setup configure --controller chatgpt --tunnel auto
52
52
  forge setup next # 按每次显示的 Next 动作继续
53
53
  ```
54
54
 
55
- `forge setup` 会持久化进度。远程主控会继续经过 Package Runtime、远程连接、主控配置和连接验证;ChatGPT 在组织支持时优先 **OpenAI Secure MCP Tunnel**,也可选择 Cloudflare Tunnel、Tailscale Funnel、已有 HTTPS `/mcp` 或暂缓远程连接。本地主控 Codex/Claude 不需要 tunnel。
55
+ `forge setup` 会持久化进度。对 ChatGPT,`--tunnel auto` 现在明确表示 **优先 OpenAI Secure MCP Tunnel**:Forge 保持 Canonical Runtime 私有,安装一个仅监听 loopback 的 OAuth Gateway,再由官方 `tunnel-client` 通过出站连接接入 OpenAI。Secure Tunnel 不可用时,再显式选择 Cloudflare Tunnel、Tailscale Funnel、已有 HTTPS `/mcp` 或暂缓远程连接。本地主控 Codex/Claude 不需要 tunnel。ChatGPT App/connector 是应用身份,Secure Tunnel 是网络传输层;切换 tunnel 不会创建另一套 Forge 工具 schema。
56
56
 
57
57
  接着阅读:[安装与启动](docs/tutorials/01-install-and-start.zh-CN.md) · [连接 ChatGPT](docs/tutorials/02-connect-chatgpt.zh-CN.md) · [第一个仓库任务](docs/tutorials/03-first-repository-task.zh-CN.md)。仓库不是首次 setup 的前置条件;需要时再 `forge adopt --repo /path/to/your-project`。
58
58
 
59
+ 已安装用户升级:`npm install -g @moretea-labs/forge@latest` → `forge --version` → `forge setup next`。
60
+
59
61
  从源码安装已审查 checkout:
60
62
 
61
63
  ```bash
@@ -76,4 +78,4 @@ Forge 把读取、普通本地写入、远程操作、破坏性操作、工作
76
78
 
77
79
  [中文文档中心](docs/README.zh-CN.md) · [Wiki](docs/wiki/Home.md) · [SUPPORT.md](SUPPORT.md) · [CONTRIBUTING.md](CONTRIBUTING.md) · [CHANGELOG.md](CHANGELOG.md)
78
80
 
79
- 当前稳定版本是 `1.5.0`,稳定安装使用 npm `latest`。
81
+ 当前稳定版本是 `1.5.1`,稳定安装使用 npm `latest`。
@@ -1,6 +1,6 @@
1
1
  # Third-party notices
2
2
 
3
- This inventory covers the direct runtime and development dependencies declared by `@moretea-labs/forge@1.5.0`.
3
+ This inventory covers the direct runtime and development dependencies declared by `@moretea-labs/forge@1.5.1`.
4
4
 
5
5
  License values come from `package-lock.json` where available. For lock entries without a license field, the declared license was verified from package metadata during release preparation. This file is informational and does not replace the license text distributed by each dependency.
6
6
 
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.5.0",
3
- "templateVersion": "1.5.0",
2
+ "version": "1.5.1",
3
+ "templateVersion": "1.5.1",
4
4
  "skillName": "forge",
5
5
  "contractId": "tasks-first-harness-v1",
6
6
  "compatibility": {
@@ -27,16 +27,40 @@ The Package Runtime does not require a Forge Git checkout, Bun compilation, Code
27
27
 
28
28
  ## Local boundaries
29
29
 
30
- Forge MCP remains loopback-only, normally at:
30
+ Forge keeps two MCP boundaries on loopback by default:
31
31
 
32
32
  ```text
33
- http://127.0.0.1:8765/mcp
33
+ ChatGPT OAuth Gateway: http://127.0.0.1:8767/mcp
34
+ Canonical Runtime: http://127.0.0.1:8765/mcp
34
35
  ```
35
36
 
37
+ The OAuth Gateway is the endpoint for Secure Tunnel or HTTPS connectors. It proxies authenticated controller traffic to the bearer-only Canonical Runtime; remote clients should never target the internal Runtime directly. `forge mcp setup chatgpt --user-level` records the actual OAuth endpoint, so custom ports remain supported.
38
+
36
39
  Fresh user-level setup keeps the repository-centric Utility Console disabled until a repository/workbench target is configured. When enabled later it uses a separate loopback port (normally 8766). Never expose it to the internet.
37
40
 
38
41
  Service-level MCP configuration lives below `Controller Home/mcp/`. OAuth credentials and bearer fallback tokens are local secrets and must not be pasted into chat or committed. Repository-specific `.forge/mcp.policy.json` can narrow access when a repository is later adopted.
39
42
 
43
+ ## App identity vs. transport
44
+
45
+ The ChatGPT App/connector is the application identity and owns the visible Forge tool schema. **Secure MCP Tunnel is the transport.** Switching an existing Forge App from a public HTTPS/Cloudflare route to Tunnel changes how commands reach Forge; it does not create a second Forge tool schema.
46
+
47
+ Default path:
48
+
49
+ ```text
50
+ ChatGPT App -> OpenAI Secure MCP Tunnel -> tunnel-client
51
+ -> Forge loopback OAuth Gateway -> Canonical Runtime
52
+ ```
53
+
54
+ A fresh chat is useful for isolated A/B testing, but Forge does not require a new conversation merely because transport changed when the same App is already connected and its tool schema is unchanged. If published tool definitions change, use ChatGPT's current app refresh/re-publish workflow.
55
+
56
+ Upgrade existing Forge installs with:
57
+
58
+ ```bash
59
+ npm install -g @moretea-labs/forge@latest
60
+ forge --version
61
+ forge setup next
62
+ ```
63
+
40
64
  ## Remote controller connection
41
65
 
42
66
  The setup profile owns the connection choice. Supported paths are:
@@ -47,7 +71,7 @@ The setup profile owns the connection choice. Supported paths are:
47
71
  4. **Existing HTTPS** — user-managed reverse proxy/tunnel ending in `/mcp`.
48
72
  5. **None** — local setup only; remote connectivity can be configured later.
49
73
 
50
- For OpenAI Secure MCP Tunnel, setup uses the official supervised runtime surface and considers it ready only when `tunnel-client runtimes status forge --json` reports the runtime running, healthy, and ready.
74
+ For OpenAI Secure MCP Tunnel, setup uses the official managed runtime surface and considers a matching local alias ready only when `tunnel-client` reports the configured `tunnel_id` running, healthy, and ready. Alias names are not tunnel identity.
51
75
 
52
76
  For public HTTPS paths, the configured connector URL is:
53
77
 
@@ -6,8 +6,8 @@ Homebrew is planned as a convenience layer after the first stable npm/GitHub rel
6
6
 
7
7
  Do not publish a formula until all of the following are true:
8
8
 
9
- - `@moretea-labs/forge@1.5.0` exists on npm;
10
- - Git tag and GitHub Release `v1.5.0` exist and point to the tested commit;
9
+ - `@moretea-labs/forge@1.5.1` exists on npm;
10
+ - Git tag and GitHub Release `v1.5.1` exist and point to the tested commit;
11
11
  - the npm tarball has an immutable checksum;
12
12
  - macOS installation and `forge --version` / `forge doctor` tests pass;
13
13
  - upgrade and uninstall behavior is documented.
@@ -18,7 +18,7 @@ forge setup next
18
18
 
19
19
  ## 2. Follow setup until the selected path is usable
20
20
 
21
- For a hosted controller, setup guides the user-level Package Runtime and a remote connection. Prefer OpenAI Secure MCP Tunnel when eligible; Cloudflare Tunnel, Tailscale Funnel, an existing HTTPS `/mcp` endpoint, or deferred remote access are alternatives.
21
+ For ChatGPT, `--tunnel auto` prefers OpenAI Secure MCP Tunnel and does not silently switch to a public ingress provider just because one is installed. The packaged setup keeps the bearer-only Canonical Runtime private and manages a separate loopback OAuth Gateway for the ChatGPT App. Cloudflare Tunnel, Tailscale Funnel, an existing HTTPS `/mcp` endpoint, or deferred remote access are explicit alternatives. Existing users update with `npm install -g @moretea-labs/forge@latest`, then `forge setup next`.
22
22
 
23
23
  The normal package Runtime is `forge runtime service install-package`. Source immutable Runtime and Standalone Recovery are advanced maintainer paths.
24
24
 
@@ -18,7 +18,7 @@ forge setup next
18
18
 
19
19
  ## 2. 按 Next 完成所选路径
20
20
 
21
- 远程主控会继续配置 Package Runtime 和远程连接。有权限时优先 OpenAI Secure MCP Tunnel;也可以选择 Cloudflare Tunnel、Tailscale Funnel、已有 HTTPS `/mcp`,或暂缓远程连接。
21
+ 对 ChatGPT,`--tunnel auto` 会优先 OpenAI Secure MCP Tunnel,不会因为机器上恰好安装了 cloudflared/Tailscale 就静默改用公网入口。Package setup 会保持 bearer-only Canonical Runtime 私有,并管理一个独立的 loopback OAuth Gateway 供 ChatGPT App 使用。Cloudflare Tunnel、Tailscale Funnel、已有 HTTPS `/mcp` 或暂缓远程连接都是显式 fallback。已安装用户运行 `npm install -g @moretea-labs/forge@latest` 后再执行 `forge setup next`。
22
22
 
23
23
  普通 package Runtime 是 `forge runtime service install-package`。源码 immutable Runtime 与 Standalone Recovery 属于高级维护路径。
24
24
 
@@ -20,7 +20,7 @@ See [Platform Support](../operations/platform-support.md) for the exact matrix.
20
20
 
21
21
  ## 2. Install Forge
22
22
 
23
- Release candidates use npm `next`:
23
+ Stable releases use npm `latest`:
24
24
 
25
25
  ```bash
26
26
  npm install -g @moretea-labs/forge
@@ -38,7 +38,7 @@ npm ci --ignore-scripts --no-audit --no-fund
38
38
  npm install -g . --omit=optional --no-audit --no-fund
39
39
  ```
40
40
 
41
- The published package exposes `forge`, `forge-hook`, and `forge-runtime`. Normal package users do **not** need a Forge source checkout, Bun compilation, CodeGraph, or Standalone Recovery to start the user-level Runtime.
41
+ The published package exposes `forge`, `forge-hook`, and `forge-runtime`. Normal package users do **not** need a Forge source checkout, Bun compilation, CodeGraph, or Standalone Recovery to start the user-level Runtime. To upgrade, run `npm install -g @moretea-labs/forge@latest`, verify `forge --version`, then run `forge setup next` so newer setup contracts can be reconciled without reinstalling from source.
42
42
 
43
43
  ## 3. Start guided setup
44
44
 
@@ -78,7 +78,7 @@ The setup session is stored below the user-level Forge directory, so you can clo
78
78
 
79
79
  1. controller registration;
80
80
  2. the packaged user-level Runtime;
81
- 3. secure public HTTPS for a remote controller;
81
+ 3. remote connectivity — OpenAI Secure MCP Tunnel first for ChatGPT, with explicit HTTPS fallbacks;
82
82
  4. account/browser authentication that only you can complete;
83
83
  5. a connection verification.
84
84
 
@@ -90,6 +90,8 @@ The normal Runtime action is:
90
90
  forge runtime service install-package
91
91
  ```
92
92
 
93
+ For ChatGPT setups this installs two local service boundaries together: the bearer-only Canonical Runtime and a separate loopback OAuth Gateway used by Secure Tunnel or HTTPS connectors. Users should follow the OAuth endpoint printed by setup instead of tunneling directly to the internal Runtime port.
94
+
93
95
  The older `forge runtime service install --repo ...` path builds an immutable Git/source Runtime and is an **advanced maintainer path**, not normal onboarding. Standalone Recovery is likewise optional advanced infrastructure.
94
96
 
95
97
  ## 5. Repositories are optional at first
@@ -90,6 +90,8 @@ setup 进度保存在用户级 Forge 目录,可以退出终端后继续。根
90
90
  forge runtime service install-package
91
91
  ```
92
92
 
93
+ 对 ChatGPT,package 安装会同时维护两个本地服务边界:内部 bearer-only Canonical Runtime,以及供 Secure Tunnel/HTTPS connector 使用的 loopback OAuth Gateway。不要把 tunnel 直接指向内部 Runtime 端口,以 setup 输出的本地 OAuth endpoint 为准。
94
+
93
95
  旧的 `forge runtime service install --repo ...` 会构建 Git/source immutable Runtime,属于**高级维护路径**;Standalone Recovery 同样不是普通用户初始化的前置条件。
94
96
 
95
97
  ## 5. 首次 setup 不要求仓库
@@ -42,7 +42,7 @@ Run `forge setup next`. Forge supports these connection providers:
42
42
 
43
43
  ### A. OpenAI Secure MCP Tunnel — preferred when available
44
44
 
45
- Secure MCP Tunnel lets an OpenAI-hosted product reach the local MCP server through an outbound tunnel, without creating public inbound access. See OpenAI's [Secure MCP Tunnel guide](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels) and the official [`openai/tunnel-client`](https://github.com/openai/tunnel-client).
45
+ Secure MCP Tunnel lets an OpenAI-hosted product reach the local MCP server through an outbound tunnel, without creating public inbound access. `--tunnel auto` treats this as the first-choice ChatGPT transport and does not silently choose Cloudflare/Tailscale merely because those CLIs are installed. See OpenAI's [Secure MCP Tunnel guide](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels) and the official [`openai/tunnel-client`](https://github.com/openai/tunnel-client).
46
46
 
47
47
  You need a tunnel ID and tunnel-use permission from OpenAI Platform. Record only the non-secret tunnel ID in Forge:
48
48
 
@@ -67,13 +67,19 @@ tunnel-client runtimes connect \
67
67
  --alias forge \
68
68
  --tunnel-id tunnel_0123456789abcdef0123456789abcdef \
69
69
  --runtime-api-key env:CONTROL_PLANE_API_KEY \
70
- --mcp-server-url http://127.0.0.1:8765/mcp
70
+ --mcp-server-url http://127.0.0.1:8767/mcp
71
71
 
72
72
  tunnel-client runtimes status forge --json
73
73
  ```
74
74
 
75
75
  Forge reports this provider ready only when `tunnel-client` reports the managed runtime as running, healthy, and ready.
76
76
 
77
+ #### What address changes?
78
+
79
+ With Secure Tunnel there is no public Forge `/mcp` address to paste. The ChatGPT App selects the `tunnel_id`; `tunnel-client` forwards commands to Forge's **loopback OAuth Gateway** (default `127.0.0.1:8767/mcp`), which proxies to the internal bearer-only Runtime (default `127.0.0.1:8765/mcp`). If you customize ports, use the OAuth endpoint printed by `forge mcp setup chatgpt --user-level`.
80
+
81
+ The App/connector identity and Forge tool schema are separate from the transport. Switching from Cloudflare/HTTPS to Secure Tunnel changes the network path, not the 19-tool Forge schema. A fresh chat is useful for isolated A/B testing, but it is not required merely because transport changed when the same App is already connected.
82
+
77
83
  ### B. Cloudflare Tunnel
78
84
 
79
85
  Choose this when you need a stable public HTTPS endpoint and control the Cloudflare account/domain:
@@ -40,7 +40,7 @@ http://127.0.0.1:8765/mcp
40
40
 
41
41
  ### A. OpenAI Secure MCP Tunnel——有权限时优先
42
42
 
43
- Secure MCP Tunnel 通过出站连接把本机/private MCP 接到 OpenAI 托管产品,不要求给 Forge 开公网入站。详见 OpenAI 的 [Secure MCP Tunnel](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels) 与官方 [`openai/tunnel-client`](https://github.com/openai/tunnel-client)。
43
+ Secure MCP Tunnel 通过出站连接把本机/private MCP 接到 OpenAI 托管产品,不要求给 Forge 开公网入站。 `--tunnel auto` 会把它作为 ChatGPT 的第一优先传输,不会仅仅因为机器上装了 cloudflared/Tailscale 就静默改用公网方案。详见 OpenAI 的 [Secure MCP Tunnel](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels) 与官方 [`openai/tunnel-client`](https://github.com/openai/tunnel-client)。
44
44
 
45
45
  需要先在 OpenAI Platform 获得 tunnel ID 和使用权限。Forge 只记录**非秘密**的 tunnel ID:
46
46
 
@@ -65,13 +65,19 @@ tunnel-client runtimes connect \
65
65
  --alias forge \
66
66
  --tunnel-id tunnel_0123456789abcdef0123456789abcdef \
67
67
  --runtime-api-key env:CONTROL_PLANE_API_KEY \
68
- --mcp-server-url http://127.0.0.1:8765/mcp
68
+ --mcp-server-url http://127.0.0.1:8767/mcp
69
69
 
70
70
  tunnel-client runtimes status forge --json
71
71
  ```
72
72
 
73
73
  只有 tunnel-client 返回 `process_running + healthy + ready` 后,Forge 才把这个连接判为 ready。
74
74
 
75
+ #### 地址到底变成什么?
76
+
77
+ 使用 Secure Tunnel 后,不再需要给 Forge 准备一个公网 `/mcp` 地址。ChatGPT App 选择 `tunnel_id`;本机 `tunnel-client` 把命令转发到 Forge 的 **loopback OAuth Gateway**(默认 `127.0.0.1:8767/mcp`),再由 Gateway 代理到内部 bearer-only Runtime(默认 `127.0.0.1:8765/mcp`)。如果改过端口,以 `forge mcp setup chatgpt --user-level` 输出的 OAuth endpoint 为准。
78
+
79
+ App/connector 身份及 Forge 工具 schema 与网络传输层是两件事。Cloudflare/HTTPS 切到 Secure Tunnel 改变的是网络路径,不会生成另一套 19-tool Forge schema。同一个 App 已连接且 schema 没变化时,单纯切换网络不要求新开会话;新会话主要用于隔离 A/B 或排查客户端缓存。
80
+
75
81
  ### B. Cloudflare Tunnel
76
82
 
77
83
  需要稳定公网 HTTPS、并且你管理 Cloudflare 账号/域名时使用:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moretea-labs/forge",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Local-first action assistant for software work with resumable execution, bounded tools, and reviewable evidence",
5
5
  "license": "MIT",
6
6
  "author": "Moretea Labs contributors",
@@ -17,6 +17,7 @@ import { Command } from 'commander';
17
17
  import { runDoctor, type CheckStatus, type DoctorReport } from './doctor';
18
18
  import { runStatus, type StatusReport } from './status';
19
19
  import { runMcpSetupChatgpt } from '../mcp/setup';
20
+ import { resolveRepoPreferredControllerHome } from '../repositories/controller-home';
20
21
  import {
21
22
  configureSetupProfile,
22
23
  detectSetupPlatform,
@@ -790,7 +791,8 @@ export function openSetupSession(options: SetupSessionOptions = {}): SetupSessio
790
791
  const platform = options.platform ?? detectSetupPlatform({ env: options.env });
791
792
  const target = options.target ?? setupHostTarget(profile);
792
793
  const report = options.report ?? runInitHook({ ...options, target });
793
- const nextAction = profileNextAction(profile, platform, { controllerHome: options.controllerHome, accountHome: options.accountHome }) ?? report.agent_actions[0];
794
+ const controllerHome = resolveRepoPreferredControllerHome(options.cwd ?? process.cwd(), options.controllerHome);
795
+ const nextAction = profileNextAction(profile, platform, { controllerHome, accountHome: options.accountHome }) ?? report.agent_actions[0];
794
796
  const now = (options.now ?? (() => new Date()))().toISOString();
795
797
  const reuse = previous && previous.status !== 'closed';
796
798
  return writeSetupSessionAt(path, {
@@ -50,6 +50,7 @@ interface McpSetupChatgptOptions {
50
50
  endpoint?: string;
51
51
  serverName?: string;
52
52
  localControllerPort?: string;
53
+ connectorPort?: string;
53
54
  }
54
55
 
55
56
  interface McpSetupLocalControllerOptions {
@@ -237,6 +238,7 @@ export function buildMcpCommand(): Command {
237
238
  .option('--host <host>', 'Local MCP HTTP bind host', '127.0.0.1')
238
239
  .option('--port <port>', 'Local MCP HTTP bind port', '8765')
239
240
  .option('--local-controller-port <port>', 'Local-only Utility Console port; defaults to MCP port + 1')
241
+ .option('--connector-port <port>', 'Loopback OAuth connector port for ChatGPT tunnels; defaults to MCP port + 2')
240
242
  .option('--endpoint <url>', 'Stable public HTTPS /mcp endpoint to store in ignored local config')
241
243
  .option('--server-name <name>', 'ChatGPT Connector/MCP server name to record in ignored local config')
242
244
  .action((rawOpts: McpSetupChatgptOptions) => {
@@ -72,6 +72,7 @@ export function buildRuntimeCommand(): Command {
72
72
  },
73
73
  servicePath: result.servicePath,
74
74
  pid: result.pid,
75
+ connector: result.connector,
75
76
  warnings: result.warnings,
76
77
  next: `forge runtime status --controller-home ${home}`,
77
78
  });
@@ -206,6 +206,20 @@ export function detectSetupPlatform(options: { platform?: NodeJS.Platform; arch?
206
206
  }
207
207
 
208
208
 
209
+ function isLoopbackMcpEndpoint(value: string | undefined): boolean {
210
+ if (!value) return false;
211
+ try {
212
+ const parsed = new URL(value);
213
+ return parsed.protocol === 'http:'
214
+ && (parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost' || parsed.hostname === '::1')
215
+ && parsed.pathname === '/mcp'
216
+ && !parsed.search
217
+ && !parsed.hash;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
209
223
  export interface ControllerGuidance {
210
224
  controller: SetupControllerKind;
211
225
  ready: boolean;
@@ -244,8 +258,9 @@ export function resolveControllerGuidance(
244
258
  const localReady = localConfig?.profile === 'controller'
245
259
  && localConfig.server?.host === '127.0.0.1'
246
260
  && Number.isInteger(localConfig.server?.port);
261
+ const connectorReady = controller !== 'chatgpt' || isLoopbackMcpEndpoint(localConfig?.chatgpt?.localEndpoint);
247
262
  const endpointReady = !profile.tunnel.endpoint || localConfig?.chatgpt?.endpoint === profile.tunnel.endpoint;
248
- if (!localReady || !endpointReady) {
263
+ if (!localReady || !connectorReady || !endpointReady) {
249
264
  const endpoint = profile.tunnel.endpoint ? ` --endpoint ${profile.tunnel.endpoint}` : '';
250
265
  return {
251
266
  controller,
@@ -319,7 +334,7 @@ export function resolveRuntimeGuidance(
319
334
  export function resolveTunnelGuidance(
320
335
  profile: SetupProfile | undefined,
321
336
  platform = detectSetupPlatform(),
322
- options: { controllerHome?: string } = {},
337
+ options: { controllerHome?: string; env?: NodeJS.ProcessEnv } = {},
323
338
  ): TunnelGuidance {
324
339
  if (!profile || !setupNeedsRemoteAccess(profile)) {
325
340
  return { provider: 'none', ready: true, title: 'Remote controller connection', detail: 'Not required by the configured local controller set.' };
@@ -329,15 +344,11 @@ export function resolveTunnelGuidance(
329
344
  }
330
345
  let provider = profile.tunnel.provider;
331
346
  if (provider === 'auto') {
332
- if (platform.commands.tunnelClient) provider = 'openai';
333
- else if (platform.commands.cloudflared) provider = 'cloudflare';
334
- else if (platform.commands.tailscale) provider = 'tailscale';
335
- else {
336
- return {
337
- provider: 'auto', ready: false, title: 'Choose secure remote access',
338
- detail: 'For ChatGPT, prefer OpenAI Secure MCP Tunnel when your workspace/Platform organization supports it; otherwise choose Cloudflare Tunnel, Tailscale Funnel, an existing HTTPS endpoint, or defer remote access. Forge never requires public ingress when a private tunnel is available.',
339
- };
340
- }
347
+ // ChatGPT's private outbound path is the default. Do not silently choose a
348
+ // public ingress provider merely because cloudflared/tailscale happens to
349
+ // be installed; users without Secure Tunnel access can explicitly select a
350
+ // fallback provider.
351
+ provider = 'openai';
341
352
  }
342
353
  if (provider === 'openai') {
343
354
  if (!profile.tunnel.tunnelId) {
@@ -353,9 +364,22 @@ export function resolveTunnelGuidance(
353
364
  detail: 'Install the supported tunnel-client binary from OpenAI Platform Tunnels or the official openai/tunnel-client release. The runtime API key remains an environment/file reference owned by tunnel-client; Forge does not store it.',
354
365
  };
355
366
  }
356
- const alias = 'forge';
357
- const status = spawnSync('tunnel-client', ['runtimes', 'status', alias, '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000 });
358
- if (status.status === 0) {
367
+ const preferredAlias = 'forge';
368
+ const aliases = [preferredAlias];
369
+ const listed = spawnSync('tunnel-client', ['runtimes', 'list', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000, env: options.env ?? process.env });
370
+ if (listed.status === 0) {
371
+ try {
372
+ const value = JSON.parse(listed.stdout || '{}') as { aliases?: Array<{ alias?: string; tunnel_id?: string }> };
373
+ for (const entry of value.aliases ?? []) {
374
+ if (entry.tunnel_id === profile.tunnel.tunnelId && entry.alias) aliases.push(entry.alias);
375
+ }
376
+ } catch {
377
+ // Older clients may not expose local aliases as JSON. Keep the preferred alias fallback.
378
+ }
379
+ }
380
+ for (const alias of Array.from(new Set(aliases))) {
381
+ const status = spawnSync('tunnel-client', ['runtimes', 'status', alias, '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000, env: options.env ?? process.env });
382
+ if (status.status !== 0) continue;
359
383
  try {
360
384
  const value = JSON.parse(status.stdout || '{}') as { process_running?: boolean; healthy?: boolean; ready?: boolean; tunnel_id?: string };
361
385
  if (value.process_running === true && value.healthy === true && value.ready === true
@@ -369,13 +393,20 @@ export function resolveTunnelGuidance(
369
393
  // Treat malformed or older status output as not-ready instead of claiming success.
370
394
  }
371
395
  }
396
+ const alias = preferredAlias;
372
397
  const localConfig = loadMcpServiceLocalConfig(resolveControllerHome(options.controllerHome));
373
- const configuredPort = localConfig?.server?.port;
374
- const mcpPort = typeof configuredPort === 'number' && Number.isInteger(configuredPort) ? configuredPort : 8765;
398
+ const localEndpoint = localConfig?.chatgpt?.localEndpoint;
399
+ if (!isLoopbackMcpEndpoint(localEndpoint)) {
400
+ return {
401
+ provider, ready: false, title: 'Prepare the local ChatGPT OAuth endpoint',
402
+ detail: 'Secure Tunnel must terminate at Forge’s loopback OAuth Gateway, not the bearer-only Canonical Runtime. Refresh the user-level ChatGPT MCP configuration first.',
403
+ command: 'forge mcp setup chatgpt --user-level',
404
+ };
405
+ }
375
406
  return {
376
407
  provider, ready: false, title: 'Connect OpenAI Secure MCP Tunnel',
377
- detail: 'Create a supervised tunnel-client runtime and verify it with runtimes status. Set CONTROL_PLANE_API_KEY in the invoking environment; Forge passes only an env reference and never reads or stores the key.',
378
- command: `tunnel-client runtimes connect --alias ${alias} --tunnel-id ${profile.tunnel.tunnelId} --runtime-api-key env:CONTROL_PLANE_API_KEY --mcp-server-url http://127.0.0.1:${mcpPort}/mcp`,
408
+ detail: 'Create a supervised tunnel-client runtime and verify it with runtimes status. Keep the runtime API key in an environment or file reference owned by tunnel-client; Forge never reads or stores the key.',
409
+ command: `tunnel-client runtimes connect --alias ${alias} --tunnel-id ${profile.tunnel.tunnelId} --runtime-api-key env:CONTROL_PLANE_API_KEY --mcp-server-url ${localEndpoint}`,
379
410
  };
380
411
  }
381
412
  if (provider === 'none') {
@@ -0,0 +1,17 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
+
4
+ export type ConsoleAssetName = "app.js" | "app.css";
5
+
6
+ export function resolveConsoleAssetPath(
7
+ asset: ConsoleAssetName,
8
+ executablePath = process.execPath,
9
+ ): string | URL {
10
+ const releaseAssetPath = join(dirname(executablePath), "ui-dist", asset);
11
+ if (existsSync(releaseAssetPath)) return releaseAssetPath;
12
+ return new URL(`./ui-dist/${asset}`, import.meta.url);
13
+ }
14
+
15
+ export function readConsoleAsset(asset: ConsoleAssetName): string {
16
+ return readFileSync(resolveConsoleAssetPath(asset), "utf8");
17
+ }
@@ -73,6 +73,7 @@ import {
73
73
  verifyEditSession,
74
74
  } from "../editing/edit-session";
75
75
  import { localBridgeDashboardHtml } from "./dashboard";
76
+ import { readConsoleAsset, type ConsoleAssetName } from "./console-assets";
76
77
  import { applyConsoleAutomationAction, listConsoleAutomations, summarizeConsoleAutomations } from "./console-automations";
77
78
  import {
78
79
  ackConsoleHandoff,
@@ -994,7 +995,7 @@ export async function startLocalBridgeServer(
994
995
  if (asset !== "app.js" && asset !== "app.css") { response.status(404).end(); return; }
995
996
  response.setHeader("Cache-Control", "no-cache");
996
997
  response.type(asset.endsWith(".css") ? "text/css" : "application/javascript");
997
- response.send(readFileSync(new URL(`./ui-dist/${asset}`, import.meta.url), "utf8"));
998
+ response.send(readConsoleAsset(asset as ConsoleAssetName));
998
999
  });
999
1000
 
1000
1001
  app.get("/", (_request, response) => {
@@ -21,6 +21,8 @@ export interface McpLocalConfig {
21
21
  chatgpt?: {
22
22
  serverName?: string;
23
23
  endpoint?: string;
24
+ /** Loopback OAuth MCP endpoint used by ChatGPT tunnels/connectors. */
25
+ localEndpoint?: string;
24
26
  };
25
27
  profile?: string;
26
28
  toolset?: 'core' | 'advanced' | 'full';
@@ -303,6 +303,7 @@ export function runMcpSetupChatgpt(opts: {
303
303
  endpoint?: string;
304
304
  serverName?: string;
305
305
  localControllerPort?: string;
306
+ connectorPort?: string;
306
307
  }): McpSetupResult {
307
308
  const repoRoot = resolveMcpRepoRoot(opts.repo ?? ".");
308
309
  const controllerHome = opts.userLevel
@@ -319,6 +320,11 @@ export function runMcpSetupChatgpt(opts: {
319
320
  : existingConfig?.localController?.port ?? (parsedPort < 65_535 ? parsedPort + 1 : 8766);
320
321
  if (!Number.isInteger(localControllerPort) || localControllerPort < 1 || localControllerPort > 65_535) throw new Error(`forge mcp setup chatgpt: invalid Local Controller port ${opts.localControllerPort}`);
321
322
  if (localControllerPort === parsedPort) throw new Error('forge mcp setup chatgpt: Local Controller port must differ from MCP port');
323
+ const connectorPort = opts.connectorPort !== undefined
324
+ ? Number(opts.connectorPort)
325
+ : parsedPort <= 65_533 ? parsedPort + 2 : 8767;
326
+ if (!Number.isInteger(connectorPort) || connectorPort < 1 || connectorPort > 65_535) throw new Error(`forge mcp setup chatgpt: invalid connector port ${opts.connectorPort}`);
327
+ if (connectorPort === parsedPort || connectorPort === localControllerPort) throw new Error('forge mcp setup chatgpt: connector port must differ from Runtime and Local Controller ports');
322
328
  const existingServerName = existingConfig?.chatgpt?.serverName;
323
329
  const migratedServerName =
324
330
  existingServerName && !LEGACY_DEFAULT_SERVER_NAMES.has(existingServerName)
@@ -356,6 +362,7 @@ export function runMcpSetupChatgpt(opts: {
356
362
  ...existingConfig?.chatgpt,
357
363
  serverName,
358
364
  ...(endpoint ? { endpoint } : {}),
365
+ localEndpoint: `http://127.0.0.1:${connectorPort}/mcp`,
359
366
  },
360
367
  profile: existingConfig?.profile ?? "controller",
361
368
  toolset,
@@ -421,7 +428,8 @@ export function runMcpSetupChatgpt(opts: {
421
428
  "[forge mcp] Profile: controller",
422
429
  `[forge mcp] Toolset: ${config.toolset}`,
423
430
  `[forge mcp] ChatGPT MCP server name: ${serverName}`,
424
- `[forge mcp] Local endpoint: http://${host}:${port}/mcp`,
431
+ `[forge mcp] Internal Runtime endpoint: http://${host}:${port}/mcp`,
432
+ `[forge mcp] ChatGPT local OAuth endpoint: http://127.0.0.1:${connectorPort}/mcp`,
425
433
  `[forge mcp] Local Controller: http://${config.localController.host}:${config.localController.port}/`,
426
434
  `[forge mcp] Optional external delegation: ${config.devMode.agentRunner ? `enabled (${config.devMode.allowedAgents.join(',') || 'none'})` : 'disabled'}`,
427
435
  endpoint
@@ -95,6 +95,24 @@ function isInitializeRequest(body: unknown): boolean {
95
95
  return typeof body === 'object' && body !== null && (body as Record<string, unknown>).method === 'initialize';
96
96
  }
97
97
 
98
+ function isServerDiscoverRequest(body: unknown): boolean {
99
+ return typeof body === 'object' && body !== null && (body as Record<string, unknown>).method === 'server/discover';
100
+ }
101
+
102
+ function sendLegacyServerDiscoverUnsupported(res: Response, body: unknown): void {
103
+ const record = typeof body === 'object' && body !== null ? body as Record<string, unknown> : {};
104
+ const id = typeof record.id === 'string' || typeof record.id === 'number' || record.id === null ? record.id : null;
105
+ res.setHeader('Cache-Control', 'no-store');
106
+ res.status(404).json({
107
+ jsonrpc: '2.0',
108
+ id,
109
+ error: {
110
+ code: -32601,
111
+ message: 'Method not found',
112
+ },
113
+ });
114
+ }
115
+
98
116
  function initializeClientIdentity(req: Request, body: unknown, route: McpSessionRoute, principalId: string): string {
99
117
  const params = typeof body === 'object' && body !== null
100
118
  ? (body as { params?: { clientInfo?: { name?: unknown; version?: unknown } } }).params
@@ -676,6 +694,15 @@ async function handleMcpPost(
676
694
  return;
677
695
  }
678
696
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
697
+ // MCP 2026-07-28 clients probe legacy servers with server/discover before
698
+ // falling back to the initialize-era protocol. Forge still serves the
699
+ // legacy stateful transport, so reject the unsupported modern RPC with the
700
+ // protocol-prescribed Method not found / HTTP 404 response instead of
701
+ // misclassifying it as a missing-session HTTP 400.
702
+ if (isServerDiscoverRequest(body)) {
703
+ sendLegacyServerDiscoverUnsupported(res, body);
704
+ return;
705
+ }
679
706
  if (isInitializeRequest(body)) {
680
707
  if (sessionId) {
681
708
  res.setHeader('Mcp-Session-Reset', 'reinitialized');
@@ -0,0 +1,136 @@
1
+ import { createHash } from 'crypto';
2
+ import { closeSync, mkdirSync, openSync, renameSync, writeFileSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { basename, dirname, join, resolve } from 'path';
5
+ import { spawn, spawnSync } from 'child_process';
6
+ import { bootstrapLaunchAgentWithRetryV2, installLaunchAgent, launchAgentPath } from '../../cli/controller/launch-agents';
7
+ import type { PackageRuntimeRelease } from './package-runtime-release';
8
+
9
+ export interface PackageConnectorServicePaths {
10
+ label: string;
11
+ serviceRoot: string;
12
+ sourcePlistPath: string;
13
+ installedPlistPath: string;
14
+ stdoutPath: string;
15
+ stderrPath: string;
16
+ }
17
+
18
+ export interface PackageConnectorServiceResult {
19
+ endpoint: string;
20
+ mode: 'launchd' | 'systemd-user' | 'portable';
21
+ persistent: boolean;
22
+ servicePath?: string;
23
+ pid?: number;
24
+ }
25
+
26
+ function xml(value: string): string {
27
+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
28
+ }
29
+
30
+ function atomicWrite(path: string, content: string, mode = 0o600): void {
31
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
32
+ const temporary = `${path}.${process.pid}.tmp`;
33
+ writeFileSync(temporary, content, { encoding: 'utf8', mode });
34
+ renameSync(temporary, path);
35
+ }
36
+
37
+ export function packageConnectorServicePaths(controllerHome: string, accountHome = process.env.HOME ?? homedir()): PackageConnectorServicePaths {
38
+ const home = resolve(controllerHome);
39
+ const suffix = createHash('sha256').update(home).digest('hex').slice(0, 12);
40
+ const label = `com.moretea.forge.mcp-gateway.${suffix}`;
41
+ const serviceRoot = join(home, 'runtime', 'connector-service');
42
+ return {
43
+ label,
44
+ serviceRoot,
45
+ sourcePlistPath: join(serviceRoot, `${label}.plist`),
46
+ installedPlistPath: launchAgentPath(label, accountHome),
47
+ stdoutPath: join(serviceRoot, 'logs', 'stdout.log'),
48
+ stderrPath: join(serviceRoot, 'logs', 'stderr.log'),
49
+ };
50
+ }
51
+
52
+ export function packageConnectorLaunchSpec(input: { release: PackageRuntimeRelease; controllerHome: string; endpoint: string; executable?: string }): { executable: string; args: string[]; environment: Record<string, string>; port: number } {
53
+ const parsed = new URL(input.endpoint);
54
+ const port = Number(parsed.port);
55
+ if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' || parsed.pathname !== '/mcp' || !Number.isInteger(port) || port < 1 || port > 65_535) {
56
+ throw new Error('FORGE_PACKAGE_CONNECTOR_ENDPOINT_INVALID');
57
+ }
58
+ const executable = resolve(input.executable ?? process.execPath);
59
+ const packageRoot = resolve(input.release.packageRoot);
60
+ const cliEntry = join(packageRoot, 'src', 'cli', 'index.ts');
61
+ const nodeLoader = join(packageRoot, 'src', 'runtime', 'shared', 'node-ts-loader.mjs');
62
+ const isBun = Boolean(process.versions.bun) || /(?:^|[/\\-])bun(?:$|[/\\]|\.exe$)/i.test(basename(executable));
63
+ const cliArgs = [
64
+ 'mcp', 'serve', '--repo', packageRoot, '--controller-home', resolve(input.controllerHome),
65
+ '--transport', 'http', '--host', '127.0.0.1', '--port', String(port), '--profile', 'controller', '--auth', 'oauth',
66
+ ];
67
+ return {
68
+ executable,
69
+ args: isBun ? [cliEntry, ...cliArgs] : ['--loader', nodeLoader, cliEntry, ...cliArgs],
70
+ environment: { FORGE_CONTROLLER_HOME: resolve(input.controllerHome), FORGE_CONTROLLER_LIFECYCLE_OWNER: '1' },
71
+ port,
72
+ };
73
+ }
74
+
75
+ export function renderPackageConnectorLaunchAgent(input: { paths: PackageConnectorServicePaths; launch: ReturnType<typeof packageConnectorLaunchSpec> }): string {
76
+ const environmentXml = Object.entries(input.launch.environment).sort(([a], [b]) => a.localeCompare(b))
77
+ .map(([key, value]) => ` <key>${xml(key)}</key>\n <string>${xml(value)}</string>`).join('\n');
78
+ const args = [input.launch.executable, ...input.launch.args];
79
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key>\n <string>${xml(input.paths.label)}</string>\n <key>ProgramArguments</key>\n <array>\n${args.map((arg) => ` <string>${xml(arg)}</string>`).join('\n')}\n </array>\n <key>EnvironmentVariables</key>\n <dict>\n${environmentXml}\n </dict>\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <dict><key>SuccessfulExit</key><false/></dict>\n <key>ThrottleInterval</key><integer>5</integer>\n <key>StandardOutPath</key><string>${xml(input.paths.stdoutPath)}</string>\n <key>StandardErrorPath</key><string>${xml(input.paths.stderrPath)}</string>\n</dict>\n</plist>\n`;
80
+ }
81
+
82
+ export function renderPackageConnectorSystemdUserUnit(input: { launch: ReturnType<typeof packageConnectorLaunchSpec> }): string {
83
+ const quote = (value: string) => `"${value.replaceAll('%', '%%').replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
84
+ const env = Object.entries(input.launch.environment).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `Environment=${quote(`${k}=${v}`)}`);
85
+ return ['[Unit]', 'Description=Forge ChatGPT OAuth Gateway', 'After=network-online.target', '', '[Service]', 'Type=simple', `ExecStart=${[input.launch.executable, ...input.launch.args].map(quote).join(' ')}`, ...env, 'Restart=on-failure', 'RestartSec=5', '', '[Install]', 'WantedBy=default.target', ''].join('\n');
86
+ }
87
+
88
+ function installSystemd(paths: PackageConnectorServicePaths, launch: ReturnType<typeof packageConnectorLaunchSpec>, env: NodeJS.ProcessEnv): string {
89
+ const unitName = `${paths.label}.service`;
90
+ const unitPath = join(env.HOME ?? homedir(), '.config', 'systemd', 'user', unitName);
91
+ atomicWrite(unitPath, renderPackageConnectorSystemdUserUnit({ launch }), 0o644);
92
+ for (const args of [['--user', 'daemon-reload'], ['--user', 'enable', '--now', unitName]]) {
93
+ const result = spawnSync('systemctl', args, { encoding: 'utf8', env, timeout: 30_000 });
94
+ if (result.status !== 0) throw new Error(`FORGE_PACKAGE_CONNECTOR_SYSTEMD_INSTALL_FAILED: ${(result.stderr || result.stdout || '').trim()}`);
95
+ }
96
+ return unitPath;
97
+ }
98
+
99
+ function startPortable(paths: PackageConnectorServicePaths, launch: ReturnType<typeof packageConnectorLaunchSpec>, env: NodeJS.ProcessEnv): number {
100
+ mkdirSync(join(paths.serviceRoot, 'logs'), { recursive: true, mode: 0o700 });
101
+ const stdout = openSync(paths.stdoutPath, 'a', 0o600), stderr = openSync(paths.stderrPath, 'a', 0o600);
102
+ // A connector is not the Canonical Runtime writer. Never let a transient
103
+ // installer/worker write claim escape into this long-lived process; the claim
104
+ // would become stale as soon as its parent exits. Keep only the explicit
105
+ // Controller Home and lifecycle-role marker from launch.environment.
106
+ const childEnv = { ...env };
107
+ for (const key of [
108
+ 'FORGE_RUNTIME_INSTANCE_ID', 'FORGE_RUNTIME_OWNER_PID', 'FORGE_RELEASE_AUTHORITY_REVISION',
109
+ 'FORGE_RELEASE_FENCING_TOKEN', 'FORGE_RELEASE_ID', 'FORGE_ARTIFACT_IDENTITY', 'FORGE_WORKER_PROTOCOL_VERSION',
110
+ ]) delete childEnv[key];
111
+ try {
112
+ const child = spawn(launch.executable, launch.args, { detached: true, stdio: ['ignore', stdout, stderr], env: { ...childEnv, ...launch.environment } });
113
+ if (!child.pid) throw new Error('FORGE_PACKAGE_CONNECTOR_START_FAILED');
114
+ child.unref();
115
+ return child.pid;
116
+ } finally { closeSync(stdout); closeSync(stderr); }
117
+ }
118
+
119
+ export async function installPackageConnectorService(input: { release: PackageRuntimeRelease; controllerHome: string; endpoint: string; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; forcePortable?: boolean }): Promise<PackageConnectorServiceResult> {
120
+ const paths = packageConnectorServicePaths(input.controllerHome, input.env?.HOME);
121
+ mkdirSync(join(paths.serviceRoot, 'logs'), { recursive: true, mode: 0o700 });
122
+ const launch = packageConnectorLaunchSpec({ release: input.release, controllerHome: input.controllerHome, endpoint: input.endpoint });
123
+ const platform = input.platform ?? process.platform, env = input.env ?? process.env;
124
+ if (!input.forcePortable && platform === 'darwin') {
125
+ atomicWrite(paths.sourcePlistPath, renderPackageConnectorLaunchAgent({ paths, launch }), 0o600);
126
+ installLaunchAgent(paths.sourcePlistPath, paths.label);
127
+ const result = await bootstrapLaunchAgentWithRetryV2({ label: paths.label, plistPath: paths.installedPlistPath });
128
+ if (!result.ok) throw new Error(`FORGE_PACKAGE_CONNECTOR_LAUNCHD_INSTALL_FAILED: ${result.diagnostics.join('; ')}`);
129
+ return { endpoint: input.endpoint, mode: 'launchd', persistent: true, servicePath: paths.installedPlistPath };
130
+ }
131
+ if (!input.forcePortable && platform === 'linux') {
132
+ const probe = spawnSync('systemctl', ['--user', 'show-environment'], { encoding: 'utf8', env, timeout: 30_000 });
133
+ if (probe.status === 0) return { endpoint: input.endpoint, mode: 'systemd-user', persistent: true, servicePath: installSystemd(paths, launch, env) };
134
+ }
135
+ return { endpoint: input.endpoint, mode: 'portable', persistent: false, pid: startPortable(paths, launch, env) };
136
+ }
@@ -14,6 +14,8 @@ import {
14
14
  type ForgeRuntimeServiceConfig,
15
15
  } from './service';
16
16
  import { materializePackageRuntimeRelease, type PackageRuntimeRelease } from './package-runtime-release';
17
+ import { loadMcpServiceLocalConfig } from '../../cli/mcp/auth';
18
+ import { installPackageConnectorService, type PackageConnectorServiceResult } from './package-connector-service';
17
19
 
18
20
  export type PackageRuntimeServiceMode = 'launchd' | 'systemd-user' | 'portable';
19
21
 
@@ -26,6 +28,7 @@ export interface PackageRuntimeServiceInstallResult {
26
28
  servicePath?: string;
27
29
  pid?: number;
28
30
  warnings: string[];
31
+ connector?: PackageConnectorServiceResult;
29
32
  }
30
33
 
31
34
  export interface PackageRuntimeServiceOptions {
@@ -147,26 +150,36 @@ export async function installPackageRuntimeService(options: PackageRuntimeServic
147
150
  repositoryRoot: release.packageRoot,
148
151
  host: options.host ?? '127.0.0.1',
149
152
  port: options.port ?? 8765,
150
- authTokenFile: resolve(options.authTokenFile),
153
+ authTokenFile: options.authTokenFile,
151
154
  ...(options.exclusiveWorkId?.trim() ? { exclusiveWorkId: options.exclusiveWorkId.trim() } : {}),
152
155
  };
153
156
  writeForgeRuntimeServiceConfig(config);
154
157
  syncForgeRuntimeActiveEntrypoint(controllerHome);
155
158
  const platform = options.platform ?? process.platform;
156
159
  const env = options.env ?? process.env;
160
+
161
+ let base: PackageRuntimeServiceInstallResult;
157
162
  if (!options.forcePortable && platform === 'darwin') {
158
163
  const paths = await installForgeRuntimeService({ config, runnerPath: join(release.packageRoot, 'bin', 'forge-runtime-service.mjs'), nodeExecutable: process.execPath });
159
- return { status: 'installed', mode: 'launchd', persistent: true, controllerHome, release, servicePath: paths.installedPlistPath, warnings: [] };
160
- }
161
- if (!options.forcePortable && platform === 'linux' && systemdUserAvailable(env)) {
164
+ base = { status: 'installed', mode: 'launchd', persistent: true, controllerHome, release, servicePath: paths.installedPlistPath, warnings: [] };
165
+ } else if (!options.forcePortable && platform === 'linux' && systemdUserAvailable(env)) {
162
166
  const unitPath = installSystemdUserService(controllerHome, env);
163
- return { status: 'installed', mode: 'systemd-user', persistent: true, controllerHome, release, servicePath: unitPath, warnings: [] };
167
+ base = { status: 'installed', mode: 'systemd-user', persistent: true, controllerHome, release, servicePath: unitPath, warnings: [] };
168
+ } else {
169
+ const pid = startPortableRuntime(controllerHome, env);
170
+ base = {
171
+ status: 'installed', mode: 'portable', persistent: false, controllerHome, release, pid,
172
+ warnings: [platform === 'win32'
173
+ ? 'Native Windows is preview: Forge started detached Runtime and OAuth Gateway processes for this session; automatic login/reboot persistence is not yet claimed.'
174
+ : 'systemd --user is unavailable: Forge started detached Runtime and OAuth Gateway processes; enable a user service manager for reboot persistence.'],
175
+ };
164
176
  }
165
- const pid = startPortableRuntime(controllerHome, env);
166
- return {
167
- status: 'installed', mode: 'portable', persistent: false, controllerHome, release, pid,
168
- warnings: [platform === 'win32'
169
- ? 'Native Windows is preview: Forge started a detached user process for this session; automatic login/reboot persistence is not yet claimed.'
170
- : 'systemd --user is unavailable: Forge started a detached user process; enable a user service manager for reboot persistence.'],
171
- };
177
+
178
+ const localConfig = loadMcpServiceLocalConfig(controllerHome);
179
+ const connectorEndpoint = localConfig?.chatgpt?.localEndpoint;
180
+ if (!connectorEndpoint) return base;
181
+ const connector = await installPackageConnectorService({
182
+ release, controllerHome, endpoint: connectorEndpoint, platform, env, forcePortable: options.forcePortable === true,
183
+ });
184
+ return { ...base, connector };
172
185
  }
@@ -29,6 +29,7 @@ export interface StagedRuntimeRelease {
29
29
  codeGraphNodeArtifactIdentity?: string;
30
30
  codeGraphSidecarArtifactIdentity?: string;
31
31
  codeGraphLibraryArtifactIdentity?: string;
32
+ controllerUiArtifactIdentity?: string;
32
33
  manifestSha256: string;
33
34
  sourceCommit: string;
34
35
  }
@@ -395,6 +396,17 @@ export function stageRuntimeRelease(input: {
395
396
  const codeGraphSidecarArtifactIdentity = `sha256:${sha256(codeGraphSidecarPath)}`;
396
397
  const codeGraphLibraryArtifactIdentity = `sha256:${sha256Directory(codeGraphLibraryPath)}`;
397
398
 
399
+ const controllerUiRoot = 'ui-dist' as const;
400
+ const sourceControllerUiPath = join(sourceRoot, 'src', 'cli', 'local-bridge', controllerUiRoot);
401
+ const sourceControllerUiJs = join(sourceControllerUiPath, 'app.js');
402
+ const sourceControllerUiCss = join(sourceControllerUiPath, 'app.css');
403
+ if (!existsSync(sourceControllerUiJs) || !existsSync(sourceControllerUiCss)) {
404
+ throw new Error(`RUNTIME_RELEASE_CONTROLLER_UI_SOURCE_MISSING: ${sourceControllerUiPath}`);
405
+ }
406
+ const controllerUiPath = join(staging, controllerUiRoot);
407
+ cpSync(sourceControllerUiPath, controllerUiPath, { recursive: true, force: false });
408
+ const controllerUiArtifactIdentity = `sha256:${sha256Directory(controllerUiPath)}`;
409
+
398
410
  const desktopHelperEntrypoint = 'forge-desktop-helper.mjs' as const;
399
411
  const sourceDesktopHelperPath = join(sourceRoot, 'bin', desktopHelperEntrypoint);
400
412
  if (!existsSync(sourceDesktopHelperPath)) {
@@ -429,6 +441,8 @@ export function stageRuntimeRelease(input: {
429
441
  codeGraphSidecarArtifactIdentity,
430
442
  codeGraphLibraryRoot,
431
443
  codeGraphLibraryArtifactIdentity,
444
+ controllerUiRoot,
445
+ controllerUiArtifactIdentity,
432
446
  arguments: [],
433
447
  configurationSchemaVersion: 1,
434
448
  controllerHome: resolve(input.controllerHome),
@@ -460,6 +474,7 @@ export function stageRuntimeRelease(input: {
460
474
  codeGraphNodeArtifactIdentity,
461
475
  codeGraphSidecarArtifactIdentity,
462
476
  codeGraphLibraryArtifactIdentity,
477
+ controllerUiArtifactIdentity,
463
478
  manifestSha256: createHash('sha256').update(`${JSON.stringify(manifest, null, 2)}\n`).digest('hex'),
464
479
  sourceCommit,
465
480
  };
@@ -506,4 +521,8 @@ export function assertRuntimeReleaseFiles(release: StagedRuntimeRelease): void {
506
521
  if (release.codeGraphLibraryArtifactIdentity && !existsSync(join(release.releasePath, 'codegraph-lib', 'dist', 'index.js'))) {
507
522
  throw new Error(`RUNTIME_RELEASE_CODEGRAPH_LIBRARY_MISSING: ${join(release.releasePath, 'codegraph-lib', 'dist', 'index.js')}`);
508
523
  }
524
+ if (release.controllerUiArtifactIdentity
525
+ && (!existsSync(join(release.releasePath, 'ui-dist', 'app.js')) || !existsSync(join(release.releasePath, 'ui-dist', 'app.css')))) {
526
+ throw new Error(`RUNTIME_RELEASE_CONTROLLER_UI_MISSING: ${join(release.releasePath, 'ui-dist')}`);
527
+ }
509
528
  }