mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
* diagnostics(issue-1830): trace interruption causality * test(issue-1830): lock interruption ownership matrix * fix(codex): preserve stream deadline contract * fix(diagnostics): harden interruption trace lifecycle Refs #1830 * fix(diagnostics): harden interruption trace settlement Refs #1830 * fix(diagnostics): preserve interruption causality * fix(diagnostics): address interruption trace review * fix(diagnostics): preserve tracing observer contracts * fix(diagnostics): preserve interruption trace contracts * test(permissions): cover interactive hook interrupts
731 lines
22 KiB
TypeScript
731 lines
22 KiB
TypeScript
import { describe, test, expect, afterEach, beforeAll, afterAll } from 'bun:test'
|
|
import { query, forkSession, getSessionMessages, unstable_v2_createSession } from '../../src/entrypoints/sdk/index.js'
|
|
import {
|
|
buildPermissionContext,
|
|
createDefaultCanUseTool,
|
|
createExternalCanUseTool,
|
|
createPermissionTarget,
|
|
} from '../../src/entrypoints/sdk/permissions.js'
|
|
import { randomUUID } from 'crypto'
|
|
import { rmSync } from 'fs'
|
|
import {
|
|
drainQuery,
|
|
withTempDir,
|
|
createSessionJsonl,
|
|
createMinimalConversation,
|
|
createMultiTurnConversation,
|
|
UUID_REGEX,
|
|
isExpectedDrainAbort,
|
|
} from './helpers/query-test-doubles.js'
|
|
import {
|
|
acquireSharedMutationLock,
|
|
releaseSharedMutationLock,
|
|
} from '../../src/test/sharedMutationLock.js'
|
|
import { clearAgentDefinitionsCache } from '../../src/tools/AgentTool/loadAgentsDir.js'
|
|
|
|
// Tests that drain fully (no early interrupt) trigger init(), which checks
|
|
// for auth credentials. Provide a stub key so init() succeeds without network.
|
|
const AUTH_KEY = 'ANTHROPIC_API_KEY'
|
|
const DISABLE_BUILTIN_AGENTS_KEY = 'CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS'
|
|
let savedApiKey: string | undefined
|
|
let savedDisableBuiltinAgents: string | undefined
|
|
let hadSavedMacro = false
|
|
let savedMacro: unknown
|
|
|
|
beforeAll(async () => {
|
|
await acquireSharedMutationLock('tests/sdk/query-lifecycle.test.ts')
|
|
savedApiKey = process.env[AUTH_KEY]
|
|
savedDisableBuiltinAgents = process.env[DISABLE_BUILTIN_AGENTS_KEY]
|
|
hadSavedMacro = Object.hasOwn(globalThis, 'MACRO')
|
|
savedMacro = (globalThis as Record<string, unknown>).MACRO
|
|
if (!savedApiKey) {
|
|
process.env[AUTH_KEY] = 'sk-test-lifecycle-stub'
|
|
}
|
|
process.env[DISABLE_BUILTIN_AGENTS_KEY] = '1'
|
|
;(globalThis as Record<string, unknown>).MACRO = {
|
|
VERSION: '0.0.0-test',
|
|
DISPLAY_VERSION: '0.0.0-test',
|
|
BUILD_TIME: 'test',
|
|
ISSUES_EXPLAINER: 'test',
|
|
PACKAGE_URL: 'test',
|
|
NATIVE_PACKAGE_URL: undefined,
|
|
}
|
|
clearAgentDefinitionsCache()
|
|
})
|
|
|
|
afterAll(() => {
|
|
try {
|
|
if (savedApiKey === undefined) {
|
|
delete process.env[AUTH_KEY]
|
|
} else {
|
|
process.env[AUTH_KEY] = savedApiKey
|
|
}
|
|
if (savedDisableBuiltinAgents === undefined) {
|
|
delete process.env[DISABLE_BUILTIN_AGENTS_KEY]
|
|
} else {
|
|
process.env[DISABLE_BUILTIN_AGENTS_KEY] = savedDisableBuiltinAgents
|
|
}
|
|
if (hadSavedMacro) {
|
|
;(globalThis as Record<string, unknown>).MACRO = savedMacro
|
|
} else {
|
|
delete (globalThis as Record<string, unknown>).MACRO
|
|
}
|
|
clearAgentDefinitionsCache()
|
|
} finally {
|
|
releaseSharedMutationLock()
|
|
}
|
|
})
|
|
|
|
describe('Query.sessionId accessor (API-1)', () => {
|
|
test('query() returns a Query with sessionId for fresh query', () => {
|
|
const q = query({
|
|
prompt: 'test',
|
|
options: { cwd: process.cwd() },
|
|
})
|
|
expect(q.sessionId).toBeDefined()
|
|
expect(typeof q.sessionId).toBe('string')
|
|
expect(q.sessionId).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
)
|
|
q.interrupt()
|
|
})
|
|
|
|
test('query() with sessionId option returns that sessionId', () => {
|
|
const sid = '12345678-1234-1234-1234-123456789012'
|
|
const q = query({
|
|
prompt: 'test',
|
|
options: { cwd: process.cwd(), sessionId: sid },
|
|
})
|
|
expect(q.sessionId).toBe(sid)
|
|
q.interrupt()
|
|
})
|
|
|
|
test('query() with continue:true still has a sessionId', () => {
|
|
const q = query({
|
|
prompt: 'test',
|
|
options: { cwd: process.cwd(), continue: true },
|
|
})
|
|
expect(q.sessionId).toBeDefined()
|
|
expect(typeof q.sessionId).toBe('string')
|
|
q.interrupt()
|
|
})
|
|
|
|
test('two queries have different sessionIds', () => {
|
|
const q1 = query({ prompt: 'a', options: { cwd: process.cwd() } })
|
|
const q2 = query({ prompt: 'b', options: { cwd: process.cwd() } })
|
|
expect(q1.sessionId).not.toBe(q2.sessionId)
|
|
q1.interrupt()
|
|
q2.interrupt()
|
|
})
|
|
})
|
|
|
|
describe('Engine lazy-init guard (COR-1)', () => {
|
|
test('QueryImpl close() works after construction', () => {
|
|
const q = query({
|
|
prompt: 'test',
|
|
options: { cwd: process.cwd() },
|
|
})
|
|
expect(() => q.close()).not.toThrow()
|
|
})
|
|
|
|
test('QueryImpl close() aborts its wrapper controller after an engine override', () => {
|
|
const abortController = new AbortController()
|
|
const q = query({
|
|
prompt: 'test',
|
|
options: { cwd: process.cwd(), abortController },
|
|
})
|
|
;(q as unknown as {
|
|
setEngine(engine: { interrupt(): void }): void
|
|
}).setEngine({ interrupt() {} })
|
|
|
|
q.close()
|
|
|
|
expect(abortController.signal.aborted).toBe(true)
|
|
})
|
|
|
|
test('SDKSession getMessages() works after construction', async () => {
|
|
const { unstable_v2_createSession } = await import('../../src/entrypoints/sdk/index.js')
|
|
const session = unstable_v2_createSession({
|
|
cwd: process.cwd(),
|
|
})
|
|
expect(session.sessionId).toBeDefined()
|
|
expect(Array.isArray(session.getMessages())).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('query() construction validation', () => {
|
|
test('query() with no cwd throws immediately', () => {
|
|
expect(() =>
|
|
query({ prompt: 'test', options: {} as any })
|
|
).toThrow('query() requires options.cwd')
|
|
})
|
|
|
|
test('query() with empty string prompt creates valid Query', () => {
|
|
const q = query({
|
|
prompt: '',
|
|
options: { cwd: process.cwd() },
|
|
})
|
|
expect(q.sessionId).toBeDefined()
|
|
expect(typeof q.sessionId).toBe('string')
|
|
q.interrupt()
|
|
})
|
|
|
|
test('query() with async iterable prompt creates valid Query', () => {
|
|
async function* prompts() {
|
|
yield { type: 'user' as const, message: { role: 'user' as const, content: 'hello' } }
|
|
}
|
|
const q = query({
|
|
prompt: prompts(),
|
|
options: { cwd: process.cwd() },
|
|
})
|
|
expect(q.sessionId).toBeDefined()
|
|
q.interrupt()
|
|
})
|
|
})
|
|
|
|
describe('Query interrupt lifecycle', () => {
|
|
test('interrupt() followed by iterator drain does not hang', async () => {
|
|
const q = query({
|
|
prompt: 'test interrupt drain',
|
|
options: { cwd: process.cwd() },
|
|
})
|
|
q.interrupt()
|
|
const messages = await drainQuery(q)
|
|
expect(Array.isArray(messages)).toBe(true)
|
|
}, 10_000)
|
|
|
|
test('close() followed by iterator drain does not hang', async () => {
|
|
const q = query({
|
|
prompt: 'test close drain',
|
|
options: { cwd: process.cwd() },
|
|
})
|
|
q.close()
|
|
const messages = await drainQuery(q)
|
|
expect(Array.isArray(messages)).toBe(true)
|
|
}, 10_000)
|
|
|
|
test('interrupt() and close() both called — no double-error', async () => {
|
|
const q = query({
|
|
prompt: 'test double abort',
|
|
options: { cwd: process.cwd() },
|
|
})
|
|
q.interrupt()
|
|
q.close()
|
|
const messages = await drainQuery(q)
|
|
expect(Array.isArray(messages)).toBe(true)
|
|
}, 10_000)
|
|
|
|
test('interrupt() before iteration starts — completes cleanly', async () => {
|
|
const q = query({
|
|
prompt: 'test early interrupt',
|
|
options: { cwd: process.cwd() },
|
|
})
|
|
q.interrupt()
|
|
const messages = await drainQuery(q)
|
|
expect(Array.isArray(messages)).toBe(true)
|
|
}, 10_000)
|
|
|
|
test('interrupt() from external AbortController propagates', async () => {
|
|
const ac = new AbortController()
|
|
const q = query({
|
|
prompt: 'test external abort',
|
|
options: { cwd: process.cwd(), abortController: ac },
|
|
})
|
|
ac.abort()
|
|
const messages = await drainQuery(q)
|
|
expect(Array.isArray(messages)).toBe(true)
|
|
}, 10_000)
|
|
|
|
test('interrupt() with reason "interrupt" does not yield synthetic user cancellation message', async () => {
|
|
const ac = new AbortController()
|
|
const q = query({
|
|
prompt: 'test interrupt reason',
|
|
options: { cwd: process.cwd(), abortController: ac },
|
|
})
|
|
ac.abort('interrupt')
|
|
const messages = await drainQuery(q)
|
|
expect(Array.isArray(messages)).toBe(true)
|
|
const userCancelMsg = messages.find((m: any) =>
|
|
m.type === 'user' &&
|
|
Array.isArray(m.message?.content) &&
|
|
m.message.content[0]?.text === '[Request interrupted by user]'
|
|
)
|
|
expect(userCancelMsg).toBeUndefined()
|
|
}, 10_000)
|
|
|
|
test('Stop hook regression test - aborting with reason interrupt suppresses cancellation message', async () => {
|
|
const { query: queryLoop } = await import('../../src/query.js')
|
|
const { getDefaultAppState } = await import('../../src/state/AppStateStore.js')
|
|
const { asSystemPrompt } = await import('../../src/utils/systemPromptType.js')
|
|
|
|
const abortController = new AbortController()
|
|
const appStateRef = {
|
|
current: {
|
|
...getDefaultAppState(),
|
|
},
|
|
}
|
|
|
|
const toolUseContext: any = {
|
|
options: {
|
|
commands: [],
|
|
debug: false,
|
|
mainLoopModel: 'sonnet',
|
|
tools: [],
|
|
verbose: false,
|
|
thinkingConfig: { type: 'disabled' },
|
|
mcpClients: [],
|
|
mcpResources: {},
|
|
isNonInteractiveSession: true,
|
|
agentDefinitions: { activeAgents: [], allAgents: [], allowedAgentTypes: [] },
|
|
},
|
|
abortController,
|
|
getAppState: () => appStateRef.current,
|
|
setAppState: (updater: any) => {
|
|
appStateRef.current = updater(appStateRef.current)
|
|
},
|
|
}
|
|
|
|
const mockExecuteStopHooks = async function* () {
|
|
yield {
|
|
message: {
|
|
type: 'progress' as const,
|
|
toolUseID: 'mock-tool-use-id',
|
|
data: {
|
|
command: 'mock-command',
|
|
promptText: 'mock-prompt',
|
|
},
|
|
},
|
|
}
|
|
abortController.abort('interrupt')
|
|
yield {
|
|
message: {
|
|
type: 'progress' as const,
|
|
toolUseID: 'mock-tool-use-id-2',
|
|
data: {
|
|
command: 'mock-command-2',
|
|
promptText: 'mock-prompt-2',
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
const mockCallModel = async function* () {
|
|
yield {
|
|
type: 'assistant' as const,
|
|
uuid: 'assistant-1',
|
|
message: {
|
|
id: 'assistant-1',
|
|
type: 'message' as const,
|
|
role: 'assistant' as const,
|
|
model: 'test-model',
|
|
stop_reason: 'end_turn' as const,
|
|
usage: {
|
|
input_tokens: 1,
|
|
output_tokens: 1,
|
|
cache_creation_input_tokens: 0,
|
|
cache_read_input_tokens: 0,
|
|
},
|
|
content: [{ type: 'text' as const, text: 'Hello' }],
|
|
},
|
|
}
|
|
}
|
|
|
|
const q = queryLoop({
|
|
messages: [],
|
|
systemPrompt: asSystemPrompt([]),
|
|
userContext: {},
|
|
systemContext: {},
|
|
canUseTool: async () => ({ behavior: 'allow' }),
|
|
toolUseContext,
|
|
querySource: 'sdk',
|
|
deps: {
|
|
callModel: mockCallModel as any,
|
|
microcompact: async (messages) => ({ messages }),
|
|
autocompact: async () => ({ wasCompacted: false }),
|
|
uuid: () => 'uuid-1',
|
|
stopHookExecutionDeps: {
|
|
executeStopHooks: mockExecuteStopHooks as any,
|
|
isTeammate: () => false,
|
|
getStopHookMessage: () => 'stop hook blocked',
|
|
},
|
|
},
|
|
})
|
|
|
|
const messages: any[] = []
|
|
try {
|
|
for await (const msg of q) {
|
|
messages.push(msg)
|
|
}
|
|
} catch (err) {
|
|
if (!isExpectedDrainAbort(err)) throw err
|
|
}
|
|
|
|
const userCancelMsg = messages.find(
|
|
(m: any) =>
|
|
m.type === 'user' &&
|
|
Array.isArray(m.message?.content) &&
|
|
m.message.content[0]?.text === '[Request interrupted by user]',
|
|
)
|
|
expect(userCancelMsg).toBeUndefined()
|
|
}, 10_000)
|
|
|
|
test('Stop hook regression test - default abort still yields cancellation message', async () => {
|
|
const { query: queryLoop } = await import('../../src/query.js')
|
|
const { getDefaultAppState } = await import('../../src/state/AppStateStore.js')
|
|
const { asSystemPrompt } = await import('../../src/utils/systemPromptType.js')
|
|
|
|
const abortController = new AbortController()
|
|
const appStateRef = {
|
|
current: {
|
|
...getDefaultAppState(),
|
|
},
|
|
}
|
|
|
|
const toolUseContext: any = {
|
|
options: {
|
|
commands: [],
|
|
debug: false,
|
|
mainLoopModel: 'sonnet',
|
|
tools: [],
|
|
verbose: false,
|
|
thinkingConfig: { type: 'disabled' },
|
|
mcpClients: [],
|
|
mcpResources: {},
|
|
isNonInteractiveSession: true,
|
|
agentDefinitions: { activeAgents: [], allAgents: [], allowedAgentTypes: [] },
|
|
},
|
|
abortController,
|
|
getAppState: () => appStateRef.current,
|
|
setAppState: (updater: any) => {
|
|
appStateRef.current = updater(appStateRef.current)
|
|
},
|
|
}
|
|
|
|
const mockExecuteStopHooks = async function* () {
|
|
yield {
|
|
message: {
|
|
type: 'progress' as const,
|
|
toolUseID: 'mock-tool-use-id',
|
|
data: {
|
|
command: 'mock-command',
|
|
promptText: 'mock-prompt',
|
|
},
|
|
},
|
|
}
|
|
abortController.abort()
|
|
yield {
|
|
message: {
|
|
type: 'progress' as const,
|
|
toolUseID: 'mock-tool-use-id-2',
|
|
data: {
|
|
command: 'mock-command-2',
|
|
promptText: 'mock-prompt-2',
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
const mockCallModel = async function* () {
|
|
yield {
|
|
type: 'assistant' as const,
|
|
uuid: 'assistant-1',
|
|
message: {
|
|
id: 'assistant-1',
|
|
type: 'message' as const,
|
|
role: 'assistant' as const,
|
|
model: 'test-model',
|
|
stop_reason: 'end_turn' as const,
|
|
usage: {
|
|
input_tokens: 1,
|
|
output_tokens: 1,
|
|
cache_creation_input_tokens: 0,
|
|
cache_read_input_tokens: 0,
|
|
},
|
|
content: [{ type: 'text' as const, text: 'Hello' }],
|
|
},
|
|
}
|
|
}
|
|
|
|
const q = queryLoop({
|
|
messages: [],
|
|
systemPrompt: asSystemPrompt([]),
|
|
userContext: {},
|
|
systemContext: {},
|
|
canUseTool: async () => ({ behavior: 'allow' }),
|
|
toolUseContext,
|
|
querySource: 'sdk',
|
|
deps: {
|
|
callModel: mockCallModel as any,
|
|
microcompact: async (messages) => ({ messages }),
|
|
autocompact: async () => ({ wasCompacted: false }),
|
|
uuid: () => 'uuid-1',
|
|
stopHookExecutionDeps: {
|
|
executeStopHooks: mockExecuteStopHooks as any,
|
|
isTeammate: () => false,
|
|
getStopHookMessage: () => 'stop hook blocked',
|
|
},
|
|
},
|
|
})
|
|
|
|
const messages: any[] = []
|
|
try {
|
|
for await (const msg of q) {
|
|
messages.push(msg)
|
|
}
|
|
} catch (err) {
|
|
if (!isExpectedDrainAbort(err)) throw err
|
|
}
|
|
|
|
const userCancelMsg = messages.find(
|
|
(m: any) =>
|
|
m.type === 'user' &&
|
|
Array.isArray(m.message?.content) &&
|
|
m.message.content[0]?.text === '[Request interrupted by user]',
|
|
)
|
|
expect(userCancelMsg).toBeDefined()
|
|
}, 10_000)
|
|
})
|
|
|
|
describe('Query resume lifecycle', () => {
|
|
const tempDirs: string[] = []
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs) {
|
|
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
|
}
|
|
tempDirs.length = 0
|
|
})
|
|
|
|
test('query() with sessionId and existing JSONL — session loads messages', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const sid = randomUUID()
|
|
const entries = createMinimalConversation(sid)
|
|
createSessionJsonl(dir, sid, entries)
|
|
|
|
const q = query({
|
|
prompt: 'continue conversation',
|
|
options: { cwd: dir, sessionId: sid },
|
|
})
|
|
expect(q.sessionId).toBe(sid)
|
|
q.interrupt()
|
|
await drainQuery(q)
|
|
})
|
|
})
|
|
|
|
test('query() with continue:true and no prior sessions — creates fresh session', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const q = query({
|
|
prompt: 'fresh start',
|
|
options: { cwd: dir, continue: true },
|
|
})
|
|
expect(q.sessionId).toBeDefined()
|
|
expect(UUID_REGEX.test(q.sessionId)).toBe(true)
|
|
q.interrupt()
|
|
await drainQuery(q)
|
|
})
|
|
})
|
|
|
|
test('query() with fork:true — creates new sessionId', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const sid = randomUUID()
|
|
const entries = createMinimalConversation(sid)
|
|
createSessionJsonl(dir, sid, entries)
|
|
|
|
const q = query({
|
|
prompt: 'forked conversation',
|
|
options: { cwd: dir, sessionId: sid, fork: true },
|
|
})
|
|
// Fork happens lazily during iteration. The first item is enough to
|
|
// trigger session resolution without advancing into the API request.
|
|
const iterator = q[Symbol.asyncIterator]()
|
|
try {
|
|
const first = await iterator.next()
|
|
expect(first.done).toBe(false)
|
|
} finally {
|
|
q.interrupt()
|
|
await iterator.return?.()
|
|
}
|
|
expect(q.sessionId).toBeDefined()
|
|
expect(q.sessionId).not.toBe(sid)
|
|
})
|
|
}, 10_000)
|
|
|
|
test('query() with resumeSessionAt — truncates at specified message', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const sid = randomUUID()
|
|
const entries = createMultiTurnConversation(sid, 3)
|
|
createSessionJsonl(dir, sid, entries)
|
|
|
|
const secondAssistantUuid = entries[3].uuid as string
|
|
|
|
const q = query({
|
|
prompt: 'resume at message',
|
|
options: { cwd: dir, sessionId: sid, resumeSessionAt: secondAssistantUuid },
|
|
})
|
|
expect(q.sessionId).toBe(sid)
|
|
q.interrupt()
|
|
await drainQuery(q)
|
|
})
|
|
})
|
|
|
|
test('query() with resumeSessionAt pointing to invalid UUID — throws', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const sid = randomUUID()
|
|
const entries = createMinimalConversation(sid)
|
|
createSessionJsonl(dir, sid, entries)
|
|
|
|
const fakeUuid = randomUUID()
|
|
const q = query({
|
|
prompt: 'bad resume point',
|
|
options: { cwd: dir, sessionId: sid, resumeSessionAt: fakeUuid },
|
|
})
|
|
let caught = false
|
|
try {
|
|
for await (const _ of q) {
|
|
// drain
|
|
}
|
|
} catch (err: any) {
|
|
caught = true
|
|
expect(err.message).toContain('resumeSessionAt')
|
|
expect(err.message).toContain('not found')
|
|
}
|
|
expect(caught).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Secure-by-default permissions (SEC-2)', () => {
|
|
test('createDefaultCanUseTool denies all tools when no callback is provided', async () => {
|
|
const canUseTool = createDefaultCanUseTool(
|
|
buildPermissionContext({ cwd: process.cwd() }),
|
|
{ warn: () => {} },
|
|
)
|
|
|
|
const result = await canUseTool(
|
|
{ name: 'Read' } as any,
|
|
{ file_path: 'test.txt' },
|
|
{} as any,
|
|
{} as any,
|
|
'tool-use-id',
|
|
undefined,
|
|
)
|
|
|
|
expect(result.behavior).toBe('deny')
|
|
expect(result.message).toContain('no canUseTool or onPermissionRequest')
|
|
expect(result.decisionReason).toEqual({ type: 'mode', mode: 'default' })
|
|
})
|
|
|
|
test('canUseTool callback overrides deny-by-default', async () => {
|
|
const allowedTools: string[] = []
|
|
const q = query({
|
|
prompt: 'test with callback',
|
|
options: {
|
|
cwd: process.cwd(),
|
|
canUseTool: async (name, _input) => {
|
|
allowedTools.push(name)
|
|
return { behavior: 'allow' }
|
|
},
|
|
},
|
|
})
|
|
q.interrupt()
|
|
const messages = await drainQuery(q)
|
|
expect(Array.isArray(messages)).toBe(true)
|
|
// The callback was registered — even though we interrupted early,
|
|
// the canUseTool function was wired correctly (no crash)
|
|
})
|
|
|
|
test('canUseTool callback can selectively deny tools', async () => {
|
|
const q = query({
|
|
prompt: 'test selective deny',
|
|
options: {
|
|
cwd: process.cwd(),
|
|
canUseTool: async (name, _input) => {
|
|
// Deny Bash specifically, allow everything else
|
|
if (name === 'Bash') {
|
|
return { behavior: 'deny', message: 'Bash is not allowed' }
|
|
}
|
|
return { behavior: 'allow' }
|
|
},
|
|
},
|
|
})
|
|
q.interrupt()
|
|
const messages = await drainQuery(q)
|
|
expect(Array.isArray(messages)).toBe(true)
|
|
})
|
|
|
|
test('V2 session with no callback denies tools by default', async () => {
|
|
const session = unstable_v2_createSession({
|
|
cwd: process.cwd(),
|
|
})
|
|
expect(session.sessionId).toBeDefined()
|
|
// Session created successfully — deny-by-default applies at runtime
|
|
// when tools are actually used
|
|
session.interrupt()
|
|
})
|
|
|
|
test('V2 session with canUseTool callback allows tools', async () => {
|
|
let callbackInvoked = false
|
|
const session = unstable_v2_createSession({
|
|
cwd: process.cwd(),
|
|
canUseTool: async (name, _input) => {
|
|
callbackInvoked = true
|
|
return { behavior: 'allow' }
|
|
},
|
|
})
|
|
expect(session.sessionId).toBeDefined()
|
|
// Callback is wired — would be invoked when tools execute
|
|
session.interrupt()
|
|
})
|
|
})
|
|
|
|
describe('Permission timeout eventing (PTO-1)', () => {
|
|
test('timeout emits permission_timeout message in stream', async () => {
|
|
const permissionTarget = createPermissionTarget()
|
|
const timeoutMessages: unknown[] = []
|
|
const warnings: string[] = []
|
|
const fallback = createDefaultCanUseTool(
|
|
buildPermissionContext({ cwd: process.cwd() }),
|
|
{ warn: () => {} },
|
|
)
|
|
const canUseTool = createExternalCanUseTool(
|
|
undefined,
|
|
fallback,
|
|
permissionTarget,
|
|
() => {
|
|
// Deliberately do NOT resolve the pending permission; force timeout.
|
|
},
|
|
message => timeoutMessages.push(message),
|
|
10,
|
|
'session-id',
|
|
{ warn: message => warnings.push(message) },
|
|
)
|
|
|
|
const result = await canUseTool(
|
|
{ name: 'Read' } as any,
|
|
{ file_path: 'test.txt' },
|
|
{} as any,
|
|
{} as any,
|
|
'tool-use-id',
|
|
undefined,
|
|
)
|
|
|
|
expect(result.behavior).toBe('deny')
|
|
expect(timeoutMessages).toHaveLength(1)
|
|
expect(timeoutMessages[0]).toMatchObject({
|
|
type: 'permission_timeout',
|
|
tool_name: 'Read',
|
|
tool_use_id: 'tool-use-id',
|
|
timed_out_after_ms: 10,
|
|
session_id: 'session-id',
|
|
})
|
|
expect(warnings).toHaveLength(1)
|
|
expect(warnings[0]).toContain('timed out after 10ms')
|
|
expect(permissionTarget.pendingPermissionPrompts.size).toBe(0)
|
|
}, 15_000)
|
|
})
|