From 2bf434dc09ca8e4ab7e5df9195850647cc53882c Mon Sep 17 00:00:00 2001 From: Bill Date: Fri, 16 Oct 2026 14:47:50 +0800 Subject: [PATCH] fix: preserve browser environment and validate short repository roots --- docs/ZCODE_RUNTIME.md | 8 +- ...026-10-16-environment-portability-fixes.md | 81 ++++++ plugins/codex-zcode-bridge/dist/bridge.mjs | 10 +- .../worker/vendor/pkg/browser-host.cjs | 9 +- .../codex-zcode-bridge/worker/worker-main.mjs | 6 +- scripts/lib/repo-root-pattern.mjs | 57 +++++ scripts/validate-plugin.mjs | 3 +- src/browser/executable.ts | 15 +- src/runtime/child-env.ts | 6 + test/browser-executable.test.ts | 96 +++++++ test/child-env.test.ts | 38 +++ test/validate-plugin-root-pattern.test.ts | 240 ++++++++++++++++++ test/worker-env-portability.test.ts | 149 +++++++++++ 13 files changed, 709 insertions(+), 9 deletions(-) create mode 100644 docs/reports/2026-10-16-environment-portability-fixes.md create mode 100644 scripts/lib/repo-root-pattern.mjs create mode 100644 test/browser-executable.test.ts create mode 100644 test/validate-plugin-root-pattern.test.ts create mode 100644 test/worker-env-portability.test.ts diff --git a/docs/ZCODE_RUNTIME.md b/docs/ZCODE_RUNTIME.md index 1fedf98..a4d38ff 100644 --- a/docs/ZCODE_RUNTIME.md +++ b/docs/ZCODE_RUNTIME.md @@ -1,9 +1,9 @@ # ZCode 运行配置边界 > Status: AUTHORITATIVE -> Last updated: 2026-10-15 -> Last verified: 2026-10-15,最新上游整合分支的验证结果见[审查说明](reports/2026-10-15-browser-and-transport-fixes.md);旧版本实测与当前分支回归分别记录。 -> Verified against: 上游fae74e45033a50fb560c0014d1a6d60a7dc771d1 + 本分支修复;受测源码指纹见审查说明。 +> Last updated: 2026-10-16 +> Last verified: 2026-10-16,独立验证 Windows/Linux worker 环境转发、Windows 自动发现及真实浏览器后端(见[修复报告](reports/2026-10-16-environment-portability-fixes.md#codex-独立验收2026-10-16));其余条目沿用 2026-10-15 核对范围。 +> Verified against: 0e1e319561edf94e0f0cd481ac455ec784eb19dd + 本分支 F1–F3 修复;受测产物指纹见修复报告。 当前生产路径是 `node zcode.cjs app-server --stdio`,不是历史 CLI `--prompt --json`。解析入口为 `NodeRuntimeResolver`,配置项与用户设置方法见仓库 README。 @@ -13,7 +13,7 @@ Bridge 只读取官方 builtin/personal provider 配置,不复制、不改写 persisted runtime-config 中已知字段只能是字符串或 null;缺文件允许环境/发现回退,存在但损坏、非 object 或超过 64 KiB 会报配置错误。默认 mode 仍为已披露的 yolo;不能把坏配置当成首次未设置而回退到该模式。 -浏览器宿主配置(ADR-007):`ZCODE_BRIDGE_BROWSER_USE` 取 `off`(默认,含空值/未设置/非法值回退为 off)或 `headless`;`ZCODE_BRIDGE_BROWSER_EXECUTABLE` 可选,须为已存在文件的绝对路径,缺省按官方实现解析已安装 Chromium/Edge(不下载浏览器)。两项均为普通字符串配置键,遵循配置优先于环境变量的既有规则;非法 USE 值不阻断任务,仅保持关闭并由 doctor 给出 warning。不向 app-server 传递任何 headless CLI 参数;浏览器由 worker 内的 managed CDP runtime 以独立临时 profile 启动,生命周期见 ARCHITECTURE.md。 +浏览器宿主配置(ADR-007):`ZCODE_BRIDGE_BROWSER_USE` 取 `off`(默认,含空值/未设置/非法值回退为 off)或 `headless`;`ZCODE_BRIDGE_BROWSER_EXECUTABLE` 可选,须为已存在文件的绝对路径,缺省按官方实现解析已安装 Chromium/Edge(不下载浏览器)。两项均为普通字符串配置键,遵循配置优先于环境变量的既有规则;非法 USE 值不阻断任务,仅保持关闭并由 doctor 给出 warning。detached worker 进程按显式键清单转发这两项非秘密设置(2026-10-16 修复:此前 env-only 配置在真实 spawn 边界丢失),其余任意父进程环境与秘密仍不透传。Windows worker 环境保留 `ProgramFiles` / `ProgramFiles(x86)`(接受常见大小写)等非敏感系统目录变量供自动发现使用,发现解析同样不区分大小写键;显式 `ZCODE_BRIDGE_BROWSER_EXECUTABLE` 优先级不变。不向 app-server 传递任何 headless CLI 参数;浏览器由 worker 内的 managed CDP runtime 以独立临时 profile 启动,生命周期见 ARCHITECTURE.md。 执行和模型目录 RPC 客户端都是私有集成实现,当前假运行时测试覆盖调用选择、事件、交互和清理。真实协议随安装版本变化;本次已新建真实会话验证记录版本的浏览器链路,不能将这次 smoke 扩展为全协议或其他版本认证。 diff --git a/docs/reports/2026-10-16-environment-portability-fixes.md b/docs/reports/2026-10-16-environment-portability-fixes.md new file mode 100644 index 0000000..e7ef94a --- /dev/null +++ b/docs/reports/2026-10-16-environment-portability-fixes.md @@ -0,0 +1,81 @@ +# 跨环境审查 F1–F3 环境可移植性修复报告 + +> Status: RESEARCH +> Date: 2026-10-16 +> Base: `0e1e319561edf94e0f0cd481ac455ec784eb19dd`(上游 1.2.5,PR #43 合并后的 master) +> 执行:Bridge worker(实现与本机 Windows 自测);末尾独立验收部分由 Codex 据实补录,前面的 worker 记录保留原验证范围。 + +## 来源与范围 + +本次 Codex 独立审查(2026-10-16,审查记录与原始证据由 Codex 保存,只读引用)复现了三个缺陷:F1 env-only 浏览器设置未传给真实 detached worker;F2 Windows 系统浏览器自动发现所需的 `ProgramFiles` / `ProgramFiles(x86)` 被过滤;F3 仓库位于短目录(如容器 `/app`)时 `validate:plugin` 把仓库路径当无边界子串,在 JSON Schema URL 片段(`/vocab/applicator` 等)上误报"开发目录泄漏"。本分支在 1.2.5 基线上修复三项根因,版本号不变;不修改 MCP/schema/账号或权限合同、不放宽秘密边界、不改变依赖锁与 CI/Node 版本。 + +首轮实现后,Codex 对 F3 的独立探针又发现两类漏检:JS 字符串转义形式(双反斜杠)的 Windows 绝对路径、以及注释中前导空格之后的绝对路径未被拒绝;返修期间进一步收束为按路径结构判定边界(见下)。本报告描述最终实现;首轮与返修的红/绿日志均按任务记录另行保存,未改写。 + +## 修改 + +- `src/runtime/child-env.ts`:`createWorkerEnv` 显式键清单加入 `ZCODE_BRIDGE_BROWSER_USE` 与 `ZCODE_BRIDGE_BROWSER_EXECUTABLE`(非秘密字符串,worker 内 adapter 从自身环境读取);Windows `createMinimalOsEnv` 白名单加入 `PROGRAMFILES`、`PROGRAMFILES(X86)`(按 `toUpperCase` 比较,保留原始键大小写)。其余任意环境与秘密仍不透传。(F1/F2,首轮实现,返修未动。) +- `src/browser/executable.ts`:Windows 浏览器发现改用大小写不敏感的环境键读取(`windowsEnvValue`),接受 `ProgramFiles` / `programfiles` 等常见形式;空值跳过、显式 `executablePath` 优先与候选顺序不变。(F2,首轮实现,返修未动。) +- `scripts/validate-plugin.mjs` + 新增 `scripts/lib/repo-root-pattern.mjs`:泄漏检查的根路径正则抽出为可回归测试的 helper。最终语义按路径结构而非名称字符黑名单判定: + - 右界:根之后只允许路径分隔符、字符串/行终止符或文本结束;空格、`%20`、`@`、`+`、`(`、字母、`.` 等一律视为更长路径段的延续(`/app Backup`、`/app@2`、`/app.git`、`file:///app%20Backup` 均不是本仓库根)。 + - 左界:根必须出现在绝对路径可以开始的位置(文本起始、空白、引号或 `=`/`:`/`(`/`,`/`;`/`/`/`\` 等记号分隔符之后);名称字符或 URL authority 尾部(`example.test/app`、`[::1]/app`)说明是更长名称或普通 HTTP URL 的一部分。原始路径分支上,同一记号内更早出现的 `http(s)://` 抑制匹配(覆盖 `https://…/docs/app/…` 等深层 URL 段)。 + - 每个原始分隔符位置匹配一到两个 `/` 或 `\`:bundle 中 Windows 绝对路径既有原生单分隔符形式,也有 JS 字符串转义的双反斜杠形式,二者是同一路径。 + - 完整标准 file URI(Node 内置 `pathToFileURL`,含 scheme)作为独立备选,不受上述 HTTP 抑制——嵌在 HTTP URL 里的 `file:` 引用(如 redirect target)仍是本地开发路径泄漏;POSIX 风格根同时纳入 `pathToFileURL(root, { windows: false })` 的跨平台拼写,避免验证平台差异。该备选沿用相同左右界(编码同级后缀 `%20Backup` 不误报)。 + - 旧实现的可比行为:其分隔符字符类按"替换后成对"生效,因此能命中 JS 转义等双分隔符形式,但漏掉原生单分隔符绝对路径(Windows 常见形态);POSIX 短根上则退化为无边界子串导致误报。新实现两类形式均覆盖。 + - 路径泄漏检查本身保留,未取消、未硬编码跳过 `/app`;runtime 依赖与资源检查(vendored package/browsers.json、playwright-core 静态导入禁令)原样保留。 +- 提交物 `plugins/codex-zcode-bridge/dist/bridge.mjs`、`worker/worker-main.mjs`、`worker/vendor/pkg/browser-host.cjs` 由 `npm run build` 重建,与源码同步,无手改(返修轮未触及生产源码,产物与首轮一致)。 + +## 回归(红/绿均有旧代码失败证据) + +测试分四组:`test/worker-env-portability.test.ts`(真实 `createWorkerSpawner` 跨进程探针:两项浏览器设置到达、任意秘密不透传、未设置时键缺席且无 `ZCODE_BRIDGE_*` 整体转发)、`test/browser-executable.test.ts`(fixture 目录 + 假 playwright,无真实浏览器)、`test/child-env.test.ts`(函数级转发与 Windows 白名单)、`test/validate-plugin-root-pattern.test.ts`(helper 语义 + 真实 CLI 注入)。 + +- 首轮红相:旧生产代码上 16 项中 9 项失败(含 F1 跨进程 `null !== 'headless'`、F2 大小写/白名单、F3 `/app` 误报与 Windows 单分隔符漏检)。 +- 返修红相:在首轮实现上,evaluator 输入的三个 helper 用例(注释前导空格、JS 转义双反斜杠、Windows 注释)与真实 CLI 注入(转义字符串、注释、斜杠、标准 file URI)均未被拒绝;返修后全部通过。另有审查补充的 `/app@2`、`/app+backup`、`/app(copy)`、`[::1]/app`、`file:///app%20Backup`、`pathToFileURL` 本机盘符同级后缀、深层 URL 段、HTTP 内嵌 `file:` URI 等负例/正例。 +- 真实 CLI 回归在临时 fixture 仓库(根名含空格、Unicode 与正则字符)中复制现行 validator + helper 运行:baseline(含 schema URL、同段 URL、同级目录与编码同级目录负例)必须 exit 0;追加泄漏行(转义字符串、注释、斜杠、未编码与标准 file URI、HTTP 内嵌 file URI)必须被"embeds the development repository's absolute path"诊断拒绝。 + +## Worker 本机验证(Windows,2026-10-16) + +| 检查 | 版本与结果 | +|---|---| +| 首轮红/绿(旧生产代码) | Node 22.18.0:红 16 项中 9 失败;绿 16/16 通过 | +| 首轮完整验证 | Node 22.18.0:typecheck、完整隔离测试 358 项(357 通过/0 失败/1 既有 Windows POSIX 权限跳过)、build、validate:plugin、`git diff --check` 全部 exit 0 | +| 返修红/绿(首轮实现) | Node 22.18.0:红相 3 项失败(注释/转义/CLI 注入);绿相 22/22 通过 | +| 返修 typecheck / build / validate:plugin / diff-check | Node 22.18.0,均 exit 0;产物与首轮一致 | +| 返修后未重跑全仓测试 | 按分工由 Codex 在最终验收独立执行;首轮全套记录保留 | +| 本次相关回归 | Node 24.19.0:22/22 通过,exit 0 | + +命令清单、真实退出码与完整 stdout/stderr 按任务约定保存于统一日志目录(本地交付物,不进仓库);evaluator 探针只读复现,未在 evaluator 目录写入。 + +## Worker 交付时未运行项(现由下述独立验收补齐) + +- 本机为 Windows,无法在允许路径内把仓库放到真实 `/app` 根复现容器场景;F3 的端到端 `/app` 验收由 Codex Linux 沙盒执行(语义已由 helper 与真实 CLI 回归覆盖)。 +- 真实浏览器自动发现(系统安装 Edge/Chrome + `ZCODE_BRIDGE_BROWSER_USE=headless` 无显式路径)按任务约定由 Codex 在 Windows 验收;本报告只覆盖 fixture 回归。 +- 沙盒/环境清理、提交、PR 更新与发布不在 worker 职责内。 + +## Codex 独立验收(2026-10-16) + +在 worker 第二次交付后冻结代码,独立审查完整差异及额外正反探针,再在原生 Windows 和 rootless Linux 容器验证。首轮未通过审查及返修日志保留;以下结果来自最终交付,不能用首轮结果代替。 + +| 独立检查 | 结果 | +|---|---| +| Windows / Node 22.18.0 / `npm test` | 364 项:363 通过、0 失败、0 取消、1 跳过(既有 POSIX 权限测试) | +| Linux / Node 22.18.0 / `npm test` | 365 项:363 通过、0 失败、0 取消、2 跳过(Windows 启动 hook 与进程树语义);总数差异来自平台条件子测试 | +| 两平台 Node 22.18.0 | `npm run typecheck`、`npm run build`、`npm run validate:plugin`、`npm run test:clean-entrypoints` 全部 exit 0;clean-entrypoints 各 4/4 通过 | +| Node 24 相关回归 | Windows 24.19.0、Linux 24.20.0,各 36/36 通过(四个本次相关文件 + browser-host 原始回归) | +| 真实 detached worker | 两平台均收到 env-only 浏览器配置、任意测试秘密仍被过滤、worker 退出;Windows 无显式路径时自动发现系统 Edge | +| 短目录校验 | Linux 仓库实际置于 `/app`,完整 build 与 validator 通过;追加绝对注释、路径字符串、file URI 后分别被校验器拒绝 | +| 独立 F3 探针 | 19 个路径/URL 边界用例全部通过;Windows 真实 CLI 基线通过,JS 转义绝对路径与注释注入均被拒绝 | +| 独立插件包及真实浏览器 | Windows Edge / Linux Chromium,各 16 项通过:MCP 初始化和 1.2.5 版本、缺浏览器错误、vendored 后端、导航、填写、单次点击、页面确认、服务器恰好一次接收、截图、关闭 | +| 产物一致性 | 三个提交 JS bundle 的 Windows 交付与 Linux 干净重建 SHA256 一致(统一 LF 后比较);Windows 验证期间 217 个交付文件均未变化;随后只补录本报告和运行文档的验收状态 | +| 清理 | 两个任务容器和一个派生镜像移除,原有 8 个镜像保留;浏览器及 worker 无残留、测试端口关闭;Windows 临时目录、下载输入与探针 fixture 共 8 个目录移入回收站,原路径不存在;证据与原始失败日志保留 | + +Linux 验收环境使用非 root UID 1000、无网络、无宿主挂载、移除 capabilities、no-new-privileges;依赖准备阶段使用公开源联网安装,随后在独立断网容器运行。Windows 使用仓库之外含空格/中文的独立插件包和最小环境变量,属于目录与进程环境隔离,**不是 OS 沙箱**。两平台真实浏览器演示均未读取账户或调用模型服务。 + +受测产物 SHA256(LF 规范化): + +| 提交物 | SHA256 | +|---|---| +| `dist/bridge.mjs` | `6d7cf97a5c9f386f5b743b258d7ea6d0c823b75cb96b4a74e439a5e25542f8d4` | +| `worker/worker-main.mjs` | `a2b2cef48d6df746f345447c1e0082b02ff045da573287db9a664dced3404d84` | +| `worker/vendor/pkg/browser-host.cjs` | `b750aeff5cae48d6cdb71e068b0b8d96592cba52b57a514fedad515615c55b4e` | + +结论:F1–F3 在上述范围内独立验收通过,适合提交维护者审查。此结论不包含 macOS/ARM 实机、真实模型端到端协议、所有浏览器版本或未运行的 Node 版本认证;既有 Windows 锁并发抖动本轮未复现,也未据此宣称根治。远端 CI 与维护者合并决定另行记录在 PR 中。本次未更改本机已安装插件,未发布版本。 diff --git a/plugins/codex-zcode-bridge/dist/bridge.mjs b/plugins/codex-zcode-bridge/dist/bridge.mjs index 5c5eec6..2c5ea46 100644 --- a/plugins/codex-zcode-bridge/dist/bridge.mjs +++ b/plugins/codex-zcode-bridge/dist/bridge.mjs @@ -23565,7 +23565,11 @@ var WINDOWS_OS_ENV = /* @__PURE__ */ new Set([ "PROGRAMDATA", "OS", "PROCESSOR_ARCHITECTURE", - "NUMBER_OF_PROCESSORS" + "NUMBER_OF_PROCESSORS", + // Non-sensitive system install directories; browser auto-discovery resolves + // system Chrome/Edge under these roots (src/browser/executable.ts). + "PROGRAMFILES", + "PROGRAMFILES(X86)" ]); var POSIX_OS_ENV = /* @__PURE__ */ new Set([ "PATH", @@ -23600,6 +23604,10 @@ function createWorkerEnv(source) { "ZCODE_BRIDGE_DEFAULT_REASONING_LEVEL", "ZCODE_BRIDGE_MODE", "ZCODE_BRIDGE_TIMEOUT_MS", + // Env-only browser configuration (ADR-007): non-secret strings the worker's + // adapter reads from its own environment when no persisted setting exists. + "ZCODE_BRIDGE_BROWSER_USE", + "ZCODE_BRIDGE_BROWSER_EXECUTABLE", "ZCODE_HOME", "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE", "ZCODE_PERSONAL_PROVIDER_CONFIG_FILE", diff --git a/plugins/codex-zcode-bridge/worker/vendor/pkg/browser-host.cjs b/plugins/codex-zcode-bridge/worker/vendor/pkg/browser-host.cjs index 3d0e097..f1fde7c 100644 --- a/plugins/codex-zcode-bridge/worker/vendor/pkg/browser-host.cjs +++ b/plugins/codex-zcode-bridge/worker/vendor/pkg/browser-host.cjs @@ -163225,8 +163225,15 @@ var MAC_BROWSER_PATHS = [ "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" ]; +function windowsEnvValue(env, key) { + const wanted = key.toUpperCase(); + for (const [name, value2] of Object.entries(env)) { + if (name.toUpperCase() === wanted && value2 !== void 0 && value2 !== "") return value2; + } + return void 0; +} function windowsBrowserPaths(env) { - return [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter((root) => Boolean(root?.trim())).flatMap((root) => [ + return [windowsEnvValue(env, "PROGRAMFILES"), windowsEnvValue(env, "PROGRAMFILES(X86)"), windowsEnvValue(env, "LOCALAPPDATA")].filter((root) => Boolean(root?.trim())).flatMap((root) => [ (0, import_node_path.join)(root, "Google", "Chrome", "Application", "chrome.exe"), (0, import_node_path.join)(root, "Chromium", "Application", "chrome.exe"), (0, import_node_path.join)(root, "Microsoft", "Edge", "Application", "msedge.exe") diff --git a/plugins/codex-zcode-bridge/worker/worker-main.mjs b/plugins/codex-zcode-bridge/worker/worker-main.mjs index 852c62d..7ac8178 100644 --- a/plugins/codex-zcode-bridge/worker/worker-main.mjs +++ b/plugins/codex-zcode-bridge/worker/worker-main.mjs @@ -1103,7 +1103,11 @@ var WINDOWS_OS_ENV = /* @__PURE__ */ new Set([ "PROGRAMDATA", "OS", "PROCESSOR_ARCHITECTURE", - "NUMBER_OF_PROCESSORS" + "NUMBER_OF_PROCESSORS", + // Non-sensitive system install directories; browser auto-discovery resolves + // system Chrome/Edge under these roots (src/browser/executable.ts). + "PROGRAMFILES", + "PROGRAMFILES(X86)" ]); var POSIX_OS_ENV = /* @__PURE__ */ new Set([ "PATH", diff --git a/scripts/lib/repo-root-pattern.mjs b/scripts/lib/repo-root-pattern.mjs new file mode 100644 index 0000000..b4b35ee --- /dev/null +++ b/scripts/lib/repo-root-pattern.mjs @@ -0,0 +1,57 @@ +// Builds the pattern validate-plugin.mjs uses to detect whether a bundle +// embeds the development repository's absolute path. Kept in scripts/lib so +// the boundary semantics can be regression-tested directly. +// +// A genuine path reference is delimited by path/string structure, not by a +// blacklist of "name characters": +// - On the right, the root may only be followed by a path separator, a +// string/line terminator, or the end of the input. Anything else — a space, +// `%20`, `@`, `+`, `(`, a letter, a dot — continues a longer path segment +// ("/app Backup", "/app@2", "/app.git" are sibling names, not this root). +// - On the left, the root must start in a context where an absolute path can +// begin: start of input, whitespace (a comment like "// /app/src"), a +// quote, or a token separator like `=`, `:`, `(`, `,`, `;`, `/`, `\`. +// A name character or a URL authority tail ("example.test/app", +// "[::1]/app") means the hit is the middle of a longer name or an ordinary +// HTTP URL path, not a local absolute path. Deeper URL path segments are +// covered by the scheme lookbehind on the raw branch: any http(s) URL +// opened earlier in the same token (no whitespace/quote/backtick in +// between) suppresses the raw match. +// Each raw separator position matches one or two separators (`/` or `\`): +// bundles embed Windows absolute paths both raw (C:\repo\src) and +// JS-string-escaped (C:\\repo\\src with literal doubled backslashes), and +// both spellings name the same development path. +// +// The second alternative is the root's complete standard file URI +// (pathToFileURL, scheme included): spaces and non-ASCII segments are +// percent-encoded there, so the raw pattern cannot see them. It is NOT +// subject to the http(s) suppression — a file: URI embedded inside an HTTP +// URL (a redirect target) is still a reference to the local development +// path. The delimiters still apply: "file:///app%20Backup/src" extends the +// segment and must not match, while "file:///app/src" must. Scheme-less +// percent-encoded spellings are not covered by this check. +import { pathToFileURL } from "node:url"; + +export function buildRepoRootLeakPattern(root) { + const escapedRoot = root + .replace(/[.*+?^${}()|[\]\\]/gu, "\\$&") + .replace(/\\\\/gu, "[/\\\\]{1,2}"); + // pathToFileURL is platform-dependent for drive-less roots (Windows + // prepends the current drive), so a POSIX-style root also contributes its + // cross-platform spelling; hosts and tests agree regardless of platform. + const encodedCandidates = new Set([pathToFileURL(root).href]); + if (root.startsWith("/")) encodedCandidates.add(pathToFileURL(root, { windows: false }).href); + const encodedRoot = [...encodedCandidates] + .map((candidate) => candidate.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")) + .join("|"); + // Complement classes: "previous char (if any) must be an allowed start + // context" and "next char (if any) must be an allowed terminator". The + // backtick is expressed as \u0060 so it can live inside these templates. + const startContext = String.raw`[^\s"'=(:,;\\/\u0060]`; + const terminator = String.raw`[^/\\'"\n\r\t\u0060]`; + const httpUrlToken = String.raw`(? Boolean(root?.trim())) .flatMap((root) => [ join(root, "Google", "Chrome", "Application", "chrome.exe"), diff --git a/src/runtime/child-env.ts b/src/runtime/child-env.ts index 3d6631a..a9d334c 100644 --- a/src/runtime/child-env.ts +++ b/src/runtime/child-env.ts @@ -3,6 +3,9 @@ const WINDOWS_OS_ENV = new Set([ "PATH", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA", "OS", "PROCESSOR_ARCHITECTURE", "NUMBER_OF_PROCESSORS", + // Non-sensitive system install directories; browser auto-discovery resolves + // system Chrome/Edge under these roots (src/browser/executable.ts). + "PROGRAMFILES", "PROGRAMFILES(X86)", ]); const POSIX_OS_ENV = new Set([ "PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "LC_CTYPE", "TERM", @@ -26,6 +29,9 @@ export function createWorkerEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv { "ZCODE_BRIDGE_DEFAULT_PROVIDER_ID", "ZCODE_BRIDGE_DEFAULT_MODEL_ID", "ZCODE_BRIDGE_DEFAULT_REASONING_LEVEL", "ZCODE_BRIDGE_MODE", "ZCODE_BRIDGE_TIMEOUT_MS", + // Env-only browser configuration (ADR-007): non-secret strings the worker's + // adapter reads from its own environment when no persisted setting exists. + "ZCODE_BRIDGE_BROWSER_USE", "ZCODE_BRIDGE_BROWSER_EXECUTABLE", "ZCODE_HOME", "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE", "ZCODE_PERSONAL_PROVIDER_CONFIG_FILE", "ZCODE_DATA_BASE_DIR", "ZCODE_WINDOWS_APP_INSTALL_DIR", diff --git a/test/browser-executable.test.ts b/test/browser-executable.test.ts new file mode 100644 index 0000000..8973173 --- /dev/null +++ b/test/browser-executable.test.ts @@ -0,0 +1,96 @@ +// F2 regression (2026-10-16 cross-environment review): browser auto-discovery +// on Windows must accept the common casings of the ProgramFiles variables the +// worker env forwards, using fixture directories instead of a real browser. +// The win32 path table is exercised via the platform option, so no real +// browser and no Windows OS are required. +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { resolveInstalledBrowserExecutable, type PlaywrightChromiumModule } from "../src/browser/executable.js"; + +function fakePlaywrightWithoutBundledBrowser(): PlaywrightChromiumModule { + return { chromium: { executablePath: () => "" } } as unknown as PlaywrightChromiumModule; +} + +function fakeBrowserExe(base: string, ...segments: string[]): string { + const exePath = path.join(base, ...segments); + mkdirSync(path.dirname(exePath), { recursive: true }); + writeFileSync(exePath, "fixture browser executable", "utf8"); + return exePath; +} + +test("uppercase system directory variables discover installed browsers (existing contract)", () => { + const dir = mkdtempSync(path.join(tmpdir(), "bridge-exe-uppercase-")); + try { + const edge = fakeBrowserExe(dir, "x86", "Microsoft", "Edge", "Application", "msedge.exe"); + const resolved = resolveInstalledBrowserExecutable(fakePlaywrightWithoutBundledBrowser(), { + platform: "win32", + env: { PROGRAMFILES: path.join(dir, "missing"), "PROGRAMFILES(X86)": path.join(dir, "x86") }, + }); + assert.equal(resolved, edge); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("common Windows casings (ProgramFiles / ProgramFiles(x86) / localappdata) discover the same installs", () => { + const dir = mkdtempSync(path.join(tmpdir(), "bridge-exe-casing-")); + try { + const chrome = fakeBrowserExe(dir, "pf", "Google", "Chrome", "Application", "chrome.exe"); + const chromium = fakeBrowserExe(dir, "local", "Chromium", "Application", "chrome.exe"); + for (const env of [ + { ProgramFiles: path.join(dir, "pf"), "ProgramFiles(x86)": path.join(dir, "x86") }, + { PROGRAMFILES: path.join(dir, "pf"), "PROGRAMFILES(X86)": path.join(dir, "x86") }, + { programfiles: path.join(dir, "pf"), "programfiles(x86)": path.join(dir, "x86") }, + ]) { + const resolved = resolveInstalledBrowserExecutable(fakePlaywrightWithoutBundledBrowser(), { platform: "win32", env }); + assert.equal(resolved, chrome, `casing ${Object.keys(env).join("/")} must resolve the ProgramFiles install`); + } + const resolvedLocal = resolveInstalledBrowserExecutable(fakePlaywrightWithoutBundledBrowser(), { + platform: "win32", + env: { localappdata: path.join(dir, "local") }, + }); + assert.equal(resolvedLocal, chromium, "lowercase localappdata must resolve the per-user install"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("whitespace-only directory values are skipped and discovery falls through to the next root", () => { + const dir = mkdtempSync(path.join(tmpdir(), "bridge-exe-blank-")); + try { + const edge = fakeBrowserExe(dir, "x86", "Microsoft", "Edge", "Application", "msedge.exe"); + const resolved = resolveInstalledBrowserExecutable(fakePlaywrightWithoutBundledBrowser(), { + platform: "win32", + env: { ProgramFiles: " ", "ProgramFiles(x86)": path.join(dir, "x86") }, + }); + assert.equal(resolved, edge); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("an explicit executable path keeps priority over discovered system installs", () => { + const dir = mkdtempSync(path.join(tmpdir(), "bridge-exe-explicit-")); + try { + const explicit = fakeBrowserExe(dir, "explicit", "custom-browser.exe"); + fakeBrowserExe(dir, "pf", "Google", "Chrome", "Application", "chrome.exe"); + const resolved = resolveInstalledBrowserExecutable(fakePlaywrightWithoutBundledBrowser(), { + platform: "win32", + executablePath: explicit, + env: { ProgramFiles: path.join(dir, "pf") }, + }); + assert.equal(resolved, explicit); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("nothing installed still raises the documented ZCODE_BRIDGE_BROWSER_EXECUTABLE guidance", () => { + assert.throws( + () => resolveInstalledBrowserExecutable(fakePlaywrightWithoutBundledBrowser(), { platform: "win32", env: {} }), + /ZCODE_BRIDGE_BROWSER_EXECUTABLE/u, + ); +}); diff --git a/test/child-env.test.ts b/test/child-env.test.ts index 514a4b7..29e5612 100644 --- a/test/child-env.test.ts +++ b/test/child-env.test.ts @@ -30,3 +30,41 @@ test("child environments keep only OS and explicit Bridge settings", () => { assert.equal(child.API_TOKEN, undefined); assert.equal(child.ZCODE_BRIDGE_NODE, undefined); }); + +test("worker environments forward the env-only browser settings and nothing broader", () => { + const worker = createWorkerEnv({ + PATH: "safe-path", + ZCODE_BRIDGE_BROWSER_USE: "headless", + ZCODE_BRIDGE_BROWSER_EXECUTABLE: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", + ZCODE_BRIDGE_BROWSER_SECRET: "must-not-leak", + }); + assert.equal(worker.ZCODE_BRIDGE_BROWSER_USE, "headless"); + assert.equal(worker.ZCODE_BRIDGE_BROWSER_EXECUTABLE, "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"); + assert.equal(worker.ZCODE_BRIDGE_BROWSER_SECRET, undefined, "only the two documented browser keys are forwarded"); + + const unset = createWorkerEnv({ PATH: "safe-path" }); + assert.equal("ZCODE_BRIDGE_BROWSER_USE" in unset, false, "default off means the key stays absent"); + assert.equal("ZCODE_BRIDGE_BROWSER_EXECUTABLE" in unset, false); +}); + +test("Windows child environments keep the Program Files directories in common casings", () => { + // Real Windows casing, as the OS environment block and the forwarded worker + // env present it; browser auto-discovery reads these roots. + const child = createMinimalOsEnv({ + PATH: "safe-path", + ProgramFiles: "C:\\Program Files", + "ProgramFiles(x86)": "C:\\Program Files (x86)", + }); + if (process.platform === "win32") { + const keys = Object.keys(child).map((key) => key.toUpperCase()); + assert.ok(keys.includes("PROGRAMFILES"), "ProgramFiles must survive the OS env filter"); + assert.ok(keys.includes("PROGRAMFILES(X86)"), "ProgramFiles(x86) must survive the OS env filter"); + const forwarded = Object.entries(child) + .filter(([key]) => key.toUpperCase() === "PROGRAMFILES" || key.toUpperCase() === "PROGRAMFILES(X86)") + .map(([, value]) => value) + .sort(); + assert.deepEqual(forwarded, ["C:\\Program Files", "C:\\Program Files (x86)"].sort()); + } else { + assert.equal(Object.keys(child).includes("ProgramFiles"), false, "POSIX child envs do not forward Windows-only directories"); + } +}); diff --git a/test/validate-plugin-root-pattern.test.ts b/test/validate-plugin-root-pattern.test.ts new file mode 100644 index 0000000..b7ebf7e --- /dev/null +++ b/test/validate-plugin-root-pattern.test.ts @@ -0,0 +1,240 @@ +// F3 regression (2026-10-16 cross-environment review): the plugin validator's +// development-path leak check must treat the repository root as a complete +// path segment. A short root like /app must not hit unrelated URL fragments +// (/vocab/applicator, /apps, /app.git), while genuine absolute references — +// including file:// URLs, comment lines, and Windows backslash, +// JS-string-escaped (doubled backslash), and forward-slash forms — must +// still be rejected. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { buildRepoRootLeakPattern } from "../scripts/lib/repo-root-pattern.mjs"; + +test("a short root like /app does not match unrelated URL or path segments", () => { + const pattern = buildRepoRootLeakPattern("/app"); + const benign = [ + '"https://json-schema.org/draft/2019-09/vocab/applicator"', + '"https://json-schema.org/draft/2019-09/vocab/applicability#items"', + '"/apps/monitor"', + '"/app.git/config"', + '"/appendix/table"', + '"/application/index.js"', + '"myapp/src/main.ts"', + '"./appdata/cache"', + ]; + for (const text of benign) { + assert.equal(pattern.test(text), false, `must not flag unrelated fragment: ${text}`); + pattern.lastIndex = 0; + } +}); + +test("genuine absolute references to a short root are still rejected", () => { + const pattern = buildRepoRootLeakPattern("/app"); + const leaks = [ + '"/app/src/runtime/child-env.ts"', + '"file:///app/plugins/codex-zcode-bridge/dist/bridge.mjs"', + '"/app\\worker\\worker-main.mjs"', + '"cwd=/app"', + '"/bin:/app/node_modules/.bin"', + '"/app"', + "// /app/src/leak.ts\n", + ]; + for (const text of leaks) { + assert.equal(pattern.test(text), true, `must flag real development path: ${text}`); + pattern.lastIndex = 0; + } +}); + +test("URLs whose host ends before an /app path segment are not flagged", () => { + const pattern = buildRepoRootLeakPattern("/app"); + assert.equal(pattern.test('"https://example.test/app/config"'), false); +}); + +test("path-structure delimiters decide the boundary, not name-character blacklists (2026-10-16 review round 2)", () => { + const pattern = buildRepoRootLeakPattern("/app"); + // Every non-delimiter character after the root continues a longer segment. + for (const benign of [ + '"/app@2/src/x"', + '"/app+backup/src/x"', + '"/app(copy)/src/x"', + '"file:///app%20Backup/src/main.ts"', + '"/app Backup/src/main.ts"', + '"/app.git/config"', + ]) { + assert.equal(pattern.test(benign), false, `must not flag sibling segment: ${benign}`); + pattern.lastIndex = 0; + } + // HTTP URL paths — first or deeper segments, plain or IPv6 hosts — are not + // local absolute paths. + assert.equal(pattern.test('"https://[::1]/app/config"'), false); + assert.equal(pattern.test('"https://example.test/docs/app/config"'), false); + // The corresponding genuine references stay flagged. + assert.equal(pattern.test('"file:///app/src/main.ts"'), true); + assert.equal(pattern.test('"/app/src/main.ts"'), true); +}); + +test("Windows roots match one separator in both backslash and forward-slash forms", () => { + const root = "C:\\Program Files\\Bridge Dev (x86)"; + const pattern = buildRepoRootLeakPattern(root); + assert.equal(pattern.test(`"${root}\\src\\main.ts"`), true, "backslash form must be flagged"); + assert.equal(pattern.test(`"${root.replaceAll("\\", "/")}/src/main.ts"`), true, "forward-slash form must be flagged"); + assert.equal(pattern.test(`"${root} Backup\\src\\main.ts"`), false, "sibling directory with a suffix must not be flagged"); + assert.equal(pattern.test(`"${root}2\\src\\main.ts"`), false, "sibling directory with a digit suffix must not be flagged"); + assert.equal(pattern.test('"C:\\Program Files\\BridgeDev\\src\\main.ts"'), false, "different directory must not be flagged"); +}); + +test("JS-string-escaped Windows paths and absolute comments are rejected (2026-10-16 review round 2)", () => { + const root = "C:\\Program Files\\Bridge Dev"; + const pattern = buildRepoRootLeakPattern(root); + // Bundles embed absolute paths inside JS strings, where each backslash is + // doubled; both spellings name the same development path. + assert.equal(pattern.test(JSON.stringify(`${root}\\src\\leak.ts`)), true, "escaped (doubled backslash) form must be flagged"); + assert.equal(pattern.test(`// ${root}\\src\\leak.ts\n`), true, "comment with a separating space before the path must be flagged"); + assert.equal( + pattern.test(JSON.stringify(`${root} Backup\\src\\main.ts`)), + false, + "escaped sibling directory with a space suffix must not be flagged", + ); +}); + +test("roots with unicode or regex metacharacters stay escaped and bounded", () => { + const root = "/home/开发者/bridge[v2](副本)"; + const pattern = buildRepoRootLeakPattern(root); + assert.equal(pattern.test(`"${root}/src/a.ts"`), true, "exact unicode root leak must be flagged"); + assert.equal(pattern.test(`"${root}备份/src/a.ts"`), false, "unicode suffix extends the segment; not this root"); + assert.equal(pattern.test('"/home/开发者/bridgev2/src/a.ts"'), false, "metacharacters must stay literal"); +}); + +test("the standard file URI spelling of the root is rejected, its encoded sibling suffix is not", () => { + const root = "C:\\Program Files\\Bridge Dev 修订"; + const pattern = buildRepoRootLeakPattern(root); + assert.equal(pattern.test(pathToFileURL(path.join(root, "src", "leak.ts")).href), true, "pathToFileURL form must be flagged"); + assert.equal( + pattern.test(pathToFileURL(path.join(`${root} Backup`, "src", "main.ts")).href), + false, + "percent-encoded sibling suffix (%20Backup) must not be flagged", + ); + const shortRoot = buildRepoRootLeakPattern("/app"); + assert.equal(shortRoot.test('"file:///app/src/main.ts"'), true, "short-root file URI reference must be flagged"); + assert.equal(shortRoot.test('"file:///app%20Backup/src/main.ts"'), false, "short-root encoded sibling suffix must not be flagged"); + // pathToFileURL on Windows prepends the current drive for POSIX-style roots; + // the sibling suffix must stay a non-match in that spelling too. + assert.equal(shortRoot.test(pathToFileURL("/app Backup/src/main.ts").href), false, "native-drive encoded sibling suffix must not be flagged"); + assert.equal(shortRoot.test(pathToFileURL("/app/src/main.ts", { windows: false }).href), true, "cross-platform file URI spelling must be flagged"); +}); + +test("a file URI embedded inside an HTTP URL is still rejected (raw branch is suppressed, the file URI branch is not)", () => { + const root = "E:/Programs/example repository"; + const pattern = buildRepoRootLeakPattern(root); + const uri = pathToFileURL(`${root}/src/leak.ts`).href; + assert.equal( + pattern.test(JSON.stringify(`https://example.test/redirect?target=${uri}`)), + true, + "http-wrapped file URI reference to the development path must be flagged", + ); + // Ordinary HTTP URLs stay clean even with deeper path segments. + assert.equal(pattern.test('"https://example.test/redirect?target=/docs/app/config"'), false); + const shortRoot = buildRepoRootLeakPattern("/app"); + assert.equal(shortRoot.test(JSON.stringify("https://example.test/redirect?target=file:///app/src")), true); +}); + +test("root matching keeps case-insensitivity for Windows-style roots", () => { + const pattern = buildRepoRootLeakPattern("/App"); + assert.equal(pattern.test('"/app/src"'), true); + assert.equal(pattern.test('"/APP/src"'), true); +}); + +test("a repeated probe does not skip later matches (no sticky/global state)", () => { + const pattern = buildRepoRootLeakPattern("/app"); + assert.equal(pattern.test('"https://json-schema.org/draft/2019-09/vocab/applicator"'), false); + assert.equal(pattern.test('"/app/src/leak.ts"'), true); + assert.equal(pattern.test('"https://json-schema.org/draft/2019-09/vocab/applicability"'), false); +}); + +/** + * Runs the real scripts/validate-plugin.mjs against a fixture repository + * (space + unicode + regex characters in the root name) with the current + * scripts/lib/repo-root-pattern.mjs, mirroring the Codex review's CLI probes: + * the baseline copy must validate cleanly, and injected development-path + * references (escaped string literal, comment, forward-slash, file URL) must + * be rejected specifically by the absolute-path leak check. + */ +test("the real validator rejects injected development-path references and keeps the baseline clean", { timeout: 120_000 }, () => { + const scriptsDir = fileURLToPath(new URL("../../scripts/", import.meta.url)); + const tempBase = mkdtempSync(path.join(tmpdir(), "bridge-validate-plugin-cli-")); + assert.ok(tempBase.startsWith(tmpdir()), "fixture must live under the system temp directory"); + const root = path.join(tempBase, `bridge 修订 (v2)[x]`); + try { + const pluginRoot = path.join(root, "plugins", "codex-zcode-bridge"); + for (const directory of [ + path.join(root, "scripts", "lib"), + path.join(pluginRoot, ".codex-plugin"), + path.join(pluginRoot, "dist"), + path.join(pluginRoot, "worker", "vendor", "pkg"), + path.join(pluginRoot, "hooks"), + path.join(pluginRoot, "skills", "zcode-bridge"), + ]) { + mkdirSync(directory, { recursive: true }); + } + copyFileSync(path.join(scriptsDir, "validate-plugin.mjs"), path.join(root, "scripts", "validate-plugin.mjs")); + copyFileSync(path.join(scriptsDir, "lib", "repo-root-pattern.mjs"), path.join(root, "scripts", "lib", "repo-root-pattern.mjs")); + + const manifest = { name: "codex-zcode-bridge", version: "1.2.5" }; + const mcp = { mcpServers: { zcode_bridge: { command: "node", args: ["./dist/bridge.mjs"], env: { ZCODE_BRIDGE_PLUGIN_MODE: "1" } } } }; + writeFileSync(path.join(root, "package.json"), JSON.stringify(manifest)); + writeFileSync(path.join(pluginRoot, "plugin.json"), JSON.stringify(manifest)); + writeFileSync(path.join(pluginRoot, ".codex-plugin", "plugin.json"), JSON.stringify(manifest)); + writeFileSync(path.join(pluginRoot, "mcp.json"), JSON.stringify(mcp)); + writeFileSync(path.join(pluginRoot, ".mcp.json"), JSON.stringify(mcp)); + writeFileSync(path.join(pluginRoot, "worker", "vendor", "package.json"), JSON.stringify({ name: "playwright-core" })); + writeFileSync(path.join(pluginRoot, "worker", "vendor", "browsers.json"), JSON.stringify({ browsers: [{ name: "chromium" }] })); + for (const file of ["hooks/hooks.json", "hooks/session-start.mjs", "hooks/configure-runtime.ps1", "skills/zcode-bridge/SKILL.md"]) { + writeFileSync(path.join(pluginRoot, file), ""); + } + + const leakPath = path.join(root, "src", "leak.ts"); + const forwardLeak = leakPath.split(path.sep).join("/"); + const baseline = [ + 'const schema = "https://json-schema.org/draft/2019-09/vocab/applicator";', + 'const sameSegment = "https://example.test/app/config";', + `const sibling = ${JSON.stringify(`${root} Backup${path.sep}src${path.sep}main.ts`)};`, + `const encodedSibling = ${JSON.stringify(pathToFileURL(path.join(`${root} Backup`, "src", "main.ts")).href)};`, + "// vendor loader keeps worker/vendor/pkg/browser-host.cjs self-contained", + ].join("\n"); + const bundlePath = path.join(pluginRoot, "dist", "bridge.mjs"); + writeFileSync(path.join(pluginRoot, "worker", "worker-main.mjs"), `${baseline}\n`, "utf8"); + writeFileSync(path.join(pluginRoot, "worker", "vendor", "pkg", "browser-host.cjs"), `${baseline}\n`, "utf8"); + + const variants: Array<[name: string, injection: string, expectLeakDiagnostic: boolean]> = [ + ["baseline", "", false], + ["escaped windows absolute", `const leak = ${JSON.stringify(leakPath)};\n`, true], + ["absolute comment", `// ${leakPath}\n`, true], + ["forward slash form", `// ${forwardLeak}\n`, true], + ["standard file uri form", `const uri = ${JSON.stringify(pathToFileURL(leakPath).href)};\n`, true], + ["http-wrapped file uri form", `const redirect = ${JSON.stringify(`https://example.test/redirect?target=${pathToFileURL(leakPath).href}`)};\n`, true], + ]; + for (const [name, injection, expectLeakDiagnostic] of variants) { + writeFileSync(bundlePath, `${baseline}\n${injection}`, "utf8"); + const run = spawnSync(process.execPath, [path.join(root, "scripts", "validate-plugin.mjs")], { + encoding: "utf8", + timeout: 60_000, + windowsHide: true, + }); + const output = `${run.stdout ?? ""}${run.stderr ?? ""}`; + const hasLeakDiagnostic = output.includes("embeds the development repository's absolute path"); + if (expectLeakDiagnostic) { + assert.notEqual(run.status, 0, `${name}: validator must reject the injected development path`); + assert.equal(hasLeakDiagnostic, true, `${name}: rejection must come from the absolute-path leak check`); + } else { + assert.equal(run.status, 0, `${name}: baseline must validate cleanly (stderr: ${output.trim().slice(0, 300)})`); + assert.equal(hasLeakDiagnostic, false, `${name}: benign content must not trip the leak check`); + } + } + } finally { + rmSync(tempBase, { recursive: true, force: true }); + } +}); diff --git a/test/worker-env-portability.test.ts b/test/worker-env-portability.test.ts new file mode 100644 index 0000000..aecdc55 --- /dev/null +++ b/test/worker-env-portability.test.ts @@ -0,0 +1,149 @@ +// F1/F2 regression (2026-10-16 cross-environment review): the production +// createWorkerSpawner launches a REAL detached worker process, so assertions +// here cross the actual spawn boundary instead of snapshotting +// createWorkerEnv. The probe worker only records non-sensitive env fields. +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createWorkerSpawner } from "../src/manager/spawn-worker.js"; + +interface ProbeReceipt { + browser_use: string | null; + browser_executable: string | null; + secret_probe: string | null; + unrelated_secret: string | null; + programfiles: string | null; + programfiles_x86: string | null; + zcode_bridge_keys: string[]; +} + +/** Case-insensitive lookup matching Windows env semantics for plain objects. */ +function envLookup(entries: Record, key: string): string | null { + for (const [name, value] of Object.entries(entries)) { + if (name.toUpperCase() === key && typeof value === "string" && value !== "") return value; + } + return null; +} + +function writeProbeWorker(scriptPath: string, receiptPath: string): void { + writeFileSync(scriptPath, [ + '"use strict";', + "const fs = require('node:fs');", + `const receipt = ${JSON.stringify(receiptPath)};`, + "function lookup(key) {", + " for (const [name, value] of Object.entries(process.env)) {", + " if (name.toUpperCase() === key && typeof value === 'string' && value !== '') return value;", + " }", + " return null;", + "}", + "const snapshot = {", + " browser_use: lookup('ZCODE_BRIDGE_BROWSER_USE'),", + " browser_executable: lookup('ZCODE_BRIDGE_BROWSER_EXECUTABLE'),", + " secret_probe: lookup('ZCODE_BRIDGE_SECRET_PROBE'),", + " unrelated_secret: lookup('BRIDGE_TEST_UNRELATED_SECRET'),", + " programfiles: lookup('PROGRAMFILES'),", + " programfiles_x86: lookup('PROGRAMFILES(X86)'),", + " zcode_bridge_keys: Object.keys(process.env).filter((name) => name.startsWith('ZCODE_BRIDGE_')).sort(),", + "};", + "fs.writeFileSync(receipt, JSON.stringify(snapshot, null, 2));", + ].join("\n"), "utf8"); +} + +async function waitForReceipt(receiptPath: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + try { + return JSON.parse(readFileSync(receiptPath, "utf8")) as ProbeReceipt; + } catch { + if (Date.now() > deadline) throw new Error(`probe worker receipt not written within ${timeoutMs} ms: ${receiptPath}`); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } +} + +function restoreEnv(before: NodeJS.ProcessEnv): void { + for (const key of Object.keys(process.env)) if (!(key in before)) delete process.env[key]; + for (const [key, value] of Object.entries(before)) process.env[key] = value; +} + +test("a real detached worker receives env-only browser settings while secrets stay filtered", { timeout: 60_000 }, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "bridge-worker-env-probe-")); + try { + const dataRoot = path.join(dir, "data-root"); + const receiptPath = path.join(dir, "receipt.json"); + const probeWorker = path.join(dir, "probe-worker.cjs"); + writeProbeWorker(probeWorker, receiptPath); + // An existing absolute file standing in for an explicitly configured browser. + const fakeExecutable = path.join(dir, "fake-msedge.exe"); + writeFileSync(fakeExecutable, "probe fixture", "utf8"); + + const envBefore = { ...process.env }; + try { + process.env["ZCODE_BRIDGE_BROWSER_USE"] = "headless"; + process.env["ZCODE_BRIDGE_BROWSER_EXECUTABLE"] = fakeExecutable; + process.env["ZCODE_BRIDGE_SECRET_PROBE"] = "must-not-leak"; + process.env["BRIDGE_TEST_UNRELATED_SECRET"] = "must-not-leak"; + if (process.platform === "win32") { + // Real Windows casing, values that differ per variable so a mixed-up + // forward cannot pass. Auto-discovery needs these directory roots. + process.env["ProgramFiles"] = path.join(dir, "Program Files"); + process.env["ProgramFiles(x86)"] = path.join(dir, "Program Files (x86)"); + } + const spawnWorker = createWorkerSpawner({ + name: "worker-env-probe-host", + settingsDirectory: path.join(dir, "settings"), + workerEntryPath: probeWorker, + }); + spawnWorker(dataRoot, "task_env_probe", 1); + } finally { + restoreEnv(envBefore); + } + + const receipt = await waitForReceipt(receiptPath, 30_000); + assert.equal(receipt.browser_use, "headless", "env-only ZCODE_BRIDGE_BROWSER_USE must reach the detached worker"); + assert.equal(receipt.browser_executable, fakeExecutable, "env-only ZCODE_BRIDGE_BROWSER_EXECUTABLE must reach the detached worker"); + assert.equal(receipt.secret_probe, null, "an arbitrary ZCODE_BRIDGE_ secret must not reach the worker"); + assert.equal(receipt.unrelated_secret, null, "an arbitrary non-Bridge secret must not reach the worker"); + if (process.platform === "win32") { + assert.equal(receipt.programfiles, path.join(dir, "Program Files"), "ProgramFiles must survive the worker env filter (browser auto-discovery)"); + assert.equal(receipt.programfiles_x86, path.join(dir, "Program Files (x86)"), "ProgramFiles(x86) must survive the worker env filter (browser auto-discovery)"); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("without browser settings the worker env stays default-off (keys absent, no broad ZCODE_BRIDGE_ forwarding)", { timeout: 60_000 }, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "bridge-worker-env-default-")); + try { + const dataRoot = path.join(dir, "data-root"); + const receiptPath = path.join(dir, "receipt.json"); + const probeWorker = path.join(dir, "probe-worker.cjs"); + writeProbeWorker(probeWorker, receiptPath); + + const envBefore = { ...process.env }; + try { + delete process.env["ZCODE_BRIDGE_BROWSER_USE"]; + delete process.env["ZCODE_BRIDGE_BROWSER_EXECUTABLE"]; + const spawnWorker = createWorkerSpawner({ + name: "worker-env-default-host", + settingsDirectory: path.join(dir, "settings"), + workerEntryPath: probeWorker, + }); + spawnWorker(dataRoot, "task_env_default", 1); + } finally { + restoreEnv(envBefore); + } + + const receipt = await waitForReceipt(receiptPath, 30_000); + assert.equal(receipt.browser_use, null, "unset browser mode must not arrive as any value (default off)"); + assert.equal(receipt.browser_executable, null, "unset browser executable must not arrive"); + // Only the spawner's own host-profile injection may appear: forwarding must + // stay an explicit key list, not a ZCODE_BRIDGE_* namespace copy. + assert.deepEqual(receipt.zcode_bridge_keys, ["ZCODE_BRIDGE_HOST_PROFILE"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});