|
|
@@ -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') }
|
|
|
-
|
|
|
- // read-only first: nothing materialized, ambient temp.
|
|
|
- const confinedRo = sandbox.confine(['true'], readOnly)
|
|
|
- expect(confinedRo.argv).toEqual([
|
|
|
+ const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('switch') }
|
|
|
+ const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('switch') }
|
|
|
+
|
|
|
+ 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(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(flag(upgraded.argv, '--temp-write-sid')).not.toBe(WORKSPACE_SID)
|
|
|
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 }],
|
|
|
- })
|
|
|
- await second.fiber.dispose()
|
|
|
- } finally {
|
|
|
- cleanup()
|
|
|
- }
|
|
|
- })
|
|
|
+ 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)
|
|
|
|
|
|
- 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 }] })
|
|
|
+ await second.fiber.dispose()
|
|
|
+ await first.fiber.dispose()
|
|
|
} 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 () => {
|
|
|
+ it('forks and workspace changes receive distinct temp capabilities while each workspace grant is reused', async () => {
|
|
|
try {
|
|
|
- const { sandbox } = await setup()
|
|
|
- const ws = workspaceRoot()
|
|
|
- scratch.push(ws)
|
|
|
+ const { sandbox, fiber } = await setup()
|
|
|
+ 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
|
|
|
|
|
|
- // 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)
|
|
|
+ await fiber.dispose()
|
|
|
} finally {
|
|
|
cleanup()
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => {
|
|
|
+ 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)
|
|
|
- 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')
|
|
|
+ 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)
|
|
|
-
|
|
|
- // 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 () => {
|
|
|
+ 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)
|
|
|
- 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.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'], 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
|
|
|
+ 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 stay self-managed: no --write-sid, the ambient temp root, no grants', async () => {
|
|
|
+ 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 policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
|
|
|
- const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
|
|
+ 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'))
|
|
|
- })
|
|
|
})
|