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:
Huanqi Cao
2026-08-08 11:46:05 +08:00
parent aa790346e4
commit d8acd2b656
12 changed files with 113 additions and 30 deletions

View File

@@ -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"
}

View File

@@ -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', () => {

View File

@@ -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-'))

View File

@@ -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:^",

View File

@@ -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]

View File

@@ -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])
})
})

View File

@@ -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",