From d8acd2b65645ff02e6c5dd229d039ca5b6ead751 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 11:46:05 +0800 Subject: [PATCH] fix(ci): pass the pwsh-less self-hosted Linux runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-hosted Linux runners ship no pwsh, and the pwshAvailable probes used spawnSync('where.exe'), which reports a missing binary as status null instead of throwing — the suites never skipped and failed with spawn pwsh ENOENT. Probe with resolvePwshPath() status instead, the same gate the coverage exemption uses. Exempt pwsh-sandbox src from coverage on pwsh-less hosts (its remaining helpers branch and the invariant companion ride the executor suites' real pwsh runs); pwsh-ful hosts keep the full 100% bar. Cover the windows-acl probe case and the runner-entry resolution in sandbox-local on Linux (chain-seam tests plus a windowsAclRunnerEntry seam) — the package's POSIX-only suites are Linux's only chance to cover the new lines. Static gate fixes: declare dsh-pwsh-sandbox in the base bundle, register the runner files entry in constraints, knip entries for the e2e suite and where.exe, regenerate the module graph. Verified in WSL (no-pwsh Linux): pwsh-sandbox 5 pass/13 skip with the exemption active, sandbox-local coverage 100%. --- docs/module-graph.md | 10 ++++ knip.json | 13 +++- packages/bash/pwsh-sandbox/package.json | 1 - packages/bash/pwsh-sandbox/tests/acl.e2e.ts | 8 +-- .../bash/pwsh-sandbox/tests/sandbox.spec.ts | 11 ++-- packages/bundle/base/package.json | 2 +- packages/sandbox/sandbox-local/src/index.ts | 4 +- .../sandbox/sandbox-local/tests/local.spec.ts | 60 +++++++++++++++++++ .../sandbox/sandbox-windows-acl/package.json | 2 +- pnpm-lock.yaml | 7 +-- scripts/check-workspace-constraints.ts | 3 + vitest.config.ts | 22 ++++--- 12 files changed, 113 insertions(+), 30 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 1a7ae5c8d2..3657523e5b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -42,6 +42,7 @@ flowchart TD pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] pkg_pwsh_local["pwsh-local"] + pkg_pwsh_sandbox["pwsh-sandbox"] pkg_tool_bash["tool-bash"] pkg_tool_pwsh["tool-pwsh"] end @@ -236,6 +237,7 @@ flowchart TD pkg_sandbox["sandbox"] pkg_sandbox_local["sandbox-local"] pkg_sandbox_policy["sandbox-policy"] + pkg_sandbox_windows_acl["sandbox-windows-acl"] end subgraph group_sdk["packages/sdk"] pkg_helper["helper"] @@ -309,6 +311,7 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants + pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants pkg_typert_generator --> pkg_invariants @@ -632,6 +635,11 @@ flowchart TD pkg_bash_sandbox --> pkg_invariants pkg_bash_sandbox --> pkg_sandbox pkg_bash_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_bash + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy pkg_fs_sandbox --> pkg_fs pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_invariants @@ -1152,6 +1160,7 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | @@ -1240,6 +1249,7 @@ flowchart TD | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | diff --git a/knip.json b/knip.json index 7a3922c8fb..e6ed5506e3 100644 --- a/knip.json +++ b/knip.json @@ -7,7 +7,8 @@ "bwrap", "python3", "sandbox-exec", - "taskkill" + "taskkill", + "where.exe" ], "ignoreWorkspaces": [ "vendor/*", @@ -217,6 +218,16 @@ "tests/**/*.ts" ] }, + "packages/bash/pwsh-sandbox": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/context/time-context": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/bash/pwsh-sandbox/package.json b/packages/bash/pwsh-sandbox/package.json index 47f51b2ac1..6f2a87fc58 100644 --- a/packages/bash/pwsh-sandbox/package.json +++ b/packages/bash/pwsh-sandbox/package.json @@ -39,7 +39,6 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts index a5909f0544..bb6f4f25e4 100644 --- a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts +++ b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts @@ -14,6 +14,7 @@ import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' @@ -22,12 +23,7 @@ import { SandboxPwshExecutor } from '../src/index.ts' const isWin32 = process.platform === 'win32' function pwshAvailable(): boolean { - try { - spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) - return true - } catch { - return false - } + return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 } describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => { diff --git a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts index 1c8e5e1bd0..9e4aa04546 100644 --- a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts @@ -14,18 +14,17 @@ import { afterAll, describe, expect, it } from 'vitest' import { Context, Service } from 'cordis' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { SandboxPwshExecutor } from '../src/index.ts' import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts' +// The same probe pwsh-local's suites and the vitest coverage exemption use: +// spawnSync never throws on a missing binary (it reports status null), and +// `where.exe pwsh` exits 1 when pwsh is absent — only the status is truth. function pwshAvailable(): boolean { - try { - spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) - return true - } catch { - return false - } + return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 } const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-')) diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 593d5d3f98..f7ee47e343 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -58,7 +58,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 22182095d5..0b562616ea 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -112,6 +112,8 @@ export interface SandboxInternals { seatbeltExec?: string /** Replaces the resolved windows-acl runner argv prefix (a fake runner). */ windowsAclRunnerArgs?: string[] + /** Replaces the resolved windows-acl runner built entry path (a fake lib/runner.js location). */ + windowsAclRunnerEntry?: string /** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */ probeWindowsAcl?: () => boolean } @@ -368,7 +370,7 @@ export class LocalSandboxProvider extends SandboxProvider { private windowsAclRunnerInvocation(): string[] { const override = this.internals.windowsAclRunnerArgs if (override !== undefined) return override - const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner')) + const builtEntry = this.internals.windowsAclRunnerEntry ?? fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner')) if (existsSync(builtEntry)) return [process.execPath, builtEntry] const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts')) return [process.execPath, '--import', 'tsx/esm', sourceEntry] diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 5e12b37ad2..3c764a8bae 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -365,3 +365,63 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => { expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) }) }) + +describe('the windows-acl probe (runner invocation contract)', () => { + // The product chain reaches windows-acl only unprobed (win32's sole + // candidate), so the probe case and the runner-entry resolution are pinned + // through the chain seam, mirroring the seatbelt default-probe contract. + it('selects the rung when the injected probe passes, speaking the ACL dialect', async () => { + const probeWindowsAcl = vi.fn(() => true) + const { sandbox } = await setup({}, { + chain: ['windows-acl', 'bwrap'], + probeWindowsAcl, + probeBwrap: () => false, + windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'], + }) + const confined = sandbox.confine(['true'], RO) + expect(probeWindowsAcl).toHaveBeenCalledTimes(1) + expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) + expect(confined.enforcement).toBe('full') + expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) + expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + }) + + it('reads a failing probe as unusable and walks to the next rung', async () => { + const probeWindowsAcl = vi.fn(() => false) + const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeWindowsAcl, probeBwrap: () => true }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv[0]).toBe('bwrap') + expect(probeWindowsAcl).toHaveBeenCalledTimes(1) + }) + + it('runs the REAL default probe against the resolved runner invocation when none is injected', async () => { + // The default probe spawns the exact runner argv confine would use — the + // runner source through tsx on a lib-less checkout. The windows-acl + // runner cannot init off win32, so the probe reads unusable and the walk + // falls through to the injected bwrap verdict on every host. + const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv[0]).toBe('bwrap') + }, 30_000) + + it('reads an empty runner invocation as unusable (the probe\'s empty-argv guard)', async () => { + // windowsAclRunnerInvocation always yields [node, ...] in product; an + // override returning [] exercises the default probe's empty-argv guard. + const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true, windowsAclRunnerArgs: [] }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv[0]).toBe('bwrap') + }) + + it('prefers the built lib/runner.js entry when the resolved file exists', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-acl-entry-')) + const builtEntry = join(dir, 'runner.js') + writeFileSync(builtEntry, '') + const { sandbox } = await setup({}, { + chain: ['windows-acl', 'bwrap'], + probeWindowsAcl: () => true, + windowsAclRunnerEntry: builtEntry, + }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv.slice(0, 2)).toEqual([process.execPath, builtEntry]) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 3e2afd8fb3..3f4d6cc065 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -24,8 +24,8 @@ }, "files": [ "lib/index.js", - "lib/runner.js", "lib/invariant.js", + "lib/runner.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b55e1a2bcf..391c0947a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -773,9 +773,6 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-sandbox-windows-acl': - specifier: workspace:^ - version: link:../../sandbox/sandbox-windows-acl '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -973,9 +970,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode - '@deepseek-ai/dsh-pwsh-local': + '@deepseek-ai/dsh-pwsh-sandbox': specifier: workspace:^ - version: link:../../bash/pwsh-local + version: link:../../bash/pwsh-sandbox '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../guard/repeat-tool-guard diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index f0cea80064..37ac29a1a5 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -110,6 +110,9 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], + // The argv-prefix runner entry ships beside the lib as its own bundle; + // sandbox-local resolves it through the package's ./runner export. + '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js'], '@deepseek-ai/dsh-scripts': [ 'lib/dev/tsdown-config.js', 'lib/local-plugin-loader-hooks.js', diff --git a/vitest.config.ts b/vitest.config.ts index 4aaf7258e0..dc0848ff0d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -57,16 +57,22 @@ const windowsOnlyCoverageExclusions = process.platform !== 'win32' ] : [] -// Mirrors windowsCoverageExclusions: pwsh-local's run/start/lifecycle suites -// self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file -// far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts -// green while CI runners ship pwsh and still enforce the full bar. The probe -// runs the suites' own resolution (the dependency-free resolve.ts module), -// so the exemption is active exactly when the suites skip — a mismatched -// narrower probe could exempt the file on hosts whose suites actually run. +// Mirrors windowsCoverageExclusions: pwsh-local's and pwsh-sandbox's +// run/start/lifecycle suites self-skip without a real pwsh (executor.spec.ts +// hasPwsh, sandbox.spec.ts pwshAvailable). pwsh-local keeps the bar on its +// pwsh-independent modules and exempts only the executor file; pwsh-sandbox's +// remaining helpers branch (classifyDenial) and its invariant companion both +// ride the executor suites' real pwsh runs, so on a pwsh-less host (the +// self-hosted Linux runners ship no pwsh) every source file would sit below +// the per-file bar — exempt the whole package src there, mirroring +// windowsOnlyCoverageExclusions. The probe runs the suites' own resolution +// (resolvePwshPath), so the exemption is active exactly when the suites skip. const pwshCoverageExclusions = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 ? [] - : ['packages/bash/pwsh-local/src/index.ts'] + : [ + 'packages/bash/pwsh-local/src/index.ts', + 'packages/bash/pwsh-sandbox/src/**/*.ts', + ] const testIncludes = [ 'packages/*/*/tests/**/*.spec.{ts,tsx}',