@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 +8 -1
- package/README.md +6 -6
- package/README.zh-CN.md +4 -2
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/assets/skill-version.json +2 -2
- package/docs/forge-chatgpt-mcp-setup.md +27 -3
- package/docs/operations/homebrew.md +2 -2
- package/docs/public-usage-guide.md +1 -1
- package/docs/public-usage-guide.zh-CN.md +1 -1
- package/docs/tutorials/01-install-and-start.md +5 -3
- package/docs/tutorials/01-install-and-start.zh-CN.md +2 -0
- package/docs/tutorials/02-connect-chatgpt.md +8 -2
- package/docs/tutorials/02-connect-chatgpt.zh-CN.md +8 -2
- package/package.json +1 -1
- package/src/cli/commands/init-hook.ts +3 -1
- package/src/cli/commands/mcp.ts +2 -0
- package/src/cli/commands/runtime.ts +1 -0
- package/src/cli/commands/setup-profile.ts +49 -18
- package/src/cli/local-bridge/console-assets.ts +17 -0
- package/src/cli/local-bridge/server.ts +2 -1
- package/src/cli/mcp/auth.ts +2 -0
- package/src/cli/mcp/setup.ts +9 -1
- package/src/cli/mcp/transports/http.ts +27 -0
- package/src/runtime/root/package-connector-service.ts +136 -0
- package/src/runtime/root/package-runtime-service.ts +25 -12
- package/src/runtime/root/release-materialize.ts +19 -0
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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`
|
|
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.
|
|
81
|
+
当前稳定版本是 `1.5.1`,稳定安装使用 npm `latest`。
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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.
|
|
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
|
|
|
@@ -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
|
|
30
|
+
Forge keeps two MCP boundaries on loopback by default:
|
|
31
31
|
|
|
32
32
|
```text
|
|
33
|
-
http://127.0.0.1:
|
|
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
|
|
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.
|
|
10
|
-
- Git tag and GitHub Release `v1.5.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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:
|
|
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.
|
|
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
|
|
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, {
|
package/src/cli/commands/mcp.ts
CHANGED
|
@@ -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) => {
|
|
@@ -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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
357
|
-
const
|
|
358
|
-
|
|
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
|
|
374
|
-
|
|
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.
|
|
378
|
-
command: `tunnel-client runtimes connect --alias ${alias} --tunnel-id ${profile.tunnel.tunnelId} --runtime-api-key env:CONTROL_PLANE_API_KEY --mcp-server-url
|
|
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(
|
|
998
|
+
response.send(readConsoleAsset(asset as ConsoleAssetName));
|
|
998
999
|
});
|
|
999
1000
|
|
|
1000
1001
|
app.get("/", (_request, response) => {
|
package/src/cli/mcp/auth.ts
CHANGED
|
@@ -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';
|
package/src/cli/mcp/setup.ts
CHANGED
|
@@ -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]
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
}
|