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%.
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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-'))
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
-5
@@ -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
|
||||
|
||||
@@ -110,6 +110,9 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
'@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',
|
||||
|
||||
+14
-8
@@ -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}',
|
||||
|
||||
Reference in New Issue
Block a user