Ver Fonte

fix(ci): pass the pwsh-less self-hosted Linux runners

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%.
Huanqi Cao há 1 mês atrás
pai
commit
d8acd2b656

+ 10 - 0
docs/module-graph.md

@@ -42,6 +42,7 @@ flowchart TD
     pkg_bash_local["bash-local"]
     pkg_bash_local["bash-local"]
     pkg_bash_sandbox["bash-sandbox"]
     pkg_bash_sandbox["bash-sandbox"]
     pkg_pwsh_local["pwsh-local"]
     pkg_pwsh_local["pwsh-local"]
+    pkg_pwsh_sandbox["pwsh-sandbox"]
     pkg_tool_bash["tool-bash"]
     pkg_tool_bash["tool-bash"]
     pkg_tool_pwsh["tool-pwsh"]
     pkg_tool_pwsh["tool-pwsh"]
   end
   end
@@ -236,6 +237,7 @@ flowchart TD
     pkg_sandbox["sandbox"]
     pkg_sandbox["sandbox"]
     pkg_sandbox_local["sandbox-local"]
     pkg_sandbox_local["sandbox-local"]
     pkg_sandbox_policy["sandbox-policy"]
     pkg_sandbox_policy["sandbox-policy"]
+    pkg_sandbox_windows_acl["sandbox-windows-acl"]
   end
   end
   subgraph group_sdk["packages/sdk"]
   subgraph group_sdk["packages/sdk"]
     pkg_helper["helper"]
     pkg_helper["helper"]
@@ -309,6 +311,7 @@ flowchart TD
   pkg_host_apiproxy --> pkg_invariants
   pkg_host_apiproxy --> pkg_invariants
   pkg_host_directory_picker --> pkg_invariants
   pkg_host_directory_picker --> pkg_invariants
   pkg_host_webserver --> pkg_invariants
   pkg_host_webserver --> pkg_invariants
+  pkg_sandbox_windows_acl --> pkg_invariants
   pkg_storage --> pkg_invariants
   pkg_storage --> pkg_invariants
   pkg_subprocess --> pkg_invariants
   pkg_subprocess --> pkg_invariants
   pkg_typert_generator --> pkg_invariants
   pkg_typert_generator --> pkg_invariants
@@ -632,6 +635,11 @@ flowchart TD
   pkg_bash_sandbox --> pkg_invariants
   pkg_bash_sandbox --> pkg_invariants
   pkg_bash_sandbox --> pkg_sandbox
   pkg_bash_sandbox --> pkg_sandbox
   pkg_bash_sandbox --> pkg_sandbox_policy
   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
   pkg_fs_sandbox --> pkg_fs_local
   pkg_fs_sandbox --> pkg_fs_local
   pkg_fs_sandbox --> pkg_invariants
   pkg_fs_sandbox --> pkg_invariants
@@ -1152,6 +1160,7 @@ flowchart TD
 | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) |
 | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) |
 | [`host-directory-picker`](../packages/host/directory-picker) | `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) |
 | [`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) |
 | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
 | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) |
 | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) |
 | [`typert-generator`](../packages/typert/generator) | `typert` | [`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) |
 | [`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) |
 | [`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) |
 | [`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) |
 | [`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) |
 | [`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) |
 | [`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) |

+ 12 - 1
knip.json

@@ -7,7 +7,8 @@
     "bwrap",
     "bwrap",
     "python3",
     "python3",
     "sandbox-exec",
     "sandbox-exec",
-    "taskkill"
+    "taskkill",
+    "where.exe"
   ],
   ],
   "ignoreWorkspaces": [
   "ignoreWorkspaces": [
     "vendor/*",
     "vendor/*",
@@ -217,6 +218,16 @@
         "tests/**/*.ts"
         "tests/**/*.ts"
       ]
       ]
     },
     },
+    "packages/bash/pwsh-sandbox": {
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
+    },
     "packages/context/time-context": {
     "packages/context/time-context": {
       "entry": [
       "entry": [
         "tests/**/*.spec.ts",
         "tests/**/*.spec.ts",

+ 0 - 1
packages/bash/pwsh-sandbox/package.json

@@ -39,7 +39,6 @@
     "@deepseek-ai/dsh-sandbox": "workspace:^",
     "@deepseek-ai/dsh-sandbox": "workspace:^",
     "@deepseek-ai/dsh-sandbox-local": "workspace:^",
     "@deepseek-ai/dsh-sandbox-local": "workspace:^",
     "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
     "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
-    "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^",
     "@deepseek-ai/dsh-subprocess-local": "workspace:^",
     "@deepseek-ai/dsh-subprocess-local": "workspace:^",
     "cordis": "^4.0.0-rc.7"
     "cordis": "^4.0.0-rc.7"
   }
   }

+ 2 - 6
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 { afterAll, beforeAll, describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
 import { Context } from 'cordis'
 import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
 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 { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
 import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
 import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
 import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
 import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
@@ -22,12 +23,7 @@ import { SandboxPwshExecutor } from '../src/index.ts'
 const isWin32 = process.platform === 'win32'
 const isWin32 = process.platform === 'win32'
 
 
 function pwshAvailable(): boolean {
 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', () => {
 describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => {

+ 5 - 6
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 { Context, Service } from 'cordis'
 import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
 import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
 import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } 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 { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
 import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
 import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
 import { SandboxPwshExecutor } from '../src/index.ts'
 import { SandboxPwshExecutor } from '../src/index.ts'
 import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.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 {
 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-'))
 const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-'))

+ 1 - 1
packages/bundle/base/package.json

@@ -58,7 +58,7 @@
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-permission": "workspace:^",
     "@deepseek-ai/dsh-permission": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "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-repeat-tool-guard": "workspace:^",
     "@deepseek-ai/dsh-repository-plugin": "workspace:^",
     "@deepseek-ai/dsh-repository-plugin": "workspace:^",
     "@deepseek-ai/dsh-sandbox-local": "workspace:^",
     "@deepseek-ai/dsh-sandbox-local": "workspace:^",

+ 3 - 1
packages/sandbox/sandbox-local/src/index.ts

@@ -112,6 +112,8 @@ export interface SandboxInternals {
   seatbeltExec?: string
   seatbeltExec?: string
   /** Replaces the resolved windows-acl runner argv prefix (a fake runner). */
   /** Replaces the resolved windows-acl runner argv prefix (a fake runner). */
   windowsAclRunnerArgs?: string[]
   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). */
   /** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */
   probeWindowsAcl?: () => boolean
   probeWindowsAcl?: () => boolean
 }
 }
@@ -368,7 +370,7 @@ export class LocalSandboxProvider extends SandboxProvider {
   private windowsAclRunnerInvocation(): string[] {
   private windowsAclRunnerInvocation(): string[] {
     const override = this.internals.windowsAclRunnerArgs
     const override = this.internals.windowsAclRunnerArgs
     if (override !== undefined) return override
     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]
     if (existsSync(builtEntry)) return [process.execPath, builtEntry]
     const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts'))
     const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts'))
     return [process.execPath, '--import', 'tsx/esm', sourceEntry]
     return [process.execPath, '--import', 'tsx/esm', sourceEntry]

+ 60 - 0
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 }))
     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])
+  })
+})

+ 1 - 1
packages/sandbox/sandbox-windows-acl/package.json

@@ -24,8 +24,8 @@
   },
   },
   "files": [
   "files": [
     "lib/index.js",
     "lib/index.js",
-    "lib/runner.js",
     "lib/invariant.js",
     "lib/invariant.js",
+    "lib/runner.js",
     "lib/types/**/*.d.ts"
     "lib/types/**/*.d.ts"
   ],
   ],
   "license": "BSD-3-Clause",
   "license": "BSD-3-Clause",

+ 2 - 5
pnpm-lock.yaml

@@ -773,9 +773,6 @@ importers:
       '@deepseek-ai/dsh-sandbox-policy':
       '@deepseek-ai/dsh-sandbox-policy':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../sandbox/sandbox-policy
         version: link:../../sandbox/sandbox-policy
-      '@deepseek-ai/dsh-sandbox-windows-acl':
-        specifier: workspace:^
-        version: link:../../sandbox/sandbox-windows-acl
       '@deepseek-ai/dsh-subprocess-local':
       '@deepseek-ai/dsh-subprocess-local':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../subprocess/subprocess-local
         version: link:../../subprocess/subprocess-local
@@ -973,9 +970,9 @@ importers:
       '@deepseek-ai/dsh-plan-mode':
       '@deepseek-ai/dsh-plan-mode':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../plan/plan-mode
         version: link:../../plan/plan-mode
-      '@deepseek-ai/dsh-pwsh-local':
+      '@deepseek-ai/dsh-pwsh-sandbox':
         specifier: workspace:^
         specifier: workspace:^
-        version: link:../../bash/pwsh-local
+        version: link:../../bash/pwsh-sandbox
       '@deepseek-ai/dsh-repeat-tool-guard':
       '@deepseek-ai/dsh-repeat-tool-guard':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../guard/repeat-tool-guard
         version: link:../../guard/repeat-tool-guard

+ 3 - 0
scripts/check-workspace-constraints.ts

@@ -110,6 +110,9 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
   '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
   '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
   '@deepseek-ai/dsh-helper': ['lib/assets'],
   '@deepseek-ai/dsh-helper': ['lib/assets'],
   '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'],
   '@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': [
   '@deepseek-ai/dsh-scripts': [
     'lib/dev/tsdown-config.js',
     'lib/dev/tsdown-config.js',
     'lib/local-plugin-loader-hooks.js',
     'lib/local-plugin-loader-hooks.js',

+ 14 - 8
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
 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 = [
 const testIncludes = [
   'packages/*/*/tests/**/*.spec.{ts,tsx}',
   'packages/*/*/tests/**/*.spec.{ts,tsx}',