From 3ca895af099896711e662e04995ca410e13b7a2c Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 26 Aug 2026 16:59:26 +0800 Subject: [PATCH] feat(sandbox): add Windows restricted runner --- .github/workflows/quality.yml | 4 +- internal/aiprompts/architecture.md | 33 +- internal/aiprompts/quality-workflow.md | 2 + .../codex-desktop-complete-alignment-plan.md | 17 +- lime-rs/crates/app-server-daemon/src/lib.rs | 5 + lime-rs/crates/tool-runtime/Cargo.toml | 4 + .../src/bin/windows-sandbox-runner.rs | 6 + .../tool-runtime/src/execution_process.rs | 17 + .../src/execution_process/windows.rs | 311 ++++------------ .../src/execution_process/windows_job.rs | 196 +--------- .../execution_process/windows_runner_child.rs | 338 +++++++++++++++++ .../execution_process/windows_runner_host.rs | 346 ++++++++++++++++++ .../windows_runner_protocol.rs | 160 ++++++++ .../windows_runner_supervisor.rs | 205 +++++++++++ .../tests/windows_restricted_execution.rs | 9 +- .../app-server-client/src/sidecar-types.ts | 1 + scripts/check-app-server-client-contract.mjs | 4 +- .../electron/prepare-app-server-assets.mjs | 2 +- scripts/electron/verify-package-resources.mjs | 24 ++ .../verify-package-resources.test.mjs | 18 +- scripts/lib/electron-app-server-assets.mjs | 81 ++++ scripts/lib/electron-dev-sidecar.mjs | 42 ++- scripts/lib/electron-dev-sidecar.test.mjs | 24 +- scripts/prepare-sherpa-onnx-runtime.test.mjs | 2 +- 24 files changed, 1395 insertions(+), 456 deletions(-) create mode 100644 lime-rs/crates/tool-runtime/src/bin/windows-sandbox-runner.rs create mode 100644 lime-rs/crates/tool-runtime/src/execution_process/windows_runner_child.rs create mode 100644 lime-rs/crates/tool-runtime/src/execution_process/windows_runner_host.rs create mode 100644 lime-rs/crates/tool-runtime/src/execution_process/windows_runner_protocol.rs create mode 100644 lime-rs/crates/tool-runtime/src/execution_process/windows_runner_supervisor.rs diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index d035dd119..58e199d8e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -375,8 +375,8 @@ jobs: - name: Prepare sherpa-onnx runtime run: node scripts/prepare-sherpa-onnx-runtime.mjs --target x86_64-pc-windows-msvc - - name: Build Windows app-server, code-mode-host, and sandbox setup sidecars - run: cargo build --manifest-path lime-rs/Cargo.toml --target x86_64-pc-windows-msvc -p app-server --bin app-server -p tool-runtime --bin code-mode-host --bin windows-sandbox-setup + - name: Build Windows app-server, code-mode-host, and sandbox sidecars + run: cargo build --manifest-path lime-rs/Cargo.toml --target x86_64-pc-windows-msvc -p app-server --bin app-server -p tool-runtime --bin code-mode-host --bin windows-sandbox-setup --bin windows-sandbox-runner - name: Test Windows shell runtime fallback run: cargo test --manifest-path lime-rs/Cargo.toml -p lime-agent --test windows_shell_runtime -- --test-threads=1 diff --git a/internal/aiprompts/architecture.md b/internal/aiprompts/architecture.md index 104b815f4..5c142b415 100644 --- a/internal/aiprompts/architecture.md +++ b/internal/aiprompts/architecture.md @@ -2594,7 +2594,7 @@ Settings/Claw evidence。Responsible developer confirmation: root, 2026-08-10。 ## 42. Windows Sandbox Readiness Owner Windows sandbox readiness 是 Desktop 控制面能力;Windows restricted-token runner 的当前基础也已归入 -`tool-runtime`,但 readiness 仍必须以真实平台 enforcement 证据为准。唯一数据流为: +`tool-runtime`,但 readiness 仍必须以真实平台 enforcement 证据为准。控制面唯一数据流为: ```text Settings execution policy @@ -2606,11 +2606,32 @@ Settings execution policy ``` 非 Windows 平台或未启用 workspace sandbox 返回 `notConfigured`;Windows 已启用但 backend 不是 -`Ready + enforced=true` 返回 `updateRequired`;只有真实 enforcement 才能返回 `ready`。`tool-runtime` -当前拥有 target-gated restricted-token runner:受限 token、sandbox account user SID、capability SID、workspace/explicit write-root +`Ready + enforced=true` 返回 `updateRequired`;只有真实 enforcement 才能返回 `ready`。Windows 执行面唯一数据流为: + +```text +App Server -> tool-runtime::execution_process host + -> ACL lease + sandbox account/network policy selection + -> scoped named pipes + DPAPI credential + -> CreateProcessWithLogonW(windows-sandbox-runner.exe as sandbox account) + -> runner current primary token -> restricted token + TokenDefaultDacl + -> CreateProcessAsUserW + STARTUPINFOEX + -> job + handle list + pipe child + -> job + pseudoconsole + TTY child + -> framed output/exit/error -> existing bounded ExecutionProcess projection + <- framed stdin/close/resize/terminate +``` + +runner executable、宿主 transport、restricted token 和 child supervisor 都属于 `tool-runtime::execution_process` 同一 +current owner;它不是 Electron backend、App Server method 或平级 process supervisor。Electron 只将 runner 作为 App Server +相邻的 Windows runtime resource 打包并校验 manifest digest。宿主创建的 named pipe DACL 只允许被选中的 sandbox account, +连接后校验 client PID;framing、单帧大小、启动握手和 cleanup 必须有界。宿主直接用 sandbox-account token 调用 +`CreateProcessAsUserW` 以及 `CreateProcessWithTokenW` fallback 均为 `dead / forbidden-to-restore`:前者在普通宿主缺少 +`SeAssignPrimaryTokenPrivilege`,后者无法可靠承接 pipe child 且不支持当前 ConPTY attribute contract。 + +`tool-runtime` 当前拥有 target-gated restricted-token runner:受限 token、sandbox account user SID、capability SID、workspace/explicit write-root 双重访问检查、workspace/explicit read/write-root ACL lease、`.git/.codex/.agents` 写入拒绝、Job Object 进程树、显式继承句柄列表、stdout/stderr reader 和既有有界 retained output 均在同一 owner;ACL audit、ConPTY 与 Job/supervisor 分别由 -`windows_audit`、`windows_conpty`、`windows_job` 子模块承接,主 runner 只保留 token/spawn 编排。TTY 请求由同一 restricted-token owner 创建 ConPTY,使用 +`windows_audit`、`windows_runner*`、`windows_conpty`、`windows_job` 子模块承接,主入口只保留 policy/ACL/account 编排。TTY 请求由同一 restricted-token owner 创建 ConPTY,使用 `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE + PROC_THREAD_ATTRIBUTE_JOB_LIST` 原子附着,合并输出并承接 stdin/resize/terminate, 不回退普通 `portable-pty` 非沙箱进程。Windows 环境块继承父环境并按大小写不敏感应用请求覆盖;正常根进程 退出时遵循 Codex 语义关闭 `KILL_ON_JOB_CLOSE`,由 reaper 持有 Job 与 ACL lease 到 Job 为空,取消、超时、控制断开或 @@ -2633,6 +2654,10 @@ group 与 capability SID 的全部访问,与 Codex restricted access check 和 因平台 evidence 尚未具备,当前 `SandboxBackendStatus::Planned`、`enforced=false` 保持不变,不能由 runner 源码、 Settings 或 setup 文案推断 ready。 +Architecture impact: major; Windows restricted execution now uses a sandbox-account runner sidecar inside the existing +`tool-runtime` owner, and Windows packages must carry that runtime resource beside App Server. Responsible developer +confirmation: coso, 2026-08-26. + setup artifact 的结构事实源同样归 `tool-runtime::windows_setup`:Lime AgentRoot 下的 `.sandbox/setup_marker.json` 与 `.sandbox-secrets/sandbox_users.json` 使用单一版本号,固定 offline/online account identity,并只保存 DPAPI 密文的 base64 表达。App Server 只能消费该 owner 的结构校验结果;文件缺失、未知字段、版本/账户漂移、空或异常大的密文均 diff --git a/internal/aiprompts/quality-workflow.md b/internal/aiprompts/quality-workflow.md index 42e4a0e12..b7f841dc3 100644 --- a/internal/aiprompts/quality-workflow.md +++ b/internal/aiprompts/quality-workflow.md @@ -92,6 +92,8 @@ npm run test:rust:integration -- -p --test Agent/runtime 核心行为需要跨 owner 时,不把所有 case 堆进实现文件的 inline unit tests;使用专用测试模块或 integration target。`cargo nextest` 只在工具链、archive 和 CI shard 稳定后作为执行加速器,不改变本地 related-first 规则。 +Windows restricted execution 改动必须把 `windows-sandbox-runner.exe` 与 `app-server.exe`、`code-mode-host.exe`、`windows-sandbox-setup.exe` 成组构建。Electron Windows resources 和 `app-server.release.json` 必须包含 runner 及其 `windowsSandboxRunnerSha256`,`verify-package-resources` 对缺失或 digest 漂移 fail closed。源码/静态门禁不替代 `windows-restricted-execution-evidence-v3` 七项真实 Windows/MSVC 矩阵;矩阵通过前 readiness 保持 `Planned/enforced=false`。 + ## Gate A 与 Gate B Gate A 使用可控数据验证 Renderer projection、DOM、交互、错误文案和五语言资源。它可以使用显式 fixture,但证据必须标明 `test-only`。 diff --git a/internal/exec-plans/codex-desktop-complete-alignment-plan.md b/internal/exec-plans/codex-desktop-complete-alignment-plan.md index 2edbc2edf..b826d5017 100644 --- a/internal/exec-plans/codex-desktop-complete-alignment-plan.md +++ b/internal/exec-plans/codex-desktop-complete-alignment-plan.md @@ -5,7 +5,7 @@ > upstream: `/Users/coso/Documents/dev/rust/codex`(当前观测 HEAD:`99660ab3c7b861c916e467581fa9b8723504d66b`) > checklist progress: `44/46 (95.7%)`;这是未加权任务数,不等于交付完成度,剩余项包含 Windows 平台安全、live/verifier 与正式分发硬门槛。 -> 当前主目标(2026-08-26):关闭 C-3 Windows restricted execution 的真机证据。Codex current 的 per-user Windows Firewall 非 loopback/loopback 出站阻断、offline/online sandbox identity、`LimeSandboxUsers` 普通 token ACL、短生命周期 capability SID restricted ACL、clean-runner setup、read-back、原始 DACL rollback 与七项 evidence collector 已进入唯一 current owner;下一刀是在真实 Windows/MSVC runner 编译并执行矩阵,在证据通过前保持 `Planned/enforced=false`。 +> 当前主目标(2026-08-26):关闭 C-3 Windows restricted execution 的真机证据。Codex current 的 per-user Windows Firewall 非 loopback/loopback 出站阻断、offline/online sandbox identity、`LimeSandboxUsers` 普通 token ACL、短生命周期 capability SID restricted ACL、clean-runner setup、read-back、原始 DACL rollback 与七项 evidence collector 已进入唯一 current owner;Windows 真机已证明宿主账户直接 `CreateProcessAsUserW` 缺少 `SeAssignPrimaryTokenPrivilege`,`CreateProcessWithTokenW` fallback 无法可靠启动 pipe 子进程且不能承接 ConPTY,因此下一刀是在 `tool-runtime` 内按 Codex current 建立 sandbox-account command runner,再重跑真实 Windows/MSVC 矩阵。在证据通过前保持 `Planned/enforced=false`。 > 参考原则:`/Users/coso/Documents/dev/rust/codex` 是协议、Thread/Turn/Item、runtime lifecycle、tool/approval 和 GUI 语义的直接实现参考源;Lime 只在产品范围、provider 和桌面宿主边界做显式适配,不以旧矩阵或同名 legacy API 代替对齐。 > 目标:把 Lime 的产品主链、协议字段、GUI 工作区、执行安全和真实 Desktop 证据推进到当前 Codex Desktop 语义;不恢复已退役 runtime、v0 或 `agentSession/*` 兼容主链。 @@ -198,7 +198,17 @@ Renderer typed gateway - [x] C-3 当前工作树 macOS signed packaged candidate:Developer ID identity、主/Helper bundle identity、`codesign --verify --deep --strict`、资源 verifier 与包内 executable Shell-01 21/21 已通过;证据目录 `.tmp/codex-alignment-packaged-current-20260826/` 与 `.lime/qc/project-gates/standalone-shell-01-20260825171233-4797/shell-01-electron-smoke/summary.json`。该证据只证明当前候选包,不能替代 notarization/stapling 或正式发布。 - [ ] C-3 Windows restricted execution 真机 evidence:Codex `windows-sandbox-rs` 对齐的 restricted-token、sandbox account user SID、capability SID、TokenDefaultDacl、ACL lease/rollback、STARTUPINFOEX handle allowlist、Job Object descendant cleanup、bounded pipe reader 与 restricted ConPTY 已存在于 current target-gated owner;account user SID 只进入 restricting SID access check,不进入默认 DACL 充当 capability。ConPTY 使用 pseudoconsole + Job Object 原子属性,承接 stdin/resize/combined-output,不回退非沙箱 PTY。elevated setup helper、offline/online local account、machine-scope DPAPI artifacts、按 offline SID 生效的 Windows Firewall 非 loopback/loopback 全出站阻断与 12 条 WFP 补充过滤器已经进入 current owner;runner 按 network permission 选择 offline/online identity,readiness 对 account login、Firewall 属性和 WFP conditions 全量 read-back。Quality 通过 `scripts/lib/windows-restricted-execution-evidence.mjs --provision` 在 clean runner 的隔离 AgentRoot 先执行 setup,再执行 schema v3 七项逐 case 矩阵,并用 `actions/upload-artifact@v4` 保留 setup/test summary/stdout/stderr。证据入口固定 setup/Cargo 超时,ACL audit 对每目录超过 1,000 项、总计超过 50,000 项、超过 2 秒和 reparse-point 元数据读取失败均 fail-visible,避免不完整扫描被当作 clean。真实 Windows runner 尚未执行,因此仍缺 workspace write、外部/metadata path denial、ACL rollback、offline identity/firewall enforcement、ConPTY、timeout/descendant kill、large output 和 Everyone-write audit 的真实矩阵。production plan 继续 `SandboxBackendStatus::Planned/enforced=false`;`windowsSandbox/setupStart` 仅在真实 plan 为 `Ready + enforced` 时报告成功。 -本轮 C-3 窄写集(进行中):`lime-rs/crates/tool-runtime/{Cargo.toml,src/windows_setup.rs,src/windows_firewall.rs,src/execution_process/windows.rs,tests/windows_restricted_execution.rs}`、`lime-rs/{Cargo.toml,Cargo.lock}`、`scripts/lib/windows-restricted-execution-evidence*`、`.github/workflows/quality.yml`、本计划与 `internal/aiprompts/architecture.md`。退出条件:setup 原子提交前安装并 read-back 两条 per-user Firewall block rule 与既有 WFP filters;runtime 只在 setup artifacts/firewall/WFP 都有效时选择 offline identity,network-enabled 选择 online identity;clean Windows evidence 显式 provision 隔离 AgentRoot 后证明进程实际以预期账户运行且 offline loopback 连接被阻断;本机只能完成静态/非 Windows 回归时继续记录 Windows/MSVC blocker,不提升 readiness。 + 当前失败证据与替换方向:真实 Windows run `32947104967`(SHA `22b4262aa`)只有 2/7 通过。宿主 `CreateProcessAsUserW` 固定返回 `ERROR_PRIVILEGE_NOT_HELD`;`CreateProcessWithTokenW` 对 pipe 返回成功但子进程不进入有效执行,对 ConPTY/attribute list 返回 `ERROR_INVALID_PARAMETER`。该 fallback 归类为 `dead / forbidden-to-restore`。current 目标结构是宿主使用 DPAPI 凭证和 `CreateProcessWithLogonW` 启动 sandbox-account runner,runner 从自己的 primary token 派生 restricted token,再以 `CreateProcessAsUserW + STARTUPINFOEX` 原子附着 Job/handle list/ConPTY;有界 named-pipe framed IPC 承接 spawn、stdin/close/resize/terminate、output、exit/error。runner 仍属于 `tool-runtime::execution_process` 唯一 owner,Electron 只把其可执行文件与 App Server 资源一起打包。七项 matrix 全绿、packaged resource verifier 覆盖 runner且临时诊断清理后,才允许提升 readiness。 + +本轮 C-3 窄写集(进行中):`lime-rs/crates/tool-runtime/{Cargo.toml,src/bin/windows-sandbox-runner.rs,src/execution_process/windows*.rs,tests/windows_restricted_execution.rs}`、`lime-rs/crates/app-server-daemon/src/lib.rs`、`packages/app-server-client/src/sidecar-types.ts`、`scripts/check-app-server-client-contract.mjs`、`scripts/prepare-sherpa-onnx-runtime.test.mjs`、`scripts/lib/{electron-app-server-assets*,electron-dev-sidecar*,windows-restricted-execution-evidence*}`、`scripts/electron/{prepare-app-server-assets.mjs,verify-package-resources*}`、`.github/workflows/quality.yml`、本计划与 `internal/aiprompts/{architecture,quality-workflow}.md`。退出条件:宿主不再直接为 sandbox account token 启动用户命令;runner named-pipe ACL 只允许目标 sandbox identity,连接后校验 client PID,单帧和启动等待有界;runner 内 restricted token、Job、handle list/ConPTY、stdin/resize/terminate、output 和 ACL lease 终态语义全部通过 clean Windows 七项矩阵;Windows packaged manifest/resource verifier 必须包含 runner;本机只能完成静态/非 Windows 回归时继续记录 Windows/MSVC blocker,不提升 readiness。 + +架构影响:重大;Windows restricted execution 从宿主直接 token spawn 改为同一 `tool-runtime` owner 下的 sandbox-account runner sidecar,且 Windows Electron package 增加必需 runtime resource。 + +架构图已更新:`internal/aiprompts/architecture.md#42-windows-sandbox-readiness-owner`。 + +责任开发者确认:coso,2026-08-26。 + +确认内容:已核对目录归属、数据流、依赖方向、协议边界和验证门禁。 ### D:模型/MCP/Environment/Multi-Agent evidence @@ -327,6 +337,8 @@ Renderer typed gateway ### 本轮进度(2026-08-26) +- 2026-08-26:真实 Windows run `32947104967`(SHA `22b4262aa`)完成 2/7、失败 5/7。pipe 路径诊断为 `CreateProcessWithTokenW=success`、`ResumeThread=success`、进程持续 `STILL_ACTIVE` 但不执行 workspace marker/stdin/output;ConPTY 路径固定 `ERROR_INVALID_PARAMETER`。结合宿主 `CreateProcessAsUserW=ERROR_PRIVILEGE_NOT_HELD`,已排除 plain/extended `STARTUPINFO`、desktop、error mode、profile load、stdio allowlist 与命令行重建等单点原因,停止继续修补 fallback。下一实现按 Codex current 改为 `CreateProcessWithLogonW` 启动 sandbox-account runner,再由 runner 的当前 token 派生 restricted token并通过 framed named pipe 交接 I/O/control;本条仅记录根因与方向,C-3/readiness 仍未完成。 + - 关闭 A-Queue 视觉重复:`ThreadQueueStatus` 删除 submission preview/`thread-queue-items`,只保留标题、数量、loading/error;五语言删除无消费者的 preview/more key。补充组件负向回归,Gate B 改为断言 canonical 时间线 marker 唯一、Queue 状态条不含 marker、旧列表节点不存在。 - 验证证据:Queue 定向组件/Gate B/Sherpa 25/25,真实 `smoke:thread-queue-electron-gate-b` 通过(47 次 electron-ipc、`timelineMarkerCount=1`、`queueStatusContainsMarker=false`、`queueItemsPresent=false`、mock/console/page/invoke error 0);`npm run i18n:check` 100%;`smoke:agent-runtime-current-fixture` 完整通过;`npm run verify:gui-smoke` 21/21;`npm run test:contracts` 299 checks;renderer/node typecheck、scripts governance、legacy governance(2063 文件,0 违规/漂移)、Rust fmt/diff check 通过。 - 修复 CI 可观测性:根 `package.json` 与 lock importer 增加直接 `ws@8.18.3`;`.github/workflows/quality.yml` 的 `windows_shell_runtime` 从 20 分钟调为 60 分钟;Sherpa 旧目录清理、tar 解压前后加入阶段日志;新增 workflow/source 阶段顺序回归。`npm run test:rust:unit -- -p app-server processor::windows_sandbox::tests` 实际运行 4/4,通过 backend readiness/enforced fail-closed 边界。 @@ -374,6 +386,7 @@ Renderer typed gateway - 2026-08-26:继续对照 Codex current `windows-sandbox-rs::token` 完成 C-3 restricting identity 收口。Lime 从 `LogonUserW` base token 读取实际 `TokenUser` SID,并按 capability、account user、logon、Everyone 顺序加入 `CreateRestrictedToken` restricting SID;account user SID 明确不进入 `TokenDefaultDacl`,避免 identity 被当成 capability。同步收敛 active 计划中的重复/过时 Firewall/WFP 状态。验证:`npm run test:rust:related -- lime-rs/crates/tool-runtime/src/execution_process/windows.rs lime-rs/crates/tool-runtime/src/windows_firewall.rs lime-rs/crates/tool-runtime/src/windows_wfp.rs lime-rs/crates/tool-runtime/src/windows_setup.rs lime-rs/crates/tool-runtime/tests/windows_restricted_execution.rs` 退出码 `0`,`tool-runtime` `373/373`;evidence Vitest `7/7`;`npm run test:contracts` `299 checks`;`docs:boundary`、`governance:scripts`、`governance:legacy-report`(2063 files、0/0/0)、rustfmt 与 `git diff --check` 均通过。macOS collector 以退出码 `1` 刷新 schema v3 evidence,明确记录 `platform=darwin`、`result=evidence-pending`、`failedStage=windows-runner` 和七项全部 missing。本机未安装 Windows/MSVC target,Windows-only `TokenUser`/COM/WFP/ConPTY API 编译和七项真机矩阵仍无证据,计划保持 `active`、`44/46 (95.7%)` 与 `Planned/enforced=false`。 - 2026-08-26:继续对照 Codex current `windows-sandbox-rs::setup_main::sandbox_users` 与 write-root ACL 语义,关闭 restricted token 双重访问检查缺口。`tool-runtime::windows_setup` 现在幂等创建 `LimeSandboxUsers` 本地组,将 offline/online 账户加入组;默认 Windows readiness 与每次 runner 启动均验证组 SID 和目标账户成员关系,组缺失、成员漂移或 NetAPI 错误 fail closed。`windows_acl` 将 cwd/显式 permission 拆成 read/write/write-deny/access-deny plan:普通 token 与 restricted token 两侧分别由 sandbox group 和短生命周期 capability SID 获得 read/write ACE,metadata/read-only carveout 只拒绝 capability 写,显式 `Deny` 同时拒绝 group/capability 全部访问。lease 不再通过 `/remove:g` 删除稳定主体 ACE,而是在每个路径第一次修改前保存原始 DACL security descriptor,终态逆序恢复。Windows integration matrix 在 workspace process 存活时断言新增 SID 同时包含 sandbox group 与另一条 capability SID,并继续覆盖 online/offline identity、workspace write、metadata/external deny 和全部目标原始 SDDL rollback。本机 `npm run test:rust:related -- ...` 退出码 `0`,反向依赖链与 `tool-runtime 373/373` 通过;Windows-only NetAPI/GetFileSecurityW/SetFileSecurityW 与增强矩阵仍必须由真实 MSVC runner 编译执行,因此 checklist 保持 `44/46 (95.7%)`、状态 `active`、readiness `Planned/enforced=false`。 - 2026-08-26:收口 Windows setup 文件规模与职责边界。将本地账户创建/更新、本地组与成员关系、组 SID/readiness 校验及 account SID 解析从 1,086 行 `windows_setup.rs` 原样迁入唯一 current 子 owner `windows_setup/accounts.rs`;主编排文件降至 817 行,子模块 281 行,没有新增 backend、compat API 或行为分支。`npm run test:rust:related -- ...` 退出码 `0`,反向依赖链与 `tool-runtime 373/373` 通过;`npm run test:contracts` 完成 App Server client `299 checks` 及 command/harness/modality/scripts/release/docs guards;`npm run governance:legacy-report` 扫描 2063 文件且零引用候选、分类漂移、边界违规均为 `0`;evidence parser `7/7`,rustfmt 与 `git diff --check` 通过。macOS collector 继续以退出码 `1` 生成 schema v3 `evidence-pending`,七项全部 missing。该拆分只消除仓库 1,000 行硬约束,不改变安全完成度:真实 Windows/MSVC restricted matrix、live provider/Pier/DeepSWE verifier artifacts 与覆盖当前工作树的正式 notarized/package 仍未完成,计划保持 `active`、`44/46 (95.7%)` 与 `SandboxBackendStatus::Planned/enforced=false`。 +- 2026-08-26:继续 C-3,删除已由真机证据判定无效的 `CreateProcessWithTokenW` fallback、suspended/resume 与后置 Job 分配,改为 `CreateProcessWithLogonW -> sandbox-account runner -> current token 派生 restricted token -> CreateProcessAsUserW + STARTUPINFOEX` 唯一 current 链;有界双 named-pipe framing 承接 spawn/stdin/close/resize/terminate/output/exit/error,pipe ACL 只允许目标 account SID且连接后校验 runner PID,pipe 与 ConPTY 均通过 job-list attribute 原子入 Job,宿主 reaper 持有控制 pipe 与 ACL lease 到 runner 退出。`windows-sandbox-runner.exe` 已进入 dev/Quality 成组构建、Electron Windows resource、release manifest `windowsSandboxRunnerSha256` 和 packaged verifier;Everyone-write case 的 `icacls` 输出改为进程内捕获,避免污染 evidence parser 的稳定 test 行。本地 `cargo fmt --check`、Prettier、`git diff --check`、packaging/sidecar/evidence 定向 Vitest `66/66`、Rust related 反向依赖链、`tool-runtime 373/373`、`app-server-daemon 38/38`、contracts `299 checks`、scripts/release/docs/legacy governance 全部通过,legacy 扫描仍为 2063 files、0/0/0。当前主机未安装 Windows target,因此这些结果不替代 MSVC 编译和 schema v3 七项真机矩阵;计划继续保持 `active`、`44/46 (95.7%)` 与 `Planned/enforced=false`,下一刀是推送该窄提交并监控 Windows Quality evidence。 ## 7. 阻塞与避让 diff --git a/lime-rs/crates/app-server-daemon/src/lib.rs b/lime-rs/crates/app-server-daemon/src/lib.rs index 9ae698889..afc7bf993 100644 --- a/lime-rs/crates/app-server-daemon/src/lib.rs +++ b/lime-rs/crates/app-server-daemon/src/lib.rs @@ -427,6 +427,8 @@ pub struct AppServerReleaseArtifact { pub sha256: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub windows_sandbox_setup_sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub windows_sandbox_runner_sha256: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1016,6 +1018,7 @@ mod tests { url: "https://example/app-server-darwin-arm64.tar.gz".to_string(), sha256: "abc".to_string(), windows_sandbox_setup_sha256: None, + windows_sandbox_runner_sha256: None, }], }; let options = SidecarBinaryPathOptions { @@ -1081,6 +1084,7 @@ mod tests { url: "https://example/app-server-darwin-arm64.tar.gz".to_string(), sha256: "abc".to_string(), windows_sandbox_setup_sha256: None, + windows_sandbox_runner_sha256: None, }], }; let options = SidecarBinaryPathOptions { @@ -1126,6 +1130,7 @@ mod tests { url: "https://example/app-server-darwin-arm64.tar.gz".to_string(), sha256: "abc".to_string(), windows_sandbox_setup_sha256: None, + windows_sandbox_runner_sha256: None, }], }; let settings = DaemonSettings { diff --git a/lime-rs/crates/tool-runtime/Cargo.toml b/lime-rs/crates/tool-runtime/Cargo.toml index ad5f5c873..96e9a11c2 100644 --- a/lime-rs/crates/tool-runtime/Cargo.toml +++ b/lime-rs/crates/tool-runtime/Cargo.toml @@ -18,6 +18,10 @@ path = "src/bin/code-mode-host.rs" name = "windows-sandbox-setup" path = "src/bin/windows-sandbox-setup.rs" +[[bin]] +name = "windows-sandbox-runner" +path = "src/bin/windows-sandbox-runner.rs" + [dependencies] agent-protocol.workspace = true app-server-protocol.workspace = true diff --git a/lime-rs/crates/tool-runtime/src/bin/windows-sandbox-runner.rs b/lime-rs/crates/tool-runtime/src/bin/windows-sandbox-runner.rs new file mode 100644 index 000000000..c1b342a2c --- /dev/null +++ b/lime-rs/crates/tool-runtime/src/bin/windows-sandbox-runner.rs @@ -0,0 +1,6 @@ +fn main() { + if let Err(error) = tool_runtime::execution_process::run_windows_sandbox_runner() { + eprintln!("Windows sandbox runner failed: {error}"); + std::process::exit(1); + } +} diff --git a/lime-rs/crates/tool-runtime/src/execution_process.rs b/lime-rs/crates/tool-runtime/src/execution_process.rs index 5394c0161..5dc820556 100644 --- a/lime-rs/crates/tool-runtime/src/execution_process.rs +++ b/lime-rs/crates/tool-runtime/src/execution_process.rs @@ -18,6 +18,23 @@ mod pty; #[cfg(target_os = "windows")] mod windows; +/// Runs the Windows sandbox-account command runner sidecar. +/// +/// The executable is packaged beside App Server, while all protocol and child +/// execution semantics remain owned by this module. +#[cfg(target_os = "windows")] +pub fn run_windows_sandbox_runner() -> io::Result<()> { + windows::run_windows_sandbox_runner() +} + +#[cfg(not(target_os = "windows"))] +pub fn run_windows_sandbox_runner() -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows sandbox runner is only available on Windows", + )) +} + /// Bounded Windows ACL audit result used by the App Server setup warning. /// /// The audit is intentionally a diagnostic only. It never changes ACLs and it diff --git a/lime-rs/crates/tool-runtime/src/execution_process/windows.rs b/lime-rs/crates/tool-runtime/src/execution_process/windows.rs index 912c7e7f1..addd98b2b 100644 --- a/lime-rs/crates/tool-runtime/src/execution_process/windows.rs +++ b/lime-rs/crates/tool-runtime/src/execution_process/windows.rs @@ -8,9 +8,8 @@ use std::ptr; use std::thread; use std::time::Duration; use windows_sys::Win32::Foundation::{ - CloseHandle, GetLastError, LocalFree, SetHandleInformation, ERROR_PRIVILEGE_NOT_HELD, - ERROR_SUCCESS, HANDLE, HANDLE_FLAG_INHERIT, HLOCAL, INVALID_HANDLE_VALUE, LUID, WAIT_OBJECT_0, - WAIT_TIMEOUT, + CloseHandle, GetLastError, LocalFree, SetHandleInformation, ERROR_SUCCESS, HANDLE, + HANDLE_FLAG_INHERIT, HLOCAL, INVALID_HANDLE_VALUE, LUID, }; use windows_sys::Win32::Security::Authorization::{ ConvertStringSidToSidW, SetEntriesInAclW, EXPLICIT_ACCESS_W, GRANT_ACCESS, TRUSTEE_IS_SID, @@ -18,27 +17,23 @@ use windows_sys::Win32::Security::Authorization::{ }; use windows_sys::Win32::Security::{ AdjustTokenPrivileges, CopySid, CreateRestrictedToken, CreateWellKnownSid, GetLengthSid, - GetTokenInformation, LogonUserW, LookupPrivilegeValueW, SetTokenInformation, TokenDefaultDacl, - TokenGroups, TokenUser, ACL, LOGON32_LOGON_INTERACTIVE, LOGON32_PROVIDER_DEFAULT, - SECURITY_ATTRIBUTES, SID_AND_ATTRIBUTES, TOKEN_ADJUST_DEFAULT, TOKEN_ADJUST_PRIVILEGES, - TOKEN_ADJUST_SESSIONID, TOKEN_ASSIGN_PRIMARY, TOKEN_DUPLICATE, TOKEN_PRIVILEGES, TOKEN_QUERY, - TOKEN_USER, + GetTokenInformation, LookupPrivilegeValueW, SetTokenInformation, TokenDefaultDacl, TokenGroups, + TokenUser, ACL, SECURITY_ATTRIBUTES, SID_AND_ATTRIBUTES, TOKEN_ADJUST_DEFAULT, + TOKEN_ADJUST_PRIVILEGES, TOKEN_ADJUST_SESSIONID, TOKEN_ASSIGN_PRIMARY, TOKEN_DUPLICATE, + TOKEN_PRIVILEGES, TOKEN_QUERY, TOKEN_USER, }; -use windows_sys::Win32::Storage::FileSystem::{ReadFile, WriteFile}; -use windows_sys::Win32::System::Diagnostics::Debug::SetErrorMode; +use windows_sys::Win32::Storage::FileSystem::WriteFile; use windows_sys::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JobObjectBasicAccountingInformation, - JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, - TerminateJobObject, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, - JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_BREAKAWAY_OK, - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + CreateJobObjectW, JobObjectBasicAccountingInformation, JobObjectExtendedLimitInformation, + QueryInformationJobObject, SetInformationJobObject, TerminateJobObject, + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_BREAKAWAY_OK, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, }; use windows_sys::Win32::System::Pipes::CreatePipe; use windows_sys::Win32::System::Threading::{ - CreateProcessAsUserW, CreateProcessWithTokenW, GetCurrentProcess, GetExitCodeProcess, - OpenProcessToken, ResumeThread, WaitForSingleObject, CREATE_NO_WINDOW, CREATE_SUSPENDED, - CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, LOGON_WITH_PROFILE, - PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW, + CreateProcessAsUserW, GetCurrentProcess, OpenProcessToken, CREATE_NO_WINDOW, + CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, PROCESS_INFORMATION, + STARTF_USESTDHANDLES, STARTUPINFOEXW, }; #[path = "windows_acl.rs"] @@ -51,18 +46,25 @@ mod windows_audit; mod windows_conpty; #[path = "windows_job.rs"] mod windows_job; +#[path = "windows_runner_child.rs"] +mod windows_runner_child; +#[path = "windows_runner_host.rs"] +mod windows_runner_host; +#[path = "windows_runner_protocol.rs"] +mod windows_runner_protocol; +#[path = "windows_runner_supervisor.rs"] +mod windows_runner_supervisor; use crate::windows_setup::{ - read_windows_sandbox_password, verify_windows_sandbox_group_membership, - windows_sandbox_users_group_sid, WINDOWS_SANDBOX_OFFLINE_USERNAME, - WINDOWS_SANDBOX_ONLINE_USERNAME, + verify_windows_sandbox_group_membership, windows_sandbox_users_group_sid, + WINDOWS_SANDBOX_OFFLINE_USERNAME, WINDOWS_SANDBOX_ONLINE_USERNAME, }; use windows_acl::{build_acl_plan, AclLease}; use windows_attr::ProcessAttributeList; pub(crate) use windows_audit::audit_world_writable; use windows_conpty::RestrictedConpty; +use windows_job::create_kill_on_close_job; #[cfg(test)] use windows_job::preserve_job_descendants; -use windows_job::{create_kill_on_close_job, read_pipe_stream, supervise_restricted_process}; const DISABLE_MAX_PRIVILEGE: u32 = 0x01; const LUA_TOKEN: u32 = 0x04; @@ -71,8 +73,10 @@ const GENERIC_ALL: u32 = 0x1000_0000; const WIN_WORLD_SID: i32 = 1; const SE_GROUP_LOGON_ID: u32 = 0xC000_0000; const CONTROL_POLL_MILLIS: u32 = 25; -const JOB_REAPER_POLL_MILLIS: u64 = 250; -const CHILD_ERROR_MODE_FLAGS: u32 = 0x0001 | 0x0002; + +pub(super) fn run_windows_sandbox_runner() -> io::Result<()> { + windows_runner_child::run() +} pub(super) fn start_windows_restricted_execution_process( mut request: LocalExecutionRequest, sandbox: LocalExecutionSandbox, @@ -111,10 +115,12 @@ pub(super) fn start_windows_restricted_execution_process( let sandbox_group_sid = windows_sandbox_users_group_sid()?; let capability_sid = capability_sid(); let acl_lease = AclLease::acquire(&sandbox_group_sid, &capability_sid, acl_plan)?; - let token = create_restricted_token(&capability_sid, sandbox_account)?; - enable_process_creation_privileges(); - let spawned = spawn_restricted_process(&request, &cwd, token.raw())?; - drop(token); + let transport = windows_runner_host::spawn_runner_transport( + &request, + &cwd, + sandbox_account, + capability_sid, + )?; let start = ExecutionProcessStart { process_id: request.process_id.clone(), @@ -128,66 +134,11 @@ pub(super) fn start_windows_restricted_execution_process( let process = Arc::new(Mutex::new(process_state)); let (output_tx, output_rx) = mpsc::unbounded_channel(); let (control_tx, control_rx) = std::sync::mpsc::channel(); - let (reader_done_tx, reader_done_rx) = std::sync::mpsc::channel(); let (state_tx, state_rx) = watch::channel(initial_snapshot); let (final_tx, final_rx) = oneshot::channel(); - - let SpawnedRestrictedProcess { - process: spawned_process, - thread: spawned_thread, - job, - stdin_write, - stdout_read, - stderr_read, - pseudoconsole, - } = spawned; - let output_kind = if pseudoconsole.is_some() { - ExecutionOutputKind::Combined - } else { - ExecutionOutputKind::Stdout - }; - let stdout_process = Arc::clone(&process); - let stdout_state = state_tx.clone(); - let stdout_output = output_tx.clone(); - let stdout_reader_done = reader_done_tx.clone(); - let _stdout_reader = thread::spawn(move || { - read_pipe_stream( - stdout_read, - output_kind, - stdout_process, - stdout_output, - stdout_state, - ); - let _ = stdout_reader_done.send(()); - }); - let _stderr_reader = stderr_read.map(|stderr_read| { - let stderr_process = Arc::clone(&process); - let stderr_state = state_tx.clone(); - let stderr_reader_done = reader_done_tx.clone(); - thread::spawn(move || { - read_pipe_stream( - stderr_read, - ExecutionOutputKind::Stderr, - stderr_process, - output_tx, - stderr_state, - ); - let _ = stderr_reader_done.send(()); - }) - }); thread::spawn(move || { - supervise_restricted_process( - spawned_process, - spawned_thread, - job, - stdin_write, - pseudoconsole, - acl_lease, - process, - state_tx, - final_tx, - control_rx, - reader_done_rx, + windows_runner_supervisor::supervise( + transport, acl_lease, process, output_tx, state_tx, final_tx, control_rx, ) }); @@ -250,6 +201,12 @@ impl OwnedHandle { fn raw(&self) -> HANDLE { self.0 } + + fn into_raw(mut self) -> HANDLE { + let handle = self.0; + self.0 = 0; + handle + } } impl Drop for OwnedHandle { @@ -296,31 +253,23 @@ struct TokenDefaultDaclInfo { default_dacl: *mut ACL, } -fn create_restricted_token(capability_sid: &str, account: &str) -> io::Result { +fn current_process_token_for_restriction() -> io::Result { + let desired = TOKEN_DUPLICATE + | TOKEN_QUERY + | TOKEN_ASSIGN_PRIMARY + | TOKEN_ADJUST_DEFAULT + | TOKEN_ADJUST_SESSIONID + | TOKEN_ADJUST_PRIVILEGES; + let mut token = 0; + if unsafe { OpenProcessToken(GetCurrentProcess(), desired, &mut token) } == 0 { + return Err(last_os_error("OpenProcessToken(runner)")); + } + OwnedHandle::new(token, "OpenProcessToken(runner)") +} + +fn create_restricted_token(capability_sid: &str) -> io::Result { unsafe { - let desired = TOKEN_DUPLICATE - | TOKEN_QUERY - | TOKEN_ASSIGN_PRIMARY - | TOKEN_ADJUST_DEFAULT - | TOKEN_ADJUST_SESSIONID - | TOKEN_ADJUST_PRIVILEGES; - let password = read_windows_sandbox_password(account)?; - let username = to_wide(account); - let domain = to_wide("."); - let password = to_wide(password); - let mut base = 0; - if LogonUserW( - username.as_ptr(), - domain.as_ptr(), - password.as_ptr(), - LOGON32_LOGON_INTERACTIVE, - LOGON32_PROVIDER_DEFAULT, - &mut base, - ) == 0 - { - return Err(last_os_error("LogonUserW")); - } - let base = OwnedHandle::new(base, "LogonUserW")?; + let base = current_process_token_for_restriction()?; let capability = LocalSid::parse(capability_sid)?; let mut user = token_user_sid(base.raw())?; let mut logon = logon_sid(base.raw())?; @@ -438,28 +387,6 @@ unsafe fn enable_privilege(token: HANDLE, name: &str) -> io::Result<()> { Ok(()) } -fn enable_process_creation_privileges() { - unsafe { - let mut token = 0; - if OpenProcessToken( - GetCurrentProcess(), - TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, - &mut token, - ) == 0 - { - return; - } - let Ok(token) = OwnedHandle::new(token, "OpenProcessToken") else { - return; - }; - for privilege in ["SeIncreaseQuotaPrivilege", "SeAssignPrimaryTokenPrivilege"] { - if let Err(error) = enable_privilege(token.raw(), privilege) { - tracing::debug!(%error, privilege, "Windows process creation privilege unavailable"); - } - } - } -} - unsafe fn token_user_sid(token: HANDLE) -> io::Result> { let mut needed = 0; GetTokenInformation(token, TokenUser, ptr::null_mut(), 0, &mut needed); @@ -614,8 +541,9 @@ fn spawn_restricted_pipe_process( let mut env_block = environment_block(&request.env)?; let cwd = to_wide(cwd.as_os_str()); let mut desktop = to_wide("winsta0\\default"); - let mut attributes = ProcessAttributeList::new(1)?; + let mut attributes = ProcessAttributeList::new(2)?; attributes.set_handle_list(&[stdin_read.raw(), stdout_write.raw(), stderr_write.raw()])?; + attributes.set_job(job.raw())?; let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() }; startup.StartupInfo.cb = std::mem::size_of::() as u32; startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; @@ -625,10 +553,7 @@ fn spawn_restricted_pipe_process( startup.StartupInfo.lpDesktop = desktop.as_mut_ptr(); startup.lpAttributeList = attributes.as_mut_ptr(); let mut process_info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; - // Prevent restricted child initialization failures from opening a hidden - // Windows error dialog and leaving the process alive indefinitely. - let previous_error_mode = unsafe { SetErrorMode(CHILD_ERROR_MODE_FLAGS) }; - let mut created = unsafe { + let created = unsafe { CreateProcessAsUserW( token, ptr::null(), @@ -636,78 +561,18 @@ fn spawn_restricted_pipe_process( ptr::null_mut(), ptr::null_mut(), 1, - CREATE_UNICODE_ENVIRONMENT - | CREATE_SUSPENDED - | CREATE_NO_WINDOW - | EXTENDED_STARTUPINFO_PRESENT, + CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT, env_block.as_mut_ptr() as *mut c_void, cwd.as_ptr(), &startup.StartupInfo, &mut process_info, ) }; - if created == 0 && unsafe { GetLastError() } == ERROR_PRIVILEGE_NOT_HELD { - // Process creation may modify lpCommandLine even when it fails. Rebuild - // it before retrying or PowerShell can start without its arguments. - command_line = to_wide(&command_line_text); - let mut fallback_startup: STARTUPINFOW = unsafe { std::mem::zeroed() }; - fallback_startup.cb = std::mem::size_of::() as u32; - fallback_startup.dwFlags = STARTF_USESTDHANDLES; - fallback_startup.hStdInput = stdin_read.raw(); - fallback_startup.hStdOutput = stdout_write.raw(); - fallback_startup.hStdError = stderr_write.raw(); - // CreateProcessWithTokenW grants the token access to the inherited - // window station/desktop only when lpDesktop is null. The explicit - // CreateProcessAsUserW path keeps the current desktop contract. - fallback_startup.lpDesktop = ptr::null_mut(); - created = unsafe { - CreateProcessWithTokenW( - token, - LOGON_WITH_PROFILE, - ptr::null(), - command_line.as_mut_ptr(), - CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED | CREATE_NO_WINDOW, - env_block.as_mut_ptr() as *mut c_void, - cwd.as_ptr(), - &fallback_startup, - &mut process_info, - ) - }; - #[cfg(debug_assertions)] - eprintln!( - "CreateProcessWithTokenW pipe result={} error={} pid={}", - created, - unsafe { GetLastError() }, - process_info.dwProcessId - ); - } - unsafe { - SetErrorMode(previous_error_mode); - } if created == 0 { return Err(last_os_error("CreateProcessAsUserW")); } let process = OwnedHandle::new(process_info.hProcess, "CreateProcessAsUserW process")?; let thread = OwnedHandle::new(process_info.hThread, "CreateProcessAsUserW thread")?; - if unsafe { AssignProcessToJobObject(job.raw(), process.raw()) } == 0 { - return Err(last_os_error("AssignProcessToJobObject")); - } - let resume_result = unsafe { ResumeThread(thread.raw()) }; - #[cfg(debug_assertions)] - { - let mut exit_code = 0; - let exit_result = unsafe { GetExitCodeProcess(process.raw(), &mut exit_code) }; - eprintln!( - "restricted pipe process pid={} resume={} exit_result={} exit_code={}", - process_info.dwProcessId, resume_result, exit_result, exit_code - ); - } - if resume_result == u32::MAX { - unsafe { - TerminateJobObject(job.raw(), 1); - } - return Err(last_os_error("ResumeThread")); - } drop(stdin_read); drop(stdout_write); drop(stderr_write); @@ -736,8 +601,9 @@ fn spawn_restricted_conpty_process( let mut env_block = environment_block(&request.env)?; let cwd = to_wide(cwd.as_os_str()); let mut desktop = to_wide("winsta0\\default"); - let mut attributes = ProcessAttributeList::new(1)?; + let mut attributes = ProcessAttributeList::new(2)?; attributes.set_pseudoconsole(pseudoconsole.raw())?; + attributes.set_job(job.raw())?; let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() }; startup.StartupInfo.cb = std::mem::size_of::() as u32; startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; @@ -747,10 +613,7 @@ fn spawn_restricted_conpty_process( startup.StartupInfo.lpDesktop = desktop.as_mut_ptr(); startup.lpAttributeList = attributes.as_mut_ptr(); let mut process_info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; - // Keep restricted child initialization fail-visible instead of allowing a - // hidden Windows error dialog to hold the process open. - let previous_error_mode = unsafe { SetErrorMode(CHILD_ERROR_MODE_FLAGS) }; - let mut created = unsafe { + let created = unsafe { CreateProcessAsUserW( token, ptr::null(), @@ -758,58 +621,18 @@ fn spawn_restricted_conpty_process( ptr::null_mut(), ptr::null_mut(), 0, - CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT, + CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT, env_block.as_mut_ptr() as *mut c_void, cwd.as_ptr(), &startup.StartupInfo, &mut process_info, ) }; - if created == 0 && unsafe { GetLastError() } == ERROR_PRIVILEGE_NOT_HELD { - // Process creation may modify lpCommandLine even when it fails. - command_line = to_wide(&command_line_text); - // CreateProcessWithTokenW grants the token access to the inherited - // desktop only when lpDesktop is null. The AsUser path above retains - // the explicit desktop contract. - startup.StartupInfo.lpDesktop = ptr::null_mut(); - created = unsafe { - CreateProcessWithTokenW( - token, - LOGON_WITH_PROFILE, - ptr::null(), - command_line.as_mut_ptr(), - CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT, - env_block.as_mut_ptr() as *mut c_void, - cwd.as_ptr(), - &startup.StartupInfo, - &mut process_info, - ) - }; - #[cfg(debug_assertions)] - eprintln!( - "CreateProcessWithTokenW conpty result={} error={} pid={}", - created, - unsafe { GetLastError() }, - process_info.dwProcessId - ); - } - unsafe { - SetErrorMode(previous_error_mode); - } if created == 0 { return Err(last_os_error("CreateProcessAsUserW ConPTY")); } let process = OwnedHandle::new(process_info.hProcess, "CreateProcessAsUserW process")?; let thread = OwnedHandle::new(process_info.hThread, "CreateProcessAsUserW thread")?; - if unsafe { AssignProcessToJobObject(job.raw(), process.raw()) } == 0 { - return Err(last_os_error("AssignProcessToJobObject ConPTY")); - } - if unsafe { ResumeThread(thread.raw()) } == u32::MAX { - unsafe { - TerminateJobObject(job.raw(), 1); - } - return Err(last_os_error("ResumeThread ConPTY")); - } let stdin_write = request.stdin.then_some(stdin_write); Ok(SpawnedRestrictedProcess { process, diff --git a/lime-rs/crates/tool-runtime/src/execution_process/windows_job.rs b/lime-rs/crates/tool-runtime/src/execution_process/windows_job.rs index fc0347ebc..3917a9423 100644 --- a/lime-rs/crates/tool-runtime/src/execution_process/windows_job.rs +++ b/lime-rs/crates/tool-runtime/src/execution_process/windows_job.rs @@ -34,7 +34,7 @@ pub(super) fn preserve_job_descendants(job: &OwnedHandle) -> io::Result<()> { set_job_limit_flags(job.raw(), JOB_OBJECT_LIMIT_BREAKAWAY_OK) } -fn active_job_processes(job: &OwnedHandle) -> io::Result { +pub(super) fn active_job_processes(job: &OwnedHandle) -> io::Result { let mut accounting: JOBOBJECT_BASIC_ACCOUNTING_INFORMATION = unsafe { std::mem::zeroed() }; if unsafe { QueryInformationJobObject( @@ -52,147 +52,7 @@ fn active_job_processes(job: &OwnedHandle) -> io::Result { } } -fn reap_preserved_job(job: OwnedHandle, acl_lease: AclLease) { - loop { - match active_job_processes(&job) { - Ok(0) => break, - Ok(_) => thread::sleep(Duration::from_millis(JOB_REAPER_POLL_MILLIS)), - Err(_) => { - unsafe { - TerminateJobObject(job.raw(), 1); - } - break; - } - } - } - drop(acl_lease); -} - -#[allow(clippy::too_many_arguments)] -pub(super) fn supervise_restricted_process( - process_handle: OwnedHandle, - thread_handle: OwnedHandle, - job: OwnedHandle, - mut stdin: Option, - pseudoconsole: Option, - acl_lease: AclLease, - process: Arc>, - state_tx: watch::Sender, - final_tx: oneshot::Sender, - control_rx: std::sync::mpsc::Receiver, - reader_done_rx: std::sync::mpsc::Receiver<()>, -) { - let mut wait_error = None; - loop { - let wait = unsafe { WaitForSingleObject(process_handle.raw(), CONTROL_POLL_MILLIS) }; - if wait == WAIT_OBJECT_0 { - break; - } - drain_controls( - &job, - pseudoconsole.as_ref(), - &mut stdin, - &process, - &state_tx, - &control_rx, - ); - if wait != WAIT_TIMEOUT { - wait_error = Some(last_os_error("WaitForSingleObject").to_string()); - unsafe { - TerminateJobObject(job.raw(), 1); - } - break; - } - } - - stdin.take(); - let preserve_descendants = - should_preserve_windows_job(wait_error.is_some(), process.blocking_lock().status()); - if preserve_descendants && preserve_job_descendants(&job).is_ok() { - thread::spawn(move || reap_preserved_job(job, acl_lease)); - } else { - unsafe { - TerminateJobObject(job.raw(), 1); - } - drop(job); - drop(acl_lease); - } - let reader_count = if pseudoconsole.is_some() { 1 } else { 2 }; - drop(pseudoconsole); - // A descendant can retain an inherited pipe handle after the root exits. - // Bound the drain window so cleanup and the terminal snapshot cannot hang. - for _ in 0..reader_count { - let _ = reader_done_rx.recv_timeout(Duration::from_secs(5)); - } - drop(thread_handle); - - let mut exit_code = 1; - let exit_result = unsafe { GetExitCodeProcess(process_handle.raw(), &mut exit_code) }; - let final_snapshot = { - let mut guard = process.blocking_lock(); - if !guard.status().is_terminal() { - if let Some(error) = wait_error { - guard.fail(error); - } else if exit_result == 0 { - guard.fail(last_os_error("GetExitCodeProcess").to_string()); - } else { - guard.exit(i32::try_from(exit_code).unwrap_or(-1)); - } - } - guard.snapshot() - }; - let _ = state_tx.send(final_snapshot.clone()); - let _ = final_tx.send(final_snapshot); -} - -fn drain_controls( - job: &OwnedHandle, - pseudoconsole: Option<&RestrictedConpty>, - stdin: &mut Option, - process: &Arc>, - state_tx: &watch::Sender, - control_rx: &std::sync::mpsc::Receiver, -) { - loop { - match control_rx.try_recv() { - Ok(LocalExecutionControl::WriteStdin(bytes)) => { - if let Some(stdin) = stdin.as_ref() { - let _ = write_pipe(stdin.raw(), &bytes); - } - } - Ok(LocalExecutionControl::CloseStdin) => { - stdin.take(); - } - Ok(LocalExecutionControl::Resize { rows, cols }) => { - if let Some(pseudoconsole) = pseudoconsole { - let _ = pseudoconsole.resize(rows, cols); - } - } - Ok(LocalExecutionControl::Interrupt) => { - update_status_blocking(process, state_tx, ExecutionProcessStatus::Interrupted); - unsafe { - TerminateJobObject(job.raw(), 1); - } - } - Ok(LocalExecutionControl::Terminate) => { - update_status_blocking(process, state_tx, ExecutionProcessStatus::Terminated); - unsafe { - TerminateJobObject(job.raw(), 1); - } - } - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - update_status_blocking(process, state_tx, ExecutionProcessStatus::Terminated); - unsafe { - TerminateJobObject(job.raw(), 1); - } - break; - } - } - } -} - -fn write_pipe(handle: HANDLE, mut bytes: &[u8]) -> io::Result<()> { +pub(super) fn write_pipe(handle: HANDLE, mut bytes: &[u8]) -> io::Result<()> { while !bytes.is_empty() { let mut written = 0; if unsafe { @@ -217,55 +77,3 @@ fn write_pipe(handle: HANDLE, mut bytes: &[u8]) -> io::Result<()> { } Ok(()) } - -pub(super) fn read_pipe_stream( - handle: OwnedHandle, - kind: ExecutionOutputKind, - process: Arc>, - output_tx: mpsc::UnboundedSender, - state_tx: watch::Sender, -) { - let mut buffer = vec![0u8; PROCESS_OUTPUT_CHUNK_BYTES]; - loop { - let mut read = 0; - let ok = unsafe { - ReadFile( - handle.raw(), - buffer.as_mut_ptr(), - buffer.len() as u32, - &mut read, - ptr::null_mut(), - ) - }; - if ok == 0 || read == 0 { - break; - } - let (delta, snapshot) = { - let mut guard = process.blocking_lock(); - let delta = guard.append_output(kind, &buffer[..read as usize]); - let snapshot = guard.snapshot(); - (delta, snapshot) - }; - let _ = output_tx.send(delta); - let _ = state_tx.send(snapshot); - } -} - -fn update_status_blocking( - process: &Arc>, - state_tx: &watch::Sender, - status: ExecutionProcessStatus, -) { - let snapshot = { - let mut guard = process.blocking_lock(); - match status { - ExecutionProcessStatus::Interrupted => guard.interrupt(), - ExecutionProcessStatus::Terminated => guard.terminate(), - ExecutionProcessStatus::Failed => guard.fail("process failed"), - ExecutionProcessStatus::Exited => guard.exit(-1), - ExecutionProcessStatus::Starting | ExecutionProcessStatus::Running => {} - } - guard.snapshot() - }; - let _ = state_tx.send(snapshot); -} diff --git a/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_child.rs b/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_child.rs new file mode 100644 index 000000000..af142de32 --- /dev/null +++ b/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_child.rs @@ -0,0 +1,338 @@ +use super::windows_job::write_pipe; +use super::windows_job::{active_job_processes, preserve_job_descendants}; +use super::windows_runner_protocol::{ + decode_bytes, encode_bytes, read_frame, write_frame, RunnerMessage, RunnerSpawnRequest, +}; +use super::{ + create_restricted_token, last_os_error, spawn_restricted_process, to_wide, + LocalExecutionRequest, LocalSid, OwnedHandle, RestrictedConpty, +}; +use std::fs::File; +use std::io; +use std::os::windows::io::FromRawHandle; +use std::ptr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread; +use std::time::Duration; +use windows_sys::Win32::Foundation::{EqualSid, WAIT_OBJECT_0}; +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, ReadFile, FILE_GENERIC_READ, FILE_GENERIC_WRITE, OPEN_EXISTING, +}; +use windows_sys::Win32::System::JobObjects::TerminateJobObject; +use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, GetProcessId, WaitForSingleObject, INFINITE, +}; + +const OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); +const JOB_REAPER_POLL: Duration = Duration::from_millis(250); + +pub(super) fn run() -> io::Result<()> { + let (pipe_in, pipe_out) = parse_pipe_arguments()?; + let pipe_read = open_pipe(&pipe_in, FILE_GENERIC_READ)?; + let pipe_write = open_pipe(&pipe_out, FILE_GENERIC_WRITE)?; + let mut pipe_read = unsafe { File::from_raw_handle(pipe_read.into_raw() as _) }; + let pipe_write = Arc::new(Mutex::new(unsafe { + File::from_raw_handle(pipe_write.into_raw() as _) + })); + + let request = match read_frame(&mut pipe_read) { + Ok(Some(RunnerMessage::Spawn { payload })) => payload, + Ok(Some(_)) => { + return send_start_error( + &pipe_write, + io::Error::new( + io::ErrorKind::InvalidData, + "runner expected a spawn request", + ), + ) + } + Ok(None) => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "runner pipe closed before spawn request", + )) + } + Err(error) => return send_start_error(&pipe_write, error), + }; + + if let Err(error) = run_spawned_process(request, pipe_read, Arc::clone(&pipe_write)) { + let _ = write_locked( + &pipe_write, + RunnerMessage::Error { + windows_error_code: error.raw_os_error().map(|value| value as u32), + message: error.to_string(), + }, + ); + return Err(error); + } + Ok(()) +} + +fn run_spawned_process( + request: RunnerSpawnRequest, + pipe_read: File, + pipe_write: Arc>, +) -> io::Result<()> { + verify_runner_identity(&request.expected_account_sid)?; + let token = create_restricted_token(&request.capability_sid)?; + let local_request = LocalExecutionRequest { + process_id: "windows-sandbox-runner-child".to_string(), + tool_id: "windows-sandbox-runner-child".to_string(), + tool_name: "exec_command".to_string(), + command: request.command, + cwd: Some(request.cwd.clone()), + env: request.env, + tty: request.tty, + stdin: request.stdin_open, + env_clear: true, + pty_size: request.pty_size, + sandbox: None, + }; + let spawned = spawn_restricted_process(&local_request, &request.cwd, token.raw())?; + drop(token); + + let super::SpawnedRestrictedProcess { + process, + thread: process_thread, + job, + stdin_write, + stdout_read, + stderr_read, + pseudoconsole, + } = spawned; + let process_id = unsafe { GetProcessId(process.raw()) }; + write_locked(&pipe_write, RunnerMessage::Ready { process_id })?; + + let (reader_done_tx, reader_done_rx) = mpsc::channel(); + let output_kind = if pseudoconsole.is_some() { + super::ExecutionOutputKind::Combined + } else { + super::ExecutionOutputKind::Stdout + }; + spawn_output_reader( + stdout_read, + output_kind, + Arc::clone(&pipe_write), + reader_done_tx.clone(), + ); + if let Some(stderr) = stderr_read { + spawn_output_reader( + stderr, + super::ExecutionOutputKind::Stderr, + Arc::clone(&pipe_write), + reader_done_tx, + ); + } + + let job = Arc::new(job); + let stdin = Arc::new(Mutex::new(stdin_write)); + let pseudoconsole = pseudoconsole.map(Arc::new); + let terminated = Arc::new(AtomicBool::new(false)); + spawn_control_reader( + pipe_read, + Arc::clone(&job), + Arc::clone(&stdin), + pseudoconsole.as_ref().map(Arc::clone), + Arc::clone(&terminated), + ); + + let wait = unsafe { WaitForSingleObject(process.raw(), INFINITE) }; + if wait != WAIT_OBJECT_0 { + unsafe { + TerminateJobObject(job.raw(), 1); + } + return Err(last_os_error("WaitForSingleObject(restricted child)")); + } + stdin.lock().ok().and_then(|mut value| value.take()); + let preserve_descendants = !terminated.load(Ordering::Acquire); + if preserve_descendants { + preserve_job_descendants(&job)?; + } else { + unsafe { + TerminateJobObject(job.raw(), 1); + } + } + + drop(pseudoconsole); + let reader_count = if request.tty { 1 } else { 2 }; + for _ in 0..reader_count { + let _ = reader_done_rx.recv_timeout(OUTPUT_DRAIN_TIMEOUT); + } + + let mut exit_code = 1; + if unsafe { GetExitCodeProcess(process.raw(), &mut exit_code) } == 0 { + return Err(last_os_error("GetExitCodeProcess(restricted child)")); + } + drop(process_thread); + drop(process); + write_locked( + &pipe_write, + RunnerMessage::Exit { + exit_code: i32::try_from(exit_code).unwrap_or(-1), + }, + )?; + + if preserve_descendants { + while active_job_processes(&job)? != 0 { + thread::sleep(JOB_REAPER_POLL); + } + } + Ok(()) +} + +fn spawn_output_reader( + handle: OwnedHandle, + kind: super::ExecutionOutputKind, + pipe_write: Arc>, + done: mpsc::Sender<()>, +) { + thread::spawn(move || { + let mut buffer = vec![0u8; super::PROCESS_OUTPUT_CHUNK_BYTES]; + loop { + let mut read = 0; + let ok = unsafe { + ReadFile( + handle.raw(), + buffer.as_mut_ptr(), + buffer.len() as u32, + &mut read, + ptr::null_mut(), + ) + }; + if ok == 0 || read == 0 { + break; + } + if write_locked( + &pipe_write, + RunnerMessage::Output { + kind, + data_base64: encode_bytes(&buffer[..read as usize]), + }, + ) + .is_err() + { + break; + } + } + let _ = done.send(()); + }); +} + +fn spawn_control_reader( + mut pipe_read: File, + job: Arc, + stdin: Arc>>, + pseudoconsole: Option>, + terminated: Arc, +) { + thread::spawn(move || loop { + match read_frame(&mut pipe_read) { + Ok(Some(RunnerMessage::Stdin { data_base64 })) => { + let Ok(bytes) = decode_bytes(&data_base64) else { + continue; + }; + if let Ok(guard) = stdin.lock() { + if let Some(handle) = guard.as_ref() { + let _ = write_pipe(handle.raw(), &bytes); + } + } + } + Ok(Some(RunnerMessage::CloseStdin)) => { + if let Ok(mut guard) = stdin.lock() { + guard.take(); + } + } + Ok(Some(RunnerMessage::Resize { rows, cols })) => { + if let Some(pseudoconsole) = pseudoconsole.as_ref() { + let _ = pseudoconsole.resize(rows, cols); + } + } + Ok(Some(RunnerMessage::Terminate)) => { + terminated.store(true, Ordering::Release); + unsafe { + TerminateJobObject(job.raw(), 1); + } + } + Ok(Some(_)) => {} + Ok(None) | Err(_) => { + terminated.store(true, Ordering::Release); + unsafe { + TerminateJobObject(job.raw(), 1); + } + break; + } + } + }); +} + +fn parse_pipe_arguments() -> io::Result<(String, String)> { + let mut pipe_in = None; + let mut pipe_out = None; + for argument in std::env::args().skip(1) { + if let Some(value) = argument.strip_prefix("--pipe-in=") { + pipe_in = Some(value.to_string()); + } else if let Some(value) = argument.strip_prefix("--pipe-out=") { + pipe_out = Some(value.to_string()); + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unknown windows sandbox runner argument: {argument}"), + )); + } + } + match (pipe_in, pipe_out) { + (Some(pipe_in), Some(pipe_out)) => Ok((pipe_in, pipe_out)), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "windows sandbox runner requires --pipe-in and --pipe-out", + )), + } +} + +fn open_pipe(name: &str, access: u32) -> io::Result { + let name = to_wide(name); + let handle = unsafe { + CreateFileW( + name.as_ptr(), + access, + 0, + ptr::null_mut(), + OPEN_EXISTING, + 0, + 0, + ) + }; + OwnedHandle::new(handle, "CreateFileW(windows sandbox runner pipe)") +} + +fn verify_runner_identity(expected_sid: &str) -> io::Result<()> { + let token = super::current_process_token_for_restriction()?; + let actual_sid = unsafe { super::token_user_sid(token.raw())? }; + let expected_sid = LocalSid::parse(expected_sid)?; + if unsafe { EqualSid(actual_sid.as_ptr() as *mut _, expected_sid.raw()) } == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Windows sandbox runner identity does not match the requested account", + )); + } + Ok(()) +} + +fn write_locked(writer: &Arc>, message: RunnerMessage) -> io::Result<()> { + let mut writer = writer + .lock() + .map_err(|_| io::Error::other("Windows sandbox runner writer lock poisoned"))?; + write_frame(&mut *writer, message) +} + +fn send_start_error(writer: &Arc>, error: io::Error) -> io::Result<()> { + let _ = write_locked( + writer, + RunnerMessage::Error { + windows_error_code: error.raw_os_error().map(|value| value as u32), + message: error.to_string(), + }, + ); + Err(error) +} diff --git a/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_host.rs b/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_host.rs new file mode 100644 index 000000000..6d2b0240c --- /dev/null +++ b/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_host.rs @@ -0,0 +1,346 @@ +use super::windows_runner_protocol::{read_frame, write_frame, RunnerMessage, RunnerSpawnRequest}; +use super::{argv_to_command_line, last_os_error, to_wide, LocalExecutionRequest, OwnedHandle}; +use crate::windows_setup::{read_windows_sandbox_password, resolve_windows_account_sid}; +use std::ffi::c_void; +use std::fs::File; +use std::io; +use std::os::windows::io::FromRawHandle; +use std::path::{Path, PathBuf}; +use std::ptr; +use std::thread; +use std::time::{Duration, Instant}; +use windows_sys::Win32::Foundation::{ + GetLastError, LocalFree, ERROR_PIPE_CONNECTED, ERROR_PIPE_LISTENING, HANDLE, HLOCAL, + WAIT_OBJECT_0, WAIT_TIMEOUT, +}; +use windows_sys::Win32::Security::Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, +}; +use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; +use windows_sys::Win32::System::Diagnostics::Debug::SetErrorMode; +use windows_sys::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, GetNamedPipeClientProcessId, PeekNamedPipe, + SetNamedPipeHandleState, PIPE_NOWAIT, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, + PIPE_TYPE_BYTE, PIPE_WAIT, +}; +use windows_sys::Win32::System::Threading::{ + CreateProcessWithLogonW, TerminateProcess, WaitForSingleObject, CREATE_NO_WINDOW, + CREATE_UNICODE_ENVIRONMENT, PROCESS_INFORMATION, STARTUPINFOW, +}; + +const PIPE_ACCESS_INBOUND: u32 = 0x0000_0001; +const PIPE_ACCESS_OUTBOUND: u32 = 0x0000_0002; +const RUNNER_START_TIMEOUT: Duration = Duration::from_secs(15); +const RUNNER_POLL_INTERVAL: Duration = Duration::from_millis(5); +const RUNNER_ERROR_MODE_FLAGS: u32 = 0x0001 | 0x0002; + +pub(super) struct RunnerTransport { + pub(super) pipe_write: File, + pub(super) pipe_read: File, + pub(super) process: OwnedHandle, +} + +pub(super) fn spawn_runner_transport( + request: &LocalExecutionRequest, + cwd: &Path, + account: &str, + capability_sid: String, +) -> io::Result { + let account_sid = resolve_windows_account_sid(account).map_err(io::Error::other)?; + let account_sid = sid_string(&account_sid)?; + let nonce = uuid::Uuid::new_v4().simple(); + let pipe_in_name = format!(r"\\.\pipe\lime-sandbox-runner-{nonce}-in"); + let pipe_out_name = format!(r"\\.\pipe\lime-sandbox-runner-{nonce}-out"); + let pipe_in = create_named_pipe(&pipe_in_name, PIPE_ACCESS_OUTBOUND, &account_sid)?; + let pipe_out = create_named_pipe(&pipe_out_name, PIPE_ACCESS_INBOUND, &account_sid)?; + + let runner = resolve_runner_executable()?; + let runner_args = vec![ + runner.to_string_lossy().into_owned(), + format!("--pipe-in={pipe_in_name}"), + format!("--pipe-out={pipe_out_name}"), + ]; + let mut command_line = to_wide(argv_to_command_line(&runner_args)); + let runner_wide = to_wide(runner.as_os_str()); + let cwd_wide = to_wide(cwd.as_os_str()); + let username = to_wide(account); + let domain = to_wide("."); + let password = to_wide(read_windows_sandbox_password(account)?); + let mut startup: STARTUPINFOW = unsafe { std::mem::zeroed() }; + startup.cb = std::mem::size_of::() as u32; + let mut process_info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + let previous_error_mode = unsafe { SetErrorMode(RUNNER_ERROR_MODE_FLAGS) }; + let created = unsafe { + CreateProcessWithLogonW( + username.as_ptr(), + domain.as_ptr(), + password.as_ptr(), + 0, + runner_wide.as_ptr(), + command_line.as_mut_ptr(), + CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, + ptr::null(), + cwd_wide.as_ptr(), + &startup, + &mut process_info, + ) + }; + unsafe { + SetErrorMode(previous_error_mode); + } + if created == 0 { + return Err(last_os_error( + "CreateProcessWithLogonW(windows-sandbox-runner)", + )); + } + let process = OwnedHandle::new(process_info.hProcess, "windows sandbox runner process")?; + drop(OwnedHandle::new( + process_info.hThread, + "windows sandbox runner thread", + )?); + + if let Err(error) = connect_named_pipe(&pipe_in, process.raw(), process_info.dwProcessId) + .and_then(|_| connect_named_pipe(&pipe_out, process.raw(), process_info.dwProcessId)) + { + unsafe { + TerminateProcess(process.raw(), 1); + } + return Err(error); + } + + let mut transport = RunnerTransport { + pipe_write: unsafe { File::from_raw_handle(pipe_in.into_raw() as _) }, + pipe_read: unsafe { File::from_raw_handle(pipe_out.into_raw() as _) }, + process, + }; + let startup_result = (|| { + write_frame( + &mut transport.pipe_write, + RunnerMessage::Spawn { + payload: RunnerSpawnRequest { + command: request.command.clone(), + cwd: cwd.to_path_buf(), + env: request.env.clone(), + capability_sid, + expected_account_sid: account_sid, + tty: request.tty, + stdin_open: request.stdin, + pty_size: request.pty_size, + }, + }, + )?; + wait_for_complete_frame( + &transport.pipe_read, + transport.process.raw(), + RUNNER_START_TIMEOUT, + )?; + match read_frame(&mut transport.pipe_read)? { + Some(RunnerMessage::Ready { .. }) => Ok(()), + Some(RunnerMessage::Error { + message, + windows_error_code, + }) => Err(io::Error::other(format_runner_error( + &message, + windows_error_code, + ))), + Some(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "Windows sandbox runner sent an unexpected startup frame", + )), + None => Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Windows sandbox runner closed before ready", + )), + } + })(); + if let Err(error) = startup_result { + unsafe { + TerminateProcess(transport.process.raw(), 1); + } + return Err(error); + } + Ok(transport) +} + +fn resolve_runner_executable() -> io::Result { + let current = std::env::current_exe()?; + let name = "windows-sandbox-runner.exe"; + let mut candidates = Vec::new(); + if let Some(parent) = current.parent() { + candidates.push(parent.join(name)); + if parent.file_name().is_some_and(|value| value == "deps") { + if let Some(target_profile) = parent.parent() { + candidates.push(target_profile.join(name)); + } + } + } + candidates + .into_iter() + .find(|candidate| candidate.is_file()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!( + "windows-sandbox-runner.exe was not found beside {}", + current.display() + ), + ) + }) +} + +fn create_named_pipe(name: &str, access: u32, account_sid: &str) -> io::Result { + let sddl = to_wide(format!("D:(A;;GA;;;{account_sid})")); + let mut descriptor: PSECURITY_DESCRIPTOR = ptr::null_mut(); + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + 1, + &mut descriptor, + ptr::null_mut(), + ) + } == 0 + { + return Err(last_os_error( + "ConvertStringSecurityDescriptorToSecurityDescriptorW(runner pipe)", + )); + } + let mut attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: descriptor, + bInheritHandle: 0, + }; + let name = to_wide(name); + let handle = unsafe { + CreateNamedPipeW( + name.as_ptr(), + access, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_NOWAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, + 64 * 1024, + 64 * 1024, + 0, + &mut attributes, + ) + }; + unsafe { + LocalFree(descriptor as HLOCAL); + } + OwnedHandle::new(handle, "CreateNamedPipeW(windows sandbox runner)") +} + +fn connect_named_pipe(pipe: &OwnedHandle, process: HANDLE, expected_pid: u32) -> io::Result<()> { + let deadline = Instant::now() + RUNNER_START_TIMEOUT; + loop { + let connected = unsafe { ConnectNamedPipe(pipe.raw(), ptr::null_mut()) }; + let error = if connected == 0 { + unsafe { GetLastError() } + } else { + 0 + }; + if connected != 0 || error == ERROR_PIPE_CONNECTED { + break; + } + if error != ERROR_PIPE_LISTENING { + return Err(io::Error::from_raw_os_error(error as i32)); + } + if unsafe { WaitForSingleObject(process, 0) } == WAIT_OBJECT_0 { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Windows sandbox runner exited before connecting", + )); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "timed out connecting Windows sandbox runner pipe", + )); + } + thread::sleep(RUNNER_POLL_INTERVAL); + } + let wait_mode = PIPE_READMODE_BYTE | PIPE_WAIT; + if unsafe { SetNamedPipeHandleState(pipe.raw(), &wait_mode, ptr::null(), ptr::null()) } == 0 { + return Err(last_os_error("SetNamedPipeHandleState(runner pipe)")); + } + let mut client_pid = 0; + if unsafe { GetNamedPipeClientProcessId(pipe.raw(), &mut client_pid) } == 0 { + return Err(last_os_error("GetNamedPipeClientProcessId")); + } + if client_pid != expected_pid { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("runner pipe client pid {client_pid} did not match {expected_pid}"), + )); + } + Ok(()) +} + +fn wait_for_complete_frame(pipe: &File, process: HANDLE, timeout: Duration) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + + let handle = pipe.as_raw_handle() as HANDLE; + let deadline = Instant::now() + timeout; + let mut length = [0u8; 4]; + loop { + let mut bytes_read = 0; + let mut available = 0; + if unsafe { + PeekNamedPipe( + handle, + length.as_mut_ptr().cast::(), + length.len() as u32, + &mut bytes_read, + &mut available, + ptr::null_mut(), + ) + } == 0 + { + return Err(last_os_error("PeekNamedPipe(runner ready)")); + } + if bytes_read == length.len() as u32 { + let frame_bytes = u32::from_le_bytes(length) as usize + length.len(); + if available as usize >= frame_bytes { + return Ok(()); + } + } + match unsafe { WaitForSingleObject(process, 0) } { + WAIT_OBJECT_0 => { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Windows sandbox runner exited before ready", + )) + } + WAIT_TIMEOUT => {} + _ => return Err(last_os_error("WaitForSingleObject(runner ready)")), + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "timed out waiting for Windows sandbox runner ready frame", + )); + } + thread::sleep(RUNNER_POLL_INTERVAL); + } +} + +fn sid_string(sid: &[u8]) -> io::Result { + let mut value = ptr::null_mut(); + if unsafe { ConvertSidToStringSidW(sid.as_ptr() as *mut c_void, &mut value) } == 0 { + return Err(last_os_error("ConvertSidToStringSidW(runner account)")); + } + let mut length = 0; + while unsafe { *value.add(length) } != 0 { + length += 1; + } + let result = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(value, length) }); + unsafe { + LocalFree(value as HLOCAL); + } + Ok(result) +} + +pub(super) fn format_runner_error(message: &str, code: Option) -> String { + match code { + Some(code) => format!("{message} (Windows error {code})"), + None => message.to_string(), + } +} diff --git a/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_protocol.rs b/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_protocol.rs new file mode 100644 index 000000000..7334031e3 --- /dev/null +++ b/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_protocol.rs @@ -0,0 +1,160 @@ +use super::ExecutionOutputKind; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{self, Read, Write}; +use std::path::PathBuf; + +pub(super) const RUNNER_PROTOCOL_VERSION: u8 = 1; +const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RunnerSpawnRequest { + pub command: Vec, + pub cwd: PathBuf, + pub env: HashMap, + pub capability_sid: String, + pub expected_account_sid: String, + pub tty: bool, + pub stdin_open: bool, + pub pty_size: Option<(u16, u16)>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum RunnerMessage { + Spawn { + payload: RunnerSpawnRequest, + }, + Ready { + process_id: u32, + }, + Output { + kind: ExecutionOutputKind, + data_base64: String, + }, + Stdin { + data_base64: String, + }, + CloseStdin, + Resize { + rows: u16, + cols: u16, + }, + Terminate, + Exit { + exit_code: i32, + }, + Error { + message: String, + windows_error_code: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct RunnerFrame { + pub version: u8, + #[serde(flatten)] + pub message: RunnerMessage, +} + +impl RunnerFrame { + pub(super) fn new(message: RunnerMessage) -> Self { + Self { + version: RUNNER_PROTOCOL_VERSION, + message, + } + } +} + +pub(super) fn encode_bytes(bytes: &[u8]) -> String { + BASE64_STANDARD.encode(bytes) +} + +pub(super) fn decode_bytes(value: &str) -> io::Result> { + BASE64_STANDARD + .decode(value) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +pub(super) fn write_frame(writer: &mut impl Write, message: RunnerMessage) -> io::Result<()> { + let payload = serde_json::to_vec(&RunnerFrame::new(message)) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if payload.len() > MAX_FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Windows sandbox runner frame exceeds {MAX_FRAME_BYTES} bytes"), + )); + } + let length = u32::try_from(payload.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "runner frame is too large"))?; + writer.write_all(&length.to_le_bytes())?; + writer.write_all(&payload)?; + writer.flush() +} + +pub(super) fn read_frame(reader: &mut impl Read) -> io::Result> { + let mut length = [0u8; 4]; + match reader.read_exact(&mut length) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), + Err(error) => return Err(error), + } + let length = u32::from_le_bytes(length) as usize; + if length > MAX_FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Windows sandbox runner frame exceeds {MAX_FRAME_BYTES} bytes"), + )); + } + let mut payload = vec![0u8; length]; + reader.read_exact(&mut payload)?; + let frame: RunnerFrame = serde_json::from_slice(&payload) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if frame.version != RUNNER_PROTOCOL_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "unsupported Windows sandbox runner protocol version {}", + frame.version + ), + )); + } + Ok(Some(frame.message)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn framed_output_round_trips_raw_bytes() { + let mut bytes = Vec::new(); + write_frame( + &mut bytes, + RunnerMessage::Output { + kind: ExecutionOutputKind::Stdout, + data_base64: encode_bytes(&[0, 1, 2, 255]), + }, + ) + .expect("frame should encode"); + + let message = read_frame(&mut bytes.as_slice()) + .expect("frame should decode") + .expect("frame should be present"); + let RunnerMessage::Output { data_base64, .. } = message else { + panic!("unexpected runner message") + }; + assert_eq!(decode_bytes(&data_base64).unwrap(), [0, 1, 2, 255]); + } + + #[test] + fn oversized_frame_is_rejected_before_allocation() { + let mut encoded = ((MAX_FRAME_BYTES + 1) as u32).to_le_bytes().to_vec(); + encoded.extend_from_slice(b"{}"); + let error = read_frame(&mut encoded.as_slice()).expect_err("oversized frame must fail"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } +} diff --git a/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_supervisor.rs b/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_supervisor.rs new file mode 100644 index 000000000..9eb1a4c09 --- /dev/null +++ b/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_supervisor.rs @@ -0,0 +1,205 @@ +use super::windows_acl::AclLease; +use super::windows_runner_host::RunnerTransport; +use super::windows_runner_protocol::{ + decode_bytes, encode_bytes, read_frame, write_frame, RunnerMessage, +}; +use super::{ + ExecutionOutputDelta, ExecutionProcess, ExecutionProcessSnapshot, ExecutionProcessStatus, + LocalExecutionControl, OwnedHandle, CONTROL_POLL_MILLIS, +}; +use std::fs::File; +use std::io; +use std::sync::mpsc::{self, Receiver, TryRecvError}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; +use tokio::sync::{mpsc as tokio_mpsc, oneshot, watch, Mutex}; +use windows_sys::Win32::Foundation::WAIT_OBJECT_0; +use windows_sys::Win32::System::Threading::{TerminateProcess, WaitForSingleObject}; + +#[allow(clippy::too_many_arguments)] +pub(super) fn supervise( + transport: RunnerTransport, + acl_lease: AclLease, + process: Arc>, + output_tx: tokio_mpsc::UnboundedSender, + state_tx: watch::Sender, + final_tx: oneshot::Sender, + control_rx: Receiver, +) { + let RunnerTransport { + mut pipe_write, + mut pipe_read, + process: runner_process, + } = transport; + let (event_tx, event_rx) = mpsc::channel(); + thread::spawn(move || loop { + let event = read_frame(&mut pipe_read); + let terminal = matches!( + event, + Ok(Some( + RunnerMessage::Exit { .. } | RunnerMessage::Error { .. } + )) | Ok(None) + | Err(_) + ); + if event_tx.send(event).is_err() || terminal { + break; + } + }); + + let mut terminal_exit = None; + let mut failure = None; + let mut control_disconnected = false; + while terminal_exit.is_none() && failure.is_none() { + if !control_disconnected { + match control_rx.recv_timeout(Duration::from_millis(CONTROL_POLL_MILLIS as u64)) { + Ok(control) => { + if let Err(error) = + forward_control(control, &mut pipe_write, &process, &state_tx) + { + failure = Some(error.to_string()); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + control_disconnected = true; + update_status(&process, &state_tx, ExecutionProcessStatus::Terminated); + if let Err(error) = write_frame(&mut pipe_write, RunnerMessage::Terminate) { + failure = Some(error.to_string()); + } + } + } + } else { + thread::sleep(Duration::from_millis(CONTROL_POLL_MILLIS as u64)); + } + + loop { + match event_rx.try_recv() { + Ok(Ok(Some(RunnerMessage::Output { kind, data_base64 }))) => { + match decode_bytes(&data_base64) { + Ok(bytes) => append_output(&process, &output_tx, &state_tx, kind, &bytes), + Err(error) => { + failure = Some(error.to_string()); + break; + } + } + } + Ok(Ok(Some(RunnerMessage::Exit { exit_code }))) => { + terminal_exit = Some(exit_code); + break; + } + Ok(Ok(Some(RunnerMessage::Error { + message, + windows_error_code, + }))) => { + failure = Some(super::windows_runner_host::format_runner_error( + &message, + windows_error_code, + )); + break; + } + Ok(Ok(Some(_))) => { + failure = Some("Windows sandbox runner sent an unexpected frame".to_string()); + break; + } + Ok(Ok(None)) => { + failure = Some("Windows sandbox runner closed before exit".to_string()); + break; + } + Ok(Err(error)) => { + failure = Some(format!("Windows sandbox runner IPC failed: {error}")); + break; + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + failure = Some("Windows sandbox runner event reader closed".to_string()); + break; + } + } + } + } + + let final_snapshot = { + let mut guard = process.blocking_lock(); + if !guard.status().is_terminal() { + if let Some(error) = failure.as_ref() { + guard.fail(error.clone()); + } else if let Some(exit_code) = terminal_exit { + guard.exit(exit_code); + } + } + guard.snapshot() + }; + let _ = state_tx.send(final_snapshot.clone()); + let _ = final_tx.send(final_snapshot); + + if failure.is_some() { + unsafe { + TerminateProcess(runner_process.raw(), 1); + } + } + thread::spawn(move || reap_runner(runner_process, pipe_write, acl_lease)); +} + +fn forward_control( + control: LocalExecutionControl, + pipe_write: &mut File, + process: &Arc>, + state_tx: &watch::Sender, +) -> io::Result<()> { + let message = match control { + LocalExecutionControl::WriteStdin(bytes) => RunnerMessage::Stdin { + data_base64: encode_bytes(&bytes), + }, + LocalExecutionControl::CloseStdin => RunnerMessage::CloseStdin, + LocalExecutionControl::Resize { rows, cols } => RunnerMessage::Resize { rows, cols }, + LocalExecutionControl::Interrupt => { + update_status(process, state_tx, ExecutionProcessStatus::Interrupted); + RunnerMessage::Terminate + } + LocalExecutionControl::Terminate => { + update_status(process, state_tx, ExecutionProcessStatus::Terminated); + RunnerMessage::Terminate + } + }; + write_frame(pipe_write, message) +} + +fn append_output( + process: &Arc>, + output_tx: &tokio_mpsc::UnboundedSender, + state_tx: &watch::Sender, + kind: super::ExecutionOutputKind, + bytes: &[u8], +) { + let (delta, snapshot) = { + let mut guard = process.blocking_lock(); + let delta = guard.append_output(kind, bytes); + (delta, guard.snapshot()) + }; + let _ = output_tx.send(delta); + let _ = state_tx.send(snapshot); +} + +fn update_status( + process: &Arc>, + state_tx: &watch::Sender, + status: ExecutionProcessStatus, +) { + let snapshot = { + let mut guard = process.blocking_lock(); + match status { + ExecutionProcessStatus::Interrupted => guard.interrupt(), + ExecutionProcessStatus::Terminated => guard.terminate(), + ExecutionProcessStatus::Failed => guard.fail("process failed"), + ExecutionProcessStatus::Exited => guard.exit(-1), + ExecutionProcessStatus::Starting | ExecutionProcessStatus::Running => {} + } + guard.snapshot() + }; + let _ = state_tx.send(snapshot); +} + +fn reap_runner(process: OwnedHandle, _pipe_write: File, _acl_lease: AclLease) { + let _ = unsafe { WaitForSingleObject(process.raw(), u32::MAX) } == WAIT_OBJECT_0; +} diff --git a/lime-rs/crates/tool-runtime/tests/windows_restricted_execution.rs b/lime-rs/crates/tool-runtime/tests/windows_restricted_execution.rs index 45ab63822..22c8b1752 100644 --- a/lime-rs/crates/tool-runtime/tests/windows_restricted_execution.rs +++ b/lime-rs/crates/tool-runtime/tests/windows_restricted_execution.rs @@ -407,9 +407,14 @@ async fn world_writable_audit_reports_everyone_write_acl() { "/grant", "Everyone:(OI)(CI)(M)", ]) - .status() + .output() .expect("icacls should start"); - assert!(grant.success(), "icacls grant should succeed"); + assert!( + grant.status.success(), + "icacls grant should succeed: stdout={}, stderr={}", + String::from_utf8_lossy(&grant.stdout), + String::from_utf8_lossy(&grant.stderr) + ); let environment = env::vars().collect::>(); let audit = audit_windows_world_writable(fixture.path(), &environment); diff --git a/packages/app-server-client/src/sidecar-types.ts b/packages/app-server-client/src/sidecar-types.ts index 3ede83738..7e1712c24 100644 --- a/packages/app-server-client/src/sidecar-types.ts +++ b/packages/app-server-client/src/sidecar-types.ts @@ -145,6 +145,7 @@ export type AppServerReleaseArtifact = { sha256: string; codeModeHostSha256?: string; windowsSandboxSetupSha256?: string; + windowsSandboxRunnerSha256?: string; }; export type AppServerReleaseManifest = { diff --git a/scripts/check-app-server-client-contract.mjs b/scripts/check-app-server-client-contract.mjs index c70155ce1..fd169cda3 100644 --- a/scripts/check-app-server-client-contract.mjs +++ b/scripts/check-app-server-client-contract.mjs @@ -7520,8 +7520,8 @@ const checks = [ "cargo clean --manifest-path lime-rs/Cargo.toml -p v8", "Prepare sherpa-onnx runtime", "node scripts/prepare-sherpa-onnx-runtime.mjs --target x86_64-pc-windows-msvc", - "Build Windows app-server, code-mode-host, and sandbox setup sidecars", - "cargo build --manifest-path lime-rs/Cargo.toml --target x86_64-pc-windows-msvc -p app-server --bin app-server -p tool-runtime --bin code-mode-host --bin windows-sandbox-setup", + "Build Windows app-server, code-mode-host, and sandbox sidecars", + "cargo build --manifest-path lime-rs/Cargo.toml --target x86_64-pc-windows-msvc -p app-server --bin app-server -p tool-runtime --bin code-mode-host --bin windows-sandbox-setup --bin windows-sandbox-runner", ], absentSnippets: [ "cargo check --manifest-path lime-rs/Cargo.toml -p app-server --bin app-server -p tool-runtime --bin code-mode-host", diff --git a/scripts/electron/prepare-app-server-assets.mjs b/scripts/electron/prepare-app-server-assets.mjs index 77def1164..c9d6018ab 100644 --- a/scripts/electron/prepare-app-server-assets.mjs +++ b/scripts/electron/prepare-app-server-assets.mjs @@ -5,5 +5,5 @@ import { prepareElectronAppServerAssets } from "../lib/electron-app-server-asset const result = await prepareElectronAppServerAssets(); console.log( - `[electron-assets] prepared app-server sidecar ${result.binaryPath}, code-mode host ${result.codeModeHostBinaryPath}, and ${result.manifestPath}`, + `[electron-assets] prepared app-server sidecar ${result.binaryPath}, code-mode host ${result.codeModeHostBinaryPath}, Windows sandbox runner ${result.windowsSandboxRunnerBinaryPath || "n/a"}, and ${result.manifestPath}`, ); diff --git a/scripts/electron/verify-package-resources.mjs b/scripts/electron/verify-package-resources.mjs index eef593e5c..e194323d2 100644 --- a/scripts/electron/verify-package-resources.mjs +++ b/scripts/electron/verify-package-resources.mjs @@ -119,6 +119,10 @@ function windowsSandboxSetupBinaryName(platform = process.platform) { return platform === "win32" ? "windows-sandbox-setup.exe" : null; } +function windowsSandboxRunnerBinaryName(platform = process.platform) { + return platform === "win32" ? "windows-sandbox-runner.exe" : null; +} + function sha256(filePath) { return createHash("sha256").update(readFileSync(filePath)).digest("hex"); } @@ -204,6 +208,8 @@ export function verifyResourceRoot( }); let windowsSandboxSetupPath = null; let windowsSandboxSetupIntegrity = null; + let windowsSandboxRunnerPath = null; + let windowsSandboxRunnerIntegrity = null; if (platform === "win32") { windowsSandboxSetupPath = path.join( root, @@ -221,6 +227,22 @@ export function verifyResourceRoot( execFileSyncImpl, }, ); + windowsSandboxRunnerPath = path.join( + root, + "app-server", + key, + windowsSandboxRunnerBinaryName(platform), + ); + assertFile(windowsSandboxRunnerPath, "Windows sandbox runner"); + windowsSandboxRunnerIntegrity = verifySidecarIntegrity( + windowsSandboxRunnerPath, + artifact.windowsSandboxRunnerSha256, + { + platform, + label: "Windows sandbox runner", + execFileSyncImpl, + }, + ); } for (const name of [ @@ -247,6 +269,8 @@ export function verifyResourceRoot( codeModeHostIntegrity: codeModeHost, windowsSandboxSetupPath, windowsSandboxSetupIntegrity, + windowsSandboxRunnerPath, + windowsSandboxRunnerIntegrity, sha256: appServer, }; } diff --git a/scripts/electron/verify-package-resources.test.mjs b/scripts/electron/verify-package-resources.test.mjs index 72802db55..7d7f6103e 100644 --- a/scripts/electron/verify-package-resources.test.mjs +++ b/scripts/electron/verify-package-resources.test.mjs @@ -56,9 +56,11 @@ function createResourceRoot({ appServer = "signed app-server", codeModeHost = "signed code-mode-host", windowsSandboxSetup = "signed windows-sandbox-setup", + windowsSandboxRunner = "signed windows-sandbox-runner", manifestAppServer = "unsigned app-server", manifestCodeModeHost = "unsigned code-mode-host", manifestWindowsSandboxSetup = "unsigned windows-sandbox-setup", + manifestWindowsSandboxRunner = "unsigned windows-sandbox-runner", platformKey = "darwin-arm64", } = {}) { const root = mkdtempSync(path.join(tmpdir(), "lime-electron-resources-")); @@ -81,6 +83,10 @@ function createResourceRoot({ path.join(sidecarDir, "windows-sandbox-setup.exe"), windowsSandboxSetup, ); + writeFileSync( + path.join(sidecarDir, "windows-sandbox-runner.exe"), + windowsSandboxRunner, + ); } for (const name of [ "icon.png", @@ -102,7 +108,12 @@ function createResourceRoot({ sha256: sha256(manifestAppServer), codeModeHostSha256: sha256(manifestCodeModeHost), ...(platformKey.startsWith("win32-") - ? { windowsSandboxSetupSha256: sha256(manifestWindowsSandboxSetup) } + ? { + windowsSandboxSetupSha256: sha256(manifestWindowsSandboxSetup), + windowsSandboxRunnerSha256: sha256( + manifestWindowsSandboxRunner, + ), + } : {}), }, ], @@ -238,15 +249,17 @@ describe("verify-electron-package-resources sidecar integrity", () => { ]); }); - it("Windows 资源必须包含 sandbox setup helper 并校验哈希", () => { + it("Windows 资源必须包含 sandbox setup helper 与 command runner 并校验哈希", () => { const root = createResourceRoot({ platformKey: "win32-x64", appServer: "app-server", codeModeHost: "code-mode-host", windowsSandboxSetup: "windows-sandbox-setup", + windowsSandboxRunner: "windows-sandbox-runner", manifestAppServer: "app-server", manifestCodeModeHost: "code-mode-host", manifestWindowsSandboxSetup: "windows-sandbox-setup", + manifestWindowsSandboxRunner: "windows-sandbox-runner", }); const result = verifyResourceRoot(root, { @@ -255,6 +268,7 @@ describe("verify-electron-package-resources sidecar integrity", () => { }); expect(result.windowsSandboxSetupIntegrity.acceptedBecause).toBe("sha256"); + expect(result.windowsSandboxRunnerIntegrity.acceptedBecause).toBe("sha256"); }); it("拒绝哈希变化且严格 codesign 失败的 macOS code-mode host", () => { diff --git a/scripts/lib/electron-app-server-assets.mjs b/scripts/lib/electron-app-server-assets.mjs index ce3e26a78..0551d2099 100644 --- a/scripts/lib/electron-app-server-assets.mjs +++ b/scripts/lib/electron-app-server-assets.mjs @@ -16,6 +16,7 @@ import { appServerBinaryName, codeModeHostBinaryName, resolveDevAppServerBinary, + windowsSandboxRunnerBinaryName, } from "./electron-dev-sidecar.mjs"; import { ensureMacBinaryRpath, @@ -47,6 +48,12 @@ export function windowsSandboxSetupResourceBinaryName( return platform === "win32" ? "windows-sandbox-setup.exe" : null; } +export function windowsSandboxRunnerResourceBinaryName( + platform = process.platform, +) { + return windowsSandboxRunnerBinaryName(platform); +} + export function appServerResourcePlatformKey( platform = process.platform, arch = process.arch, @@ -117,10 +124,24 @@ export function electronWindowsSandboxSetupBinaryDestination({ return path.resolve(outputRoot, "app-server", platformKey, name); } +export function electronWindowsSandboxRunnerBinaryDestination({ + outputRoot = electronAppServerResourcesRoot(), + platform = process.platform, + arch = process.arch, +} = {}) { + const name = windowsSandboxRunnerResourceBinaryName(platform); + if (!name) { + return null; + } + const platformKey = appServerResourcePlatformKey(platform, arch); + return path.resolve(outputRoot, "app-server", platformKey, name); +} + export async function buildElectronAppServerReleaseManifest({ binaryPath, codeModeHostBinaryPath, windowsSandboxSetupBinaryPath, + windowsSandboxRunnerBinaryPath, version, platform = appServerResourcePlatformKey(), sha256File = hashFile, @@ -150,6 +171,14 @@ export async function buildElectronAppServerReleaseManifest({ ), ), ); + artifact.windowsSandboxRunnerSha256 = await sha256File( + path.resolve( + requiredValue( + windowsSandboxRunnerBinaryPath, + "windowsSandboxRunnerBinaryPath", + ), + ), + ); } return { @@ -167,6 +196,7 @@ export async function prepareElectronAppServerAssets({ sourceBinary, sourceCodeModeHostBinary, sourceWindowsSandboxSetupBinary, + sourceWindowsSandboxRunnerBinary, resolveBinary = resolveDevAppServerBinary, env = process.env, readPackageJson = readJsonFile, @@ -200,6 +230,12 @@ export async function prepareElectronAppServerAssets({ platform, arch, }); + const windowsSandboxRunnerDestination = + electronWindowsSandboxRunnerBinaryDestination({ + outputRoot, + platform, + arch, + }); const manifestPath = electronAppServerManifestPath({ outputRoot }); const resolvedSourceBinary = path.resolve( sourceBinary ?? @@ -236,6 +272,16 @@ export async function prepareElectronAppServerAssets({ ), ) : null; + const resolvedSourceWindowsSandboxRunnerBinary = + windowsSandboxRunnerDestination + ? path.resolve( + sourceWindowsSandboxRunnerBinary ?? + path.join( + path.dirname(resolvedSourceBinary), + windowsSandboxRunnerResourceBinaryName(platform), + ), + ) + : null; if ( windowsSandboxSetupDestination && resolvedSourceWindowsSandboxSetupBinary === windowsSandboxSetupDestination @@ -244,6 +290,14 @@ export async function prepareElectronAppServerAssets({ `Windows sandbox setup asset source must not equal packaged destination: ${windowsSandboxSetupDestination}`, ); } + if ( + windowsSandboxRunnerDestination && + resolvedSourceWindowsSandboxRunnerBinary === windowsSandboxRunnerDestination + ) { + throw new Error( + `Windows sandbox runner asset source must not equal packaged destination: ${windowsSandboxRunnerDestination}`, + ); + } await makeDir(path.dirname(destination), { recursive: true }); await rm(destination, { force: true }); @@ -251,6 +305,9 @@ export async function prepareElectronAppServerAssets({ if (windowsSandboxSetupDestination) { await rm(windowsSandboxSetupDestination, { force: true }); } + if (windowsSandboxRunnerDestination) { + await rm(windowsSandboxRunnerDestination, { force: true }); + } await copy(resolvedSourceBinary, destination); await copy(resolvedSourceCodeModeHostBinary, codeModeHostDestination); if (windowsSandboxSetupDestination) { @@ -259,6 +316,12 @@ export async function prepareElectronAppServerAssets({ windowsSandboxSetupDestination, ); } + if (windowsSandboxRunnerDestination) { + await copy( + resolvedSourceWindowsSandboxRunnerBinary, + windowsSandboxRunnerDestination, + ); + } await clearLaunchBlockingXattrs(destination, platform); await clearLaunchBlockingXattrs(codeModeHostDestination, platform); const sourceStat = await getStat(resolvedSourceBinary); @@ -268,6 +331,9 @@ export async function prepareElectronAppServerAssets({ const windowsSandboxSetupSourceStat = windowsSandboxSetupDestination ? await getStat(resolvedSourceWindowsSandboxSetupBinary) : null; + const windowsSandboxRunnerSourceStat = windowsSandboxRunnerDestination + ? await getStat(resolvedSourceWindowsSandboxRunnerBinary) + : null; await changeMode(destination, sourceStat.mode); await changeMode(codeModeHostDestination, codeModeHostSourceStat.mode); if (windowsSandboxSetupDestination) { @@ -276,6 +342,12 @@ export async function prepareElectronAppServerAssets({ windowsSandboxSetupSourceStat.mode, ); } + if (windowsSandboxRunnerDestination) { + await changeMode( + windowsSandboxRunnerDestination, + windowsSandboxRunnerSourceStat.mode, + ); + } prepareRuntimeBinary({ binaryPath: destination, platform }); prepareRuntimeBinary({ binaryPath: codeModeHostDestination, platform }); if (windowsSandboxSetupDestination) { @@ -284,6 +356,12 @@ export async function prepareElectronAppServerAssets({ platform, }); } + if (windowsSandboxRunnerDestination) { + prepareRuntimeBinary({ + binaryPath: windowsSandboxRunnerDestination, + platform, + }); + } const runtimeLibraries = await copyRuntimeLibraries({ repoRoot, platform, @@ -298,6 +376,7 @@ export async function prepareElectronAppServerAssets({ binaryPath: destination, codeModeHostBinaryPath: codeModeHostDestination, windowsSandboxSetupBinaryPath: windowsSandboxSetupDestination, + windowsSandboxRunnerBinaryPath: windowsSandboxRunnerDestination, version, platform: appServerResourcePlatformKey(platform, arch), sha256File, @@ -308,9 +387,11 @@ export async function prepareElectronAppServerAssets({ sourceBinary: resolvedSourceBinary, sourceCodeModeHostBinary: resolvedSourceCodeModeHostBinary, sourceWindowsSandboxSetupBinary: resolvedSourceWindowsSandboxSetupBinary, + sourceWindowsSandboxRunnerBinary: resolvedSourceWindowsSandboxRunnerBinary, binaryPath: destination, codeModeHostBinaryPath: codeModeHostDestination, windowsSandboxSetupBinaryPath: windowsSandboxSetupDestination, + windowsSandboxRunnerBinaryPath: windowsSandboxRunnerDestination, manifestPath, manifest, runtimeLibraries, diff --git a/scripts/lib/electron-dev-sidecar.mjs b/scripts/lib/electron-dev-sidecar.mjs index 329c2dd36..5879fc8cf 100644 --- a/scripts/lib/electron-dev-sidecar.mjs +++ b/scripts/lib/electron-dev-sidecar.mjs @@ -24,6 +24,10 @@ export function windowsSandboxSetupBinaryName(platform = process.platform) { return platform === "win32" ? "windows-sandbox-setup.exe" : null; } +export function windowsSandboxRunnerBinaryName(platform = process.platform) { + return platform === "win32" ? "windows-sandbox-runner.exe" : null; +} + export function localAppServerBinaryPath({ repoRoot = process.cwd(), platform = process.platform, @@ -53,6 +57,15 @@ export function localWindowsSandboxSetupBinaryPath({ return binaryName ? path.resolve(targetDirectory, "debug", binaryName) : null; } +export function localWindowsSandboxRunnerBinaryPath({ + repoRoot = process.cwd(), + platform = process.platform, + targetDirectory = resolveCargoTargetDirectory({ repoRoot, platform }), +} = {}) { + const binaryName = windowsSandboxRunnerBinaryName(platform); + return binaryName ? path.resolve(targetDirectory, "debug", binaryName) : null; +} + export function resolveCargoTargetDirectory({ env = process.env, platform = process.platform, @@ -170,11 +183,18 @@ export function resolveDevAppServerBinary({ platform, ...(targetDirectory ? { targetDirectory } : {}), }); + const windowsSandboxRunnerPath = localWindowsSandboxRunnerBinaryPath({ + repoRoot, + platform, + ...(targetDirectory ? { targetDirectory } : {}), + }); const hasUsableBinaries = () => isUsableBinary(binaryPath, { exists }) && isUsableBinary(codeModeHostPath, { exists }) && (!windowsSandboxSetupPath || - isUsableBinary(windowsSandboxSetupPath, { exists })); + isUsableBinary(windowsSandboxSetupPath, { exists })) && + (!windowsSandboxRunnerPath || + isUsableBinary(windowsSandboxRunnerPath, { exists })); if (forceBuild || !hasUsableBinaries()) { build({ repoRoot, platform, env }); } @@ -185,6 +205,7 @@ export function resolveDevAppServerBinary({ binaryPath, codeModeHostPath, windowsSandboxSetupPath, + windowsSandboxRunnerPath, ] .filter(Boolean) .join(", ")}`, @@ -196,6 +217,9 @@ export function resolveDevAppServerBinary({ if (windowsSandboxSetupPath) { prepareBinary({ binaryPath: windowsSandboxSetupPath, platform }); } + if (windowsSandboxRunnerPath) { + prepareBinary({ binaryPath: windowsSandboxRunnerPath, platform }); + } return binaryPath; } @@ -280,6 +304,8 @@ export function cargoBuildAppServerArgs({ repoRoot = process.cwd() } = {}) { "code-mode-host", "--bin", "windows-sandbox-setup", + "--bin", + "windows-sandbox-runner", ]; } @@ -325,6 +351,13 @@ export function buildLocalAppServer({ if (windowsSetupBinaryPath) { prepareBinary({ binaryPath: windowsSetupBinaryPath, platform }); } + const windowsRunnerBinaryPath = localWindowsSandboxRunnerBinaryPath({ + repoRoot, + platform, + }); + if (windowsRunnerBinaryPath) { + prepareBinary({ binaryPath: windowsRunnerBinaryPath, platform }); + } } export function buildLocalAppServerAsync({ @@ -367,6 +400,13 @@ export function buildLocalAppServerAsync({ if (windowsSetupBinaryPath) { prepareBinary({ binaryPath: windowsSetupBinaryPath, platform }); } + const windowsRunnerBinaryPath = localWindowsSandboxRunnerBinaryPath({ + repoRoot, + platform, + }); + if (windowsRunnerBinaryPath) { + prepareBinary({ binaryPath: windowsRunnerBinaryPath, platform }); + } } catch (error) { reject(error); return; diff --git a/scripts/lib/electron-dev-sidecar.test.mjs b/scripts/lib/electron-dev-sidecar.test.mjs index 00fd545f7..dd5850ee2 100644 --- a/scripts/lib/electron-dev-sidecar.test.mjs +++ b/scripts/lib/electron-dev-sidecar.test.mjs @@ -9,6 +9,7 @@ import { localAppServerBinaryPath, localCodeModeHostBinaryPath, localWindowsSandboxSetupBinaryPath, + localWindowsSandboxRunnerBinaryPath, resolveDevAppServerBackendEnv, resolveDevAppServerBinary, resolveCargoTargetDirectory, @@ -48,6 +49,17 @@ describe("electron dev sidecar", () => { ).toBe( path.resolve("/repo/lime/lime-rs/target/debug/windows-sandbox-setup.exe"), ); + expect( + localWindowsSandboxRunnerBinaryPath({ + repoRoot: "/repo/lime", + platform: "win32", + targetDirectory: path.resolve("/repo/lime/lime-rs/target"), + }), + ).toBe( + path.resolve( + "/repo/lime/lime-rs/target/debug/windows-sandbox-runner.exe", + ), + ); }); it("读取仓库 cargo target-dir 配置", () => { @@ -204,9 +216,17 @@ describe("electron dev sidecar", () => { expect(builds).toEqual([ { repoRoot: "/repo/lime", platform: "win32", env: {} }, ]); - expect(prepared.at(-1)).toEqual([ + expect(prepared).toContainEqual([ { binaryPath: setupPath, platform: "win32" }, ]); + expect(prepared).toContainEqual([ + { + binaryPath: path.resolve( + "/repo/lime/lime-rs/target/debug/windows-sandbox-runner.exe", + ), + platform: "win32", + }, + ]); }); it("空 app-server 文件不算可用二进制", () => { @@ -332,6 +352,8 @@ describe("electron dev sidecar", () => { "code-mode-host", "--bin", "windows-sandbox-setup", + "--bin", + "windows-sandbox-runner", ], options: { cwd: "/repo/lime", diff --git a/scripts/prepare-sherpa-onnx-runtime.test.mjs b/scripts/prepare-sherpa-onnx-runtime.test.mjs index cd99dca08..9b5725b39 100644 --- a/scripts/prepare-sherpa-onnx-runtime.test.mjs +++ b/scripts/prepare-sherpa-onnx-runtime.test.mjs @@ -110,7 +110,7 @@ version = "1.13.0" ); const sherpaOffset = windowsJob.indexOf("Prepare sherpa-onnx runtime"); const sidecarBuildOffset = windowsJob.indexOf( - "Build Windows app-server, code-mode-host, and sandbox setup sidecars", + "Build Windows app-server, code-mode-host, and sandbox sidecars", ); const timeoutContractOffset = windowsJob.indexOf( "Test Windows command timeout contract",