diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index 181e591..75fe152 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -100,7 +100,8 @@ jobs:
- name: Upload macOS artifacts
uses: actions/upload-artifact@v6
with:
- name: opencodex-macos-${{ matrix.arch }}
+ # 外层 Artifact 名称携带完整提交哈希,内部 DMG 文件名保持不变。
+ name: opencodex-macos-${{ matrix.arch }}-${{ github.sha }}
path: release/*.dmg
if-no-files-found: error
@@ -136,7 +137,8 @@ jobs:
- name: Upload Windows artifact
uses: actions/upload-artifact@v6
with:
- name: opencodex-windows
+ # 外层 Artifact 名称携带完整提交哈希,内部 EXE 文件名保持不变。
+ name: opencodex-windows-${{ github.sha }}
path: release/*.exe
if-no-files-found: error
diff --git a/gateway/runtime/http/static-assets.cjs b/gateway/runtime/http/static-assets.cjs
index 52c5ce5..4a53623 100644
--- a/gateway/runtime/http/static-assets.cjs
+++ b/gateway/runtime/http/static-assets.cjs
@@ -65,6 +65,7 @@ const RUNTIME_COMPATIBILITY_SCRIPT_PATH = "/opencodex/runtime-compatibility.js";
const RUNTIME_COMPATIBILITY_STYLE_PATH = "/opencodex/runtime-compatibility.css";
const OPENCODEX_SIDEBAR_PREVIEW_PATH = "/codex-sidebar-preview.js";
const OPENCODEX_OFFSCREEN_ANIMATION_GUARD_PATH = "/codex-offscreen-animation-guard.js";
+const CODEX_APP_HOST_MESSAGE_CODEC_PATH = "/codex-app-host-message-codec.js";
const CODEX_BRIDGE_POLYFILL_PATH = "/codex-bridge-polyfill.js";
const CODEX_REMOTE_FILE_ACTIONS_PATH = "/codex-remote-file-actions.js";
const CODEX_WORKSPACE_ROOT_PICKER_CSS_PATH = "/codex-workspace-root-picker.css";
@@ -278,6 +279,7 @@ const WEB_SHELL_STATIC_FILES = new Map([
[OPENCODEX_TOKEN_USAGE_CAPABILITY_PATH, path.join(INTERNAL_PROVIDER_DIR, "codex-token-usage-capability.js")],
[OPENCODEX_WINDOW_CONTROLS_OVERLAY_CSS_PATH, path.join(WEB_SHELL_DIR, "codex-window-controls-overlay.css")],
[OPENCODEX_WINDOW_CONTROLS_OVERLAY_PATH, path.join(INTERNAL_PROVIDER_DIR, "codex-window-controls-overlay.js")],
+ [CODEX_APP_HOST_MESSAGE_CODEC_PATH, path.join(WEB_SHELL_DIR, "codex-app-host-message-codec.js")],
[OPENCODEX_SIDEBAR_PREVIEW_PATH, path.join(INTERNAL_PROVIDER_DIR, "codex-sidebar-preview.js")],
[
OPENCODEX_OFFSCREEN_ANIMATION_GUARD_PATH,
@@ -690,6 +692,7 @@ function createStaticAssetService({
CODEX_SMART_SCHEDULING_SUMMARY_PATH,
OPENCODEX_TOKEN_USAGE_CAPABILITY_PATH,
OPENCODEX_WINDOW_CONTROLS_OVERLAY_PATH,
+ CODEX_APP_HOST_MESSAGE_CODEC_PATH,
CODEX_BRIDGE_POLYFILL_PATH,
CODEX_REMOTE_FILE_ACTIONS_PATH,
CODEX_WORKSPACE_ROOT_PICKER_PATH,
@@ -944,6 +947,7 @@ function createStaticAssetService({
``,
``,
``,
+ ``,
``,
``,
``,
diff --git a/gateway/runtime/ipc/official-runtime.cjs b/gateway/runtime/ipc/official-runtime.cjs
index 96b1c96..efc8853 100644
--- a/gateway/runtime/ipc/official-runtime.cjs
+++ b/gateway/runtime/ipc/official-runtime.cjs
@@ -2200,9 +2200,20 @@ async function connectOfficialAppHostPort(port, context = {}) {
return true;
}
+function deliverOfficialAppHostMessage(event, onMessage, close) {
+ // renderer transport 只把 null 作为 peer close;undefined 是合法的结构化值。
+ const data = event ? event.data : undefined;
+ if (data === null) {
+ close("official_closed");
+ return false;
+ }
+ onMessage(data);
+ return true;
+}
+
/**
* 在 gateway 进程里创建一条“浏览器 MessagePort <-> 官方 MessagePort”的透明中继。
- * 这里不解析 app-host RPC 的 JSON 内容,只保证字符串帧和关闭信号按顺序穿过边界。
+ * 这里不解析 AppHost RPC 内容,只保证结构化值和关闭信号按顺序穿过边界。
*/
function createOfficialAppHostRelay(options = {}) {
const { clientId = "", onClose, onError, onMessage, portId = "", remoteAddress = "" } = options;
@@ -2213,6 +2224,7 @@ function createOfficialAppHostRelay(options = {}) {
// port1 交给官方 IPC listener;port2 留在 gateway,用来和浏览器 WebSocket 互转消息。
const { port1, port2 } = new electron.MessageChannelMain();
let closed = false;
+ let nullCloseScheduled = false;
function close(reason = "closed") {
if (closed) return;
@@ -2229,29 +2241,19 @@ function createOfficialAppHostRelay(options = {}) {
}
port2.on("message", (event) => {
- // Electron MessageEvent.data 可能挂在原型 getter 上,必须直接读取,不能用 hasOwnProperty 判断。
- const data = event ? event.data : undefined;
- if (data == null) {
- // app-host 约定 null 表示端口关闭,收到后要同步释放两端资源。
- close("official_closed");
- return;
- }
- if (typeof data !== "string") {
- diagnosticWarn("official-app-host", "non_string_message_from_official", {
- clientId: shortId(clientId),
- payloadType: typeof data,
- portId: shortId(portId),
- });
- return;
- }
try {
- onMessage && onMessage(data);
+ deliverOfficialAppHostMessage(event, (data) => onMessage && onMessage(data), close);
} catch (error) {
diagnosticWarn("official-app-host", "forward_to_browser_failed", {
clientId: shortId(clientId),
error: error instanceof Error ? error.message : String(error),
portId: shortId(portId),
});
+ try {
+ onError && onError(error);
+ } catch {}
+ // 编码或下行失败后继续保留 session 只会让双方永久等待,立即关闭以便页面重连恢复。
+ close("forward_to_browser_failed");
}
});
port2.on("close", () => close("official_port_closed"));
@@ -2285,9 +2287,13 @@ function createOfficialAppHostRelay(options = {}) {
postMessage(data) {
if (closed) return false;
try {
- // 浏览器侧也用 null 作为关闭信号;其它 payload 必须保持官方 RPC 字符串原样。
+ // main-process transport 在发送 nullish 后释放 relay,先交给官方 listener 消费 terminal 帧。
port2.postMessage(data);
- if (data == null) close("browser_closed");
+ if (data == null && !nullCloseScheduled) {
+ nullCloseScheduled = true;
+ const scheduleClose = typeof setImmediate === "function" ? setImmediate : (callback) => setTimeout(callback, 0);
+ scheduleClose(() => close("browser_closed"));
+ }
return true;
} catch (error) {
diagnosticWarn("official-app-host", "forward_to_official_failed", {
@@ -2563,6 +2569,7 @@ module.exports = {
startOfficialRuntime,
webConfigScript,
__test: {
+ deliverOfficialAppHostMessage,
compactOfficialAppCatalogPayload,
configureOfficialWebContentsListenerBudget,
fileManagerPathFromSpawn,
diff --git a/gateway/runtime/ipc/ws-hub.cjs b/gateway/runtime/ipc/ws-hub.cjs
index d6023f8..6ccb0b5 100644
--- a/gateway/runtime/ipc/ws-hub.cjs
+++ b/gateway/runtime/ipc/ws-hub.cjs
@@ -5,6 +5,7 @@ try {
} catch {}
const { diagnosticLog, diagnosticWarn, shortId } = require("../core/diagnostics.cjs");
const { DEBUG_LOGS } = require("../core/config.cjs");
+const appHostMessageCodec = require("../../../web-shell/codex-app-host-message-codec.js");
// 下面这些阈值只服务于 OPENCODEX_DEBUG_WS=1 的链路排障;默认运行不会采样慢 WS 发送。
const WS_LARGE_MESSAGE_BYTES = Number(process.env.OPENCODEX_WS_LARGE_LOG_BYTES || 256 * 1024);
@@ -33,6 +34,16 @@ const WS_IPC_MAX_IN_FLIGHT = Math.max(32, Number(process.env.OPENCODEX_WS_IPC_MA
const ROUTE_ID_SCAN_MAX_NODES = 128;
const BROADCAST_DEDUPE_MAX_ENTRIES_PER_SOCKET = 16;
const BROADCAST_DEDUPE_MAX_WINDOW_MS = 60_000;
+let nextAppHostRelayGeneration = 0;
+
+function webSocketServerOptions({ perMessageDeflate, maxPayloadBytes = WS_MAX_PAYLOAD_BYTES } = {}) {
+ return {
+ noServer: true,
+ perMessageDeflate,
+ // 64 MiB 是 WebSocket 入站帧边界,不是 AppHost codec 的消息限制。
+ maxPayload: Math.max(1024 * 1024, Number(maxPayloadBytes) || WS_MAX_PAYLOAD_BYTES),
+ };
+}
function byteLength(value) {
// WebSocket bufferedAmount 用字节衡量;日志里也统一按 UTF-8 字节估算,方便对齐网络层现象。
@@ -124,11 +135,7 @@ function createWsHub(
const perMessageDeflate = wsCompressionOptions();
const effectiveMaxBufferedBytes = Math.max(1024, Number(maxBufferedBytes) || WS_MAX_BUFFERED_BYTES);
- const wss = new WebSocketServer({
- noServer: true,
- perMessageDeflate,
- maxPayload: Math.max(1024 * 1024, Number(maxPayloadBytes) || WS_MAX_PAYLOAD_BYTES),
- });
+ const wss = new WebSocketServer(webSocketServerOptions({ perMessageDeflate, maxPayloadBytes }));
if (WS_DEBUG_ENABLED) {
// 压缩配置只在排障模式打印;压缩本身始终按上面的配置生效。
diagnosticLog("ws-hub", "compression_configured", {
@@ -199,7 +206,7 @@ function createWsHub(
startedAtMs: Date.now(),
timer: null,
};
- // 聚合窗口结束后只打一条 summary,避免 app-host 高频字符串帧把会话加载日志刷爆。
+ // 聚合窗口结束后只打一条 summary,避免 AppHost 高频 wire 帧把会话加载日志刷爆。
stat.timer = setTimeout(() => flushAppHostTraffic(key), APP_HOST_TRAFFIC_FLUSH_MS);
if (stat.timer && typeof stat.timer.unref === "function") stat.timer.unref();
appHostTraffic.set(key, stat);
@@ -225,10 +232,11 @@ function createWsHub(
}
function appHostPayloadInfo(payload) {
- // app-host-port-message.data 是官方 RPC 字符串;只统计长度,不解析内容,避免耦合官方协议细节。
- if (!payload || payload.type !== "app-host-port-message" || typeof payload.data !== "string") return null;
+ // AppHost message data 只统计字符串或 wire JSON 长度,不解析官方协议内容。
+ if (!payload || payload.type !== "app-host-port-message") return null;
+ const data = typeof payload.data === "string" ? payload.data : JSON.stringify(payload.data);
return {
- bytes: byteLength(payload.data),
+ bytes: byteLength(data),
portId: typeof payload.portId === "string" ? payload.portId : "",
};
}
@@ -416,15 +424,67 @@ function createWsHub(
return ws.__codexAppHostRelays;
}
+ function relayIsCurrent(relays, context) {
+ // map 身份和 active 状态共同构成 generation 护栏,旧回调不能触碰替换后的 relay。
+ return !!context && context.terminalState === "active" && relays.get(context.portId) === context;
+ }
+
+ function failAppHostRelay(relays, context, error, reason) {
+ if (!relayIsCurrent(relays, context)) return false;
+ context.terminalState = "error";
+ relays.delete(context.portId);
+ if (!context.terminalNotified) {
+ context.terminalNotified = true;
+ safeSend(
+ context.ws,
+ {
+ type: "app-host-port-error",
+ portId: context.portId,
+ error: error instanceof Error ? error.message : String(error),
+ },
+ { suppressDiagnostic: true }
+ );
+ }
+ // 先更新索引再关闭底层端口,延迟的 onClose 只能看到 terminal 状态。
+ try {
+ context.relay?.close(reason);
+ } catch {}
+ return true;
+ }
+
+ function closeAppHostRelay(relays, context, reason, { notify = true, closePort = true } = {}) {
+ if (!relayIsCurrent(relays, context)) return false;
+ context.terminalState = reason === "replaced" ? "replaced" : "closed";
+ relays.delete(context.portId);
+ if (notify && reason !== "replaced" && !context.terminalNotified) {
+ context.terminalNotified = true;
+ safeSend(
+ context.ws,
+ { type: "app-host-port-close", portId: context.portId, reason },
+ { suppressDiagnostic: true }
+ );
+ }
+ if (closePort) {
+ try {
+ context.relay?.close(reason);
+ } catch {}
+ }
+ return true;
+ }
+
function closeAppHostRelays(ws, reason) {
const relays = ws.__codexAppHostRelays;
if (!relays || relays.size === 0) return;
// 页面断开时主动关闭官方端口,否则官方 app-host 服务会保留无主连接。
- for (const [portId, relay] of relays.entries()) {
- relays.delete(portId);
- try {
- relay.close(reason);
- } catch {}
+ for (const context of [...relays.values()]) {
+ let graceful = false;
+ if (reason === "client_disconnected") {
+ // 断开属于正常 peer-close,先给官方 listener 发送 null 再释放底层端口。
+ try {
+ graceful = context.relay?.postMessage(null) === true;
+ } catch {}
+ }
+ closeAppHostRelay(relays, context, reason, { closePort: !graceful });
}
}
@@ -585,56 +645,92 @@ function createWsHub(
const relays = appHostRelaysForSocket(ws);
const existing = relays.get(portId);
if (existing) {
- // 同一个页面重复使用 portId 时以后到者为准,先关闭旧 relay 避免双写。
- try {
- existing.close("replaced");
- } catch {}
- relays.delete(portId);
+ // 替换前先摘除旧 context,延迟的旧回调不能误伤新 relay。
+ closeAppHostRelay(relays, existing, "replaced", { notify: false });
}
- while (!existing && relays.size >= Math.max(1, Number(maxAppHostRelays) || 1)) {
- const [oldestPortId, oldestRelay] = relays.entries().next().value || [];
- if (!oldestPortId) break;
- relays.delete(oldestPortId);
- try {
- // 异常页面创建过多 MessagePort 时淘汰最旧端口,正常官方端口数量远低于此上限。
- oldestRelay?.close("relay_limit");
- } catch {}
+ while (relays.size >= Math.max(1, Number(maxAppHostRelays) || 1)) {
+ const oldestContext = relays.values().next().value;
+ if (!oldestContext) break;
+ closeAppHostRelay(relays, oldestContext, "relay_limit");
}
+ const context = {
+ clientId,
+ generation: ++nextAppHostRelayGeneration,
+ portId,
+ relay: null,
+ terminalNotified: false,
+ terminalState: "active",
+ ws,
+ };
+ // 先登记 context,再调用工厂,覆盖工厂同步触发 onMessage/onError 的竞态。
+ relays.set(portId, context);
try {
const relay = createAppHostRelay({
clientId,
portId,
remoteAddress: req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : "",
onClose(reason) {
- if (relays.get(portId) === relay) relays.delete(portId);
- safeSend(ws, { type: "app-host-port-close", portId, reason }, { suppressDiagnostic: true });
+ if (!relayIsCurrent(relays, context)) return;
+ closeAppHostRelay(relays, context, reason);
if (DEBUG_LOGS) {
diagnosticLog("ws-hub", "app_host_closed", {
clientId: shortId(clientId),
portId: shortId(portId),
+ generation: context.generation,
reason,
});
}
},
onError(error) {
- safeSend(
- ws,
- {
- type: "app-host-port-error",
- portId,
+ failAppHostRelay(relays, context, error, "relay_error");
+ },
+ onMessage(data) {
+ // 结构化值先编码为 JSON-safe AppHost wire 数据,再放入 WebSocket 控制帧。
+ if (!relayIsCurrent(relays, context)) return;
+ let wireData;
+ try {
+ wireData = appHostMessageCodec.encodeMessageData(data);
+ } catch (error) {
+ diagnosticWarn("ws-hub", "app_host_message_encode_failed", {
+ clientId: shortId(clientId),
error: error instanceof Error ? error.message : String(error),
- },
+ portId: shortId(portId),
+ });
+ failAppHostRelay(relays, context, new Error("Invalid app-host message data"), "encode_failed");
+ return;
+ }
+ const sent = safeSend(
+ context.ws,
+ { type: "app-host-port-message", portId, ...wireData },
{ suppressDiagnostic: true }
);
- },
- onMessage(data) {
- // app-host RPC 是高频字符串流,只转发不逐条写日志,避免首屏日志刷屏和拖慢关键链路。
- safeSend(ws, { type: "app-host-port-message", portId, data }, { suppressDiagnostic: true });
+ if (!sent) {
+ failAppHostRelay(
+ relays,
+ context,
+ new Error("Browser WebSocket is unavailable for app-host data"),
+ "forward_to_browser_failed"
+ );
+ }
},
});
- relays.set(portId, relay);
- safeSend(ws, { type: "app-host-port-connected", portId }, { suppressDiagnostic: true });
+ context.relay = relay;
+ if (context.terminalState !== "active") {
+ try {
+ relay.close("connect_failed");
+ } catch {}
+ return true;
+ }
+ if (!safeSend(ws, { type: "app-host-port-connected", portId }, { suppressDiagnostic: true })) {
+ failAppHostRelay(
+ relays,
+ context,
+ new Error("Browser WebSocket is unavailable for app-host connection"),
+ "forward_to_browser_failed"
+ );
+ return true;
+ }
if (DEBUG_LOGS) {
// app-host 端口连接/关闭是前端组件生命周期的一部分,默认只保留失败日志。
diagnosticLog("ws-hub", "app_host_connect", {
@@ -648,24 +744,15 @@ function createWsHub(
error: error instanceof Error ? error.message : String(error),
portId: shortId(portId),
});
- safeSend(
- ws,
- {
- type: "app-host-port-error",
- portId,
- error: error instanceof Error ? error.message : String(error),
- },
- { suppressDiagnostic: true }
- );
+ failAppHostRelay(relays, context, error, "connect_failed");
}
return true;
}
function handleAppHostPortMessage(ws, req, message) {
- // 浏览器端 MessagePort 的后续字符串帧都从这里回写到官方 Electron port。
+ // 浏览器端 MessagePort 的后续 AppHost wire 帧先恢复值,再回写到官方 Electron port。
const clientId = normalizedWsClientId(ws, message);
const portId = message && typeof message.portId === "string" ? message.portId : "";
- const data = message ? message.data : undefined;
if (!clientId || ws.__codexWebClientId !== clientId || !validAppHostPortId(portId)) {
diagnosticWarn("ws-hub", "app_host_message_rejected", {
clientId: shortId(clientId),
@@ -674,22 +761,34 @@ function createWsHub(
});
return true;
}
- if (!(data == null || typeof data === "string")) {
- // 官方 app-host 当前只使用字符串 JSON-RPC 帧;非字符串直接拒绝,避免污染官方端口。
- diagnosticWarn("ws-hub", "app_host_non_string_message_rejected", {
+ const relays = appHostRelaysForSocket(ws);
+ let data;
+ try {
+ data = appHostMessageCodec.decodeMessageData(message);
+ } catch (error) {
+ diagnosticWarn("ws-hub", "app_host_message_decode_failed", {
clientId: shortId(clientId),
- payloadType: typeof data,
+ error: error instanceof Error ? error.message : String(error),
portId: shortId(portId),
});
+ const failedContext = relays.get(portId);
+ if (failedContext) {
+ failAppHostRelay(relays, failedContext, new Error("Invalid app-host message data"), "decode_failed");
+ } else {
+ safeSend(
+ ws,
+ { type: "app-host-port-error", portId, error: "Invalid app-host message data" },
+ { suppressDiagnostic: true }
+ );
+ }
return true;
}
- const relays = appHostRelaysForSocket(ws);
- let relay = relays.get(portId);
- if (!relay) {
+ let context = relays.get(portId);
+ if (!context) {
// WS 重连会释放旧 socket 上的 relay,但浏览器 MessagePort 仍会继续发送;按原身份懒重建后再转发首帧。
handleAppHostConnect(ws, req, { ...message, type: "app-host-connect" });
- relay = relays.get(portId);
- if (!relay) {
+ context = relays.get(portId);
+ if (!context) {
diagnosticWarn("ws-hub", "app_host_message_missing_relay", {
clientId: shortId(clientId),
portId: shortId(portId),
@@ -701,10 +800,34 @@ function createWsHub(
// 观察器属于独立展示层;hub 仍只负责透明转发,不解析 App Server 协议或路由语义。
observeAppHostFrame?.({ clientId, data, direction: "client", portId });
} catch {}
- if (WS_DEBUG_ENABLED && typeof data === "string") recordAppHostTraffic(ws, "browser-to-official", portId, byteLength(data));
- relay.postMessage(data);
- // null 是关闭信号,发送给官方后即可从索引移除,后续 close 回调再到达也不会重复处理。
- if (data == null && relays.get(portId) === relay) relays.delete(portId);
+ if (WS_DEBUG_ENABLED) {
+ // 诊断只统计收到的 wire 帧,不为 codec 增加额外字节限制。
+ const wireText = typeof message.data === "string" ? message.data : JSON.stringify(message.data);
+ recordAppHostTraffic(ws, "browser-to-official", portId, byteLength(wireText));
+ }
+ if (!relayIsCurrent(relays, context)) return true;
+ try {
+ const forwarded = context.relay?.postMessage(data);
+ if (forwarded === false) throw new Error("Official app-host port is unavailable");
+ } catch (error) {
+ failAppHostRelay(relays, context, error, "forward_to_official_failed");
+ return true;
+ }
+ // nullish 交给官方 listener 后释放 context;官方 relay 会延迟关闭底层端口。
+ if (data == null) closeAppHostRelay(relays, context, "browser_closed", { notify: false, closePort: false });
+ return true;
+ }
+
+ function handleAppHostPortError(ws, message) {
+ const clientId = normalizedWsClientId(ws, message);
+ const portId = message && typeof message.portId === "string" ? message.portId : "";
+ if (!clientId || ws.__codexWebClientId !== clientId || !validAppHostPortId(portId)) return true;
+ const relays = appHostRelaysForSocket(ws);
+ const context = relays.get(portId);
+ if (context) {
+ // 浏览器侧已经报告失败,gateway 只清理该 context,避免反向再发一条 error。
+ closeAppHostRelay(relays, context, "browser_error", { notify: false });
+ }
return true;
}
@@ -778,6 +901,7 @@ function createWsHub(
}
if (message.type === "app-host-connect") return handleAppHostConnect(ws, req, message);
if (message.type === "app-host-port-message") return handleAppHostPortMessage(ws, req, message);
+ if (message.type === "app-host-port-error") return handleAppHostPortError(ws, message);
return false;
}
@@ -908,5 +1032,5 @@ function createWsHub(
module.exports = {
createWsHub,
- __test: { routeIdFromPayload },
+ __test: { routeIdFromPayload, webSocketServerOptions },
};
diff --git a/gateway/runtime/model-router/presentation.cjs b/gateway/runtime/model-router/presentation.cjs
index b76c59f..fe7824d 100644
--- a/gateway/runtime/model-router/presentation.cjs
+++ b/gateway/runtime/model-router/presentation.cjs
@@ -79,15 +79,17 @@ function createSmartSchedulingPresentation({ compatibilityService, modelRouter,
}
function observeAppHostFrame({ clientId, data, direction = "client" } = {}) {
- if (
- direction !== "client" ||
- typeof data !== "string" ||
- (!data.includes("turn/") && !data.includes("thread/"))
- ) {
- return;
- }
+ if (direction !== "client") return;
try {
- visitProtocolMessages(JSON.parse(data), (message) => {
+ const decoded =
+ data && typeof data === "object"
+ ? data
+ : typeof data === "string" && (data.includes("turn/") || data.includes("thread/"))
+ ? JSON.parse(data)
+ : null;
+ if (!decoded) return;
+ // 结构化 AppHost 帧已经由 transport adapter 解码,legacy string wire 帧仍按关键词预筛选后解析。
+ visitProtocolMessages(decoded, (message) => {
if (!["turn/start", "thread/settings/update"].includes(message?.method)) return;
rememberClient(message.params?.threadId || message.params?.thread?.id, clientId);
});
diff --git a/gateway/test/app-host-message-codec.test.cjs b/gateway/test/app-host-message-codec.test.cjs
new file mode 100644
index 0000000..adbb180
--- /dev/null
+++ b/gateway/test/app-host-message-codec.test.cjs
@@ -0,0 +1,96 @@
+const assert = require("node:assert/strict");
+const test = require("node:test");
+
+const codecPath = require.resolve("../../web-shell/codex-app-host-message-codec.js");
+const codec = require(codecPath);
+
+test("round-trips AppHost primitives and byte views", () => {
+ // 每种二进制 view 都必须恢复为同名构造类型,不能退化成普通数组。
+ const typedViews = {
+ Int8Array: new Int8Array([-1, 2]),
+ Uint8Array: new Uint8Array([0, 255]),
+ Uint8ClampedArray: new Uint8ClampedArray([0, 255]),
+ Int16Array: new Int16Array([-3, 4]),
+ Uint16Array: new Uint16Array([5, 6]),
+ Int32Array: new Int32Array([-7, 8]),
+ Uint32Array: new Uint32Array([9, 10]),
+ Float32Array: new Float32Array([1.5, -2.5]),
+ Float64Array: new Float64Array([3.5, -4.5]),
+ BigInt64Array: new BigInt64Array([-11n, 12n]),
+ BigUint64Array: new BigUint64Array([13n, 14n]),
+ };
+ const source = {
+ array: [undefined, null, true, "text", NaN, Infinity, -Infinity, -0],
+ date: new Date("2026-09-01T00:00:00.000Z"),
+ bigint: 12345678901234567890n,
+ buffer: Uint8Array.from([0, 1, 255]).buffer,
+ dataView: new DataView(Uint8Array.from([9, 2, 3, 8]).buffer, 1, 2),
+ typedViews,
+ };
+ const value = codec.decode(codec.encode(source));
+ assert.equal(value.array[0], undefined);
+ assert.equal(value.array[4], NaN);
+ assert.equal(value.array[5], Infinity);
+ assert.equal(value.array[6], -Infinity);
+ assert.equal(Object.is(value.array[7], -0), true);
+ assert.equal(value.date.toISOString(), source.date.toISOString());
+ assert.equal(value.bigint, source.bigint);
+ assert.deepEqual([...new Uint8Array(value.buffer)], [0, 1, 255]);
+ assert.deepEqual([...new Uint8Array(value.dataView.buffer)], [2, 3]);
+ for (const [name, view] of Object.entries(typedViews)) {
+ assert.equal(value.typedViews[name].constructor.name, name);
+ assert.deepEqual([...value.typedViews[name]], [...view]);
+ }
+});
+
+test("preserves legacy string/null and root undefined transport semantics", () => {
+ assert.deepEqual(codec.encodeMessageData("legacy"), { data: "legacy" });
+ assert.deepEqual(codec.encodeMessageData(null), { data: null });
+ const encodedUndefined = codec.encodeMessageData(undefined);
+ assert.equal(encodedUndefined.dataEncoding, codec.encoding);
+ assert.equal(codec.decodeMessageData(encodedUndefined), undefined);
+ assert.equal(codec.decodeMessageData({ data: "legacy" }), "legacy");
+ assert.equal(codec.decodeMessageData({ data: null }), null);
+});
+
+test("enforces official depth and BigInt guardrails", () => {
+ let depth255 = 0;
+ for (let index = 0; index < 255; index += 1) depth255 = [depth255];
+ assert.doesNotThrow(() => codec.encode(depth255));
+ assert.doesNotThrow(() => codec.decode(codec.encode(depth255)));
+ let depth256 = 0;
+ for (let index = 0; index < 256; index += 1) depth256 = [depth256];
+ assert.throws(() => codec.encode(depth256), /depth limit/);
+
+ const maxDigits = BigInt("9".repeat(16_384));
+ assert.doesNotThrow(() => codec.encode(maxDigits));
+ assert.throws(() => codec.encode(BigInt(`1${"0".repeat(16_384)}`)), /BigInt digit limit/);
+ assert.equal(codec.decode(["bigint", `-${"9".repeat(16_384)}`]) < 0n, true);
+ assert.throws(() => codec.decode(["bigint", `1${"0".repeat(16_384)}`]), /bigint node/);
+ assert.doesNotThrow(() => codec.encode(-maxDigits));
+ let wireDepth256 = ["null"];
+ for (let index = 0; index < 256; index += 1) wireDepth256 = ["array", [wireDepth256]];
+ assert.throws(() => codec.decode(wireDepth256), /depth limit/);
+});
+
+test("rejects cycles, malformed tuples, invalid bytes and unknown tags", () => {
+ const cycle = {};
+ cycle.self = cycle;
+ assert.throws(() => codec.encode(cycle), /cycle/);
+ assert.throws(() => codec.decode(["string"]), /string node/);
+ assert.throws(() => codec.decode(["number", 1, 2]), /number node/);
+ assert.throws(() => codec.decode(["bytes", "Uint8Array", "bad"]), /base64/);
+ assert.throws(() => codec.decode(["bytes", "Int16Array", "AA=="]), /byte length/);
+ assert.throws(() => codec.decode(["bytes", "UnknownArray", ""]), /Unsupported app-host byte view/);
+ assert.throws(() => codec.decode(["object", [["duplicate", ["null"]], ["duplicate", ["null"]]]]), /Duplicate/);
+ assert.throws(() => codec.decode(["unknown"]), /Unknown app-host wire tag/);
+ assert.throws(() => codec.decodeMessageData({ dataEncoding: "unknown", data: null }), /Unsupported app-host data encoding/);
+});
+
+test("caps wide payloads and does not pollute CommonJS global scope", () => {
+ assert.doesNotThrow(() => codec.encode(Array.from({ length: 10_000 }, (_, index) => index)));
+ assert.throws(() => codec.encode(new Array(1_000_000)), /node limit/);
+ const wideWire = ["array", Array.from({ length: 1_000_000 }, () => ["undefined"])];
+ assert.throws(() => codec.decode(wideWire), /node limit/);
+ assert.equal(Object.prototype.hasOwnProperty.call(globalThis, "__OpenCodexAppHostMessageCodec"), false);
+});
diff --git a/gateway/test/app-host-provider.test.cjs b/gateway/test/app-host-provider.test.cjs
new file mode 100644
index 0000000..842b132
--- /dev/null
+++ b/gateway/test/app-host-provider.test.cjs
@@ -0,0 +1,332 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const test = require("node:test");
+const vm = require("node:vm");
+
+const BRIDGE_SOURCE = fs.readFileSync(
+ path.resolve(__dirname, "..", "..", "web-shell", "internal", "providers", "codex-bridge-polyfill.js"),
+ "utf8"
+);
+const CODEC = require("../../web-shell/codex-app-host-message-codec.js");
+
+function sourceFunctionDeclaration(source, name) {
+ // 测试只抽取生产函数本身,避免为浏览器 Provider 搭建完整 DOM 环境。
+ const start = source.indexOf(`function ${name}(`);
+ assert.notEqual(start, -1, `missing function ${name}`);
+ const bodyStart = source.indexOf("{", start);
+ let depth = 0;
+ for (let index = bodyStart; index < source.length; index += 1) {
+ if (source[index] === "{") depth += 1;
+ else if (source[index] === "}") {
+ depth -= 1;
+ if (depth === 0) return source.slice(start, index + 1);
+ }
+ }
+ assert.fail(`unterminated function ${name}`);
+}
+
+class FakePort {
+ constructor() {
+ this.listeners = new Map();
+ this.posted = [];
+ this.closed = false;
+ this.started = false;
+ this.throwOnPost = false;
+ }
+ addEventListener(type, callback) {
+ if (!this.listeners.has(type)) this.listeners.set(type, new Set());
+ this.listeners.get(type).add(callback);
+ }
+ start() {
+ this.started = true;
+ }
+ close() {
+ this.closed = true;
+ }
+ postMessage(value) {
+ if (this.throwOnPost) throw new Error("browser post failed");
+ this.posted.push(value);
+ return true;
+ }
+ emit(type, data) {
+ for (const callback of this.listeners.get(type) || []) callback({ data });
+ }
+}
+
+function createHarness({ countStringify = false } = {}) {
+ const windowListeners = new Map();
+ const sent = [];
+ const diagnostics = [];
+ const published = [];
+ let stringifyCount = 0;
+ let portSequence = 0;
+ const nativeStringify = JSON.stringify;
+ const ws = {
+ readyState: 1,
+ send(payload) {
+ sent.push(JSON.parse(payload));
+ },
+ };
+ const w = {
+ WebSocket: { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 },
+ crypto: { randomUUID: () => `provider-port-${++portSequence}` },
+ __OpenCodexAppHostMessageCodec: CODEC,
+ };
+ const adapterHost = {
+ events: {
+ observe({ target, type, callback }) {
+ if (target !== w) throw new Error("unexpected event target");
+ windowListeners.set(type, callback);
+ return () => windowListeners.delete(type);
+ },
+ },
+ };
+ const declarations = [
+ "appHostMessageCodec",
+ "encodeAppHostMessageData",
+ "decodeAppHostMessageData",
+ "appHostPortId",
+ "appHostWsPayload",
+ "sendAppHostWsPayload",
+ "flushAppHostRelayMessages",
+ "flushAllAppHostRelayMessages",
+ "markGatewayWsReady",
+ "appHostPendingPayloadChars",
+ "prepareAppHostRelayPayload",
+ "queueAppHostRelayPayload",
+ "sendAppHostRelayError",
+ "forceFinalizeAppHostRelay",
+ "failAppHostRelay",
+ "finalizeAppHostRelay",
+ "closeAppHostRelay",
+ "handleAppHostGatewayMessage",
+ "installAppHostMessagePortBridge",
+ ].map((name) => sourceFunctionDeclaration(BRIDGE_SOURCE, name)).join("\n");
+ const context = {
+ w,
+ window: w,
+ adapterHost,
+ modificationScope: null,
+ modificationEffects: null,
+ providerGeneration: {},
+ ws,
+ wsReady: true,
+ clientId: "provider-client",
+ appHostPortRelays: new Map(),
+ APP_HOST_RELAY_MAX_ENTRIES: 64,
+ APP_HOST_PENDING_MESSAGE_LIMIT: 2000,
+ APP_HOST_PENDING_MESSAGE_CHARS_LIMIT: 16 * 1024 * 1024,
+ diagnostics,
+ published,
+ clientDiagnostic(event, data) {
+ diagnostics.push({ event, data });
+ },
+ publishAppHostData(data, direction) {
+ published.push({ data, direction });
+ },
+ payloadShape(value) {
+ return Array.isArray(value) ? `array(${value.length})` : typeof value;
+ },
+ websocketStateName() {
+ return "open";
+ },
+ settleWsReadyWaiters() {},
+ };
+ if (countStringify) {
+ // 统计 Provider 自己的序列化次数,不把 fake WebSocket 的 JSON.parse 算进去。
+ context.JSON = {
+ parse: JSON.parse,
+ stringify(value) {
+ stringifyCount += 1;
+ return nativeStringify(value);
+ },
+ };
+ }
+ const install = vm.runInNewContext(
+ `${declarations}\n({ installAppHostMessagePortBridge, handleAppHostGatewayMessage, markGatewayWsReady })`,
+ context
+ );
+ return { context, install, sent, windowListeners, published, get stringifyCount() { return stringifyCount; } };
+}
+
+function connectHarness(harness) {
+ const port = new FakePort();
+ harness.install.installAppHostMessagePortBridge();
+ harness.windowListeners.get("message")({
+ source: harness.context.w,
+ data: { type: "connect-app-host" },
+ ports: [port],
+ });
+ assert.equal(port.started, true);
+ const connectFrame = harness.sent.at(-1);
+ assert.equal(connectFrame.type, "app-host-connect");
+ const portId = connectFrame.portId;
+ return { port, portId };
+}
+
+test("production AppHost provider encodes structured values and sends undefined exactly once", () => {
+ const harness = createHarness();
+ const { port, portId } = connectHarness(harness);
+ const value = { method: "turn/start", args: [undefined, 3n] };
+ port.emit("message", value);
+ const structured = harness.sent.at(-1);
+ assert.equal(structured.type, "app-host-port-message");
+ assert.equal(structured.portId, portId);
+ assert.equal(structured.dataEncoding, CODEC.encoding);
+ assert.deepEqual(CODEC.decodeMessageData(structured), value);
+ port.emit("message", "legacy");
+ assert.equal(harness.sent.at(-1).data, "legacy");
+ port.emit("message", undefined);
+ const terminalFrames = harness.sent.filter((message) => message.type === "app-host-port-message").slice(-1);
+ assert.equal(terminalFrames.length, 1);
+ assert.equal(CODEC.decodeMessageData(terminalFrames[0]), undefined);
+ assert.equal(harness.sent.some((message) => message.data === null), false);
+ assert.equal(port.closed, true);
+ assert.deepEqual(harness.published.at(-1), { data: undefined, direction: "client" });
+});
+
+test("production AppHost provider reports downlink decode and post failures locally", () => {
+ const malformedHarness = createHarness();
+ const malformed = connectHarness(malformedHarness);
+ malformedHarness.install.handleAppHostGatewayMessage({
+ type: "app-host-port-message",
+ portId: malformed.portId,
+ dataEncoding: "unknown",
+ data: ["undefined"],
+ });
+ assert.equal(malformed.port.closed, true);
+ assert.equal(malformedHarness.sent.filter((message) => message.type === "app-host-port-error").length, 1);
+ assert.equal(malformedHarness.sent.some((message) => message.data === null), false);
+
+ const postHarness = createHarness();
+ const post = connectHarness(postHarness);
+ post.port.throwOnPost = true;
+ postHarness.install.handleAppHostGatewayMessage({
+ type: "app-host-port-message",
+ portId: post.portId,
+ ...CODEC.encodeMessageData({ method: "thread/settings/update" }),
+ });
+ assert.equal(post.port.closed, true);
+ assert.equal(postHarness.sent.filter((message) => message.type === "app-host-port-error").length, 1);
+ assert.equal(postHarness.sent.some((message) => message.data === null), false);
+});
+
+test("production AppHost provider reports repeated browser failures only once", () => {
+ const messageErrorHarness = createHarness();
+ const messageError = connectHarness(messageErrorHarness);
+ messageError.port.emit("messageerror");
+ messageError.port.emit("messageerror");
+ assert.equal(
+ messageErrorHarness.sent.filter((message) => message.type === "app-host-port-error").length,
+ 1
+ );
+
+ const encodeHarness = createHarness();
+ const encodeFailure = connectHarness(encodeHarness);
+ encodeFailure.port.emit("message", Symbol("unsupported"));
+ encodeFailure.port.emit("message", Symbol("unsupported-again"));
+ assert.equal(encodeHarness.sent.filter((message) => message.type === "app-host-port-error").length, 1);
+ assert.equal(encodeFailure.port.closed, true);
+});
+
+test("production AppHost provider keeps a terminal frame FIFO across websocket reconnect", () => {
+ const harness = createHarness();
+ harness.context.wsReady = false;
+ const port = new FakePort();
+ harness.install.installAppHostMessagePortBridge();
+ harness.windowListeners.get("message")({
+ source: harness.context.w,
+ data: { type: "connect-app-host" },
+ ports: [port],
+ });
+ const state = [...harness.context.appHostPortRelays.values()][0];
+ assert.equal(harness.sent.length, 0);
+ port.emit("message", null);
+ assert.equal(state.closing, true);
+ assert.equal(state.pending.length, 2);
+ harness.context.wsReady = true;
+ harness.install.markGatewayWsReady();
+ assert.deepEqual(harness.sent.map((message) => message.type), ["app-host-connect", "app-host-port-message"]);
+ assert.equal(harness.sent[1].data, null);
+ assert.equal(port.closed, true);
+});
+
+test("production AppHost provider force-closes a closing relay on gateway terminal events", () => {
+ const harness = createHarness();
+ const { port, portId } = connectHarness(harness);
+ harness.context.wsReady = false;
+ port.emit("message", null);
+ const state = harness.context.appHostPortRelays.get(portId);
+ assert.equal(state.closing, true);
+ assert.equal(state.pending.length, 1);
+
+ harness.install.handleAppHostGatewayMessage({ type: "app-host-port-error", portId, error: "relay failed" });
+ assert.equal(state.closed, true);
+ assert.equal(state.pending.length, 0);
+ assert.equal(harness.context.appHostPortRelays.has(portId), false);
+ assert.equal(port.closed, true);
+
+ // 关闭后的迟到 MessagePort 事件不得再次发布到消费者。
+ const publishedCount = harness.published.length;
+ port.emit("message", { method: "turn/start" });
+ assert.equal(harness.published.length, publishedCount);
+
+ const secondPort = new FakePort();
+ harness.windowListeners.get("message")({
+ source: harness.context.w,
+ data: { type: "connect-app-host" },
+ ports: [secondPort],
+ });
+ const secondState = [...harness.context.appHostPortRelays.values()].find((candidate) => candidate.port !== port);
+ secondPort.emit("message", null);
+ harness.install.handleAppHostGatewayMessage({
+ type: "app-host-port-close",
+ portId: secondState.portId,
+ reason: "official_closed",
+ });
+ assert.equal(secondState.closed, true);
+ assert.equal(secondPort.closed, true);
+});
+
+test("production AppHost provider evicts closing relays without stalling at the relay limit", () => {
+ const harness = createHarness();
+ harness.context.wsReady = false;
+ harness.install.installAppHostMessagePortBridge();
+ const ports = [];
+ const connectEvent = harness.windowListeners.get("message");
+ for (let index = 0; index < harness.context.APP_HOST_RELAY_MAX_ENTRIES; index += 1) {
+ const port = new FakePort();
+ ports.push(port);
+ connectEvent({ source: harness.context.w, data: { type: "connect-app-host" }, ports: [port] });
+ }
+ ports[0].emit("message", null);
+ assert.equal([...harness.context.appHostPortRelays.values()][0].closing, true);
+
+ assert.doesNotThrow(() => {
+ connectEvent({ source: harness.context.w, data: { type: "connect-app-host" }, ports: [new FakePort()] });
+ });
+ assert.equal(ports[0].closed, true);
+ assert.equal(harness.context.appHostPortRelays.size, harness.context.APP_HOST_RELAY_MAX_ENTRIES);
+});
+
+test("production AppHost provider serializes queued frames once and reuses them after reconnect", () => {
+ const harness = createHarness({ countStringify: true });
+ harness.context.wsReady = false;
+ const port = new FakePort();
+ harness.install.installAppHostMessagePortBridge();
+ harness.windowListeners.get("message")({
+ source: harness.context.w,
+ data: { type: "connect-app-host" },
+ ports: [port],
+ });
+ port.emit("message", { method: "turn/start", params: { threadId: "thread-1" } });
+ assert.equal(harness.sent.length, 0);
+ assert.equal(harness.stringifyCount, 2);
+
+ // hello-ack 只发送已缓存字符串,不应再次遍历结构化 wire 数据。
+ harness.context.wsReady = true;
+ harness.install.markGatewayWsReady();
+ assert.equal(harness.stringifyCount, 2);
+ assert.deepEqual(harness.sent.map((message) => message.type), ["app-host-connect", "app-host-port-message"]);
+});
diff --git a/gateway/test/model-router-presentation.test.cjs b/gateway/test/model-router-presentation.test.cjs
index 1a26a2f..aa7ffd4 100644
--- a/gateway/test/model-router-presentation.test.cjs
+++ b/gateway/test/model-router-presentation.test.cjs
@@ -62,10 +62,10 @@ test("presentation correlates turns and model selections and sends safe route st
});
presentation.observeAppHostFrame({
clientId: "client-3",
- data: JSON.stringify({
+ data: {
method: "thread/settings/update",
params: { threadId: "thread-3", model: "auto" },
- }),
+ },
});
router.emit({ status: "classifying", threadId: "thread-1" });
router.emit({
diff --git a/gateway/test/official-runtime.test.cjs b/gateway/test/official-runtime.test.cjs
index c9df7f7..aecbb02 100644
--- a/gateway/test/official-runtime.test.cjs
+++ b/gateway/test/official-runtime.test.cjs
@@ -26,6 +26,17 @@ function threadStreamStateMessage(conversationId, sourceClientId, change) {
};
}
+test("official AppHost delivery treats only null as peer close", () => {
+ const delivered = [];
+ const closed = [];
+ const deliver = __test.deliverOfficialAppHostMessage;
+ assert.equal(deliver({ data: undefined }, (value) => delivered.push(value), (reason) => closed.push(reason)), true);
+ assert.equal(delivered.length, 1);
+ assert.equal(delivered[0], undefined);
+ assert.equal(deliver({ data: null }, (value) => delivered.push(value), (reason) => closed.push(reason)), false);
+ assert.deepEqual(closed, ["official_closed"]);
+});
+
test("bridges only the primary official renderer to the Web client", () => {
const primary = { id: 1, isDestroyed: () => false };
const samePrimaryWrapper = { id: 1, isDestroyed: () => false };
diff --git a/gateway/test/smart-scheduling-summary.test.cjs b/gateway/test/smart-scheduling-summary.test.cjs
index a4189c5..8c9a9e3 100644
--- a/gateway/test/smart-scheduling-summary.test.cjs
+++ b/gateway/test/smart-scheduling-summary.test.cjs
@@ -409,9 +409,16 @@ function createHarness() {
sendClientMessage(message) {
summary.handleAppHostData(JSON.stringify(message), "client");
},
+ sendStructuredClientMessage(message) {
+ // 结构化 AppHost 帧不应先 stringify 再 parse,直接交给同一消费者。
+ summary.handleAppHostData(message, "client");
+ },
sendServerMessage(message) {
summary.handleAppHostData(JSON.stringify(message), "server");
},
+ sendStructuredServerMessage(message) {
+ summary.handleAppHostData(message, "server");
+ },
sendViewMessage(payload) {
windowListeners.get("opencodex:plugin-event")?.({
detail: { eventName: "view:message", payload },
@@ -601,7 +608,7 @@ test("root-routed new Auto task renders directly in an otherwise empty official
type: "navigate-to-route",
path: "/local/client-new-thread%3Atemporary",
});
- harness.sendClientMessage({
+ harness.sendStructuredClientMessage({
id: "turn-new",
method: "turn/start",
params: { threadId: "thread-new", model: "auto" },
@@ -777,13 +784,15 @@ test("manual selection wins over delayed selected and turn metadata", async () =
await resolveRoute(harness, "thread-a", null);
assert.equal(harness.activeRoute(), null);
- harness.sendServerMessage({
- method: "turn/started",
- params: {
- threadId: "thread-a",
- turn: { id: "turn-a" },
- _meta: {
- "opencodex/smart-scheduling": { model: "luna", effort: "high" },
+ harness.sendStructuredServerMessage({
+ message: {
+ method: "turn/started",
+ params: {
+ threadId: "thread-a",
+ turn: { id: "turn-a" },
+ _meta: {
+ "opencodex/smart-scheduling": { model: "luna", effort: "high" },
+ },
},
},
});
diff --git a/gateway/test/static-assets.test.cjs b/gateway/test/static-assets.test.cjs
index 38fae0b..4ebb314 100644
--- a/gateway/test/static-assets.test.cjs
+++ b/gateway/test/static-assets.test.cjs
@@ -211,12 +211,18 @@ test("runtime compatibility diagnostics are public, grouped, explained, and repo
assert.match(diagnosticsStyles, /\.feature-title-line/);
const loginShell = fs.readFileSync(WEB_SHELL_INDEX, "utf8");
assert.match(loginShell, /href="\/settings\/developer\/runtime-compatibility"/);
+ assert.ok(loginShell.indexOf("/codex-app-host-message-codec.js") < loginShell.indexOf("/opencodex-modification-activate.js"));
const bootstrap = runtimeBootstrapSource(service);
const compatibilityIndex = bootstrap.indexOf("OpenCodexRuntimeCompatibility");
const sidebarIndex = bootstrap.indexOf("__opencodexSidebarPreviewInstalled");
+ const codecIndex = bootstrap.indexOf("opencodex-structured-clone-v1");
+ const bridgeIndex = bootstrap.indexOf("__codexAppHostMessagePortBridgeInstalled");
assert.ok(compatibilityIndex >= 0);
assert.ok(sidebarIndex > compatibilityIndex);
+ assert.ok(codecIndex >= 0 && bridgeIndex > codecIndex);
+ assert.equal(service.isPublicStaticPath("/codex-app-host-message-codec.js"), true);
+ assert.match(service.staticFile("/codex-app-host-message-codec.js"), /codex-app-host-message-codec\.js$/);
});
test("runtime compatibility page follows the public authentication locale", (t) => {
@@ -584,6 +590,11 @@ test("remote renderer defers plugin summary image bytes until an image mounts",
test("bridge reconnects active app-host ports after websocket hello", () => {
const bridge = fs.readFileSync(BRIDGE_POLYFILL, "utf-8");
+ assert.match(bridge, /encodeAppHostMessageData\(portData\)/);
+ assert.match(bridge, /decodeAppHostMessageData\(message\)/);
+ assert.match(bridge, /failAppHostRelay\(state, "Invalid app-host message data", "encode_failed"\)/);
+ assert.match(bridge, /failAppHostRelay\(state, "Browser MessagePort is unavailable", "post_to_browser_failed"\)/);
+ assert.match(bridge, /prepareAppHostRelayPayload\(state, \{ type: "app-host-connect" \}\)/);
assert.match(bridge, /state\.pending\.unshift\(connectPayload\)/);
assert.match(bridge, /state\.pendingChars \+= appHostPendingPayloadChars\(connectPayload\)/);
assert.match(bridge, /for \(const state of appHostPortRelays\.values\(\)\) state\.connected = false/);
diff --git a/gateway/test/web-runtime-performance.test.cjs b/gateway/test/web-runtime-performance.test.cjs
index 406c0a0..a7fd9ea 100644
--- a/gateway/test/web-runtime-performance.test.cjs
+++ b/gateway/test/web-runtime-performance.test.cjs
@@ -1582,12 +1582,15 @@ test("app-host oversized frames bypass the limit only when they can be sent imme
const sent = [];
const closed = [];
function appHostWsPayload(state, payload) { return { portId: state.portId, ...payload }; }
- function sendAppHostWsPayload(payload) {
+ function sendAppHostWsPayload(payload, serialized) {
if (!sendable) return false;
- sent.push(payload);
+ sent.push(serialized || payload);
return true;
}
function clientDiagnostic() {}
+ function failAppHostRelay(state, error, reason) {
+ closeAppHostRelay(state, reason);
+ }
function closeAppHostRelay(state, reason) {
state.closed = true;
state.pending.length = 0;
@@ -1596,15 +1599,16 @@ test("app-host oversized frames bypass the limit only when they can be sent imme
}
function flushAppHostRelayMessages(state) {
while (!state.closed && state.pending.length > 0) {
- if (!sendAppHostWsPayload(state.pending[0])) return;
- const payload = state.pending.shift();
- state.pendingChars = Math.max(0, state.pendingChars - appHostPendingPayloadChars(payload));
+ const prepared = state.pending[0];
+ if (!sendAppHostWsPayload(prepared.payload, prepared.serialized)) return;
+ state.pending.shift();
+ state.pendingChars = Math.max(0, state.pendingChars - appHostPendingPayloadChars(prepared));
}
}
${sourceSection(
BRIDGE_SOURCE,
" function appHostPendingPayloadChars",
- "\n\n function closeAppHostRelay"
+ "\n\n function sendAppHostRelayError"
)}
return {
closed,
@@ -1614,16 +1618,16 @@ test("app-host oversized frames bypass the limit only when they can be sent imme
};
})()`
);
- const makeState = (portId) => ({ closed: false, flushing: false, pending: [], pendingChars: 0, portId });
+ const makeState = (portId) => ({ closed: false, closing: false, flushing: false, pending: [], pendingChars: 0, portId });
const offlineLarge = makeState("offline-large");
- relay.queue(offlineLarge, { data: "x".repeat(300), type: "app-host-port-message" });
+ relay.queue(offlineLarge, { data: "x".repeat(500), type: "app-host-port-message" });
assert.equal(offlineLarge.closed, true);
assert.deepEqual(Array.from(relay.closed), ["queue_overflow"]);
relay.setSendable(true);
const onlineLarge = makeState("online-large");
- relay.queue(onlineLarge, { data: "x".repeat(300), type: "app-host-port-message" });
+ relay.queue(onlineLarge, { data: "x".repeat(500), type: "app-host-port-message" });
assert.equal(onlineLarge.closed, false);
assert.equal(onlineLarge.pending.length, 0);
assert.equal(relay.sent.length, 1);
@@ -1732,7 +1736,7 @@ test("browser Statsig defaults preserve the official new-worktree capability", (
assert.match(BRIDGE_SOURCE, /STATSIG_DEFAULT_FEATURE_OVERRIDES\s*=\s*\{[\s\S]*?"505458": true/);
});
-test("token usage passive parsing bounds wide and cyclic payload traversal", () => {
+test("token usage passive parsing bounds wide and cyclic payload traversal", async () => {
const compatibilityHits = [];
const window = {
clearTimeout,
@@ -1762,6 +1766,35 @@ test("token usage passive parsing bounds wide and cyclic payload traversal", ()
const capability = window.__OpenCodexCreateTokenUsageCapability();
const release = capability.acquireConsumer("performance-test");
+ const structuredUsageMessage = {
+ method: "thread/tokenUsage/updated",
+ params: {
+ threadId: "structured-thread",
+ turnId: "structured-turn",
+ tokenUsage: { cachedInputTokens: 8, inputTokens: 10, outputTokens: 2 },
+ },
+ };
+ capability.handleAppHostData(structuredUsageMessage);
+ const structuredUsage = await capability.getForTurn({ threadId: "structured-thread", turnId: "structured-turn" });
+ const legacyUsageMessage = JSON.stringify({
+ method: "thread/tokenUsage/updated",
+ params: {
+ threadId: "legacy-thread",
+ turnId: "legacy-turn",
+ tokenUsage: { cachedInputTokens: 8, inputTokens: 10, outputTokens: 2 },
+ },
+ });
+ capability.handleAppHostData(legacyUsageMessage);
+ const legacyUsage = await capability.getForTurn({ threadId: "legacy-thread", turnId: "legacy-turn" });
+ // 结构化值和旧字符串只差 transport 形状,normalized usage 必须保持一致。
+ const comparableUsage = (usage) => {
+ const { threadId, turnId, updatedAt, ...rest } = usage || {};
+ return rest;
+ };
+ assert.deepEqual(comparableUsage(structuredUsage), comparableUsage(legacyUsage));
+ assert.equal(structuredUsage.source, "app-host");
+ assert.equal(legacyUsage.source, "app-host");
+ compatibilityHits.length = 0;
let passiveArrayReads = 0;
const passiveWide = new Proxy(Array.from({ length: 50_000 }, () => "ordinary"), {
get(target, key, receiver) {
@@ -1949,7 +1982,7 @@ test("whole-document observer filters stay scoped to their feature mounts", () =
);
assert.match(BRIDGE_SOURCE, /reconnectDeferredUntilVisible = true/);
assert.match(BRIDGE_SOURCE, /const releaseSocket = modificationScope\?\.own/);
- assert.match(BRIDGE_SOURCE, /closeAppHostRelay\(state, "page_replaced", false\)/);
+ assert.match(BRIDGE_SOURCE, /closeAppHostRelay\(state, "page_replaced"\)/);
assert.match(BRIDGE_SOURCE, /document\.visibilityState === "hidden"/);
assert.match(BRIDGE_SOURCE, /if \(!CLIENT_DIAGNOSTICS_ENABLED\) return/);
assert.match(BRIDGE_SOURCE, /CLIENT_DIAGNOSTICS_ENABLED \? ipcDiagnosticSummary/);
@@ -1960,7 +1993,10 @@ test("whole-document observer filters stay scoped to their feature mounts", () =
assert.match(BRIDGE_SOURCE, /activeBrowserNotifications\.size > BROWSER_NOTIFICATION_MAX_ACTIVE/);
assert.match(BRIDGE_SOURCE, /appHostPortRelays\.size >= APP_HOST_RELAY_MAX_ENTRIES/);
assert.match(BRIDGE_SOURCE, /nextPendingChars > APP_HOST_PENDING_MESSAGE_CHARS_LIMIT/);
- assert.match(BRIDGE_SOURCE, /nextPendingChars > APP_HOST_PENDING_MESSAGE_CHARS_LIMIT &&\s*sendAppHostWsPayload\(framedPayload\)/);
+ assert.match(
+ BRIDGE_SOURCE,
+ /nextPendingChars > APP_HOST_PENDING_MESSAGE_CHARS_LIMIT &&\s*sendAppHostWsPayload\(prepared\.payload, prepared\.serialized\)/
+ );
assert.doesNotMatch(BRIDGE_SOURCE, /state\.pending\.length > 0 && nextPendingChars/);
assert.match(BRIDGE_SOURCE, /state\.pending\.length = 0;[\s\S]*state\.pendingChars = 0/);
assert.match(BRIDGE_SOURCE, /TERMINAL_QUEUE_MAX_PENDING_PER_SESSION/);
diff --git a/gateway/test/ws-hub.test.cjs b/gateway/test/ws-hub.test.cjs
index 62dc54a..05df677 100644
--- a/gateway/test/ws-hub.test.cjs
+++ b/gateway/test/ws-hub.test.cjs
@@ -4,6 +4,7 @@ const test = require("node:test");
const WebSocket = require("ws");
const { createWsHub, __test } = require("../runtime/ipc/ws-hub.cjs");
+const appHostMessageCodec = require("../../web-shell/codex-app-host-message-codec.js");
function waitForMessage(socket, predicate) {
return new Promise((resolve, reject) => {
@@ -36,6 +37,29 @@ function waitForClose(socket) {
return new Promise((resolve) => socket.once("close", resolve));
}
+function waitForCondition(predicate, description) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description}`)), 2000);
+ const check = () => {
+ let matched = false;
+ try {
+ matched = predicate();
+ } catch (error) {
+ clearTimeout(timer);
+ reject(error);
+ return;
+ }
+ if (matched) {
+ clearTimeout(timer);
+ resolve();
+ return;
+ }
+ setImmediate(check);
+ };
+ check();
+ });
+}
+
test("recreates an app-host relay when the browser WebSocket reconnects", async (t) => {
const server = http.createServer();
const relays = [];
@@ -99,6 +123,316 @@ test("recreates an app-host relay when the browser WebSocket reconnects", async
assert.deepEqual(relays[1].messages, ["thread/list"]);
});
+test("bridges structured AppHost values while retaining legacy frames", async (t) => {
+ const server = http.createServer();
+ const relays = [];
+ createWsHub(server, {
+ createAppHostRelay({ onMessage, onError, onClose }) {
+ const relay = {
+ messages: [],
+ emitClose: onClose,
+ emitError: onError,
+ emitMessage: onMessage,
+ close(reason) {
+ this.closeReason = reason;
+ },
+ postMessage(value) {
+ this.messages.push(value);
+ return true;
+ },
+ };
+ relays.push(relay);
+ return relay;
+ },
+ handleNotificationEvent() {},
+ isAuthed: () => true,
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const socket = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
+ t.after(async () => {
+ socket.close();
+ await new Promise((resolve) => server.close(resolve));
+ });
+ await waitForOpen(socket);
+ const clientId = "structured-client";
+ const portId = "structured-port";
+ socket.send(JSON.stringify({ type: "hello", clientId }));
+ await waitForMessage(socket, (message) => message.type === "hello-ack");
+ socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId }));
+ await waitForMessage(socket, (message) => message.type === "app-host-port-connected");
+
+ const value = { type: "turn/start", nested: [undefined, 7n] };
+ socket.send(
+ JSON.stringify({
+ type: "app-host-port-message",
+ clientId,
+ portId,
+ ...appHostMessageCodec.encodeMessageData(value),
+ })
+ );
+ await waitForCondition(() => relays[0].messages.length >= 1, "first AppHost relay message");
+ assert.deepEqual(relays[0].messages[0], value);
+
+ socket.send(JSON.stringify({ type: "app-host-port-message", clientId, portId, data: "legacy" }));
+ await waitForCondition(() => relays[0].messages.length >= 2, "legacy AppHost relay message");
+ assert.equal(relays[0].messages[1], "legacy");
+
+ const downlink = waitForMessage(socket, (message) => message.type === "app-host-port-message");
+ relays[0].emitMessage(value);
+ const wire = await downlink;
+ assert.equal(wire.dataEncoding, appHostMessageCodec.encoding);
+ assert.deepEqual(appHostMessageCodec.decodeMessageData(wire), value);
+});
+
+test("reports one relay error and isolates stale replacement callbacks", async (t) => {
+ const server = http.createServer();
+ const relays = [];
+ createWsHub(server, {
+ createAppHostRelay({ onMessage, onError, onClose }) {
+ const relayIndex = relays.length;
+ const relay = {
+ emitClose: onClose,
+ emitError: onError,
+ emitMessage: onMessage,
+ close(reason) {
+ onClose(reason);
+ },
+ postMessage() {
+ if (relayIndex === 0) throw new Error("post failed");
+ return true;
+ },
+ };
+ relays.push(relay);
+ return relay;
+ },
+ handleNotificationEvent() {},
+ isAuthed: () => true,
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const socket = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
+ t.after(async () => {
+ socket.close();
+ await new Promise((resolve) => server.close(resolve));
+ });
+ await waitForOpen(socket);
+ const clientId = "failure-client";
+ const portId = "failure-port";
+ socket.send(JSON.stringify({ type: "hello", clientId }));
+ await waitForMessage(socket, (message) => message.type === "hello-ack");
+ socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId }));
+ await waitForMessage(socket, (message) => message.type === "app-host-port-connected");
+ socket.send(
+ JSON.stringify({
+ type: "app-host-port-message",
+ clientId,
+ portId,
+ ...appHostMessageCodec.encodeMessageData({ request: "fails" }),
+ })
+ );
+ const error = await waitForMessage(socket, (message) => message.type === "app-host-port-error");
+ assert.match(error.error, /post failed/);
+ socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId }));
+ await waitForMessage(socket, (message) => message.type === "app-host-port-connected");
+ assert.equal(relays.length, 2);
+ relays[0].emitClose("late-close");
+ relays[0].emitError(new Error("late-error"));
+ const downlink = waitForMessage(socket, (message) => message.type === "app-host-port-message");
+ relays[1].emitMessage({ type: "replacement/ok" });
+ await downlink;
+ assert.equal(relays.length, 2);
+});
+
+test("reports one terminal error for official encode and browser decode failures", async (t) => {
+ const server = http.createServer();
+ const relays = [];
+ const events = [];
+ createWsHub(server, {
+ createAppHostRelay({ onMessage, onError, onClose }) {
+ const relay = {
+ emitClose: onClose,
+ emitError: onError,
+ emitMessage: onMessage,
+ close(reason) {
+ // 真实 MessagePort close 会触发 onClose,测试不能只检查 map 是否删除。
+ onClose(reason);
+ },
+ postMessage() {
+ return true;
+ },
+ };
+ relays.push(relay);
+ return relay;
+ },
+ handleNotificationEvent() {},
+ isAuthed: () => true,
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const socket = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
+ socket.on("message", (raw) => events.push(JSON.parse(String(raw))));
+ t.after(async () => {
+ socket.close();
+ await new Promise((resolve) => server.close(resolve));
+ });
+ await waitForOpen(socket);
+ const clientId = "terminal-event-client";
+ const portId = "terminal-event-port";
+ socket.send(JSON.stringify({ type: "hello", clientId }));
+ await waitForMessage(socket, (message) => message.type === "hello-ack");
+ socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId }));
+ await waitForMessage(socket, (message) => message.type === "app-host-port-connected");
+
+ relays[0].emitMessage(Symbol("unsupported"));
+ await waitForMessage(socket, (message) => message.type === "app-host-port-error");
+ relays[0].emitError(new Error("late-error"));
+ relays[0].emitClose("late-close");
+ assert.equal(events.filter((message) => message.type === "app-host-port-error").length, 1);
+ assert.equal(events.filter((message) => message.type === "app-host-port-close").length, 0);
+
+ socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId }));
+ await waitForMessage(socket, (message) => message.type === "app-host-port-connected");
+ socket.send(JSON.stringify({ type: "app-host-port-message", clientId, portId, dataEncoding: "unknown", data: null }));
+ await waitForMessage(
+ socket,
+ (message) => message.type === "app-host-port-error" && message.portId === portId
+ );
+ assert.equal(events.filter((message) => message.type === "app-host-port-error").length, 2);
+ assert.equal(events.filter((message) => message.type === "app-host-port-close").length, 0);
+});
+
+test("isolates a failed AppHost port from sibling ports and clients", async (t) => {
+ const server = http.createServer();
+ const relays = [];
+ createWsHub(server, {
+ createAppHostRelay({ onMessage, onError, onClose, clientId, portId }) {
+ const relay = {
+ clientId,
+ emitClose: onClose,
+ emitError: onError,
+ emitMessage: onMessage,
+ portId,
+ close(reason) {
+ onClose(reason);
+ },
+ postMessage() {
+ return true;
+ },
+ };
+ relays.push(relay);
+ return relay;
+ },
+ handleNotificationEvent() {},
+ isAuthed: () => true,
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const first = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
+ const second = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
+ t.after(async () => {
+ first.close();
+ second.close();
+ await new Promise((resolve) => server.close(resolve));
+ });
+ await Promise.all([waitForOpen(first), waitForOpen(second)]);
+ const firstClient = "isolated-client-one";
+ const secondClient = "isolated-client-two";
+ first.send(JSON.stringify({ type: "hello", clientId: firstClient }));
+ second.send(JSON.stringify({ type: "hello", clientId: secondClient }));
+ await Promise.all([
+ waitForMessage(first, (message) => message.type === "hello-ack"),
+ waitForMessage(second, (message) => message.type === "hello-ack"),
+ ]);
+ const connect = (socket, clientId, portId) => {
+ socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId }));
+ return waitForMessage(socket, (message) => message.type === "app-host-port-connected" && message.portId === portId);
+ };
+ await Promise.all([
+ connect(first, firstClient, "isolated-port-one"),
+ connect(first, firstClient, "isolated-port-two"),
+ connect(second, secondClient, "isolated-port-three"),
+ ]);
+
+ const firstPortError = waitForMessage(
+ first,
+ (message) => message.type === "app-host-port-error" && message.portId === "isolated-port-one"
+ );
+ const firstRelay = relays.find((relay) => relay.portId === "isolated-port-one");
+ const secondRelay = relays.find((relay) => relay.portId === "isolated-port-two");
+ const thirdRelay = relays.find((relay) => relay.portId === "isolated-port-three");
+ assert.ok(firstRelay && secondRelay && thirdRelay);
+ firstRelay.emitError(new Error("isolated failure"));
+ await firstPortError;
+
+ const secondPortMessage = waitForMessage(
+ first,
+ (message) => message.type === "app-host-port-message" && message.portId === "isolated-port-two"
+ );
+ const thirdPortMessage = waitForMessage(
+ second,
+ (message) => message.type === "app-host-port-message" && message.portId === "isolated-port-three"
+ );
+ secondRelay.emitMessage({ method: "thread/list" });
+ thirdRelay.emitMessage({ method: "thread/read" });
+ assert.deepEqual(await secondPortMessage, {
+ type: "app-host-port-message",
+ portId: "isolated-port-two",
+ data: ["object", [["method", ["string", "thread/list"]]]],
+ dataEncoding: appHostMessageCodec.encoding,
+ });
+ assert.deepEqual(await thirdPortMessage, {
+ type: "app-host-port-message",
+ portId: "isolated-port-three",
+ data: ["object", [["method", ["string", "thread/read"]]]],
+ dataEncoding: appHostMessageCodec.encoding,
+ });
+});
+
+test("closes only the relay when forwarding to the browser websocket fails", async (t) => {
+ const server = http.createServer();
+ const relays = [];
+ const hub = createWsHub(server, {
+ createAppHostRelay({ onMessage, onError, onClose }) {
+ const relay = {
+ closeReason: "",
+ emitMessage: onMessage,
+ close(reason) {
+ this.closeReason = reason;
+ onClose(reason);
+ },
+ postMessage() {
+ return true;
+ },
+ };
+ relays.push(relay);
+ return relay;
+ },
+ handleNotificationEvent() {},
+ isAuthed: () => true,
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const socket = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
+ const originalSend = socket.send.bind(socket);
+ t.after(async () => {
+ socket.send = originalSend;
+ socket.close();
+ await new Promise((resolve) => server.close(resolve));
+ });
+ await waitForOpen(socket);
+ const clientId = "send-failure-client";
+ const portId = "send-failure-port";
+ socket.send(JSON.stringify({ type: "hello", clientId }));
+ await waitForMessage(socket, (message) => message.type === "hello-ack");
+ socket.send(JSON.stringify({ type: "app-host-connect", clientId, portId }));
+ await waitForMessage(socket, (message) => message.type === "app-host-port-connected");
+
+ const serverSocket = [...hub.clients][0];
+ const originalServerSend = serverSocket.send.bind(serverSocket);
+ serverSocket.send = () => {
+ throw new Error("browser websocket send failed");
+ };
+ relays[0].emitMessage({ method: "turn/start" });
+ assert.equal(relays[0].closeReason, "forward_to_browser_failed");
+ serverSocket.send = originalServerSend;
+});
+
test("caps app-host relays per browser socket", async (t) => {
const server = http.createServer();
const relays = [];
@@ -266,3 +600,8 @@ test("bounds diagnostic route extraction for wide payload arrays", () => {
assert.equal(__test.routeIdFromPayload(wide), "");
assert.ok(reads > 0 && reads <= 128, `diagnostic route scan read ${reads} array items`);
});
+
+test("keeps the WebSocket ingress boundary at 64 MiB", () => {
+ assert.equal(__test.webSocketServerOptions().maxPayload, 64 * 1024 * 1024);
+ assert.equal(__test.webSocketServerOptions({ maxPayloadBytes: 2 * 1024 * 1024 }).maxPayload, 2 * 1024 * 1024);
+});
diff --git a/package.json b/package.json
index ba539f8..11b2851 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"sync:version": "node scripts/sync-app-version.cjs",
"check:version": "node scripts/sync-app-version.cjs --check",
"pretest": "pnpm run build:gateway",
- "test": "node --test gateway/test/auth-rate-limit.test.cjs gateway/test/auth-request.test.cjs gateway/test/compatibility-registry.test.cjs gateway/test/compatibility-service.test.cjs gateway/test/gateway-lifecycle.test.cjs gateway/test/history-preview.test.cjs gateway/test/http-utils.test.cjs gateway/test/local-files.test.cjs gateway/test/loopback-host.test.cjs gateway/test/mobile-sidebar-auto-collapse.test.cjs gateway/test/model-router-classifier.test.cjs gateway/test/model-router-injection-health.test.cjs gateway/test/model-router-presentation.test.cjs gateway/test/model-router-transport.test.cjs gateway/test/model-router.test.cjs gateway/test/modification-browser-host.test.cjs gateway/test/modification-equivalence.test.cjs gateway/test/modification-kernel.test.cjs gateway/test/modification-node-provider.test.cjs gateway/test/modification-production.test.cjs gateway/test/official-desktop-compat.test.cjs gateway/test/official-electron-module-hook.test.cjs gateway/test/official-runtime.test.cjs gateway/test/open-file-context.test.cjs gateway/test/picked-files.test.cjs gateway/test/plugin-config.test.cjs gateway/test/project-recent-sort.test.cjs gateway/test/runtime-compatibility-page.test.cjs gateway/test/service-control.test.cjs gateway/test/smart-model-router.integration.test.cjs gateway/test/smart-scheduling-summary.test.cjs gateway/test/static-assets.test.cjs gateway/test/virtual-model.test.cjs gateway/test/web-runtime-compatibility.test.cjs gateway/test/web-runtime-performance.test.cjs gateway/test/workspace-root-context.test.cjs gateway/test/workspace-root-picker.test.cjs gateway/test/workspace-roots.test.cjs gateway/test/ws-hub-client-ready.test.cjs gateway/test/ws-hub.test.cjs launcher/test/log-writer.test.cjs",
+ "test": "node --test gateway/test/app-host-message-codec.test.cjs gateway/test/app-host-provider.test.cjs gateway/test/auth-rate-limit.test.cjs gateway/test/auth-request.test.cjs gateway/test/compatibility-registry.test.cjs gateway/test/compatibility-service.test.cjs gateway/test/gateway-lifecycle.test.cjs gateway/test/history-preview.test.cjs gateway/test/http-utils.test.cjs gateway/test/local-files.test.cjs gateway/test/loopback-host.test.cjs gateway/test/mobile-sidebar-auto-collapse.test.cjs gateway/test/model-router-classifier.test.cjs gateway/test/model-router-injection-health.test.cjs gateway/test/model-router-presentation.test.cjs gateway/test/model-router-transport.test.cjs gateway/test/model-router.test.cjs gateway/test/modification-browser-host.test.cjs gateway/test/modification-equivalence.test.cjs gateway/test/modification-kernel.test.cjs gateway/test/modification-node-provider.test.cjs gateway/test/modification-production.test.cjs gateway/test/official-desktop-compat.test.cjs gateway/test/official-electron-module-hook.test.cjs gateway/test/official-runtime.test.cjs gateway/test/open-file-context.test.cjs gateway/test/picked-files.test.cjs gateway/test/plugin-config.test.cjs gateway/test/project-recent-sort.test.cjs gateway/test/runtime-compatibility-page.test.cjs gateway/test/service-control.test.cjs gateway/test/smart-model-router.integration.test.cjs gateway/test/smart-scheduling-summary.test.cjs gateway/test/static-assets.test.cjs gateway/test/virtual-model.test.cjs gateway/test/web-runtime-compatibility.test.cjs gateway/test/web-runtime-performance.test.cjs gateway/test/workspace-root-context.test.cjs gateway/test/workspace-root-picker.test.cjs gateway/test/workspace-roots.test.cjs gateway/test/ws-hub-client-ready.test.cjs gateway/test/ws-hub.test.cjs launcher/test/log-writer.test.cjs",
"eval:model-router-prompts": "node gateway/dev/model-router-prompt-ab.cjs",
"web:dev": "pnpm run build:gateway && node gateway/dev/run-gateway.cjs",
"build": "pnpm run build:gateway",
diff --git a/web-shell/codex-app-host-message-codec.js b/web-shell/codex-app-host-message-codec.js
new file mode 100644
index 0000000..f0dcb22
--- /dev/null
+++ b/web-shell/codex-app-host-message-codec.js
@@ -0,0 +1,260 @@
+(function (root, factory) {
+ const codec = factory();
+ // CommonJS 只导出模块;浏览器脚本才挂全局,避免网关加载时污染 Node globalThis。
+ if (typeof module === "object" && module && module.exports) {
+ module.exports = codec;
+ } else if (root) {
+ root.__OpenCodexAppHostMessageCodec = codec;
+ }
+})(typeof globalThis === "undefined" ? this : globalThis, function () {
+ "use strict";
+
+ const ENCODING = "opencodex-structured-clone-v1";
+ const MAX_DEPTH = 256;
+ // 官方字符串 decoder 的 BigInt 防御边界为 16384 位,transport adapter 复用该上限。
+ const MAX_BIGINT_DIGITS = 16_384;
+ // 节点上限只保护 gateway 的 CPU 和内存,不改变 WebSocket 的帧大小边界。
+ const MAX_NODES = 1_000_000;
+ const TYPED_ARRAY_NAMES = new Set([
+ "Int8Array",
+ "Uint8Array",
+ "Uint8ClampedArray",
+ "Int16Array",
+ "Uint16Array",
+ "Int32Array",
+ "Uint32Array",
+ "Float32Array",
+ "Float64Array",
+ "BigInt64Array",
+ "BigUint64Array",
+ ]);
+
+ function traversalState() {
+ return { nodes: 0, seen: new WeakSet() };
+ }
+
+ function visitNode(state, depth) {
+ state.nodes += 1;
+ if (state.nodes > MAX_NODES) throw new TypeError("App-host message exceeds the node limit");
+ // 官方 serializer 在 depth >= 256 时拒绝当前节点,因此允许深度为 0..255。
+ if (depth >= MAX_DEPTH) throw new TypeError("App-host message exceeds the depth limit");
+ }
+
+ function bigintDigitCount(value) {
+ const text = typeof value === "string" ? value : value.toString();
+ return text.startsWith("-") ? text.length - 1 : text.length;
+ }
+
+ function rememberContainer(value, state) {
+ if (state.seen.has(value)) throw new TypeError("App-host message contains a cycle");
+ state.seen.add(value);
+ }
+
+ function bytesFromView(value) {
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
+ }
+
+ function bytesToBase64(bytes) {
+ if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") {
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
+ }
+ let binary = "";
+ // 分块转换,避免大型 RPC 二进制参数触发浏览器调用栈或参数数量限制。
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
+ binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + 0x8000));
+ }
+ return btoa(binary);
+ }
+
+ function base64ToBytes(value) {
+ if (
+ typeof value !== "string" ||
+ value.length % 4 !== 0 ||
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
+ ) {
+ throw new TypeError("Invalid app-host base64 payload");
+ }
+ if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") {
+ const buffer = Buffer.from(value, "base64");
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
+ }
+ const binary = atob(value);
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
+ return bytes;
+ }
+
+ function byteViewName(value) {
+ if (value instanceof ArrayBuffer) return "ArrayBuffer";
+ if (value instanceof DataView) return "DataView";
+ const name = value && value.constructor && value.constructor.name;
+ // Node Buffer 在 MessagePort 边界会恢复为 Uint8Array,这里主动使用同一语义。
+ if (name === "Buffer") return "Uint8Array";
+ return TYPED_ARRAY_NAMES.has(name) ? name : "";
+ }
+
+ function encodeNode(value, state, depth) {
+ visitNode(state, depth);
+ if (value === null) return ["null"];
+ if (value === undefined) return ["undefined"];
+ if (typeof value === "string") return ["string", value];
+ if (typeof value === "boolean") return ["boolean", value];
+ if (typeof value === "bigint") {
+ if (bigintDigitCount(value) > MAX_BIGINT_DIGITS) {
+ throw new TypeError("App-host message exceeds the BigInt digit limit");
+ }
+ return ["bigint", value.toString()];
+ }
+ if (typeof value === "number") {
+ if (Number.isNaN(value)) return ["number", "nan"];
+ if (value === Infinity) return ["number", "infinity"];
+ if (value === -Infinity) return ["number", "-infinity"];
+ if (Object.is(value, -0)) return ["number", "-0"];
+ return ["number", value];
+ }
+ if (!value || typeof value !== "object") {
+ throw new TypeError(`Unsupported app-host value type: ${typeof value}`);
+ }
+
+ if (Object.prototype.toString.call(value) === "[object Date]") {
+ const timestamp = value.getTime();
+ return ["date", Number.isNaN(timestamp) ? null : timestamp];
+ }
+
+ const viewName = byteViewName(value);
+ if (viewName) return ["bytes", viewName, bytesToBase64(bytesFromView(value))];
+
+ rememberContainer(value, state);
+ if (Array.isArray(value)) {
+ // 稀疏数组按官方 serializer 的逐索引读取规则编码为显式 undefined。
+ const items = Array.from(value, (item) => encodeNode(item, state, depth + 1));
+ state.seen.delete(value);
+ return ["array", items];
+ }
+
+ const prototype = Object.getPrototypeOf(value);
+ if (prototype !== Object.prototype && prototype !== null) {
+ state.seen.delete(value);
+ throw new TypeError(`Unsupported app-host object: ${value.constructor?.name || "unknown"}`);
+ }
+ const entries = Object.keys(value).map((key) => [key, encodeNode(value[key], state, depth + 1)]);
+ state.seen.delete(value);
+ return ["object", entries];
+ }
+
+ function decodedByteView(name, base64) {
+ if (typeof name !== "string" || !TYPED_ARRAY_NAMES.has(name) && name !== "ArrayBuffer" && name !== "DataView") {
+ throw new TypeError(`Unsupported app-host byte view: ${String(name)}`);
+ }
+ const bytes = base64ToBytes(base64);
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
+ if (name === "ArrayBuffer") return buffer;
+ if (name === "DataView") return new DataView(buffer);
+ if (typeof globalThis[name] !== "function") throw new TypeError(`Unsupported app-host byte view: ${name}`);
+ try {
+ return new globalThis[name](buffer);
+ } catch (error) {
+ throw new TypeError(`Invalid ${name} byte length: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+
+ function decodeNode(node, state, depth) {
+ visitNode(state, depth);
+ if (!Array.isArray(node) || typeof node[0] !== "string") throw new TypeError("Invalid app-host wire node");
+ const requireNodeLength = (length) => {
+ if (node.length !== length) throw new TypeError(`Invalid app-host ${node[0]} node`);
+ };
+ switch (node[0]) {
+ case "null":
+ requireNodeLength(1);
+ return null;
+ case "undefined":
+ requireNodeLength(1);
+ return undefined;
+ case "string":
+ requireNodeLength(2);
+ if (typeof node[1] !== "string") throw new TypeError("Invalid app-host string node");
+ return node[1];
+ case "boolean":
+ requireNodeLength(2);
+ if (typeof node[1] !== "boolean") throw new TypeError("Invalid app-host boolean node");
+ return node[1];
+ case "bigint":
+ requireNodeLength(2);
+ if (
+ typeof node[1] !== "string" ||
+ !/^-?\d+$/.test(node[1]) ||
+ bigintDigitCount(node[1]) > MAX_BIGINT_DIGITS
+ ) {
+ throw new TypeError("Invalid app-host bigint node");
+ }
+ return BigInt(node[1]);
+ case "number":
+ requireNodeLength(2);
+ if (typeof node[1] === "number" && Number.isFinite(node[1])) return node[1];
+ if (node[1] === "nan") return NaN;
+ if (node[1] === "infinity") return Infinity;
+ if (node[1] === "-infinity") return -Infinity;
+ if (node[1] === "-0") return -0;
+ throw new TypeError("Invalid app-host number node");
+ case "date":
+ requireNodeLength(2);
+ if (!(node[1] === null || (typeof node[1] === "number" && Number.isFinite(node[1])))) {
+ throw new TypeError("Invalid app-host date node");
+ }
+ return new Date(node[1] === null ? NaN : node[1]);
+ case "bytes":
+ requireNodeLength(3);
+ return decodedByteView(node[1], node[2]);
+ case "array":
+ requireNodeLength(2);
+ if (!Array.isArray(node[1])) throw new TypeError("Invalid app-host array node");
+ return node[1].map((item) => decodeNode(item, state, depth + 1));
+ case "object": {
+ requireNodeLength(2);
+ if (!Array.isArray(node[1])) throw new TypeError("Invalid app-host object node");
+ const value = {};
+ for (const entry of node[1]) {
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") {
+ throw new TypeError("Invalid app-host object entry");
+ }
+ if (Object.prototype.hasOwnProperty.call(value, entry[0])) throw new TypeError("Duplicate app-host object key");
+ Object.defineProperty(value, entry[0], {
+ configurable: true,
+ enumerable: true,
+ value: decodeNode(entry[1], state, depth + 1),
+ writable: true,
+ });
+ }
+ return value;
+ }
+ default:
+ throw new TypeError(`Unknown app-host wire tag: ${node[0]}`);
+ }
+ }
+
+ function encode(value) {
+ return encodeNode(value, traversalState(), 0);
+ }
+
+ function decode(value) {
+ return decodeNode(value, traversalState(), 0);
+ }
+
+ function encodeMessageData(data) {
+ // 旧版字符串 RPC 保持原始帧,只有新版结构化值进入 codec。
+ if (data === null || typeof data === "string") return { data };
+ return { data: encode(data), dataEncoding: ENCODING };
+ }
+
+ function decodeMessageData(message) {
+ if (!message || typeof message !== "object") throw new TypeError("Invalid app-host transport message");
+ if (message.dataEncoding === ENCODING) return decode(message.data);
+ if (message.dataEncoding != null) throw new TypeError(`Unsupported app-host data encoding: ${String(message.dataEncoding)}`);
+ if (message.data === null || typeof message.data === "string") return message.data;
+ throw new TypeError("Unencoded app-host data must be a string or null");
+ }
+
+ return { decode, decodeMessageData, encode, encodeMessageData, encoding: ENCODING };
+});
diff --git a/web-shell/index.html b/web-shell/index.html
index 50ab2ec..1608a25 100644
--- a/web-shell/index.html
+++ b/web-shell/index.html
@@ -25,6 +25,7 @@
+