diff --git a/docs/webui.md b/docs/webui.md index 406116a0c..831611051 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -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. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index ed96e6634..e4b4eacf4 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -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: diff --git a/packages/webui/server/lib/config.js b/packages/webui/server/lib/config.js index 4e03ac834..e401fe3de 100644 --- a/packages/webui/server/lib/config.js +++ b/packages/webui/server/lib/config.js @@ -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. diff --git a/packages/webui/server/lib/mcode-acp.js b/packages/webui/server/lib/mcode-acp.js index 5b1a59bd0..23e3852b4 100644 --- a/packages/webui/server/lib/mcode-acp.js +++ b/packages/webui/server/lib/mcode-acp.js @@ -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 diff --git a/packages/webui/server/lib/runtime-host.js b/packages/webui/server/lib/runtime-host.js new file mode 100644 index 000000000..76e2e8477 --- /dev/null +++ b/packages/webui/server/lib/runtime-host.js @@ -0,0 +1,324 @@ +// webui/server/lib/runtime-host.js +// +// In-process runtime host skeleton (S2 of the runtime-first migration). +// Today the webui server reaches the engine via `mcode acp` child +// processes (one long-lived singleton, one per turn). S2 lays the +// foundation to drop that boundary: a process-internal runtime host +// owns the same SQLite-backed CliService, callable in the same way +// as the ACP transport, but without spawn cost or per-turn IPC. +// +// Two hosts live here, matching design §4.1: +// +// 1. catalogue host — long-lived, exposes read-only operations +// (listSessions, listModels, getSession, …). One per process; +// replacing the `mcode acp` singleton for catalogue traffic. +// +// 2. turn host — per-turn, owns sendMessage / abortSession / steer. +// Every turn wraps its conversation in this object. Exceptions +// thrown by the runtime are caught at the turn boundary and +// turned into an error-shaped stream frame, so a runtime-side +// crash cannot take down the webui server (R1). `close()` is +// fire-and-forget because the turn host owns no resources of its +// own — it shares the underlying CliService with the catalogue +// host. +// +// `MCODE_WEBUI_TRANSPORT` configures whether routes should *use* this +// host. S2 ships the host but leaves every route on its current +// transport; the switch is only wired into route handlers in S3+. The +// default is `acp` (== today). See docs/webui.md "Transport selection" +// for the full semantics. +// +// This file does NOT import server/lib/config.js on purpose: the +// runtime host is constructed with explicit options (dataDir etc.) +// rather than env reads at module-load time. That keeps the test +// suite simple — see test/server/runtime-host.test.js — and means +// multiple webui instances in one process (a long-lived concern for +// testing and embedders) do not fight over a single shared config. + +import { createLocalRuntimeHostV2, getDefaultLocalRuntimeConfig } from "@mavis/local-runtime-v2"; +import { readLocalRuntimeAuthContext } from "@mavis/config"; +import { TuiRuntimeAdapter } from "@minimax/code/runtime-adapter"; + +// `createLocalRuntimeHostV2` returns `cliService` as an optional +// field — it is present after `host.ready` resolves and `ensureBuiltinAgents` +// has run. The catalogue host owns a single instance for its lifetime. + +/** Maximum time `close()` will wait on apiHost.close() before giving up. */ +const CATALOGUE_CLOSE_BOUND_MS = 5_000; +/** Maximum time `abortSession` will wait for the stream to terminate. */ +const TURN_ABORT_BOUND_MS = 5_000; + +// --------------------------------------------------------------------------- +// Auth context bridge +// --------------------------------------------------------------------------- + +/** + * Build the auth-context getter the runtime calls on demand. Reads + * the shared projection from the dataDir each invocation — equivalent + * in freshness to the per-turn auth reload the acp subprocess used to + * provide. When no auth exists, returns undefined (the runtime's own + * code path then surfaces "not authenticated" to the caller, which is + * the same behaviour as today's child-process path). + */ +function buildAuthContextGetter(dataDir) { + return () => { + try { + return readLocalRuntimeAuthContext(dataDir) ?? undefined; + } catch { + return undefined; + } + }; +} + +// --------------------------------------------------------------------------- +// Catalogue host — long-lived +// --------------------------------------------------------------------------- + +/** + * @typedef {object} CatalogueHostOptions + * @property {string} dataDir Runtime data directory (the + * resolved v2 contract root, not + * the webui data dir). + * @property {() => object} [configGetter] Local-runtime config getter; + * defaults to the v2 default. + * @property {string} [runtimeOwnerKind] Defaults to "tui" — the runtime + * owner-kind taxonomy only + * recognises cli/tui for the + * embedded capability. Lease is + * keyed per-instance so this never + * collides with a concurrent + * `mcode acp` TUI. + */ + +/** + * Construct the long-lived catalogue host. Boots an in-process + * `CliService` wrapped by `TuiRuntimeAdapter` and ready for read-only + * catalogue traffic. The returned object also exposes `close()` which + * is bounded — see CATALOGUE_CLOSE_BOUND_MS. + * + * @param {CatalogueHostOptions} options + * @returns {Promise<{ + * adapter: TuiRuntimeAdapter, + * apiHost: { close: () => Promise }, + * close: () => Promise, + * }>} + */ +export async function createCatalogueHost(options) { + if (!options || typeof options.dataDir !== "string" || options.dataDir.length === 0) { + throw new Error("createCatalogueHost: options.dataDir is required"); + } + const dataDir = options.dataDir; + const configGetter = options.configGetter ?? getDefaultLocalRuntimeConfig; + // The runtime owner-kind taxonomy only recognises `cli`/`tui` for the + // embedded capability (see runtime.ts#isV2RuntimeOwner). The runtime + // sees every embedded owner as equivalent for capability purposes; + // the lease is keyed by instance-id so this never collides with a + // concurrent `mcode acp` TUI — see runtime.ts §2.1 of the migration + // design. + const runtimeOwnerKind = options.runtimeOwnerKind ?? "tui"; + + const host = await createLocalRuntimeHostV2({ + dataDir, + configGetter, + runtimeOwnerKind, + // We are not the TUI; we don't share the TUI's owner lease. The + // lease file is keyed per-instance-id so this never collides with + // a concurrently running `mcode acp` TUI. + capabilities: { cliEmbedded: true }, + // Startup policy mirrors the TUI acp surface: don't auto-resume + // persisted background work just because the webui booted. + startupExecutionPolicy: "quarantined", + // Auth is read on demand by the getter below. Passing undefined + // here keeps boot unconditional; round-trip failures surface as + // authRequired at the call site, which is the same behaviour as + // the per-turn child process today. + authContextGetter: buildAuthContextGetter(dataDir), + }); + + // The v2 host only attaches `cliService` once the readiness gate + // passes; throw if the contract drifts. + await host.ready; + if (!host.cliService) { + throw new Error( + "createCatalogueHost: runtime did not expose cliService after ready", + ); + } + // Bring up the builtin agents the TUI acp surface brings up — the + // catalogue host talks to the same catalog the chat path does. + await host.apiHost.ensureBuiltinAgents(); + + const adapter = new TuiRuntimeAdapter(host.cliService); + + // Bounded close: try to drain dependencies in order; if any link + // hangs, give up at CATALOGUE_CLOSE_BOUND_MS so a wedged runtime + // cannot wedge webui's graceful-shutdown path. R8 mitigation. + let closing = null; + const close = () => { + if (!closing) { + closing = (async () => { + const timeout = new Promise((resolve) => + setTimeout(() => resolve("timeout"), CATALOGUE_CLOSE_BOUND_MS), + ); + const result = await Promise.race([host.apiHost.close(), timeout]); + return result === "timeout" ? "timeout" : "ok"; + })(); + } + return closing; + }; + + return { + adapter, + apiHost: host.apiHost, + controller: host.controller, + close, + }; +} + +// --------------------------------------------------------------------------- +// Turn host — per-turn, share-nothing with the catalogue +// --------------------------------------------------------------------------- + +/** + * @typedef {object} TurnHost + * @property {(...args: any[]) => AsyncGenerator} sendMessage + * Wrapped sendMessage: every thrown error becomes an + * `{type:'error', message}` stream frame so the consumer + * sees the failure as a turn-local event, not a process + * crash (R1). + * @property {(req: object) => Promise<{success: boolean, elapsedMs: number}>} abortSession + * Calls adapter.abortSession, then waits up to + * TURN_ABORT_BOUND_MS for the active stream to settle. + * Always returns success=true (delivery semantics — see + * design R2); the elapsedMs field lets callers log wedge + * durations. + * @property {() => void} close + * Fire-and-forget. The turn host owns no resources of its + * own; the catalogue host is responsible for the actual + * shutdown. + */ + +/** + * Create a per-turn wrapper around a catalogue host. + * + * `sendMessage` is wrapped: any throw from the underlying adapter + * becomes an error stream frame (mutation target 1). `abortSession` + * issues an abort, waits for the stream to settle within + * TURN_ABORT_BOUND_MS, then resolves with delivery-confirmed + * `{success:true, elapsedMs}` regardless of whether the stream had + * time to drain — design R2 says we MUST NOT depend on subprocess + * kill (there are no subprocesses anymore). + * + * @param {Awaited>} catalogueHost + * @returns {TurnHost} + */ +export function createTurnHost(catalogueHost) { + if (!catalogueHost || !catalogueHost.adapter) { + throw new Error("createTurnHost: catalogueHost.adapter is required"); + } + const { adapter } = catalogueHost; + const activeStreams = new Set(); + // Per-turn AbortController. Routes don't have to construct one per + // call — the turn host owns the abort lifecycle for its turn, so a + // call to abortSession always has a signal to trip. Callers that + // bring their own signal (e.g. a request-scoped AbortController) + // pass it to sendMessage and it replaces this one for that stream + // only; the controller is still used for stream-iteration faults + // that need a clean break. + const turnController = new AbortController(); + + /** + * sendMessage: every throw becomes an error stream frame. + * The wrapper never lets an exception escape the iterator boundary + * — that's what "可弃化" means at the turn level (R1 mitigation). + * + * `signal` is the caller's optional signal. When present, it is + * forwarded to the adapter; when absent, the turn host's own + * controller provides one (so abortSession always has a signal to + * trip and the adapter can still react to cancellation). + */ + async function* safeSendMessage(req, signal) { + const effectiveSignal = signal || turnController.signal; + let stream; + try { + stream = adapter.sendMessage(req, effectiveSignal); + } catch (err) { + yield { type: "error", message: err && err.message ? err.message : String(err) }; + return; + } + activeStreams.add(stream); + try { + for await (const event of stream) { + yield event; + } + } catch (err) { + yield { type: "error", message: err && err.message ? err.message : String(err) }; + } finally { + activeStreams.delete(stream); + } + } + + /** + * abortSession: bounded termination. Two complementary mechanisms: + * 1. adapter.abortSession(req) — the runtime-side protocol abort; + * best-effort, may throw. + * 2. turnController.abort() — fires the per-turn signal that + * sendMessage forwarded to the adapter. This is what trips + * any AbortSignal listener the adapter registered. + * After both deliveries we wait for the active stream(s) to settle, + * bounded at TURN_ABORT_BOUND_MS. The wait is a race — a wedged + * runtime never settles the stream, so we MUST time out rather + * than block the request indefinitely (R2 / R8 mitigation). + */ + async function safeAbortSession(req) { + const t0 = Date.now(); + let delivered = false; + try { + const r = await adapter.abortSession(req); + delivered = r === true; + } catch { + delivered = false; + } + // Fire the per-turn signal so listeners in the adapter wake up. + if (!turnController.signal.aborted) { + turnController.abort(); + } + // Wait for the active stream to settle, bounded. We do NOT block + // on `Promise.all([...activeStreams])` directly because that + // promise can never resolve if the runtime wedges; we wait with a + // race. + if (activeStreams.size > 0) { + const settle = Promise.all( + Array.from(activeStreams).map(async (s) => { + try { + // Drain the iterator without consuming events. The + // for-await loop throws a `done` once the iterator + // completes; we ignore the value. + // eslint-disable-next-line no-unused-vars + for await (const _ of s) { + /* drain */ + } + } catch { + /* iterator rejected — treat as settled */ + } + }), + ); + const timeout = new Promise((resolve) => + setTimeout(() => resolve("timeout"), TURN_ABORT_BOUND_MS), + ); + await Promise.race([settle, timeout]); + } + return { success: true, delivered, elapsedMs: Date.now() - t0 }; + } + + function close() { + // Fire-and-forget — the catalogue host owns the actual shutdown. + // We only forget references here; nothing async, nothing blocking. + activeStreams.clear(); + } + + return { + sendMessage: safeSendMessage, + abortSession: safeAbortSession, + close, + }; +} diff --git a/packages/webui/test/server/runtime-host.test.js b/packages/webui/test/server/runtime-host.test.js new file mode 100644 index 000000000..f3e832d88 --- /dev/null +++ b/packages/webui/test/server/runtime-host.test.js @@ -0,0 +1,376 @@ +// webui/test/server/runtime-host.test.js +// +// Tests for the in-process runtime host (S2 of the runtime-first +// migration). Two hosts live in this module: +// - catalogue host: long-lived, exposes listSessions / listModels / +// getSession / etc. — the read-only "ACP singleton" replacement. +// - turn host: per-turn, wraps sendMessage + abortSession in an +// exception boundary, owns no resources. +// +// What the suite pins: +// 1. The full chain works in an isolated tmp dataDir — +// createCatalogueHost → adapter.createSession → +// adapter.listSessions → close. No mcode child processes +// should exist anywhere in this tree (process internalization). +// 2. The catalogue host close() is bounded; an apiHost.close that +// never resolves must NOT keep our close() hanging forever. +// 3. The turn host exception boundary catches errors thrown by +// the adapter and converts them into a turn failure stream, +// without taking down the process (other calls keep working). +// 4. The abort path follows abort → wait-for-stream-termination +// → discard. It does NOT depend on a child process kill. +// +// The first failure the test surfaces (before any host exists) is +// a missing module — pinning the contract before the +// implementation lands is the whole point of TDD here. + +import { test, after } from "node:test"; +import { strict as assert } from "node:assert"; +import { + mkdtempSync, + rmSync, + readdirSync, + readFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// runtime-host.js takes its dataDir as a constructor option. We do +// NOT import server/lib/config.js — the host module is env-agnostic +// by design (the runtime-first migration carries config in via a +// single object). test:release-tools gates the four env-override +// variables against tests that spawn `server.js`; this test does +// not spawn server.js, so the lint does not apply. + +const projectRoot = join(import.meta.dirname, "..", ".."); +const tmpBase = mkdtempSync(join(tmpdir(), "mcode-webui-runtime-host-")); + +function setupIsolatedDir(label) { + return mkdtempSync(join(tmpBase, `${label}-`)); +} + +after(() => { + try { + rmSync(tmpBase, { recursive: true, force: true }); + } catch {} +}); + +// ============================================================ +// 1. Full chain in isolated dataDir — proves process internalization. +// ============================================================ + +test("S2-RH-01: catalogue host boot → createSession → listSessions → close, no mcode children", async () => { + const dir = setupIsolatedDir("rh01"); + const { createCatalogueHost } = await import( + "../../server/lib/runtime-host.js" + ); + assert.equal( + typeof createCatalogueHost, + "function", + "runtime-host.js must export createCatalogueHost", + ); + + // Record child processes visible to this process BEFORE we boot the + // host. The runtime is supposed to be in-process — there must be + // zero `mcode` children spawned at any point. + const beforePids = listMcodeChildPids(); + const host = await createCatalogueHost({ dataDir: dir }); + assert.ok(host, "catalogue host must be constructed"); + assert.equal( + typeof host.adapter, + "object", + "catalogue host must expose an adapter (TuiRuntimeAdapter-shaped)", + ); + assert.equal( + typeof host.close, + "function", + "catalogue host must expose close()", + ); + + // Exercise the catalogue path. createSession + listSessions must + // both succeed against the in-process runtime — without ever + // touching a `mcode` child process. + const session = await host.adapter.createSession({ + workspaceDir: dir, + mcpServers: [], + }); + assert.ok(session, "createSession returned a session"); + assert.ok(session.sessionId, "session has a sessionId"); + + const listed = await host.adapter.listSessions(); + assert.ok(Array.isArray(listed), "listSessions returned an array"); + assert.ok( + listed.some((s) => s.sessionId === session.sessionId), + "listSessions must include the freshly-created session", + ); + + const afterCreatePids = listMcodeChildPids(); + assert.deepEqual( + afterCreatePids, + beforePids, + "no new mcode children should appear during boot or session create", + ); + + // Bounded close: must resolve in well under the API host's 60s + // upper bound (we test with a 10s ceiling). + const t0 = Date.now(); + await host.close(); + const elapsed = Date.now() - t0; + assert.ok( + elapsed < 10000, + `close() must be bounded — elapsed=${elapsed}ms`, + ); + + const afterClosePids = listMcodeChildPids(); + assert.deepEqual( + afterClosePids, + beforePids, + "no mcode children should outlive close()", + ); + + rmSync(dir, { recursive: true, force: true }); +}); + +// ============================================================ +// 2. Close() bounded drain — regression pin for R8. +// ============================================================ + +test("S2-RH-02: catalogue host close() is bounded when apiHost.close() hangs", async () => { + const dir = setupIsolatedDir("rh02"); + const { createCatalogueHost } = await import( + "../../server/lib/runtime-host.js" + ); + + // Build a real catalogue host first; then swap apiHost.close for a + // never-resolving promise to simulate a wedged dependency chain. + const host = await createCatalogueHost({ dataDir: dir }); + const realClose = host.apiHost.close.bind(host.apiHost); + // Hijack close so the host's drain logic has to time it out. + host.apiHost.close = () => new Promise(() => {}); + assert.ok(typeof realClose === "function", "realClose captured"); + void realClose; // keep ref alive to silence unused-locals + + const t0 = Date.now(); + // Bounded close must finish even though the underlying apiHost.close + // never settles. The contract is `close()` resolves within an upper + // bound (we accept up to 5500ms for jitter on top of the 5s box). + await host.close(); + const elapsed = Date.now() - t0; + assert.ok( + elapsed < 5500, + `bounded close must time out well before 5s — elapsed=${elapsed}ms`, + ); + + rmSync(dir, { recursive: true, force: true }); +}); + +// ============================================================ +// 3. Turn host exception boundary — regression pin for R1. +// ============================================================ + +test("S2-RH-03: turn host sendMessage failure does not crash the process; other calls still work", async () => { + const dir = setupIsolatedDir("rh03"); + const { createCatalogueHost, createTurnHost } = await import( + "../../server/lib/runtime-host.js" + ); + assert.equal( + typeof createTurnHost, + "function", + "runtime-host.js must export createTurnHost", + ); + + const host = await createCatalogueHost({ dataDir: dir }); + // Replace the adapter's sendMessage with an async iterator that + // yields one frame, then throws on the NEXT pull. This exercises the + // *iterator-body* exception path (the inner try/catch around the + // for-await loop). A synchronous throw on entry is caught by the + // outer try/catch, so this test deliberately fails later — exactly + // the regression the inner boundary is meant to pin. + const realSend = host.adapter.sendMessage.bind(host.adapter); + host.adapter.sendMessage = async function* () { + yield { type: "delta", content: "first" }; + throw new Error("simulated runtime-side mid-stream failure"); + }; + void realSend; + + const turn = createTurnHost(host); + let caughtExternally = false; + let streamErrorSeen = false; + try { + for await (const ev of turn.sendMessage({ id: "boom" })) { + if (ev && ev.type === "error") streamErrorSeen = true; + } + } catch { + caughtExternally = true; + } + // The contract: failures land as stream error frames, not as + // process-crashing throws. A throw from the for-await here would + // mean the turn host's exception boundary is gone — exactly the + // regression we want the mutation to expose. (Allowing + // `streamErrorSeen || caughtExternally` would silently accept a + // throw, defeating the test.) + assert.ok( + streamErrorSeen && !caughtExternally, + `sendMessage failure must surface as a stream error frame, never as a thrown exception — ` + + `streamErrorSeen=${streamErrorSeen} caughtExternally=${caughtExternally}`, + ); + + // The process is still alive: a follow-up read-only call succeeds. + const listed = await host.adapter.listSessions(); + assert.ok(Array.isArray(listed), "process survived — listSessions works"); + // catalogue close still functions. + await host.close(); + + rmSync(dir, { recursive: true, force: true }); +}); + +// ============================================================ +// 4. Abort → wait ≤5s → discard — regression pin for R2. +// +// The test exercises the bounded-drain branch (the path R2 calls out): +// `adapter.abortSession` returns, but the active stream keeps yielding +// indefinitely. abortSession MUST NOT block forever — it must give up +// at 5s and resolve with `success:true, elapsedMs≈5000`. The mutation +// (drop the 5s race for an unbounded wait) is verified separately. +// ============================================================ + +test("S2-RH-04: abortSession triggers bounded termination; no subprocess kill", async () => { + const dir = setupIsolatedDir("rh04"); + const { createCatalogueHost, createTurnHost } = await import( + "../../server/lib/runtime-host.js" + ); + + const host = await createCatalogueHost({ dataDir: dir }); + // Stub adapter.sendMessage to: + // 1. Yield one frame, then HANG forever (never settles). + // 2. Listen for AbortSignal — record that the turn host passed one, + // but do NOT let the abort settle the stream. + // This is the worst case the bounded drain protects against: abort + // delivered, but the runtime never acknowledges it. abortSession must + // time out at 5s and return anyway — never block the request. + let abortSeen = false; + let signalReceived = false; + host.adapter.sendMessage = async function* (_, signal) { + if (signal && typeof signal.addEventListener === "function") { + signalReceived = true; + signal.addEventListener( + "abort", + () => { + abortSeen = true; + // Deliberately do NOT settle the stream — the abort is + // delivered but the runtime never wakes up. This is exactly + // the case the 5s upper bound exists to protect against. + }, + { once: true }, + ); + } else { + // No signal — fail the test loudly. The turn host MUST pass one. + throw new Error("sendMessage called without AbortSignal"); + } + yield { type: "delta", content: "starting…" }; + // Hang forever — never returns. abortSession must time out. + await new Promise(() => {}); + }; + + const turn = createTurnHost(host); + const streamP = (async () => { + try { + for await (const ev of turn.sendMessage({ id: "ab" })) { + // consume frames until the iterator never resolves + } + } catch { + // The for-await will throw when the host's outer wrapper catches + // — but in this test the stream NEVER errors (it's hanging), so + // this catch never fires. We use streamP only to drain so the + // activeStreams Set eventually clears when the host's wrapper + // gives up. + } + })(); + + // Give the stream a tick to start, then abort. + await new Promise((r) => setTimeout(r, 50)); + const t0 = Date.now(); + const abortResult = await turn.abortSession({ id: "ab" }); + const elapsed = Date.now() - t0; + + // Strict assertion — the contract is delivery-confirmed success + // regardless of whether the stream had time to drain. (R2 design: + // there is no subprocess to kill, so abortSession cannot promise + // termination, only "abort delivered".) + assert.equal(abortResult.success, true, "abortSession reports success"); + assert.equal( + typeof abortResult.elapsedMs, + "number", + "abortSession must report elapsed time", + ); + // The bounded-drain branch MUST have fired: the stub hangs forever, + // so the only way abortSession can resolve is the 5s race. Assert + // the elapsed is at the bound — anything noticeably below means the + // bounded drain did not actually run. + assert.ok( + elapsed >= 4900, + `abortSession must have waited at least 4.9s for the wedged stream — ` + + `elapsed=${elapsed}ms (a low elapsed means the 5s bound didn't fire)`, + ); + assert.ok( + elapsed <= 6000, + `abortSession must finish within 5s + small jitter — elapsed=${elapsed}ms`, + ); + + // Strict check — the turn host MUST have passed an AbortSignal AND + // the signal MUST have been observed by the stream. This is the + // assertion that replaces the previous `abortSeen || true` tautology. + assert.equal( + signalReceived, + true, + "turn host must pass an AbortSignal to adapter.sendMessage", + ); + assert.equal( + abortSeen, + true, + "abort must be observed by the stream listener (proves the signal `abort` event fires)", + ); + + // The stream itself never resolves (the stub hangs forever), so + // drain it in the background and let the host's wrapper forget the + // stream when the activeStreams Set is collected. We don't await + // streamP — the test exits before that, which is the intended + // behaviour: abortSession gives up on the stream and returns. + void streamP; + + await host.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +// ============================================================ +// Helpers +// ============================================================ + +/** + * Enumerate every `mcode` child process visible to /proc. Returns + * an array of {pid, cmdline} so callers can assert that the runtime + * internalization leaves the mcode-process landscape untouched. + * + * Linux-only (matches the production layout). On other platforms the + * assertion is no-op'd so the suite still runs, but the central + * invariant only fires on Linux. Better to fail loudly here than to + * silently hide a regression. + */ +function listMcodeChildPids() { + const out = []; + let pids; + try { + pids = readdirSync("/proc").filter((n) => /^\d+$/.test(n)); + } catch { + return out; + } + for (const pid of pids) { + try { + const cmdline = readFileSync(`/proc/${pid}/comm`, "utf8").trim(); + if (cmdline === "mcode" || cmdline.startsWith("mcode-")) { + out.push({ pid, cmdline }); + } + } catch {} + } + return out; +} diff --git a/release/public-source.json b/release/public-source.json index 9dc2a9e7f..9cff8f7f0 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3454,6 +3454,7 @@ "packages/webui/server/lib/quota-forecast.js", "packages/webui/server/lib/rate-limit.js", "packages/webui/server/lib/read-json.js", + "packages/webui/server/lib/runtime-host.js", "packages/webui/server/lib/session-tree.js", "packages/webui/server/lib/sessions.js", "packages/webui/server/lib/settings.js", @@ -3623,6 +3624,7 @@ "packages/webui/test/server/router-dispatch.test.js", "packages/webui/test/server/router-origin-gate.check.mjs", "packages/webui/test/server/router-readonly.test.js", + "packages/webui/test/server/runtime-host.test.js", "packages/webui/test/server/send-run-guard.test.js", "packages/webui/test/server/server-startup.test.js", "packages/webui/test/server/state-snapshot-no-chat.test.js", diff --git a/scripts/build.mjs b/scripts/build.mjs index 199412ecd..e2f1fdbef 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -13,7 +13,7 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from "node:url"; import path from "node:path"; import { copyLocalRuntimeAssets } from "./lib/local-runtime-assets.mjs"; -import { createTuiBundleModuleLocationConfig } from "./lib/tui-npm-bundle-profile.mjs"; +import { createTuiBundleModuleLocationConfig, createWebuiBundleModuleLocationConfig } from "./lib/tui-npm-bundle-profile.mjs"; import { shouldCopyTuiRuntimeResource } from "./lib/tui-package-privacy.mjs"; import { TUI_DISABLED_BUILTIN_SKILL_NAMES } from "./lib/builtin-skills.mjs"; import { copyMcodeToolsArtifact } from './lib/mcode-tools-artifact.mjs'; @@ -32,6 +32,7 @@ const packages = new Map( }), ); const location = createTuiBundleModuleLocationConfig(); +const webuiLocation = createWebuiBundleModuleLocationConfig(); const outdir = path.join(root, "dist"); rmSync(outdir, { recursive: true, force: true }); mkdirSync(outdir, { recursive: true }); @@ -96,6 +97,17 @@ await build({ plugins: [createWorkspaceSourcePlugin({ root, packages })], metafile: true, logLevel: "info", + // S2 (runtime-first migration): the webui bundle's dependency tree + // pulls in proper-lockfile (CJS), which requires `path` at module + // load. Without a banner the synthetic __require shim throws + // "Dynamic require of \"path\" is not supported" on first import. + // The CLI bundle has had an equivalent banner since 0.5.4 + // (createTuiBundleModuleLocationConfig); the webui bundle mirrors + // it with `server.js` as the entry filename. Do NOT remove without + // first running scripts/probe-cjs-banner.mjs to confirm symptom is + // gone in your environment. + banner: { js: webuiLocation.banner }, + define: webuiLocation.define, }); copyLocalRuntimeAssets({ repositoryRoot: root, diff --git a/scripts/lib/tui-npm-bundle-profile.mjs b/scripts/lib/tui-npm-bundle-profile.mjs index 3daa592af..bec6265d9 100644 --- a/scripts/lib/tui-npm-bundle-profile.mjs +++ b/scripts/lib/tui-npm-bundle-profile.mjs @@ -1,11 +1,21 @@ export const TUI_BUNDLE_CHUNKS_DIRECTORY = "chunks"; const TUI_BUNDLE_STABLE_MODULE_URL_IDENTIFIER = "__mavis_tuiPackageEntryUrl"; +// S2 (R3 follow-up): every Node built-in import in these banners MUST +// use the `node:` prefix (`node:module`, `node:path`, `node:url`). The +// bare form (`"module"`, `"path"`, `"url"`) is ESM-valid but trips the +// webui-bundle check at scripts/check-webui-bundle.mjs:80 — that gate +// allows `node:`-prefixed specifiers unconditionally and rejects every +// other bare specifier not present in cliExternalModules. CLI bundles +// do not run through that gate, which is why the bare form lived in +// the CLI banner for years; the webui banner inherited the same shape +// in S2 and immediately failed at integration time. ESM treats +// `node:` and bare builtin names as equivalent — keep them aligned. export function createTuiBundleModuleLocationConfig() { const banner = - 'import { createRequire as __mavis_cR } from "module"; ' + - 'import { basename as __mavis_basename, dirname as __mavis_dirname, join as __mavis_join } from "path"; ' + - 'import { fileURLToPath as __mavis_fileURLToPath, pathToFileURL as __mavis_pathToFileURL } from "url"; ' + + 'import { createRequire as __mavis_cR } from "node:module"; ' + + 'import { basename as __mavis_basename, dirname as __mavis_dirname, join as __mavis_join } from "node:path"; ' + + 'import { fileURLToPath as __mavis_fileURLToPath, pathToFileURL as __mavis_pathToFileURL } from "node:url"; ' + "const __mavis_tuiCurrentModuleDir = __mavis_dirname(__mavis_fileURLToPath(import.meta.url)); " + `const __mavis_tuiPackageRoot = __mavis_basename(__mavis_tuiCurrentModuleDir) === ${JSON.stringify(TUI_BUNDLE_CHUNKS_DIRECTORY)} ? __mavis_dirname(__mavis_tuiCurrentModuleDir) : __mavis_tuiCurrentModuleDir; ` + `const ${TUI_BUNDLE_STABLE_MODULE_URL_IDENTIFIER} = __mavis_pathToFileURL(__mavis_join(__mavis_tuiPackageRoot, "cli.js")).href; ` + @@ -18,3 +28,38 @@ export function createTuiBundleModuleLocationConfig() { }), }; } + +// S2 (runtime-first migration). The webui server bundle currently ships +// without a createRequire banner, but its dependency tree pulls in +// proper-lockfile (CJS), which does `require("path")` at module load. +// Without a banner the synthetic `__require("path")` throws "Dynamic +// require of \"path\" is not supported" the first time the bundle is +// imported. The CLI bundle has had this banner since 0.5.4 +// (createTuiBundleModuleLocationConfig above); the webui bundle +// mirrors it but with `server.js` as the entry filename (no chunks +// directory in the webui tree). The banner provides: +// - `require()` — resolves CJS deps via Node's createRequire, +// anchored at the bundle's own URL so +// require("better-sqlite3") finds node_modules +// alongside dist/webui/server.js. +// - `__dirname` — required by CJS deps that compute paths at +// load time (proper-lockfile, etc.). +// Both must be present; do not strip either. +export function createWebuiBundleModuleLocationConfig() { + const banner = + 'import { createRequire as __mavis_cR } from "node:module"; ' + + 'import { dirname as __mavis_dirname } from "node:path"; ' + + 'import { fileURLToPath as __mavis_fileURLToPath, pathToFileURL as __mavis_pathToFileURL } from "node:url"; ' + + "const __mavis_webuiCurrentModuleDir = __mavis_dirname(__mavis_fileURLToPath(import.meta.url)); " + + "const __mavis_webuiPackageRoot = __mavis_webuiCurrentModuleDir; " + + 'const __mavis_webuiPackageEntryUrl = __mavis_pathToFileURL(__mavis_webuiPackageRoot + "/server.js").href; ' + + "const require = __mavis_cR(__mavis_webuiPackageEntryUrl); " + + "const __dirname = __mavis_webuiPackageRoot;"; + return { + banner, + define: Object.freeze({ + "import.meta.url": "__mavis_webuiPackageEntryUrl", + }), + }; +} +