fix(sandbox): isolate Windows temp capabilities
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md
|
||||
README.md: 2d36802305ebe34676d896404913b4686d318b97
|
||||
README.zh.md: 565910781dc0f43635dd22d08fefb32f7f4f27e3
|
||||
README.md: d9294948161cfa54d1269b35b236e651b2a9f916
|
||||
README.zh.md: c6836089012c850af8a26e768d4107f257a9914a
|
||||
|
||||
@@ -12,6 +12,8 @@ Policy is per call; the provider stores only the mechanism and cached runner ver
|
||||
|
||||
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.
|
||||
|
||||
The Windows rung keeps one deterministic write SID and standing ACE per workspace, but gives every live session/workspace pair a random private temp directory with a distinct SID and revocable ACE. Sessions sharing a workspace therefore share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session; agentless calls receive the same per-invocation isolation from the runner.
|
||||
|
||||
[`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift.
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。
|
||||
|
||||
Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,但为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可回收 ACE。因此,共享工作区的会话会共享预期的写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权;runner 会为无 agent(智能体)的调用提供同样的逐调用隔离。
|
||||
|
||||
[`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止约定漂移。
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -7,22 +7,21 @@
|
||||
*
|
||||
* The windows-acl rung additionally owns the write grants: the write SID is
|
||||
* the per-WORKSPACE identity derived from the canonical workspace path
|
||||
* (`workspaceWriteSid`), and the private temp subdirectory is DERIVED per
|
||||
* session (session id + workspace — nothing stored). The
|
||||
* (`workspaceWriteSid`), while every live session receives a RANDOM private
|
||||
* temp directory and its own derived capability (`tempWriteSid`). The
|
||||
* workspace-root ACE materializes once per workspace per server lifetime
|
||||
* and STANDS (the cross-session reuse cache — the exact-ACE skip makes
|
||||
* every later provision O(1) instead of re-propagating the tree per
|
||||
* session); the private-temp ACEs are revoked on dispose. The runner
|
||||
* receives `--write-sid` (the derived identity; its presence marks the
|
||||
* seam-managed contract) and stops managing DACLs itself. The rung reports
|
||||
* partial enforcement because WRITE_RESTRICTED must retain Everyone in its
|
||||
* receives both SIDs (their presence marks the seam-managed contract) and
|
||||
* stops managing DACLs itself. The rung reports partial enforcement because
|
||||
* WRITE_RESTRICTED must retain Everyone in its
|
||||
* restricting list and NTFS hard links alias one file object across paths.
|
||||
* @module @deepseek-ai/dsh-sandbox-local
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -38,7 +37,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
|
||||
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
@@ -112,25 +111,6 @@ function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number):
|
||||
return probe.status === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's private temp subdirectory: `<tmpdir>\dsh-<16 hex>`, derived
|
||||
* from the session id and its workspace instead of stored. The same session
|
||||
* and workspace always name the same directory — a resumed session
|
||||
* re-grants it (the exact-ACE skip keeps that O(1)) — while a fork's
|
||||
* different session id names a fresh one. The name is predictable to anyone
|
||||
* who knows the session id (the confined command sees it as
|
||||
* `DSH_SESSION_ID`), so the provider creates the directory EXCLUSIVELY and
|
||||
* rejects reparse points: a pre-placed entry fails the first confined run
|
||||
* loudly, and cannot redirect the grant onto a foreign object.
|
||||
* @param sessionId - the policy's calling-session identity.
|
||||
* @param workspaceRoot - the resolved policy root.
|
||||
* @returns the session's private temp subdirectory path.
|
||||
*/
|
||||
export function sessionTempDir(sessionId: SessionId, workspaceRoot: string): string {
|
||||
const digest = createHash('sha256').update(String(sessionId)).update('\0').update(workspaceRoot).digest('hex')
|
||||
return join(tmpdir(), `dsh-${digest.slice(0, 16)}`)
|
||||
}
|
||||
|
||||
/** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */
|
||||
export interface SandboxInternals {
|
||||
/** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */
|
||||
@@ -160,6 +140,13 @@ export interface SandboxInternals {
|
||||
/** The chain's verdict: which runner confines, and how completely it enforces. */
|
||||
type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement }
|
||||
|
||||
/** One live session/workspace pair's private temp directory and capability. */
|
||||
interface AclTempCapability {
|
||||
dir: string
|
||||
writeSid: string
|
||||
grant: AclWriteGrant
|
||||
}
|
||||
|
||||
/**
|
||||
* The runner chain per platform — selection is BY PLATFORM first, probes
|
||||
* second: a platform's chain is probed in preference order only when it has
|
||||
@@ -256,8 +243,9 @@ const RUNNER_FAILURE_RULES = {
|
||||
* Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
|
||||
* chain verdict and, on the windows-acl rung, the write grants
|
||||
* ({@link AclWriteGrant}: the standing workspace-root grant per workspace
|
||||
* and the revocable private-temp grant per session, the latter revoked on
|
||||
* provider dispose); the one-time probes spawn nothing else.
|
||||
* and the revocable private-temp grant per live session/workspace pair, the
|
||||
* latter revoked on provider dispose); the one-time probes spawn nothing
|
||||
* else.
|
||||
*/
|
||||
export class LocalSandboxProvider extends SandboxProvider {
|
||||
// Inline schema call: the config catalog walks `static Config` statically.
|
||||
@@ -279,12 +267,11 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
* Server-lifetime write grants (windows-acl rung): the STANDING
|
||||
* workspace-root grant per workspace (its ACE is the cross-session reuse
|
||||
* cache and outlives the provider — never revoked) and the REVOCABLE
|
||||
* private-temp grant per session (revoked on provider dispose).
|
||||
* private-temp grant per live session/workspace pair (revoked on provider
|
||||
* dispose).
|
||||
*/
|
||||
private readonly workspaceGrants = new Map<string, AclWriteGrant>()
|
||||
private readonly tempGrants = new Map<string, AclWriteGrant>()
|
||||
/** Session id → the private temp directory this provider created (removed on dispose). */
|
||||
private readonly tempDirs = new Map<string, string>()
|
||||
private readonly tempCapabilities = new Map<string, AclTempCapability>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
@@ -358,20 +345,19 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
|
||||
/**
|
||||
* The windows-acl runner argv for one policy. With a calling session (the
|
||||
* policy's `sessionId`), the write grants are materialized once per server
|
||||
* lifetime — the standing workspace-root grant per workspace and the
|
||||
* revocable private-temp grant per session — and the runner receives
|
||||
* `--write-sid` (the workspace-derived identity; its presence marks the
|
||||
* seam-managed DACL contract) plus, under workspace-write, the session's
|
||||
* PRIVATE temp subdirectory (derived from session id + workspace) — it
|
||||
* grants nothing and revokes nothing. Agentless calls pass the ambient
|
||||
* temp root and no `--write-sid`: the runner self-manages its DACLs.
|
||||
* policy's `sessionId`) under workspace-write, the grants are materialized
|
||||
* once per provider lifetime — the standing workspace-root grant per
|
||||
* workspace and a revocable, RANDOM private-temp capability per live
|
||||
* session/workspace pair. The runner receives `--write-sid` plus
|
||||
* `--temp-write-sid` and grants nothing itself. Agentless workspace-write
|
||||
* calls pass the ambient temp ROOT and no SID flags: the runner creates and
|
||||
* removes a random private child directory for that one invocation.
|
||||
* @param policy - the resolved per-call policy.
|
||||
* @returns the runner invocation.
|
||||
*/
|
||||
private windowsAclRunnerArgv(policy: SandboxPolicy): string[] {
|
||||
const sessionId = policy.sessionId
|
||||
if (sessionId === undefined) {
|
||||
if (sessionId === undefined || policy.mode === 'read-only') {
|
||||
return [
|
||||
...this.windowsAclRunnerInvocation(),
|
||||
'--workspace', policy.workspaceRoot,
|
||||
@@ -379,45 +365,32 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
'--mode', policy.mode,
|
||||
]
|
||||
}
|
||||
this.materializeAclGrant(sessionId, policy.workspaceRoot, policy.mode)
|
||||
const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot)
|
||||
return [
|
||||
...this.windowsAclRunnerInvocation(),
|
||||
'--workspace', policy.workspaceRoot,
|
||||
// Workspace-write sessions confine their temp writes to the PRIVATE
|
||||
// per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only
|
||||
// runs pass the ambient temp root — the runner validates it exists
|
||||
// but grants nothing. The derived write SID is the per-workspace
|
||||
// identity; the flag's presence marks the seam-managed DACL contract.
|
||||
'--temp', policy.mode === 'workspace-write' ? sessionTempDir(sessionId, policy.workspaceRoot) : tmpdir(),
|
||||
'--temp', temp.dir,
|
||||
'--mode', policy.mode,
|
||||
'--write-sid', workspaceWriteSid(policy.workspaceRoot),
|
||||
'--temp-write-sid', temp.writeSid,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the session's ACEs once per server lifetime: lazily at its
|
||||
* first confined execution, reused for every later call (the map hits are
|
||||
* the whole call). The write SID is the per-workspace identity derived
|
||||
* from the workspace. Workspace-write grants the workspace root STANDING
|
||||
* (the ACE outlives every session — the reuse cache) and the session's
|
||||
* private temp subdirectory REVOCABLY — the directory is derived from
|
||||
* session id + workspace, created here EXCLUSIVELY (a pre-existing entry
|
||||
* or a reparse point fails the first confined run loudly, so the grant
|
||||
* never lands on a foreign object); read-only materializes NOTHING — its
|
||||
* token alone restricts every write, and the standing grant from an
|
||||
* earlier workspace-write period is KEPT through a downgrade (never
|
||||
* revoked): the read-only restricted token carries no write SID (the
|
||||
* read-only list), so the ACE is inert there, while the map hit keeps the
|
||||
* re-upgrade free of re-propagation. Fail-closed: a half-materialized
|
||||
* temp grant is revoked before the error propagates.
|
||||
* Materialize one workspace-write policy's ACEs once per provider
|
||||
* lifetime. The workspace SID and standing root grant are shared by the
|
||||
* workspace. The temp directory is random and carries a distinct SID, so
|
||||
* another session on the same workspace cannot use the shared workspace
|
||||
* SID to enter it. A fresh provider always chooses a new path; crash
|
||||
* residue therefore cannot collide with or authorize a resumed session.
|
||||
* Fail-closed: a half-materialized temp grant is revoked and its directory
|
||||
* removed before the error propagates.
|
||||
* @param sessionId - the policy's calling-session identity.
|
||||
* @param workspaceRoot - the resolved policy root.
|
||||
* @param mode - the policy mode (grants exist only under workspace-write).
|
||||
* @returns the pair's private temp directory and write capability.
|
||||
*/
|
||||
private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, mode: ConfinedSandboxMode): void {
|
||||
if (mode === 'read-only') return
|
||||
private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability {
|
||||
const writeSid = workspaceWriteSid(workspaceRoot)
|
||||
const tempDir = sessionTempDir(sessionId, workspaceRoot)
|
||||
if (!this.workspaceGrants.has(workspaceRoot)) {
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
try {
|
||||
@@ -435,32 +408,37 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
}
|
||||
this.workspaceGrants.set(workspaceRoot, grant)
|
||||
}
|
||||
if (this.tempGrants.has(sessionId)) return
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
// The directory is removed again in the catch only when THIS confine
|
||||
// created it — a pre-existing entry (EEXIST) is a foreign object and is
|
||||
// never deleted.
|
||||
let created = false
|
||||
const key = JSON.stringify([String(sessionId), workspaceRoot])
|
||||
const existing = this.tempCapabilities.get(key)
|
||||
if (existing !== undefined) return existing
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
|
||||
const tempSid = tempWriteSid(tempDir)
|
||||
let grant: AclWriteGrant | undefined
|
||||
try {
|
||||
// Exclusive creation (no `recursive`): a pre-existing entry OR a
|
||||
// reparse point both fail EEXIST — the grant never lands on a foreign
|
||||
// object.
|
||||
mkdirSync(tempDir)
|
||||
created = true
|
||||
grant = AclWriteGrant.create(tempSid)
|
||||
grant.add(tempDir)
|
||||
} catch (error) {
|
||||
if (created) rmSync(tempDir, { recursive: true, force: true })
|
||||
// Revoke whatever stands and free the SID — never leave a half-grant
|
||||
// behind a failed confine (the runner never runs).
|
||||
const cleanupFailures: unknown[] = []
|
||||
if (grant !== undefined) {
|
||||
try {
|
||||
grant.dispose()
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(cleanupError)
|
||||
}
|
||||
}
|
||||
try {
|
||||
grant.dispose()
|
||||
this.removeTempDir(tempDir)
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed')
|
||||
cleanupFailures.push(cleanupError)
|
||||
}
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupFailures], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.tempGrants.set(sessionId, grant)
|
||||
this.tempDirs.set(sessionId, tempDir)
|
||||
const capability = { dir: tempDir, writeSid: tempSid, grant }
|
||||
this.tempCapabilities.set(key, capability)
|
||||
return capability
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -469,36 +447,40 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
* removed, and every SID allocation is freed; the standing workspace ACEs
|
||||
* stay (the reuse cache). Cleanup failures are reported, not thrown:
|
||||
* cordis teardown must not be aborted by grant cleanup. A crash skips all
|
||||
* of it — the next resume then fails loudly at the exclusive creation and
|
||||
* OS temp hygiene (or manual removal) recovers.
|
||||
* of it, but a new provider never reuses the residue's random path or SID;
|
||||
* OS temp hygiene (or manual removal) eventually reclaims it.
|
||||
*/
|
||||
private revokeAclGrants(): void {
|
||||
if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return
|
||||
if (this.workspaceGrants.size === 0 && this.tempCapabilities.size === 0) return
|
||||
const failures: unknown[] = []
|
||||
for (const grant of [...this.workspaceGrants.values(), ...this.tempGrants.values()]) {
|
||||
for (const grant of [...this.workspaceGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) {
|
||||
try {
|
||||
grant.dispose()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => { rmSync(dir, { recursive: true, force: true }) })
|
||||
for (const dir of this.tempDirs.values()) {
|
||||
for (const { dir } of this.tempCapabilities.values()) {
|
||||
try {
|
||||
rmTempDir(dir)
|
||||
this.removeTempDir(dir)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
this.workspaceGrants.clear()
|
||||
this.tempGrants.clear()
|
||||
this.tempDirs.clear()
|
||||
this.tempCapabilities.clear()
|
||||
if (failures.length > 0) {
|
||||
this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`)
|
||||
for (const error of failures) this.ctx.logger.warn(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove one provider-owned private temp directory (injectable for cleanup tests). */
|
||||
private removeTempDir(dir: string): void {
|
||||
const remove = this.internals.rmTempDir ?? ((path: string) => { rmSync(path, { recursive: true, force: true }) })
|
||||
remove(dir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which runner confines commands, once, for the provider's
|
||||
* lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
/**
|
||||
* windows-acl write grants: the SERVER-LIFETIME ACE materialization
|
||||
* (standing workspace grant per workspace, revocable private-temp grant per
|
||||
* session) plus the derived private-temp identity, through the REAL
|
||||
* LocalSandboxProvider.confine(). Win32 surface mocked at the package
|
||||
* boundary (the workspace-derived SID mocked to a constant); the real-FFI
|
||||
* grant behavior lives in sandbox-windows-acl's win32 tests.
|
||||
* windows-acl grant ownership through the real LocalSandboxProvider: one
|
||||
* standing capability per workspace plus one random, distinct, revocable
|
||||
* temp capability per live session/workspace pair. The Win32 grant surface
|
||||
* is mocked; native access checks live in sandbox-windows-acl's runner suite.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { LocalSandboxProvider, sessionTempDir } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
/** Cross-file state shared with the vi.mock factory (hoisting contract). */
|
||||
const mockState = vi.hoisted(() => ({
|
||||
grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>,
|
||||
addFailure: undefined as Error | undefined,
|
||||
/** Restricts {@link addFailure} to this path (undefined = every add throws). */
|
||||
addFailurePath: undefined as string | undefined,
|
||||
/** Restrict an add failure to standing (workspace) or revocable (temp). */
|
||||
addFailureStanding: undefined as boolean | undefined,
|
||||
createTempFailure: undefined as Error | undefined,
|
||||
disposeFailure: undefined as Error | undefined,
|
||||
}))
|
||||
|
||||
@@ -35,24 +34,29 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
|
||||
mockState.grants.push(this)
|
||||
}
|
||||
static create(writeSid: string): MockAclWriteGrant {
|
||||
if (writeSid.startsWith('TEMP:') && mockState.createTempFailure !== undefined) throw mockState.createTempFailure
|
||||
return new MockAclWriteGrant(writeSid)
|
||||
}
|
||||
add(path: string, standing = false): void {
|
||||
if (mockState.addFailure !== undefined && (mockState.addFailurePath === undefined || mockState.addFailurePath === path)) {
|
||||
this.added.push({ path, standing })
|
||||
if (mockState.addFailure !== undefined
|
||||
&& (mockState.addFailureStanding === undefined || mockState.addFailureStanding === standing)) {
|
||||
throw mockState.addFailure
|
||||
}
|
||||
this.added.push({ path, standing })
|
||||
}
|
||||
dispose(): void {
|
||||
if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure
|
||||
this.disposed = true
|
||||
}
|
||||
}
|
||||
return { AclWriteGrant: MockAclWriteGrant, workspaceWriteSid: () => 'S-1-4-42-42' }
|
||||
return {
|
||||
AclWriteGrant: MockAclWriteGrant,
|
||||
workspaceWriteSid: () => 'S-1-4-42-42',
|
||||
tempWriteSid: (path: string) => `TEMP:${path}`,
|
||||
}
|
||||
})
|
||||
|
||||
/** The workspace-derived write SID the mock pins for every workspace. */
|
||||
const DERIVED_SID = 'S-1-4-42-42'
|
||||
const WORKSPACE_SID = 'S-1-4-42-42'
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
@@ -62,279 +66,214 @@ async function setup() {
|
||||
return { ctx, sandbox, fiber }
|
||||
}
|
||||
|
||||
/** A workspace root the policy carries. */
|
||||
function workspaceRoot(): string {
|
||||
return mkdtempSync(join(tmpdir(), 'dsh-acl-grants-ws-'))
|
||||
}
|
||||
|
||||
function flag(argv: readonly string[], name: string): string | undefined {
|
||||
const index = argv.indexOf(name)
|
||||
return index < 0 ? undefined : argv[index + 1]
|
||||
}
|
||||
|
||||
describe('windows-acl write grants (LocalSandboxProvider)', () => {
|
||||
const scratch: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
mockState.grants = []
|
||||
mockState.addFailure = undefined
|
||||
mockState.addFailurePath = undefined
|
||||
mockState.addFailureStanding = undefined
|
||||
mockState.createTempFailure = undefined
|
||||
mockState.disposeFailure = undefined
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
for (const grant of mockState.grants) {
|
||||
for (const added of grant.added) {
|
||||
if (!added.standing) rmSync(added.path, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
it('workspace-write: first confine materializes ONCE (standing workspace + revocable private temp), the derived temp dir rides the argv', async () => {
|
||||
it('workspace-write materializes one standing workspace grant and one private temp capability, then reuses both', async () => {
|
||||
try {
|
||||
const { sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const tempDir = sessionTempDir(SessionId('sess-1'), ws)
|
||||
scratch.push(tempDir)
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') }
|
||||
|
||||
const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
const tempDir = flag(confined.argv, '--temp')
|
||||
const tempSid = flag(confined.argv, '--temp-write-sid')
|
||||
expect(tempDir).toBeDefined()
|
||||
expect(basename(tempDir ?? '')).toMatch(/^dsh-[A-Za-z0-9_-]{6}$/u)
|
||||
expect(tempSid).toBe(`TEMP:${tempDir}`)
|
||||
expect(tempSid).not.toBe(WORKSPACE_SID)
|
||||
expect(confined.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', tempDir,
|
||||
'--mode', 'workspace-write',
|
||||
'--write-sid', DERIVED_SID,
|
||||
'--write-sid', WORKSPACE_SID,
|
||||
'--temp-write-sid', tempSid,
|
||||
'--',
|
||||
'pwsh', '/Command', 'x',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]).toMatchObject({
|
||||
writeSid: DERIVED_SID,
|
||||
added: [{ path: ws, standing: true }], // standing: the reuse cache, never revoked
|
||||
disposed: false,
|
||||
})
|
||||
expect(mockState.grants[1]).toMatchObject({
|
||||
writeSid: DERIVED_SID,
|
||||
added: [{ path: tempDir, standing: false }],
|
||||
disposed: false,
|
||||
})
|
||||
expect(existsSync(tempDir)).toBe(true) // created exclusively
|
||||
expect(mockState.grants).toEqual([
|
||||
expect.objectContaining({ writeSid: WORKSPACE_SID, added: [{ path: ws, standing: true }], disposed: false }),
|
||||
expect.objectContaining({ writeSid: tempSid, added: [{ path: tempDir, standing: false }], disposed: false }),
|
||||
])
|
||||
expect(existsSync(tempDir ?? '')).toBe(true)
|
||||
|
||||
// Reuse: the second confine is the map hits.
|
||||
sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
expect(sandbox.confine(['pwsh', '/Command', 'x'], policy).argv).toEqual(confined.argv)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
await fiber.dispose()
|
||||
// dispose() runs on BOTH grants: the standing workspace ACE is left in
|
||||
// place (the mock marks it disposed only as instance teardown).
|
||||
expect(mockState.grants[0]!.disposed).toBe(true)
|
||||
expect(mockState.grants[1]!.disposed).toBe(true)
|
||||
expect(mockState.grants.every(grant => grant.disposed)).toBe(true)
|
||||
expect(existsSync(tempDir ?? '')).toBe(false)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => {
|
||||
it('read-only materializes no capability; upgrade creates them and downgrade leaves them reusable', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const { sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const tempDir = sessionTempDir(SessionId('sess-switch'), ws)
|
||||
scratch.push(tempDir)
|
||||
const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') }
|
||||
const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') }
|
||||
const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('switch') }
|
||||
const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('switch') }
|
||||
|
||||
// read-only first: nothing materialized, ambient temp.
|
||||
const confinedRo = sandbox.confine(['true'], readOnly)
|
||||
expect(confinedRo.argv).toEqual([
|
||||
expect(sandbox.confine(['true'], readOnly).argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', tmpdir(), // NOT the private subdir: read-only grants nothing
|
||||
'--temp', tmpdir(),
|
||||
'--mode', 'read-only',
|
||||
'--write-sid', DERIVED_SID,
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
expect(existsSync(tempDir)).toBe(false)
|
||||
|
||||
// Upgrade: first workspace-write materializes with the derived SID.
|
||||
const upgraded = sandbox.confine(['true'], workspaceWrite)
|
||||
expect(upgraded.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', tempDir,
|
||||
'--mode', 'workspace-write',
|
||||
'--write-sid', DERIVED_SID,
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(WORKSPACE_SID)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false })
|
||||
expect(mockState.grants[1]).toMatchObject({
|
||||
writeSid: DERIVED_SID,
|
||||
added: [{ path: tempDir, standing: false }],
|
||||
disposed: false,
|
||||
})
|
||||
expect(existsSync(tempDir)).toBe(true)
|
||||
|
||||
// Reuse: map hits.
|
||||
sandbox.confine(['true'], workspaceWrite)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
// Downgrade: standing grant KEPT (inert under read-only, free re-upgrade).
|
||||
sandbox.confine(['true'], readOnly)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false)
|
||||
expect(mockState.grants.every(grant => !grant.disposed)).toBe(true)
|
||||
expect(sandbox.confine(['true'], workspaceWrite).argv).toEqual(upgraded.argv)
|
||||
|
||||
await fiber.dispose()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('resume: a fresh provider derives the SAME temp dir for the same session and workspace and re-grants it', async () => {
|
||||
it('a fresh provider gives a resumed session a new temp path and SID, so crash residue cannot collide', async () => {
|
||||
try {
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const first = await setup()
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') }
|
||||
const first = await setup()
|
||||
const firstConfined = first.sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
const firstTemp = flag(firstConfined.argv, '--temp') ?? ''
|
||||
|
||||
// Clean restart: dispose revokes the temp ACE and removes the private
|
||||
// temp directory, so the fresh provider's exclusive creation succeeds.
|
||||
await first.fiber.dispose()
|
||||
mockState.grants = []
|
||||
// The first provider remains live: model an unclean prior process whose
|
||||
// temp directory and ACE survived. A new provider must still proceed.
|
||||
const second = await setup()
|
||||
const secondConfined = second.sandbox.confine(['true'], policy)
|
||||
expect(secondConfined.argv).toEqual(firstConfined.argv)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[1]).toMatchObject({
|
||||
writeSid: DERIVED_SID,
|
||||
added: [{ path: sessionTempDir(SessionId('resumed'), ws), standing: false }],
|
||||
})
|
||||
const secondTemp = flag(secondConfined.argv, '--temp') ?? ''
|
||||
expect(secondTemp).not.toBe(firstTemp)
|
||||
expect(flag(secondConfined.argv, '--temp-write-sid')).not.toBe(flag(firstConfined.argv, '--temp-write-sid'))
|
||||
expect(existsSync(firstTemp)).toBe(true)
|
||||
expect(existsSync(secondTemp)).toBe(true)
|
||||
|
||||
await second.fiber.dispose()
|
||||
await first.fiber.dispose()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('fork: a different session id derives a DIFFERENT private temp identity over the same workspace', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const parentPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('parent') }
|
||||
const childPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') }
|
||||
|
||||
sandbox.confine(['true'], parentPolicy)
|
||||
const parentTemp = sessionTempDir(SessionId('parent'), ws)
|
||||
scratch.push(parentTemp)
|
||||
sandbox.confine(['true'], childPolicy)
|
||||
const childTemp = sessionTempDir(SessionId('child'), ws)
|
||||
scratch.push(childTemp)
|
||||
|
||||
// Fresh temp identity, NOT the parent's (the workspace SID is shared by
|
||||
// derivation — the workspace is the same, so the standing grant is the
|
||||
// map hit and only the child's temp grant joins).
|
||||
expect(childTemp).not.toBe(parentTemp)
|
||||
expect(mockState.grants).toHaveLength(3)
|
||||
expect(mockState.grants[2]).toMatchObject({ added: [{ path: childTemp, standing: false }] })
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving the temp grant', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
|
||||
// Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it.
|
||||
const preexisting = sessionTempDir(SessionId('preexisting'), ws)
|
||||
mkdirSync(preexisting)
|
||||
scratch.push(preexisting)
|
||||
const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') }
|
||||
expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/)
|
||||
// The standing workspace grant is the intended end state and stays; the
|
||||
// failed temp grant self-disposes.
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false)
|
||||
expect(mockState.grants[1]!.disposed).toBe(true) // self-revoked
|
||||
|
||||
// Reparse point: same EEXIST (exclusive mkdir never follows links).
|
||||
const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-'))
|
||||
scratch.push(target)
|
||||
const linkPath = sessionTempDir(SessionId('reparse'), ws)
|
||||
symlinkSync(target, linkPath)
|
||||
scratch.push(linkPath)
|
||||
const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') }
|
||||
expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/)
|
||||
// Same workspace as the preexisting case: the standing workspace grant
|
||||
// is the map hit (not recreated) — only the failed temp grant joins.
|
||||
expect(mockState.grants).toHaveLength(3)
|
||||
expect(mockState.grants[2]!.disposed).toBe(true)
|
||||
|
||||
// Temp-side cleanup failure: the standing workspace grant stays (map
|
||||
// hit), the exclusive mkdir fails, AND the temp grant's dispose also
|
||||
// fails — the temp cleanup AggregateError propagates.
|
||||
mockState.grants = []
|
||||
mockState.disposeFailure = new Error('temp cleanup exploded')
|
||||
const dupTemp = sessionTempDir(SessionId('temp-cleanup-fail'), ws)
|
||||
mkdirSync(dupTemp)
|
||||
scratch.push(dupTemp)
|
||||
const dupPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-cleanup-fail') }
|
||||
expect(() => sandbox.confine(['true'], dupPolicy)).toThrow(/temp grant materialization failed and its cleanup also failed/)
|
||||
expect(mockState.grants).toHaveLength(1) // only the failed temp grant (the workspace grant was the map hit)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
scratch.push(sessionTempDir(SessionId('sess-add-fail'), ws))
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') }
|
||||
|
||||
// add() throws on the FIRST (workspace) grant: cleanup dispose() runs, original error propagates.
|
||||
mockState.addFailure = new Error('grant exploded')
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded')
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]!.disposed).toBe(true)
|
||||
|
||||
// add() AND dispose() both throw: AggregateError.
|
||||
mockState.grants = []
|
||||
mockState.addFailure = new Error('grant exploded again')
|
||||
mockState.disposeFailure = new Error('cleanup exploded')
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow(AggregateError)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a temp add failure after the exclusive mkdir removed the half-created directory again', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const tempDir = sessionTempDir(SessionId('sess-temp-add-fail'), ws)
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-temp-add-fail') }
|
||||
|
||||
// The workspace grant succeeds; only the TEMP grant's add throws (the
|
||||
// path-targeted failure keeps the workspace branch intact).
|
||||
mockState.addFailurePath = tempDir
|
||||
mockState.addFailure = new Error('temp add exploded')
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow('temp add exploded')
|
||||
expect(existsSync(tempDir)).toBe(false) // the half-created directory is removed again
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false) // the standing workspace grant stays
|
||||
expect(mockState.grants[1]!.disposed).toBe(true) // the failed temp grant self-disposes
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no grants', async () => {
|
||||
it('forks and workspace changes receive distinct temp capabilities while each workspace grant is reused', async () => {
|
||||
try {
|
||||
const { sandbox, fiber } = await setup()
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
|
||||
const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
const wsA = workspaceRoot()
|
||||
const wsB = workspaceRoot()
|
||||
scratch.push(wsA, wsB)
|
||||
const parent = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('parent') })
|
||||
const child = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('child') })
|
||||
const moved = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsB, sessionId: SessionId('parent') })
|
||||
|
||||
expect(flag(child.argv, '--temp')).not.toBe(flag(parent.argv, '--temp'))
|
||||
expect(flag(child.argv, '--temp-write-sid')).not.toBe(flag(parent.argv, '--temp-write-sid'))
|
||||
expect(flag(moved.argv, '--temp')).not.toBe(flag(parent.argv, '--temp'))
|
||||
expect(mockState.grants).toHaveLength(5) // workspace A + two temps + workspace B + one temp
|
||||
|
||||
await fiber.dispose()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('workspace grant failure disposes its SID and never creates a temp directory', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
mockState.addFailureStanding = true
|
||||
mockState.addFailure = new Error('workspace grant exploded')
|
||||
expect(() => sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-fail'),
|
||||
})).toThrow('workspace grant exploded')
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]!.disposed).toBe(true)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('temp grant creation/add failures remove the random directory; cleanup failures aggregate', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
|
||||
mockState.createTempFailure = new Error('temp SID creation exploded')
|
||||
expect(() => sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('create-fail'),
|
||||
})).toThrow('temp SID creation exploded')
|
||||
expect(mockState.grants).toHaveLength(1) // workspace only; random temp was removed
|
||||
|
||||
mockState.createTempFailure = undefined
|
||||
mockState.addFailureStanding = false
|
||||
mockState.addFailure = new Error('temp add exploded')
|
||||
expect(() => sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('add-fail'),
|
||||
})).toThrow('temp add exploded')
|
||||
const failedTempGrant = mockState.grants.at(-1)
|
||||
expect(failedTempGrant?.disposed).toBe(true)
|
||||
expect(failedTempGrant?.added).toHaveLength(1)
|
||||
expect(existsSync(failedTempGrant?.added[0]?.path ?? '')).toBe(false)
|
||||
|
||||
sandbox.internals.rmTempDir = () => { throw new Error('temp rm exploded') }
|
||||
expect(() => sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('rm-fail'),
|
||||
})).toThrow(/temp grant materialization failed and its cleanup also failed/u)
|
||||
delete sandbox.internals.rmTempDir
|
||||
|
||||
mockState.disposeFailure = new Error('temp cleanup exploded')
|
||||
expect(() => sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('aggregate-fail'),
|
||||
})).toThrow(/temp grant materialization failed and its cleanup also failed/u)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('agentless calls pass a temp root and no capabilities; the runner owns the private child lifecycle', async () => {
|
||||
try {
|
||||
const { sandbox, fiber } = await setup()
|
||||
const confined = sandbox.confine(['pwsh', '/Command', 'x'], { mode: 'workspace-write', workspaceRoot: '/ws' })
|
||||
expect(confined.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', '/ws',
|
||||
@@ -350,55 +289,26 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('a failing dispose at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => {
|
||||
it('provider teardown reports grant and directory cleanup failures without aborting teardown', async () => {
|
||||
try {
|
||||
const { ctx, sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
scratch.push(sessionTempDir(SessionId('sess-dispose'), ws))
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') }
|
||||
sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
const confined = sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('dispose'),
|
||||
})
|
||||
const tempDir = flag(confined.argv, '--temp') ?? ''
|
||||
mockState.disposeFailure = new Error('revoke exploded')
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
await fiber.dispose()
|
||||
// BOTH grants (standing workspace + revocable temp) fail their dispose.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failure(s)'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' }))
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a failing private-temp removal at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => {
|
||||
try {
|
||||
const { ctx, sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
scratch.push(sessionTempDir(SessionId('sess-rm-fail'), ws))
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-rm-fail') }
|
||||
sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
sandbox.internals.rmTempDir = () => { throw new Error('rm exploded') }
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await fiber.dispose()
|
||||
// Both grants dispose cleanly; only the directory removal fails.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure(s)'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 3 failure(s)'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' }))
|
||||
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'rm exploded' }))
|
||||
expect(existsSync(tempDir)).toBe(true) // injected removal failed; test cleanup reclaims it
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('sessionTempDir derives the same well-shaped name for the same session and workspace, distinct otherwise', () => {
|
||||
const base = sessionTempDir(SessionId('sess-a'), '/ws/a')
|
||||
expect(basename(base)).toMatch(/^dsh-[0-9a-f]{16}$/)
|
||||
expect(sessionTempDir(SessionId('sess-a'), '/ws/a')).toBe(base)
|
||||
expect(sessionTempDir(SessionId('sess-b'), '/ws/a')).not.toBe(base) // different session
|
||||
expect(sessionTempDir(SessionId('sess-a'), '/ws/b')).not.toBe(base) // different workspace
|
||||
// The separator prevents id/workspace collisions from merging inputs.
|
||||
expect(sessionTempDir(SessionId('ab'), '/ws/c')).not.toBe(sessionTempDir(SessionId('a'), '/ws/bc'))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user