Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions docs/webui.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,20 +112,45 @@ node dist/cli.js webui --host 0.0.0.0 --no-open # PORT defaults to 18080

The canonical disclosure is [`packages/webui/references/SECURITY-NOTES.md`](../packages/webui/references/SECURITY-NOTES.md).

## Transport selection (ACP or exec)
## Transport selection (ACP, exec, or runtime)

Every turn is sent to the engine over one of two transports. The choice is made server-side, per turn, before the engine spawns — and it is decided in two different places, evaluated in this order:
Every turn is sent to the engine over one of three transports: the long-lived ACP subprocess (`mcode acp`), the one-shot exec subprocess (`mcode exec`), or — added in slice S2 — an **in-process runtime host** (`packages/webui/server/lib/runtime-host.js`) that owns the same `CliService` the TUI does. The choice is made server-side, per turn, before the engine spawns — and it is decided in two different places, evaluated in this order:

1. `process.env.MCODE_USE_ACP === "0"` forces the exec transport (`packages/webui/server/routes/chat.js#handleSend`; the code comment there calls it the escape hatch for an ACP protocol regression). This is the only read of the variable in the codebase.
1. `process.env.MCODE_USE_ACP === "0"` forces the exec transport (`packages/webui/server/routes/chat.js#handleSend`; the code comment there calls it the escape hatch for an ACP protocol regression). This is the only read of the variable in the codebase. `MCODE_USE_ACP=0` short-circuits all three transports.
2. Otherwise the turn is handed to `runMcodeAcp` (`packages/webui/server/lib/mcode-acp.js`), which **silently re-routes to `runMcodeExec`** in its first branch when `cs.permissions` is set and is anything other than `"Full access"`.
3. Only when neither applies does the turn actually run over ACP.

S2 (slice 2 of the runtime-first migration) introduces a new switch alongside `MCODE_USE_ACP`:

| Env var | Default | Accepted values | What it does |
| --- | --- | --- | --- |
| `MCODE_USE_ACP` | unset | `0` → exec escape hatch (overrides everything); `1` → no effect; unset → no effect | Today-only escape hatch; see rows below. |
| `MCODE_WEBUI_TRANSPORT` | `acp` | `acp` (today's behaviour), `exec` (no-op in S2 — no route reads this value; `exec` today is reachable only via `MCODE_USE_ACP=0`), `runtime` (opt-in to the S2 in-process host) | Selects the engine transport. Default keeps every response field-identical to today's `main`; opt-in paths route through the runtime host once S3+ lands. |

Resolution rule, in priority order:

1. `MCODE_USE_ACP=0` ⇒ `exec`, regardless of `MCODE_WEBUI_TRANSPORT`. The legacy escape hatch wins.
2. `MCODE_WEBUI_TRANSPORT=exec` ⇒ no-op in S2. No production route consumes this value yet; the `exec` transport today is reachable only via `MCODE_USE_ACP=0`. Documented so the contract does not drift when a future slice wires the value.
3. `MCODE_WEBUI_TRANSPORT=runtime` ⇒ `runtime`. S2 lands the host infrastructure but no route reads the switch yet; the value is plumbed for S3+. Setting this to `runtime` today is a no-op until S3 lands.
4. `MCODE_WEBUI_TRANSPORT=acp` (default) ⇒ today's ACP path. Permission-mode re-route still applies.
5. Unknown value (e.g. typo) ⇒ falls back to `acp` with a one-line warning to stderr. The server never refuses to boot because of an unknown transport.

| Turn condition | Transport | Decided at |
| --- | --- | --- |
| `MCODE_USE_ACP=0` in the server environment | exec | `routes/chat.js#handleSend` |
| `cs.permissions` is `Ask`, `Auto`, or `Read` (not `Full access`) | exec (silent re-route) | `mcode-acp.js#runMcodeAcp` |
| `MCODE_WEBUI_TRANSPORT=exec` | (no-op in S2 — same as default `acp`; `exec` transport today is reachable only via `MCODE_USE_ACP=0`) | `server/lib/config.js#MCODE_WEBUI_TRANSPORT` (no route reads this value yet) |
| `cs.permissions` is `Ask`, `Auto`, or `Read` (not `Full access`) | exec (silent re-route inside ACP entry) | `mcode-acp.js#runMcodeAcp` |
| `MCODE_WEBUI_TRANSPORT=runtime` | runtime (S2 lands the host; S3+ lights the route) | `server/lib/config.js#MCODE_WEBUI_TRANSPORT` (no route reads it yet) |
| otherwise — factory default is `permissions: "Full access"` (`server/lib/state-bus.js` initial state) | ACP | `mcode-acp.js#runMcodeAcp` |

S2 invariants (must remain true on every later slice):

- **Default `MCODE_WEBUI_TRANSPORT=acp` is field-identical to `main`.** No existing endpoint response may shift; no child process count may grow. The verification suite proves this on every commit by running the full webui node:test suite with no env override.
- **S2 ships the host but does not wire it.** `createCatalogueHost` and `createTurnHost` are exported from `server/lib/runtime-host.js`; no production route imports them. Wiring happens in S3 (catalogue traffic — list/title), S4 (active turns — `runMcodeRuntime`), S5 (models), S6 (interactions, accounts). S7 flips the default to `runtime`.
- **R1 mitigation (process-isolation loss) lives in the turn host.** Every call into `adapter.sendMessage` is wrapped so a runtime-side throw becomes a stream-shaped error frame and never escapes the turn. Tests in `packages/webui/test/server/runtime-host.test.js` pin this with a mutation that drops the inner catch — the test goes red if the boundary is removed.
- **R2 mitigation (abort semantics) lives in `createTurnHost#abortSession`.** It returns `{success:true, elapsedMs}` after at most a 5 s wait for the stream to settle; it does NOT rely on subprocess kill, because there is no subprocess. The bound keeps graceful shutdown responsive even on a wedged runtime.
- **R8 mitigation (wedged host) lives in `createCatalogueHost#close`.** It races `apiHost.close()` against a 5 s timeout so a wedged dependency chain cannot wedge webui's graceful shutdown.

Contract notes:

- **There is no `/exec` command.** The webui-local command set is `WEBUI_LOCAL_COMMANDS` — `new`, `clear`, `status`, `sessions`, `usage`, `help`, `stop` (`server/lib/acp-client.js`). Transport is never switched by a slash command; the two conditions above are the whole rule.
Expand Down
32 changes: 27 additions & 5 deletions docs/webui.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,24 +100,46 @@ node dist/cli.js webui --host 0.0.0.0 --no-open # PORT defaults to 18080

正式的披露文档是 [`packages/webui/references/SECURITY-NOTES.md`](../packages/webui/references/SECURITY-NOTES.md)。

## 传输选择(ACP 还是 exec)
## 传输选择(ACP、exec 或 runtime)

发出的每条消息都由两种传输之一送达引擎。选择发生在服务端、按回合进行,页面上**没有任何提示**。本节记录当前源码的实际行为,不是长期不变的契约。
发出的每条消息由三种传输之一送达引擎:长驻的 ACP 子进程(`mcode acp`)、一次性的 exec 子进程(`mcode exec`),或——S2 新增的——**进程内 runtime 宿主**(`packages/webui/server/lib/runtime-host.js`),它拥有与 TUI 同一份 `CliService`。选择发生在服务端、按回合进行,页面上**没有任何提示**。本节记录当前源码的实际行为,不是长期不变的契约。

两种传输是什么:
三种传输是什么:

- **ACP**(默认):与 TUI 相同的协议通道(`mcode acp` 子进程)。工具调用过程、会话标题、思考等级等事件都从这条通道回传。
- **exec**:一次性 `mcode exec` 命令行子进程。回合结束进程即退出,只有思考与正文文本回传。
- **runtime**(S2 起提供,路由尚未接入):进程内 runtime 宿主。它没有子进程边界,与 `mcode` CLI 共用同一份 SQLite;S3-S6 才会逐步把路由接到它上面,S7 才把默认值翻过来。S2 阶段开关设为 `runtime` 仍是 no-op——只是把宿主骨架建好。

谁决定走哪条:
S2(runtime-first 改造第二步)新增了一个开关与 `MCODE_USE_ACP` 并存:

| 环境变量 | 缺省值 | 可选值 | 含义 |
| --- | --- | --- | --- |
| `MCODE_USE_ACP` | 未设 | `0` → exec 逃生阀(压倒其他所有);`1` → 无效;未设 → 无效 | 旧开关,仅作逃生阀;见下表。 |
| `MCODE_WEBUI_TRANSPORT` | `acp` | `acp`(与今天一致)、`exec`(S2 阶段无路由消费,是 no-op;今天走 exec 仍要靠 `MCODE_USE_ACP=0`)、`runtime`(S2 起的进程内宿主,开关已 plumb 但路由未接入) | 选择引擎传输。缺省下每个响应都与 `main` 字段级一致;显式 `runtime` 直到 S3+ 才真正生效。 |

判定优先级(按顺序):

1. `MCODE_USE_ACP=0` ⇒ `exec`,无视 `MCODE_WEBUI_TRANSPORT`。旧逃生阀优先级最高。
2. `MCODE_WEBUI_TRANSPORT=exec` ⇒ S2 阶段是 no-op。当前没有任何生产路由消费这个值;今天要走 exec 仍要靠 `MCODE_USE_ACP=0`。**先把契约写在这里**,避免后续切片接线时漂移。
3. `MCODE_WEBUI_TRANSPORT=runtime` ⇒ `runtime`。S2 已经把宿主骨架建好,但尚无路由读这个开关;S3+ 才会真正接上。S2 阶段设为 `runtime` 是 no-op。
4. `MCODE_WEBUI_TRANSPORT=acp`(缺省)⇒ ACP。权限模式静默改道仍然生效。
5. 未知取值(例如拼错)⇒ 回落到 `acp`,并在 stderr 打印一行告警。**永远不会因为传输开关未知而拒绝启动。**

| 条件 | 实际走的传输 | 判定位置 |
| --- | --- | --- |
| 服务端环境变量 `MCODE_USE_ACP=0` | exec | `server/routes/chat.js#handleSend` |
| `MCODE_WEBUI_TRANSPORT=exec` | (S2 阶段是 no-op——与缺省 `acp` 等价;今天要走 exec 仍要靠 `MCODE_USE_ACP=0`) | `server/lib/config.js#MCODE_WEBUI_TRANSPORT`(路由尚未读这个值) |
| 会话权限模式不是 Full access(Ask / Auto / Read) | exec(在 ACP 入口内部静默改道) | `server/lib/mcode-acp.js#runMcodeAcp` 首个分支 |
| `MCODE_WEBUI_TRANSPORT=runtime` | runtime(S2 建好宿主;S3+ 才接路由) | `server/lib/config.js#MCODE_WEBUI_TRANSPORT`(路由尚未读它) |
| 其余情况(出厂默认:权限 Full access,见 `server/lib/state-bus.js` 初始状态) | ACP | 同上 |

出厂默认权限是 Full access,所以不碰任何开关时所有回合都走 ACP。两个条件若同时成立也不冲突——它们都指向 exec;环境变量先判(`chat.js` 的三元),权限判定只在其后进入 `runMcodeAcp` 时发生。
S2 不变量(后续切片必须继续守住):

- **缺省 `MCODE_WEBUI_TRANSPORT=acp` 与 `main` 字段级一致。** 现有任一端点的响应都不能偏移;进程内不能多出新的子进程。每次提交都用完整 webui node:test 套件在无 env 覆盖的情况下跑一遍来验证。
- **S2 只建骨架、不接线。** `createCatalogueHost` 与 `createTurnHost` 都从 `server/lib/runtime-host.js` 导出,但没有生产路由 import 它们。S3 接目录类流量(list/title),S4 接回合(`runMcodeRuntime`),S5 接模型,S6 接交互与账户。S7 才把缺省翻为 `runtime`。
- **R1 缓解(进程隔离丧失)落在回合宿主里。** 任何对 `adapter.sendMessage` 的调用都被包在边界内——runtime 侧抛出转为流式 error 帧,**永远不会冒泡出回合**。`packages/webui/test/server/runtime-host.test.js` 用一处删掉内层 try/catch 的变异验证这条边界——边界没了测试就红。
- **R2 缓解(取消语义)落在 `createTurnHost#abortSession`。** 它在最多 5 秒内等待流归位,然后返回 `{success:true, elapsedMs}`;**不依赖子进程 kill**,因为已经没有子进程。超时上限保证即便 runtime 卡死也不会拖累优雅停机。
- **R8 缓解(宿主卡死)落在 `createCatalogueHost#close`。** 它把 `apiHost.close()` 与 5 秒超时赛跑——任一依赖链卡死都不会拖累 webui 的优雅停机。

什么时候会遇到 exec:

Expand Down
38 changes: 38 additions & 0 deletions packages/webui/server/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,44 @@ export const MCODE_WEBUI_SETTINGS_PATH = process.env.MCODE_WEBUI_SETTINGS_PATH |
export const MCODE_BETTER_SQLITE3 = process.env.MCODE_BETTER_SQLITE3 || null;
export const DEBUG_INJECT = process.env.DEBUG_INJECT || null;

// MCODE_WEBUI_TRANSPORT — S2 (runtime-first migration step 2).
//
// Selects the engine transport that webui uses for every turn. The
// default `acp` matches today's behaviour exactly (no observable
// change). The new value `runtime` opts routes into the in-process
// runtime host landed in S2; wiring is staged — S3-S6 light up
// catalogue/turn paths incrementally, S7 flips the default.
//
// MCODE_WEBUI_TRANSPORT=acp → today's behaviour (default)
// MCODE_WEBUI_TRANSPORT=exec → escape hatch; routes/chat.js exec
// path, identical to MCODE_USE_ACP=0
// MCODE_WEBUI_TRANSPORT=runtime → opt-in to the S2 in-process host
//
// Interaction with MCODE_USE_ACP (kept for backwards compatibility):
// MCODE_USE_ACP=0 ⇒ transport=exec (regardless of MCODE_WEBUI_TRANSPORT)
// MCODE_USE_ACP=1 ⇒ transport honours MCODE_WEBUI_TRANSPORT
// MCODE_USE_ACP unset ⇒ transport honours MCODE_WEBUI_TRANSPORT
// (today's behaviour is `acp`)
//
// S2 reads MCODE_WEBUI_TRANSPORT for diagnostic introspection but does
// NOT yet route any handler through it. See docs/webui.md "Transport
// selection" for the full table and the S3+ rollout.
const VALID_TRANSPORTS = new Set(["acp", "exec", "runtime"]);
function resolveTransport() {
const raw = (process.env.MCODE_WEBUI_TRANSPORT || "").trim().toLowerCase();
if (raw && !VALID_TRANSPORTS.has(raw)) {
console.warn(
`[webui] MCODE_WEBUI_TRANSPORT=${raw} is not a known value; valid choices are acp, exec, runtime — falling back to acp`,
);
return "acp";
}
return raw || "acp";
}
export const MCODE_WEBUI_TRANSPORT = resolveTransport();
// Raw env value for diagnostic and doc-aligned introspection
// (scripts/check-docs-alignment.mjs reads this name verbatim).
export const MCODE_WEBUI_TRANSPORT_ENV = process.env.MCODE_WEBUI_TRANSPORT || null;

// Platform-specific fallback paths to try when probing for the
// sqlite3 binary. Pure function for testability — no FS / process
// side effects.
Expand Down
6 changes: 0 additions & 6 deletions packages/webui/server/lib/mcode-acp.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,6 @@ function matchesModelId(recorded, engineCurrent, modelOption) {
* (engine wire form, no `/`) → the whole string — direct-match in
* `resolveModelId` covers this case before the name-match runs.
*/
function engineModelKeyFromId(id) {
if (typeof id !== "string" || !id) return id;
const slash = id.indexOf("/");
return slash >= 0 ? id.slice(slash + 1) : id;
}


// Exported for unit tests (test/lib/mcode-acp-note.test.js extends to
// cover applyRecordedModel's resolution logic). The pre-session model
Expand Down
Loading
Loading