Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion test/integration/providers/claude-local-provider.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,11 @@ export async function createClaudeFixture({ parent = os.tmpdir(), prefix = 'fres
return complete.split('\n').filter(Boolean).map((line) => JSON.parse(line))
},
async cleanup() {
await fs.rm(root, { recursive: true, force: true })
// maxRetries makes fs.rm retry ENOTEMPTY/EBUSY/EPERM: a native CLI that
// is still draining into its fixture (HOME/TMPDIR live under root) can
// re-create a file between the unlink sweep and the final rmdir, and
// with the default maxRetries=0 that race rejects the whole cleanup.
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
},
}
}
Expand Down
27 changes: 24 additions & 3 deletions test/integration/providers/claude-permission-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -156,18 +156,26 @@ async function runScenario(name, spec) {
let provider
let sidecar
let sidecarExit
// Resolves when the sidecar (and, after the group kill below, its whole
// process tree) has exited; the finally awaits it before touching the
// fixture so a draining native CLI cannot race fixture.cleanup().
let exited = Promise.resolve()
let failure
let settingsLocal = null
const written = {}
try {
provider = await startLocalProvider({ respond })
// detached puts the sidecar in its own process group: the native CLI it
// spawns is the fixture's concurrent writer, so the force path below has
// to kill the group, not just the sidecar.
sidecar = spawn(process.execPath, [path.join(repoRoot, 'crates/freshell-claude-sidecar/index.mjs')], {
cwd: path.join(repoRoot, 'crates/freshell-claude-sidecar'),
env: fixture.env(provider.url, { CLAUDE_CMD: pinned.nativeCli, FRESHELL_CLAUDE_ONECLI_API_KEY: '1', npm_config_update_notifier: 'false' }),
stdio: ['pipe', 'pipe', 'pipe'],
detached: true,
})
sidecar.stdin.on('error', () => {})
const exited = new Promise((resolve) => sidecar.once('exit', (code, signal) => { sidecarExit = { code, signal }; resolve() }))
exited = new Promise((resolve) => sidecar.once('exit', (code, signal) => { sidecarExit = { code, signal }; resolve() }))
createInterface({ input: sidecar.stderr }).on('line', () => {})
const send = (message) => sidecar.stdin.write(`${JSON.stringify(message)}\n`)
const waiters = []
Expand Down Expand Up @@ -223,9 +231,22 @@ async function runScenario(name, spec) {
failure = String(error?.message ?? error).slice(0, 1000)
log('error', 'scenario.failed', { scenario: name, error: failure })
} finally {
if (sidecar && !sidecarExit) sidecar.kill('SIGKILL')
if (sidecar && !sidecarExit) {
if (process.platform !== 'win32') {
try { process.kill(-sidecar.pid, 'SIGKILL') } catch { /* the group is already gone */ }
}
sidecar.kill('SIGKILL')
}
await exited
if (provider) await provider.close()
await fixture.cleanup()
try {
await fixture.cleanup()
} catch (error) {
// A cleanup failure is a reported outcome (fixtureRemoved=false below),
// never an unhandled rejection: the receipt is this harness's only
// contract, and a crash here eats it for every scenario at once.
log('error', 'fixture.cleanup_failed', { scenario: name, error: String(error?.code ?? error?.message ?? error) })
}
}
const fixtureRemoved = !(await fs.stat(fixture.root).catch(() => null))
const relabel = (value) => JSON.parse(JSON.stringify(value).replaceAll(fixture.root, '<root>'))
Expand Down
83 changes: 83 additions & 0 deletions test/integration/providers/claude-permission-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
// (PinnedClaudeMissingError): nothing here is ever skipped.

import { spawn, type ChildProcess } from 'node:child_process'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
Expand Down Expand Up @@ -162,3 +164,84 @@ describe(`Claude permission suggestions and plan approvals over the pinned CLI $
expect(empty!.tool.input).toEqual({})
})
})

// The harness's contract with this suite is ONE parseable stdout line —
// always. A cloud vitest shard (2026-10-29) lost a whole run because a
// fixture-cleanup rejection (a draining native CLI re-creating files mid
// `fs.rm`) escaped runScenario's finally as an unhandled rejection and
// killed the harness before the receipt was printed. This pins the
// containment: cleanup failures surface through the receipt
// (fixtureRemoved=false), never as a crash that eats it.
describe('Claude permission contract harness survives a fixture-cleanup failure', () => {
it('still prints one receipt line reporting fixtureRemoved=false for every scenario instead of crashing', async () => {
// Deterministic sabotage: once a scenario's own setup is complete (its
// outside/notes.txt marker exists, and nothing writes into the fixture
// root itself after that), make the root unwritable so fixture.cleanup()
// fails with EACCES — the same finally block that raced the live CLI.
const chmodded = new Set<string>()
const watcher = setInterval(() => {
void (async () => {
let entries: string[]
try {
entries = await fs.readdir(os.tmpdir())
} catch {
return
}
for (const entry of entries) {
if (!entry.startsWith('freshell-claude-perm-') || chmodded.has(entry)) continue
const root = path.join(os.tmpdir(), entry)
const setupDone = await fs.stat(path.join(root, 'outside', 'notes.txt')).then(() => true, () => false)
if (!setupDone) continue
chmodded.add(entry)
await fs.chmod(root, 0o500).catch(() => {
chmodded.delete(entry)
})
}
})()
}, 25)

const child = spawn(process.execPath, [HARNESS, REPO_ROOT], {
cwd: REPO_ROOT,
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', LANG: 'C.UTF-8' },
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout!.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk })
child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
try {
const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
const timer = setTimeout(() => {
child.kill('SIGTERM')
reject(new Error(`the cleanup-failure harness run timed out; stderr=${stderr.slice(-2000)}`))
}, 200_000)
timer.unref()
child.once('error', (error) => {
clearTimeout(timer)
reject(error)
})
child.once('exit', (code, signal) => {
clearTimeout(timer)
resolve({ code, signal })
})
})
expect(chmodded, `sabotaged fixture roots (${[...chmodded].join(', ')}); stderr=${stderr.slice(-2000)}`).toHaveLength(8)
const lines = stdout.trim().split('\n').filter(Boolean)
expect(lines, `the receipt must survive a cleanup failure; stderr tail: ${stderr.slice(-2000)}`).toHaveLength(1)
const sabotagedReceipt = JSON.parse(lines[0]!) as Receipt
expect(Object.keys(sabotagedReceipt.scenarios)).toHaveLength(8)
for (const name of Object.keys(sabotagedReceipt.scenarios)) {
expect(sabotagedReceipt.scenarios[name]!.fixtureRemoved, `${name}: the receipt reports the cleanup failure`).toBe(false)
}
expect(exit.code).toBe(1)
} finally {
clearInterval(watcher)
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
for (const entry of chmodded) {
const root = path.join(os.tmpdir(), entry)
await fs.chmod(root, 0o700).catch(() => {})
await fs.rm(root, { recursive: true, force: true }).catch(() => {})
}
}
}, 220_000)
})
Loading