mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-05-02 15:22:30 +00:00
feat: SDK Core — Permission System, Async Context, and Engine Extensions (#951)
* feat(sdk): add SDK foundation — type declarations, errors, and utilities
Adds standalone SDK building blocks with no SDK source dependencies:
- sdk.d.ts: ambient type declarations for SDK bundle
- coreSchemas.ts + coreTypes.generated.ts: Zod schemas and generated types
- errors.ts: SDK-specific error classes
- validation.ts: input validation utilities
- messageFilters.ts: extracted message filter logic
- handlePromptSubmit.ts: imports from messageFilters
- 16 generated-types tests
* fix(sdk): narrow assertFunction type from broad Function to callable signature
Code review finding: assertFunction used `asserts value is Function` which
accepts any function-like value without narrowing. Changed to
`(...args: any[]) => any` for better type safety.
* fix(sdk): update sdk.d.ts header — manually maintained, not generated
Reviewer noted the header said "Generated from index.ts" but no generator
produces this file. Updated to "Manually maintained — keep in sync with
index.ts". Drift detection added in validate-externals.ts (PR 3).
* fix(sdk): align sdk.d.ts types with canonical coreTypes.generated.ts
Tighten SDK public type contract to resolve reviewer blockers:
- PermissionResult: unknown[] → precise 6-shape discriminated union
(addRules/replaceRules/removeRules/setMode/addDirectories/removeDirectories)
- SDKSessionInfo: snake_case → camelCase (sessionId, lastModified, etc.)
- ForkSessionResult: session_id → sessionId
- SDKPermissionRequestMessage: uuid + session_id now required
- SDKPermissionTimeoutMessage: added uuid + session_id
- SessionMessage: parent_uuid → parentUuid
- SDKMessage/SDKUserMessage/SDKResultMessage: replaced loose inline
definitions with re-exports from coreTypes.generated.ts
* feat(sdk): wire existing code modules + SDK shared utilities
Modifies core modules for SDK integration:
- QueryEngine, tools, state, commands: SDK type hooks
- SDK shared utilities (shared.ts, permissions.ts)
- 21 SDK tests (shared-utils, permissions)
Stack: main ← pr1-foundation ← pr2-sdk-core
* feat(sdk): add snake_case ↔ camelCase key mapping utilities
casing.ts provides recursive key transformation for the SDK boundary
layer. Internal runtime uses snake_case; public API exposes camelCase.
Will be used by shared.ts, sessions.ts, query.ts at export boundaries.
* test(sdk): add tests for snake_case ↔ camelCase mapping utilities
Covers snakeToCamel, camelToSnake, mapKeysToCamel, mapKeysToSnake
including nested objects, arrays, null/undefined, and round-trips.
* fix(sdk): prevent permission timeout race condition with once-only resolve wrapper
Add createOnceOnlyResolve utility to prevent double-resolution of promises
when timeout and host response happen simultaneously. This ensures
deterministic behavior in the permission handling flow.
* fix(sdk): improve race condition test robustness
* fix(sdk): handle consecutive underscores in snakeToCamel conversion
Changes:
- Use _+([a-z]) regex to match multiple consecutive underscores before letters
- Add lookahead (?=. ) to preserve underscore-letter pairs at string end
- Handle dunder names (__proto__, __typename) by stripping wrapper and capitalizing
- Add tests for consecutive underscores and trailing underscore preservation
* fix(sdk): include original error message in permission callback denial
When a canUseTool callback throws an error, the catch block now
includes the original error message in the denial message, making
debugging easier for SDK consumers.
* feat(sdk): add optional timeout to env mutex for deadlock prevention
Add timeout parameter to acquireEnvMutex() to prevent infinite waits
in deadlock scenarios. The timeout is optional and defaults to no timeout
(wait forever) for backward compatibility.
Returns a MutexAcquireResult object with acquired status and optional
timeout reason for failed acquisitions.
* fix(sdk): remove timed-out callback from mutex queue to prevent deadlock
* test(sdk): add missing error path and timeout scenario tests
Add tests for timeout scenarios when host doesn't respond to permission
requests, fallback behavior when no onPermissionRequest callback, and
MCP connection edge cases for undefined/empty config.
* fix(sdk): address code review issues - race conditions, validation, error handling
- Add createPermissionTarget() factory that applies onceOnlyResolve at
registration time, fixing race condition where timeout and host response
could both try to resolve the same promise
- Add try-catch to releaseEnvMutex() to prevent permanent lock if callback throws
- Extract DEFAULT_PERMISSION_TIMEOUT_MS constant (30 seconds)
- Add MCP config validation rejecting null, non-objects, and arrays
- Preserve error stack traces in MCP connection failures
- Add runtime validation to mapMessageToSDK for null/non-object/invalid type
- Update tests to use createPermissionTarget and add validation tests
* test(sdk): add sequential timeout-then-host-response race condition tests
Adds two tests addressing reviewer request for proof that host response
after SDK timeout is safely handled with no double-resolve or leaked listener:
1. Integration test: stale host resolve called after timeout deny —
verifies no error, no mutation, map cleanup
2. Unit test: raw resolve called exactly once when timeout wins —
directly proves createOnceOnlyResolve prevents second execution
* fix: restore openclaude.json comment in REPL.tsx
Reviewer caught that the comment was incorrectly changed to
~/.claude.json during merge — project has already migrated to
~/.openclaude.json.
* fix(sdk): register pending permission before emitting onPermissionRequest
The previous code emitted onPermissionRequest before calling
registerPendingPermission, so a host responding synchronously from
the callback would find an empty map and its response was lost.
Swap the order so registration happens first.
Adds a regression test for the synchronous host response path.
* fix(sdk): make state setters context-aware for SDK isolation
When running inside runWithSdkContext(), setter functions (regenerateSessionId,
switchSession, setCwdState, setOriginalCwd) now write to the AsyncLocalStorage
context instead of global STATE. This prevents cross-session state leakage in
multi-session SDK scenarios.
Reads were already context-aware; this completes the isolation by making writes
consistent. Outside of SDK context, behavior is unchanged — all writes go to
global STATE as before.
* test(sdk): add context-aware state isolation tests
Tests verify that setters within runWithSdkContext() write to the SDK
context (not global STATE) and that parallel async contexts do not leak
state between sessions. Covers setCwdState, setOriginalCwd,
regenerateSessionId, switchSession, and an end-to-end parallel session
scenario.
* fix(sdk): selective tool schema cache invalidation for multi-engine isolation
Replace global clearToolSchemaCache() in QueryEngine.updateTools() with
selective invalidation that only removes cache entries for tools no longer
in the tool set. This preserves cached schemas for tools that remain,
avoiding unnecessary recomputation for concurrent QueryEngine instances
in multi-session SDK scenarios.
New function invalidateRemovedToolSchemas() handles both simple tool name
keys and schema-variant keys (format: "toolName:{...schemaJSON...}").
* docs(sdk): address PR2 non-blocking documentation and logging issues
- Document request_id vs tool_use_id relationship in shared.ts
(request_id for response correlation, tool_use_id for tracking)
- Add injectable SDKLogger interface to permissions.ts, replacing
direct console.warn calls with logger.warn (hosts can control noise)
- Document Node.js-only AsyncLocalStorage requirement in state.ts
(requires Node.js 12.17.0+ or 14.0.0+)
- Clarify env-mutex is host utility (SDK doesn't mutate process.env)
* fix(sdk): handle throwing onPermissionRequest and fix permission request shape
- Wrap onPermissionRequest in try-catch to clean up pending resolver on throw
- Add uuid and session_id to permission_request message to match SDK schema
- Add regression tests for throwing callback and message shape validation
* fix(sdk): use explicit no-session placeholder for standalone permission prompts
- Add NO_SESSION_PLACEHOLDER constant ('no-session') for permission requests
- Update SDKPermissionRequestMessage doc to explain session_id semantics
- Replace empty string fallback with explicit placeholder
- Add test verifying placeholder behavior when sessionId omitted
* docs(sdk): add example code to permission denial warning
Include canUseTool example in warning message to improve developer
experience and make SDK usage more discoverable for new users.
* fix(sdk): scope parentSessionId to SDK context for parallel isolation
regenerateSessionId({ setCurrentAsParent: true }) was writing to the
process-global STATE.parentSessionId even inside runWithSdkContext(),
allowing one SDK context to overwrite another's parent-session metadata.
Add parentSessionId to the SdkContext type and update both
regenerateSessionId and getParentSessionId to read/write from the
active context when one exists, using an explicit if-else pattern
rather than ?? to avoid undefined fallback leaking across contexts.
The non-SDK CLI path (no active context) continues to use STATE
directly, preserving existing behavior.
---------
Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
This commit is contained in:
+116
-11
@@ -42,6 +42,8 @@ import { SYNTHETIC_OUTPUT_TOOL_NAME } from './tools/SyntheticOutputTool/Syntheti
|
||||
import type { Message } from './types/message.js'
|
||||
import type { OrphanedPermission } from './types/textInputTypes.js'
|
||||
import { createAbortController } from './utils/abortController.js'
|
||||
import { validateArrayOf, assertNonEmptyString, assertObject, assertFunction } from './utils/validation.js'
|
||||
import { invalidateRemovedToolSchemas } from './utils/toolSchemaCache.js'
|
||||
import type { AttributionState } from './utils/commitAttribution.js'
|
||||
import { getGlobalConfig } from './utils/config.js'
|
||||
import { getCwd } from './utils/cwd.js'
|
||||
@@ -82,12 +84,7 @@ import {
|
||||
shouldEnableThinkingByDefault,
|
||||
type ThinkingConfig,
|
||||
} from './utils/thinking.js'
|
||||
|
||||
// Lazy: MessageSelector.tsx pulls React/ink; only needed for message filtering at query time
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const messageSelector =
|
||||
(): typeof import('src/components/MessageSelector.js') =>
|
||||
require('src/components/MessageSelector.js')
|
||||
import { selectableUserMessagesFilter } from './utils/messageFilters.js'
|
||||
|
||||
import {
|
||||
localCommandOutputToSDKAssistantMessage,
|
||||
@@ -360,7 +357,7 @@ export class QueryEngine {
|
||||
isNonInteractiveSession: true,
|
||||
customSystemPrompt,
|
||||
appendSystemPrompt,
|
||||
agentDefinitions: { activeAgents: agents, allAgents: [] },
|
||||
agentDefinitions: { activeAgents: agents, allAgents: agents },
|
||||
theme: resolveThemeSetting(getGlobalConfig().theme),
|
||||
maxBudgetUsd,
|
||||
},
|
||||
@@ -469,7 +466,7 @@ export class QueryEngine {
|
||||
(msg.type === 'user' &&
|
||||
!msg.isMeta && // Skip synthetic caveat messages
|
||||
!msg.toolUseResult && // Skip tool results (they'll be acked from query)
|
||||
messageSelector().selectableUserMessagesFilter(msg)) || // Skip non-user-authored messages (task notifications, etc.)
|
||||
selectableUserMessagesFilter(msg)) || // Skip non-user-authored messages (task notifications, etc.)
|
||||
(msg.type === 'system' && msg.subtype === 'compact_boundary'), // Always ack compact boundaries
|
||||
)
|
||||
const messagesToAck = replayUserMessages ? replayableMessages : []
|
||||
@@ -509,7 +506,7 @@ export class QueryEngine {
|
||||
customSystemPrompt,
|
||||
appendSystemPrompt,
|
||||
theme: resolveThemeSetting(getGlobalConfig().theme),
|
||||
agentDefinitions: { activeAgents: agents, allAgents: [] },
|
||||
agentDefinitions: { activeAgents: agents, allAgents: agents },
|
||||
maxBudgetUsd,
|
||||
},
|
||||
getAppState,
|
||||
@@ -641,7 +638,7 @@ export class QueryEngine {
|
||||
|
||||
if (fileHistoryEnabled() && persistSession) {
|
||||
messagesFromUserInput
|
||||
.filter(messageSelector().selectableUserMessagesFilter)
|
||||
.filter(selectableUserMessagesFilter)
|
||||
.forEach(message => {
|
||||
void fileHistoryMakeSnapshot(
|
||||
(updater: (prev: FileHistoryState) => FileHistoryState) => {
|
||||
@@ -1022,10 +1019,11 @@ export class QueryEngine {
|
||||
SYNTHETIC_OUTPUT_TOOL_NAME,
|
||||
)
|
||||
const callsThisQuery = currentCalls - initialStructuredOutputCalls
|
||||
const maxRetries = parseInt(
|
||||
const parsed = parseInt(
|
||||
process.env.MAX_STRUCTURED_OUTPUT_RETRIES || '5',
|
||||
10,
|
||||
)
|
||||
const maxRetries = Number.isNaN(parsed) ? 5 : parsed
|
||||
if (callsThisQuery >= maxRetries) {
|
||||
if (persistSession) {
|
||||
if (
|
||||
@@ -1177,6 +1175,105 @@ export class QueryEngine {
|
||||
return this.mutableMessages
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject messages into the engine's message store.
|
||||
* Used by SDK query() when fork=true to resume from a forked session.
|
||||
*/
|
||||
injectMessages(messages: Message[]): void {
|
||||
const validated = validateArrayOf(messages, (msg, _i) => {
|
||||
const m = msg as Record<string, unknown>
|
||||
assertNonEmptyString(m.type, 'type')
|
||||
if (m.message !== undefined) {
|
||||
assertObject(m.message, 'message')
|
||||
const inner = m.message as Record<string, unknown>
|
||||
if (inner.role !== undefined) {
|
||||
assertNonEmptyString(inner.role, 'message.role')
|
||||
}
|
||||
if (inner.content !== undefined && typeof inner.content !== 'string' && !Array.isArray(inner.content)) {
|
||||
throw new TypeError("'message.content' must be a string or array")
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}, 'injectMessages')
|
||||
this.mutableMessages.push(...validated)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject agent definitions into the engine's config.
|
||||
* Used by SDK to load agents after engine creation (async loading).
|
||||
* Validates that agents have the internal format fields
|
||||
* (agentType, whenToUse, getSystemPrompt) since SDK agents
|
||||
* are converted to this format before injection.
|
||||
*/
|
||||
injectAgents(agents: AgentDefinition[]): void {
|
||||
const validated = validateArrayOf(agents, (agent, _i) => {
|
||||
const a = agent as Record<string, unknown>
|
||||
assertNonEmptyString(a.agentType, 'agentType')
|
||||
assertNonEmptyString(a.whenToUse, 'whenToUse')
|
||||
if (typeof a.getSystemPrompt !== 'function') {
|
||||
throw new TypeError("missing or invalid 'getSystemPrompt' (expected function)")
|
||||
}
|
||||
if (a.tools !== undefined) {
|
||||
const validToolNames = new Set(this.config.tools.map(t => t.name))
|
||||
for (const toolSpec of a.tools as string[]) {
|
||||
// Wildcard '*' means all tools are allowed - skip validation
|
||||
if (toolSpec === '*') continue
|
||||
// Parse tool spec to get base tool name (may contain permission rules)
|
||||
const toolName = toolSpec.split(':')[0] ?? toolSpec
|
||||
if (!validToolNames.has(toolName)) {
|
||||
throw new TypeError(`agent references unknown tool '${toolSpec}'`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return agent
|
||||
}, 'injectAgents')
|
||||
this.config.agents = validated
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the engine's tool list dynamically.
|
||||
* Used by SDK setPermissionMode to refresh tools when permission mode changes.
|
||||
*/
|
||||
updateTools(tools: Tools): void {
|
||||
if (!Array.isArray(tools) && !(Symbol.iterator in Object(tools))) {
|
||||
throw new TypeError(`updateTools: expected iterable, got ${typeof tools}`)
|
||||
}
|
||||
const toolArray = Array.from(tools as Iterable<unknown>)
|
||||
|
||||
// Phase 1: Validate new tools
|
||||
validateArrayOf(toolArray, (tool, _i) => {
|
||||
const t = tool as Record<string, unknown>
|
||||
assertNonEmptyString(t.name, 'name')
|
||||
assertFunction(t.call, 'call')
|
||||
return tool
|
||||
}, 'updateTools')
|
||||
|
||||
// Phase 2: Validate agent compatibility BEFORE commit (transactional)
|
||||
const validToolNames = new Set(toolArray.map(t => (t as Record<string, unknown>).name as string))
|
||||
for (const agent of this.config.agents) {
|
||||
if (agent.tools) {
|
||||
for (const toolSpec of agent.tools) {
|
||||
if (toolSpec === '*') continue
|
||||
const toolName = toolSpec.split(':')[0] ?? toolSpec
|
||||
if (!validToolNames.has(toolName)) {
|
||||
throw new TypeError(
|
||||
`updateTools: agent '${agent.agentType}' references tool '${toolSpec}' which is not in the new tool set`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Commit — only reached if all validations pass
|
||||
this.config.tools = toolArray as Tools
|
||||
|
||||
// Phase 4: Invalidate schema cache for removed tools only.
|
||||
// Selective invalidation preserves cached schemas for tools that remain,
|
||||
// avoiding unnecessary recomputation for concurrent engines in multi-session
|
||||
// SDK scenarios. New tools (not yet cached) will be computed on first render.
|
||||
invalidateRemovedToolSchemas(validToolNames)
|
||||
}
|
||||
|
||||
getReadFileState(): FileStateCache {
|
||||
return this.readFileState
|
||||
}
|
||||
@@ -1188,6 +1285,14 @@ export class QueryEngine {
|
||||
setModel(model: string): void {
|
||||
this.config.userSpecifiedModel = model
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the engine's thinking config dynamically.
|
||||
* Used by SDK setMaxThinkingTokens to change the thinking token budget.
|
||||
*/
|
||||
setThinkingConfig(config: ThinkingConfig): void {
|
||||
this.config.thinkingConfig = config
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+86
-12
@@ -428,28 +428,82 @@ function getInitialState(): State {
|
||||
// AND ESPECIALLY HERE
|
||||
const STATE: State = getInitialState()
|
||||
|
||||
/**
|
||||
* Per-query SDK context for AsyncLocalStorage-based isolation.
|
||||
* When set, overrides global STATE reads for the current async context.
|
||||
*
|
||||
* **Runtime Requirement:** Uses Node.js `async_hooks.AsyncLocalStorage`.
|
||||
* Not available in browsers or non-Node JavaScript environments.
|
||||
* SDK consumers must run in a Node.js runtime (Node.js 12.17.0+ or 14.0.0+).
|
||||
*/
|
||||
type SdkContext = {
|
||||
sessionId: SessionId
|
||||
sessionProjectDir: string | null
|
||||
cwd: string
|
||||
originalCwd: string
|
||||
parentSessionId?: SessionId
|
||||
}
|
||||
|
||||
import { AsyncLocalStorage } from 'async_hooks'
|
||||
|
||||
const sdkContextStorage = new AsyncLocalStorage<SdkContext>()
|
||||
|
||||
/**
|
||||
* Run a function with an SDK-specific context that overrides global state.
|
||||
* All reads of sessionId, sessionProjectDir, cwd, originalCwd within fn
|
||||
* return context-scoped values instead of global STATE.
|
||||
*
|
||||
* **Node.js Only:** Requires AsyncLocalStorage from async_hooks module.
|
||||
* This function will throw if called in a non-Node environment where
|
||||
* async_hooks is not available.
|
||||
*/
|
||||
export function runWithSdkContext<T>(context: SdkContext, fn: () => T): T {
|
||||
return sdkContextStorage.run(context, fn)
|
||||
}
|
||||
|
||||
function getSdkContext(): SdkContext | undefined {
|
||||
return sdkContextStorage.getStore()
|
||||
}
|
||||
|
||||
export function getSessionId(): SessionId {
|
||||
return STATE.sessionId
|
||||
const ctx = getSdkContext()
|
||||
return ctx?.sessionId ?? STATE.sessionId
|
||||
}
|
||||
|
||||
export function regenerateSessionId(
|
||||
options: { setCurrentAsParent?: boolean } = {},
|
||||
): SessionId {
|
||||
const ctx = getSdkContext()
|
||||
const currentSessionId = ctx?.sessionId ?? STATE.sessionId
|
||||
if (options.setCurrentAsParent) {
|
||||
STATE.parentSessionId = STATE.sessionId
|
||||
if (ctx) {
|
||||
ctx.parentSessionId = currentSessionId
|
||||
} else {
|
||||
STATE.parentSessionId = currentSessionId
|
||||
}
|
||||
}
|
||||
// Drop the outgoing session's plan-slug entry so the Map doesn't
|
||||
// accumulate stale keys. Callers that need to carry the slug across
|
||||
// (REPL.tsx clearContext) read it before calling clearConversation.
|
||||
STATE.planSlugCache.delete(STATE.sessionId)
|
||||
STATE.planSlugCache.delete(currentSessionId)
|
||||
// Regenerated sessions live in the current project: reset projectDir to
|
||||
// null so getTranscriptPath() derives from originalCwd.
|
||||
STATE.sessionId = randomUUID() as SessionId
|
||||
STATE.sessionProjectDir = null
|
||||
return STATE.sessionId
|
||||
const newId = randomUUID() as SessionId
|
||||
if (ctx) {
|
||||
ctx.sessionId = newId
|
||||
ctx.sessionProjectDir = null
|
||||
} else {
|
||||
STATE.sessionId = newId
|
||||
STATE.sessionProjectDir = null
|
||||
}
|
||||
return newId
|
||||
}
|
||||
|
||||
export function getParentSessionId(): SessionId | undefined {
|
||||
const ctx = getSdkContext()
|
||||
if (ctx) {
|
||||
return ctx.parentSessionId
|
||||
}
|
||||
return STATE.parentSessionId
|
||||
}
|
||||
|
||||
@@ -469,12 +523,19 @@ export function switchSession(
|
||||
sessionId: SessionId,
|
||||
projectDir: string | null = null,
|
||||
): void {
|
||||
const ctx = getSdkContext()
|
||||
const currentSessionId = ctx?.sessionId ?? STATE.sessionId
|
||||
// Drop the outgoing session's plan-slug entry so the Map stays bounded
|
||||
// across repeated /resume. Only the current session's slug is ever read
|
||||
// (plans.ts getPlanSlug defaults to getSessionId()).
|
||||
STATE.planSlugCache.delete(STATE.sessionId)
|
||||
STATE.sessionId = sessionId
|
||||
STATE.sessionProjectDir = projectDir
|
||||
STATE.planSlugCache.delete(currentSessionId)
|
||||
if (ctx) {
|
||||
ctx.sessionId = sessionId
|
||||
ctx.sessionProjectDir = projectDir
|
||||
} else {
|
||||
STATE.sessionId = sessionId
|
||||
STATE.sessionProjectDir = projectDir
|
||||
}
|
||||
sessionSwitched.emit(sessionId)
|
||||
}
|
||||
|
||||
@@ -494,11 +555,13 @@ export const onSessionSwitch = sessionSwitched.subscribe
|
||||
* originalCwd). See `switchSession()`.
|
||||
*/
|
||||
export function getSessionProjectDir(): string | null {
|
||||
return STATE.sessionProjectDir
|
||||
const ctx = getSdkContext()
|
||||
return ctx?.sessionProjectDir ?? STATE.sessionProjectDir
|
||||
}
|
||||
|
||||
export function getOriginalCwd(): string {
|
||||
return STATE.originalCwd
|
||||
const ctx = getSdkContext()
|
||||
return ctx?.originalCwd ?? STATE.originalCwd
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -513,6 +576,11 @@ export function getProjectRoot(): string {
|
||||
}
|
||||
|
||||
export function setOriginalCwd(cwd: string): void {
|
||||
const ctx = getSdkContext()
|
||||
if (ctx) {
|
||||
ctx.originalCwd = cwd.normalize('NFC')
|
||||
return
|
||||
}
|
||||
STATE.originalCwd = cwd.normalize('NFC')
|
||||
}
|
||||
|
||||
@@ -525,10 +593,16 @@ export function setProjectRoot(cwd: string): void {
|
||||
}
|
||||
|
||||
export function getCwdState(): string {
|
||||
return STATE.cwd
|
||||
const ctx = getSdkContext()
|
||||
return ctx?.cwd ?? STATE.cwd
|
||||
}
|
||||
|
||||
export function setCwdState(cwd: string): void {
|
||||
const ctx = getSdkContext()
|
||||
if (ctx) {
|
||||
ctx.cwd = cwd.normalize('NFC')
|
||||
return
|
||||
}
|
||||
STATE.cwd = cwd.normalize('NFC')
|
||||
}
|
||||
|
||||
|
||||
+11
-9
@@ -351,7 +351,7 @@ const COMMANDS = memoize((): Command[] => [
|
||||
hooks,
|
||||
exportCommand,
|
||||
sandboxToggle,
|
||||
...(!isUsing3PServices() ? [logout, login()] : []),
|
||||
...(!isUsing3PServices() ? [logout, login()].filter(Boolean) : []),
|
||||
passes,
|
||||
...(peersCmd ? [peersCmd] : []),
|
||||
tasks,
|
||||
@@ -431,8 +431,8 @@ const getWorkflowCommands = feature('WORKFLOW_SCRIPTS')
|
||||
* Not memoized — auth state can change mid-session (e.g. after /login),
|
||||
* so this must be re-evaluated on every getCommands() call.
|
||||
*/
|
||||
export function meetsAvailabilityRequirement(cmd: Command): boolean {
|
||||
if (!cmd.availability) return true
|
||||
export function meetsAvailabilityRequirement(cmd: Command | null | undefined): boolean {
|
||||
if (!cmd || !cmd.availability || !Array.isArray(cmd.availability)) return true
|
||||
for (const a of cmd.availability) {
|
||||
switch (a) {
|
||||
case 'claude-ai':
|
||||
@@ -747,25 +747,27 @@ export function formatDescriptionWithSource(cmd: Command): string {
|
||||
return cmd.description ?? ''
|
||||
}
|
||||
|
||||
const desc = cmd.description ?? ''
|
||||
|
||||
if (cmd.kind === 'workflow') {
|
||||
return `${cmd.description ?? ''} (workflow)`
|
||||
return `${desc} (workflow)`
|
||||
}
|
||||
|
||||
if (cmd.source === 'plugin') {
|
||||
const pluginName = cmd.pluginInfo?.pluginManifest.name
|
||||
if (pluginName) {
|
||||
return `(${pluginName}) ${cmd.description ?? ''}`
|
||||
return `(${pluginName}) ${desc}`
|
||||
}
|
||||
return `${cmd.description ?? ''} (plugin)`
|
||||
return `${desc} (plugin)`
|
||||
}
|
||||
|
||||
if (cmd.source === 'builtin' || cmd.source === 'mcp') {
|
||||
return cmd.description ?? ''
|
||||
return desc
|
||||
}
|
||||
|
||||
if (cmd.source === 'bundled') {
|
||||
return `${cmd.description} (bundled)`
|
||||
return `${desc} (bundled)`
|
||||
}
|
||||
|
||||
return `${cmd.description} (${getSettingSourceName(cmd.source)})`
|
||||
return `${desc} (${getSettingSourceName(cmd.source)})`
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useKeybinding, useKeybindings } from '../keybindings/useKeybinding.js';
|
||||
import type { Message, PartialCompactDirection, UserMessage } from '../types/message.js';
|
||||
import { stripDisplayTags } from '../utils/displayTags.js';
|
||||
import { createUserMessage, extractTag, isEmptyMessageText, isSyntheticMessage, isToolUseResultMessage } from '../utils/messages.js';
|
||||
import { selectableUserMessagesFilter, messagesAfterAreOnlySynthetic } from '../utils/messageFilters.js';
|
||||
import { type OptionWithDescription, Select } from './CustomSelect/select.js';
|
||||
import { Spinner } from './Spinner.js';
|
||||
function isTextBlock(block: ContentBlockParam): block is TextBlockParam {
|
||||
@@ -764,67 +765,3 @@ function computeDiffStatsBetweenMessages(messages: Message[], fromMessageId: UUI
|
||||
deletions
|
||||
};
|
||||
}
|
||||
export function selectableUserMessagesFilter(message: Message): message is UserMessage {
|
||||
if (message.type !== 'user') {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(message.message.content) && message.message.content[0]?.type === 'tool_result') {
|
||||
return false;
|
||||
}
|
||||
if (isSyntheticMessage(message)) {
|
||||
return false;
|
||||
}
|
||||
if (message.isMeta) {
|
||||
return false;
|
||||
}
|
||||
if (message.isCompactSummary || message.isVisibleInTranscriptOnly) {
|
||||
return false;
|
||||
}
|
||||
const content = message.message.content;
|
||||
const lastBlock = typeof content === 'string' ? null : content[content.length - 1];
|
||||
const messageText = typeof content === 'string' ? content.trim() : lastBlock && isTextBlock(lastBlock) ? lastBlock.text.trim() : '';
|
||||
|
||||
// Filter out non-user-authored messages (command outputs, task notifications, ticks).
|
||||
if (messageText.indexOf(`<${LOCAL_COMMAND_STDOUT_TAG}>`) !== -1 || messageText.indexOf(`<${LOCAL_COMMAND_STDERR_TAG}>`) !== -1 || messageText.indexOf(`<${BASH_STDOUT_TAG}>`) !== -1 || messageText.indexOf(`<${BASH_STDERR_TAG}>`) !== -1 || messageText.indexOf(`<${TASK_NOTIFICATION_TAG}>`) !== -1 || messageText.indexOf(`<${TICK_TAG}>`) !== -1 || messageText.indexOf(`<${TEAMMATE_MESSAGE_TAG}`) !== -1) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all messages after the given index are synthetic (interruptions, cancels, etc.)
|
||||
* or non-meaningful content. Returns true if there's nothing meaningful to confirm -
|
||||
* for example, if the user hit enter then immediately cancelled.
|
||||
*/
|
||||
export function messagesAfterAreOnlySynthetic(messages: Message[], fromIndex: number): boolean {
|
||||
for (let i = fromIndex + 1; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (!msg) continue;
|
||||
|
||||
// Skip known non-meaningful message types
|
||||
if (isSyntheticMessage(msg)) continue;
|
||||
if (isToolUseResultMessage(msg)) continue;
|
||||
if (msg.type === 'progress') continue;
|
||||
if (msg.type === 'system') continue;
|
||||
if (msg.type === 'attachment') continue;
|
||||
if (msg.type === 'user' && msg.isMeta) continue;
|
||||
|
||||
// Assistant with actual content = meaningful
|
||||
if (msg.type === 'assistant') {
|
||||
const content = msg.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
const hasMeaningfulContent = content.some(block => block.type === 'text' && block.text.trim() || block.type === 'tool_use');
|
||||
if (hasMeaningfulContent) return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// User messages that aren't synthetic or meta = meaningful
|
||||
if (msg.type === 'user') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Other types (e.g., tombstone) are non-meaningful, continue
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { afterEach, expect, test } from 'bun:test'
|
||||
VERSION: '99.0.0',
|
||||
DISPLAY_VERSION: '0.0.0-test',
|
||||
BUILD_TIME: new Date().toISOString(),
|
||||
ISSUES_EXPLAINER: 'report the issue at https://github.com/anthropics/claude-code/issues',
|
||||
ISSUES_EXPLAINER: 'report the issue at https://github.com/Gitlawb/openclaude/issues',
|
||||
PACKAGE_URL: '@gitlawb/openclaude',
|
||||
NATIVE_PACKAGE_URL: undefined,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Snake_case ↔ camelCase key mappers for the SDK boundary layer.
|
||||
*
|
||||
* Internal runtime (JSONL files, session storage) uses snake_case.
|
||||
* Public SDK API exposes camelCase to consumers (JS/TS convention).
|
||||
* These utilities handle the conversion at the SDK boundary.
|
||||
*/
|
||||
|
||||
/** Convert a snake_case string to camelCase. Handles consecutive underscores and dunder names. */
|
||||
export function snakeToCamel(s: string): string {
|
||||
// Handle dunder names like __proto__ - strip leading and trailing __ and capitalize first letter
|
||||
if (s.startsWith('__') && s.endsWith('__') && s.length > 4) {
|
||||
const inner = s.slice(2, -2)
|
||||
const converted = inner.replace(/_+([a-z])/g, (_, c: string) => c.toUpperCase())
|
||||
// Capitalize first letter for dunder names
|
||||
return converted.charAt(0).toUpperCase() + converted.slice(1)
|
||||
}
|
||||
// Match one or more underscores followed by a lowercase letter,
|
||||
// but only if there's content after that letter (not at end of string)
|
||||
// This preserves trailing underscores and underscore-letter pairs at the end
|
||||
return s.replace(/_+([a-z])(?=.)/g, (_, c: string) => c.toUpperCase())
|
||||
}
|
||||
|
||||
/** Convert a camelCase string to snake_case. */
|
||||
export function camelToSnake(s: string): string {
|
||||
return s.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`)
|
||||
}
|
||||
|
||||
/** Recursively transform all keys in an object from snake_case to camelCase. */
|
||||
export function mapKeysToCamel<T>(obj: T): T {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (Array.isArray(obj)) return obj.map(mapKeysToCamel) as T
|
||||
if (typeof obj !== 'object') return obj
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
result[snakeToCamel(key)] = mapKeysToCamel(value)
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
|
||||
/** Recursively transform all keys in an object from camelCase to snake_case. */
|
||||
export function mapKeysToSnake<T>(obj: T): T {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (Array.isArray(obj)) return obj.map(mapKeysToSnake) as T
|
||||
if (typeof obj !== 'object') return obj
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
result[camelToSnake(key)] = mapKeysToSnake(value)
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* Permission handling for the SDK.
|
||||
*
|
||||
* Provides canUseTool wrappers, permission context building,
|
||||
* MCP server connection, and default permission-denying logic.
|
||||
*
|
||||
* @internal — these utilities are not part of the public SDK API.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
|
||||
import {
|
||||
getEmptyToolPermissionContext,
|
||||
type ToolPermissionContext,
|
||||
type Tool,
|
||||
} from '../../Tool.js'
|
||||
import type { MCPServerConnection, ScopedMcpServerConfig } from '../../services/mcp/types.js'
|
||||
import { connectToServer, fetchToolsForClient } from '../../services/mcp/client.js'
|
||||
import type {
|
||||
QueryPermissionMode,
|
||||
CanUseToolCallback,
|
||||
SDKPermissionRequestMessage,
|
||||
SDKPermissionTimeoutMessage,
|
||||
} from './shared.js'
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Default timeout for permission prompts (30 seconds). Reasonable for human response time. */
|
||||
export const DEFAULT_PERMISSION_TIMEOUT_MS = 30000
|
||||
|
||||
/**
|
||||
* Placeholder session_id for permission requests outside SDK session context.
|
||||
* Used when createExternalCanUseTool is called without a sessionId parameter,
|
||||
* indicating a standalone permission prompt (e.g., direct tool permission check
|
||||
* without an active SDK session). Hosts can identify such requests by checking
|
||||
* session_id === NO_SESSION_PLACEHOLDER.
|
||||
*/
|
||||
export const NO_SESSION_PLACEHOLDER = 'no-session'
|
||||
|
||||
// ============================================================================
|
||||
// Logger interface for SDK surface
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Logger interface for SDK permission system.
|
||||
* Hosts can inject a custom logger to control warning output.
|
||||
* Defaults to console.warn if no logger is provided.
|
||||
*/
|
||||
export interface SDKLogger {
|
||||
warn(message: string): void
|
||||
}
|
||||
|
||||
/** Default console-based logger used when no custom logger is provided. */
|
||||
const defaultLogger: SDKLogger = {
|
||||
warn: (message: string) => console.warn(message),
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Once-only resolve wrapper
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a resolve function that can only be called once.
|
||||
* Prevents promise twice-resolve race conditions when timeout
|
||||
* and host response happen simultaneously.
|
||||
*/
|
||||
export function createOnceOnlyResolve<T>(
|
||||
resolve: (value: T) => void,
|
||||
): (value: T) => void {
|
||||
let resolved = false
|
||||
return (value: T) => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
resolve(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Permission target factory (for race condition safety)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Factory for creating a permissionTarget with proper race condition handling.
|
||||
* The once-only resolve wrapper is applied at registration time, ensuring
|
||||
* both timeout handler and host response use the same wrapped resolve.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const permissionTarget = createPermissionTarget()
|
||||
* const canUseTool = createExternalCanUseTool(
|
||||
* undefined,
|
||||
* fallback,
|
||||
* permissionTarget,
|
||||
* onPermissionRequest,
|
||||
* onTimeout
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export function createPermissionTarget() {
|
||||
const pendingPermissionPrompts = new Map<string, { resolve: (decision: PermissionResolveDecision) => void }>()
|
||||
|
||||
const registerPendingPermission = (toolUseId: string): Promise<PermissionResolveDecision> => {
|
||||
return new Promise(resolve => {
|
||||
// Apply onceOnlyResolve at registration time - this ensures both
|
||||
// timeout handler and host response use the same wrapped resolve,
|
||||
// preventing "promise already resolved" errors
|
||||
const wrappedResolve = createOnceOnlyResolve(resolve)
|
||||
pendingPermissionPrompts.set(toolUseId, { resolve: wrappedResolve })
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
registerPendingPermission,
|
||||
pendingPermissionPrompts,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Permission resolve decision type
|
||||
// ============================================================================
|
||||
|
||||
export type PermissionResolveDecision =
|
||||
| { behavior: 'allow'; updatedInput?: Record<string, unknown> }
|
||||
| { behavior: 'deny'; message: string; decisionReason: { type: 'mode'; mode: string } }
|
||||
|
||||
// ============================================================================
|
||||
// buildPermissionContext
|
||||
// ============================================================================
|
||||
|
||||
export interface PermissionContextOptions {
|
||||
cwd: string
|
||||
permissionMode?: QueryPermissionMode
|
||||
additionalDirectories?: string[]
|
||||
allowDangerouslySkipPermissions?: boolean
|
||||
}
|
||||
|
||||
export function buildPermissionContext(options: PermissionContextOptions): ToolPermissionContext {
|
||||
const base = getEmptyToolPermissionContext()
|
||||
const mode = options.permissionMode ?? 'default'
|
||||
|
||||
// Map SDK permission mode to internal PermissionMode
|
||||
let internalMode: string = 'default'
|
||||
switch (mode) {
|
||||
case 'plan':
|
||||
internalMode = 'plan'
|
||||
break
|
||||
case 'auto-accept': // Alias for acceptEdits
|
||||
case 'acceptEdits':
|
||||
internalMode = 'acceptEdits'
|
||||
break
|
||||
case 'bypass-permissions':
|
||||
case 'bypassPermissions':
|
||||
internalMode = 'bypassPermissions'
|
||||
break
|
||||
default:
|
||||
internalMode = 'default'
|
||||
}
|
||||
|
||||
// Wire additionalDirectories into the permission context
|
||||
if (options.additionalDirectories && options.additionalDirectories.length > 0) {
|
||||
for (const dir of options.additionalDirectories) {
|
||||
base.additionalWorkingDirectories.set(dir, true)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
mode: internalMode as ToolPermissionContext['mode'],
|
||||
isBypassPermissionsModeAvailable:
|
||||
mode === 'bypass-permissions' || mode === 'bypassPermissions' || options.allowDangerouslySkipPermissions === true,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// createExternalCanUseTool
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a canUseTool function that supports external permission resolution
|
||||
* via respondToPermission().
|
||||
*
|
||||
* When a user-provided canUseTool callback exists, it takes priority.
|
||||
* Otherwise, a permission_request message is emitted to the SDK stream,
|
||||
* and the host can resolve it via respondToPermission() before the timeout.
|
||||
*
|
||||
* The flow:
|
||||
* 1. QueryEngine calls canUseTool(tool, input, ..., toolUseID, forceDecision)
|
||||
* 2. If forceDecision is set, honor it immediately
|
||||
* 3. If user canUseTool callback exists, delegate to it
|
||||
* 4. Otherwise, emit permission_request message and await external resolution
|
||||
*
|
||||
* For async external resolution, hosts should listen for permission_request
|
||||
* SDKMessages and call respondToPermission(). The pending prompt is registered
|
||||
* via registerPendingPermission() and awaited here.
|
||||
*/
|
||||
export function createExternalCanUseTool(
|
||||
userFn: CanUseToolCallback | undefined,
|
||||
fallback: CanUseToolFn,
|
||||
permissionTarget: {
|
||||
registerPendingPermission(toolUseId: string): Promise<PermissionResolveDecision>
|
||||
pendingPermissionPrompts: Map<string, { resolve: (decision: PermissionResolveDecision) => void }>
|
||||
},
|
||||
onPermissionRequest?: (message: SDKPermissionRequestMessage) => void,
|
||||
onTimeout?: (message: SDKPermissionTimeoutMessage) => void,
|
||||
// Default 30 second timeout for permission prompts - reasonable for human response time
|
||||
timeoutMs: number = DEFAULT_PERMISSION_TIMEOUT_MS,
|
||||
sessionId?: string,
|
||||
logger?: SDKLogger,
|
||||
): CanUseToolFn {
|
||||
const log = logger ?? defaultLogger
|
||||
return async (tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision) => {
|
||||
// If a forced decision was passed in, honor it
|
||||
if (forceDecision) return forceDecision
|
||||
|
||||
// If the user provided a synchronous canUseTool callback, use it
|
||||
if (userFn) {
|
||||
try {
|
||||
const result = await userFn(tool.name, input, { toolUseID })
|
||||
if (result.behavior === 'allow') {
|
||||
return { behavior: 'allow' as const, updatedInput: result.updatedInput ?? input }
|
||||
}
|
||||
return {
|
||||
behavior: 'deny' as const,
|
||||
message: result.message ?? `Tool ${tool.name} denied by canUseTool callback`,
|
||||
decisionReason: { type: 'mode' as const, mode: 'default' },
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown callback error'
|
||||
return {
|
||||
behavior: 'deny' as const,
|
||||
message: `Tool ${tool.name} denied (callback error: ${errorMessage})`,
|
||||
decisionReason: { type: 'mode' as const, mode: 'default' },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No user callback — if host registered an onPermissionRequest callback,
|
||||
// call it directly and await external resolution with timeout.
|
||||
if (toolUseID && onPermissionRequest) {
|
||||
const requestId = randomUUID()
|
||||
const messageUuid = randomUUID()
|
||||
|
||||
// Register pending permission BEFORE emitting the request so that
|
||||
// a host which responds synchronously from onPermissionRequest can
|
||||
// find the entry in pendingPermissionPrompts immediately.
|
||||
const pendingPromise = permissionTarget.registerPendingPermission(toolUseID)
|
||||
|
||||
// Wrap onPermissionRequest in try-catch since it's SDK-host-provided code.
|
||||
// If it throws, clean up the pending entry and deny/fallback cleanly.
|
||||
try {
|
||||
onPermissionRequest({
|
||||
type: 'permission_request',
|
||||
request_id: requestId,
|
||||
tool_name: tool.name,
|
||||
tool_use_id: toolUseID,
|
||||
input: input as Record<string, unknown>,
|
||||
uuid: messageUuid,
|
||||
session_id: sessionId ?? NO_SESSION_PLACEHOLDER,
|
||||
})
|
||||
} catch (err) {
|
||||
permissionTarget.pendingPermissionPrompts.delete(toolUseID)
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown host callback error'
|
||||
return {
|
||||
behavior: 'deny' as const,
|
||||
message: `Tool ${tool.name} denied (onPermissionRequest callback error: ${errorMessage})`,
|
||||
decisionReason: { type: 'mode' as const, mode: 'default' },
|
||||
}
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
const timeoutPromise = new Promise<{ timedOut: true }>(resolve => {
|
||||
timeoutId = setTimeout(() => resolve({ timedOut: true }), timeoutMs)
|
||||
})
|
||||
|
||||
const raceResult = await Promise.race([
|
||||
pendingPromise.then(result => ({ result, timedOut: false })),
|
||||
timeoutPromise,
|
||||
])
|
||||
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
if (!raceResult.timedOut && raceResult.result) {
|
||||
permissionTarget.pendingPermissionPrompts.delete(toolUseID)
|
||||
return raceResult.result
|
||||
}
|
||||
|
||||
// Timeout — emit event and clean up
|
||||
if (onTimeout) {
|
||||
onTimeout({
|
||||
type: 'permission_timeout',
|
||||
tool_name: tool.name,
|
||||
tool_use_id: toolUseID,
|
||||
timed_out_after_ms: timeoutMs,
|
||||
})
|
||||
}
|
||||
log.warn(
|
||||
`[SDK] Permission request for tool "${tool.name}" timed out after ${timeoutMs}ms. ` +
|
||||
'Denying by default. Provide a canUseTool callback or respond to permission_request ' +
|
||||
'messages within the timeout window.',
|
||||
)
|
||||
const pending = permissionTarget.pendingPermissionPrompts.get(toolUseID)
|
||||
if (pending) {
|
||||
// Resolve the pending promise with denial.
|
||||
// NOTE: For race condition safety, use createPermissionTarget() which wraps
|
||||
// the resolve at registration time. If using a custom permissionTarget,
|
||||
// callers should apply createOnceOnlyResolve in their registerPendingPermission.
|
||||
pending.resolve({ behavior: 'deny', message: 'Permission resolution timed out' })
|
||||
permissionTarget.pendingPermissionPrompts.delete(toolUseID)
|
||||
}
|
||||
}
|
||||
|
||||
// No callback or no toolUseID — fall through to default permission logic
|
||||
return fallback(tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP server connection for SDK
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Connects to MCP servers from SDK options.
|
||||
* Takes the mcpServers config and connects to each server,
|
||||
* returning connected clients and their tools.
|
||||
*
|
||||
* @param mcpServers - MCP server configurations from SDK options
|
||||
* @returns Connected clients and their tools
|
||||
*/
|
||||
export async function connectSdkMcpServers(
|
||||
mcpServers: Record<string, unknown> | undefined,
|
||||
): Promise<{ clients: MCPServerConnection[]; tools: Tool[] }> {
|
||||
if (!mcpServers || Object.keys(mcpServers).length === 0) {
|
||||
return { clients: [], tools: [] }
|
||||
}
|
||||
|
||||
const clients: MCPServerConnection[] = []
|
||||
const tools: Tool[] = []
|
||||
|
||||
// Connect to each server in parallel
|
||||
const results = await Promise.allSettled(
|
||||
Object.entries(mcpServers).map(async ([name, config]) => {
|
||||
// Validate config is a non-null object before spreading (arrays are objects but invalid for config)
|
||||
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
||||
return {
|
||||
client: {
|
||||
type: 'failed' as const,
|
||||
name,
|
||||
config: { scope: 'session' as const } as ScopedMcpServerConfig,
|
||||
error: `Invalid MCP server config for '${name}': expected object, got ${config === null ? 'null' : Array.isArray(config) ? 'array' : typeof config}`,
|
||||
},
|
||||
tools: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Convert SDK config to ScopedMcpServerConfig format
|
||||
const scopedConfig: ScopedMcpServerConfig = {
|
||||
...(config as Record<string, unknown>),
|
||||
scope: 'session' as const, // SDK servers are scoped to session
|
||||
}
|
||||
|
||||
try {
|
||||
// Connect to the server
|
||||
const client = await connectToServer(name, scopedConfig, {
|
||||
totalServers: Object.keys(mcpServers).length,
|
||||
stdioCount: 0,
|
||||
sseCount: 0,
|
||||
httpCount: 0,
|
||||
sseIdeCount: 0,
|
||||
wsIdeCount: 0,
|
||||
})
|
||||
|
||||
// If connected, fetch tools
|
||||
if (client.type === 'connected') {
|
||||
const serverTools = await fetchToolsForClient(client)
|
||||
return { client, tools: serverTools }
|
||||
}
|
||||
|
||||
// Return failed/pending client with no tools
|
||||
return { client, tools: [] }
|
||||
} catch (error) {
|
||||
// Connection failed, return failed client with full error context
|
||||
const errorMessage = error instanceof Error
|
||||
? `${error.message}${error.stack ? `\nStack: ${error.stack}` : ''}`
|
||||
: 'Unknown error'
|
||||
return {
|
||||
client: {
|
||||
type: 'failed' as const,
|
||||
name,
|
||||
config: scopedConfig,
|
||||
error: errorMessage,
|
||||
},
|
||||
tools: [],
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Process results
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
clients.push(result.value.client)
|
||||
tools.push(...result.value.tools)
|
||||
}
|
||||
}
|
||||
|
||||
return { clients, tools }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default permission-denying canUseTool
|
||||
// ============================================================================
|
||||
|
||||
let warnedDefaultPermissions = false
|
||||
|
||||
/**
|
||||
* Default canUseTool that DENIES all tool uses when no explicit
|
||||
* canUseTool or onPermissionRequest callback is provided.
|
||||
*
|
||||
* This is the secure-by-default behavior: SDK consumers must explicitly
|
||||
* provide a permission callback to allow tool execution. Permission modes
|
||||
* like 'bypass-permissions' still work because tool filtering happens at
|
||||
* the tool-list level via getTools(permissionContext) before this function
|
||||
* is ever reached.
|
||||
*/
|
||||
export function createDefaultCanUseTool(
|
||||
_permissionContext: ToolPermissionContext,
|
||||
logger?: SDKLogger,
|
||||
): CanUseToolFn {
|
||||
const log = logger ?? defaultLogger
|
||||
if (!warnedDefaultPermissions) {
|
||||
warnedDefaultPermissions = true
|
||||
log.warn(
|
||||
'[SDK] No canUseTool or onPermissionRequest callback provided. ' +
|
||||
'All tool uses will be DENIED by default. ' +
|
||||
'Provide canUseTool in query options, e.g.: ' +
|
||||
'{ canUseTool: async (name, input) => ({ behavior: "allow" }) }',
|
||||
)
|
||||
}
|
||||
return async (tool, input, _toolUseContext, _assistantMessage, _toolUseID, forceDecision) => {
|
||||
if (forceDecision) return forceDecision
|
||||
return {
|
||||
behavior: 'deny' as const,
|
||||
message: `SDK: Tool "${tool.name}" denied — no canUseTool or onPermissionRequest callback provided. Pass canUseTool in options to control tool permissions.`,
|
||||
decisionReason: { type: 'mode' as const, mode: 'default' },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Shared types, helpers, and mutex for the SDK modules.
|
||||
*
|
||||
* This module has no sibling imports — it is the foundation for
|
||||
* sessions.ts, permissions.ts, query.ts, and v2.ts.
|
||||
*/
|
||||
|
||||
import type { UUID } from 'crypto'
|
||||
import type {
|
||||
SDKMessage as GeneratedSDKMessage,
|
||||
SDKUserMessage as GeneratedSDKUserMessage,
|
||||
} from './coreTypes.generated.js'
|
||||
import { validateUuid } from '../../utils/sessionStoragePortable.js'
|
||||
|
||||
// ============================================================================
|
||||
// Session ID validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate sessionId is a proper UUID to prevent path traversal.
|
||||
* Throws if invalid.
|
||||
*/
|
||||
export function assertValidSessionId(sessionId: string): void {
|
||||
if (!validateUuid(sessionId)) {
|
||||
throw new Error(`Invalid session ID: ${sessionId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Environment mutation mutex for parallel query safety
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Global mutex for process.env mutations.
|
||||
* Prevents race conditions when multiple queries run in parallel.
|
||||
*
|
||||
* **Note:** The SDK itself does not directly mutate process.env. This mutex
|
||||
* is provided as a utility for SDK hosts who need to modify environment
|
||||
* variables during parallel query execution (e.g., setting API keys per-query).
|
||||
* Hosts must opt-in to using this mutex — there is no enforcement mechanism.
|
||||
*
|
||||
* Example usage:
|
||||
* ```typescript
|
||||
* const result = await acquireEnvMutex({ timeoutMs: 1000 })
|
||||
* if (result.acquired) {
|
||||
* try {
|
||||
* process.env.MY_API_KEY = 'key-for-this-query'
|
||||
* // ... perform query ...
|
||||
* } finally {
|
||||
* releaseEnvMutex()
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
const envMutationQueue: Array<() => void> = []
|
||||
let envMutationLocked = false
|
||||
|
||||
export interface MutexAcquireOptions {
|
||||
/** Maximum time to wait for mutex in milliseconds. Default: no timeout (wait forever). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface MutexAcquireResult {
|
||||
/** Whether the mutex was acquired successfully. */
|
||||
acquired: boolean
|
||||
/** Reason for failure if not acquired. */
|
||||
reason?: 'timeout'
|
||||
}
|
||||
|
||||
export async function acquireEnvMutex(options?: MutexAcquireOptions): Promise<MutexAcquireResult> {
|
||||
if (!envMutationLocked) {
|
||||
envMutationLocked = true
|
||||
return { acquired: true }
|
||||
}
|
||||
|
||||
if (options?.timeoutMs === undefined) {
|
||||
// No timeout - wait forever (original behavior for backward compatibility)
|
||||
return new Promise(resolve => {
|
||||
envMutationQueue.push(() => resolve({ acquired: true }))
|
||||
})
|
||||
}
|
||||
|
||||
// With timeout - race between queue and timeout
|
||||
return new Promise(resolve => {
|
||||
let resolved = false
|
||||
let callback: () => void
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
// Remove ourselves from the queue to prevent orphaned callback
|
||||
const index = envMutationQueue.indexOf(callback)
|
||||
if (index !== -1) {
|
||||
envMutationQueue.splice(index, 1)
|
||||
}
|
||||
resolve({ acquired: false, reason: 'timeout' })
|
||||
}
|
||||
}, options.timeoutMs)
|
||||
|
||||
callback = () => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeoutId)
|
||||
resolve({ acquired: true })
|
||||
}
|
||||
}
|
||||
|
||||
envMutationQueue.push(callback)
|
||||
})
|
||||
}
|
||||
|
||||
export function releaseEnvMutex(): void {
|
||||
if (envMutationQueue.length > 0) {
|
||||
const next = envMutationQueue.shift()
|
||||
if (next) {
|
||||
try {
|
||||
next()
|
||||
} catch {
|
||||
// If callback throws, ensure mutex is unlocked so next caller can acquire
|
||||
// The error is intentionally not propagated - callback errors should not
|
||||
// block the mutex system. Callers should handle their own errors.
|
||||
envMutationLocked = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
envMutationLocked = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset mutex state for testing purposes only.
|
||||
* Do not use in production code.
|
||||
* @internal
|
||||
*/
|
||||
export function resetEnvMutexForTesting(): void {
|
||||
envMutationQueue.length = 0
|
||||
envMutationLocked = false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SDK Types — snake_case public interface
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Permission request message emitted when a tool needs permission approval.
|
||||
* Hosts can respond via respondToPermission() using the request_id.
|
||||
*
|
||||
* **ID Relationship:**
|
||||
* - `request_id`: UUID generated per permission request, used as correlation ID
|
||||
* for respondToPermission(). Passed to onPermissionRequest callback for hosts
|
||||
* to identify which request they're responding to.
|
||||
* - `tool_use_id`: Identifier for the specific tool use instance, passed from
|
||||
* canUseTool(). Used internally for pending permission tracking and queue
|
||||
* filtering. Multiple permission requests for the same tool use are rare but
|
||||
* possible (e.g., retry after timeout).
|
||||
* - `session_id`: SDK session identifier. When 'no-session', indicates a
|
||||
* standalone permission prompt outside an SDK session flow (e.g., direct
|
||||
* createExternalCanUseTool usage without session context).
|
||||
* - `uuid`: Message UUID for stream correlation and transcript persistence.
|
||||
*
|
||||
* Hosts typically use `request_id` for responding; `tool_use_id` is useful
|
||||
* for tracking state or correlating with tool_use events in the message stream.
|
||||
* `session_id` enables correlation with SDK session lifecycle events.
|
||||
*/
|
||||
export type SDKPermissionRequestMessage = {
|
||||
type: 'permission_request'
|
||||
request_id: string
|
||||
tool_name: string
|
||||
tool_use_id: string
|
||||
input: Record<string, unknown>
|
||||
uuid: string
|
||||
session_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Message emitted when a permission request times out without a response.
|
||||
* Hosts can detect timeouts by checking `type === 'permission_timeout'`
|
||||
* in their `for await` loop. The `tool_use_id` matches the original
|
||||
* permission_request, allowing correlation.
|
||||
*
|
||||
* Note: `request_id` is not included in timeout messages since the request
|
||||
* is no longer pending — hosts cannot respond to timed-out requests.
|
||||
*/
|
||||
export type SDKPermissionTimeoutMessage = {
|
||||
type: 'permission_timeout'
|
||||
tool_name: string
|
||||
tool_use_id: string
|
||||
timed_out_after_ms: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A message emitted by the query engine during a conversation.
|
||||
* Re-exports the full generated type from coreTypes.generated.ts.
|
||||
*/
|
||||
export type SDKMessage = GeneratedSDKMessage | SDKPermissionTimeoutMessage
|
||||
|
||||
/**
|
||||
* A user message fed into query() via AsyncIterable.
|
||||
* Re-exports the full generated type from coreTypes.generated.ts.
|
||||
*/
|
||||
export type SDKUserMessage = GeneratedSDKUserMessage
|
||||
|
||||
/**
|
||||
* Map an internal Message object to an SDKMessage.
|
||||
* Internal messages have a different shape from SDK types — this function
|
||||
* performs the conversion instead of relying on unsafe casts.
|
||||
*
|
||||
* Validates that the message is a non-null object and has a valid type field.
|
||||
* Returns a message with type='unknown' if type is missing or invalid.
|
||||
*/
|
||||
export function mapMessageToSDK(msg: Record<string, unknown>): SDKMessage {
|
||||
// Validate input is a non-null object
|
||||
if (msg === null || typeof msg !== 'object') {
|
||||
throw new TypeError('mapMessageToSDK: expected non-null object')
|
||||
}
|
||||
|
||||
// Validate type field is a string (if present)
|
||||
const typeValue = msg.type
|
||||
if (typeValue !== undefined && typeof typeValue !== 'string') {
|
||||
throw new TypeError(`mapMessageToSDK: 'type' field must be string, got ${typeof typeValue}`)
|
||||
}
|
||||
|
||||
// Internal messages from QueryEngine already use the SDK field naming
|
||||
// convention (snake_case: parent_tool_use_id, session_id, etc.).
|
||||
// We spread all fields through and let the discriminated-union type
|
||||
// narrow via the `type` field.
|
||||
return {
|
||||
...msg,
|
||||
type: (typeValue as string) ?? 'unknown',
|
||||
} as SDKMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Session metadata returned by listSessions and getSessionInfo.
|
||||
* Uses snake_case field names matching the public SDK contract.
|
||||
*/
|
||||
export type SDKSessionInfo = {
|
||||
session_id: string
|
||||
summary: string
|
||||
last_modified: number
|
||||
file_size?: number
|
||||
custom_title?: string
|
||||
first_prompt?: string
|
||||
git_branch?: string
|
||||
cwd?: string
|
||||
tag?: string
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
/** Options for listSessions. */
|
||||
export type ListSessionsOptions = {
|
||||
/** Project directory. When omitted, returns sessions across all projects. */
|
||||
dir?: string
|
||||
/** Maximum number of sessions to return. */
|
||||
limit?: number
|
||||
/** Number of sessions to skip (pagination). */
|
||||
offset?: number
|
||||
/** Include git worktree sessions (default true). */
|
||||
includeWorktrees?: boolean
|
||||
}
|
||||
|
||||
/** Options for getSessionInfo. */
|
||||
export type GetSessionInfoOptions = {
|
||||
/** Project directory. When omitted, searches all project directories. */
|
||||
dir?: string
|
||||
}
|
||||
|
||||
/** Options for getSessionMessages. */
|
||||
export type GetSessionMessagesOptions = {
|
||||
/** Project directory. When omitted, searches all project directories. */
|
||||
dir?: string
|
||||
/** Maximum number of messages to return. */
|
||||
limit?: number
|
||||
/** Number of messages to skip (pagination). */
|
||||
offset?: number
|
||||
/** Include system messages in the output. Default false. */
|
||||
includeSystemMessages?: boolean
|
||||
}
|
||||
|
||||
/** Options for renameSession and tagSession. */
|
||||
export type SessionMutationOptions = {
|
||||
/** Project directory. When omitted, searches all project directories. */
|
||||
dir?: string
|
||||
}
|
||||
|
||||
/** Options for forkSession. */
|
||||
export type ForkSessionOptions = {
|
||||
/** Project directory. When omitted, searches all project directories. */
|
||||
dir?: string
|
||||
/** Fork up to (and including) this message UUID. */
|
||||
upToMessageId?: string
|
||||
/** Title for the forked session. */
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** Result of forkSession. */
|
||||
export type ForkSessionResult = {
|
||||
/** UUID of the newly created forked session. */
|
||||
session_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A single message in a session conversation.
|
||||
* Returned by getSessionMessages.
|
||||
*/
|
||||
export type SessionMessage = {
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: unknown
|
||||
timestamp?: string
|
||||
uuid?: string
|
||||
parent_uuid?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission mode for the query.
|
||||
* Controls how tool permissions are handled.
|
||||
*/
|
||||
export type QueryPermissionMode =
|
||||
| 'default'
|
||||
| 'plan'
|
||||
| 'auto-accept'
|
||||
| 'bypass-permissions'
|
||||
| 'bypassPermissions'
|
||||
| 'acceptEdits'
|
||||
|
||||
/**
|
||||
* Callback type for canUseTool permission checks.
|
||||
* Shared between QueryOptions and SDKSessionOptions.
|
||||
*/
|
||||
export type CanUseToolCallback = (
|
||||
name: string,
|
||||
input: unknown,
|
||||
options?: { toolUseID?: string },
|
||||
) => Promise<{ behavior: 'allow' | 'deny'; message?: string; updatedInput?: unknown }>
|
||||
|
||||
// ============================================================================
|
||||
// Internal types shared across modules
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* JSONL line types used by getSessionMessages and forkSession.
|
||||
* @internal
|
||||
*/
|
||||
export type JsonlEntry = {
|
||||
type: string
|
||||
uuid?: string
|
||||
parentUuid?: string | null
|
||||
sessionId?: string
|
||||
timestamp?: string
|
||||
message?: {
|
||||
role?: string
|
||||
content?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
isSidechain?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -49,7 +49,8 @@ import { useLogMessages } from '../hooks/useLogMessages.js';
|
||||
import { useReplBridge } from '../hooks/useReplBridge.js';
|
||||
import { type Command, type CommandResultDisplay, type ResumeEntrypoint, getCommandName, isCommandEnabled } from '../commands.js';
|
||||
import type { PromptInputMode, QueuedCommand, VimMode } from '../types/textInputTypes.js';
|
||||
import { MessageSelector, selectableUserMessagesFilter, messagesAfterAreOnlySynthetic } from '../components/MessageSelector.js';
|
||||
import { MessageSelector } from '../components/MessageSelector.js';
|
||||
import { selectableUserMessagesFilter, messagesAfterAreOnlySynthetic } from '../utils/messageFilters.js';
|
||||
import { useIdeLogging } from '../hooks/useIdeLogging.js';
|
||||
import { PermissionRequest, type ToolUseConfirm } from '../components/permissions/PermissionRequest.js';
|
||||
import { ElicitationDialog } from '../components/mcp/ElicitationDialog.js';
|
||||
|
||||
@@ -227,6 +227,7 @@ export type AppState = DeepImmutable<{
|
||||
queue: ElicitationRequestEvent[]
|
||||
}
|
||||
thinkingEnabled: boolean | undefined
|
||||
thinkingBudgetTokens?: number
|
||||
promptSuggestionEnabled: boolean
|
||||
sessionHooks: SessionHooksState
|
||||
tungstenActiveSession?: {
|
||||
|
||||
+19
-9
@@ -210,16 +210,17 @@ export function getAllBaseTools(): Tools {
|
||||
...(TerminalCaptureTool ? [TerminalCaptureTool] : []),
|
||||
...(isEnvTruthy(process.env.ENABLE_LSP_TOOL) ? [LSPTool] : []),
|
||||
...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),
|
||||
getSendMessageTool(),
|
||||
// Use filter(Boolean) to handle case where getter might return null/undefined
|
||||
...(getSendMessageTool() ? [getSendMessageTool()] : []),
|
||||
...(ListPeersTool ? [ListPeersTool] : []),
|
||||
...(isAgentSwarmsEnabled()
|
||||
? [getTeamCreateTool(), getTeamDeleteTool()]
|
||||
? [getTeamCreateTool(), getTeamDeleteTool()].filter(Boolean)
|
||||
: []),
|
||||
...(VerifyPlanExecutionTool ? [VerifyPlanExecutionTool] : []),
|
||||
...(process.env.USER_TYPE === 'ant' && REPLTool ? [REPLTool] : []),
|
||||
...(WorkflowTool ? [WorkflowTool] : []),
|
||||
...(SleepTool ? [SleepTool] : []),
|
||||
...cronTools,
|
||||
...(cronTools ?? []),
|
||||
...(RemoteTriggerTool ? [RemoteTriggerTool] : []),
|
||||
...(MonitorTool ? [MonitorTool] : []),
|
||||
BriefTool,
|
||||
@@ -234,7 +235,8 @@ export function getAllBaseTools(): Tools {
|
||||
// Include ToolSearchTool when tool search might be enabled (optimistic check)
|
||||
// The actual decision to defer tools happens at request time in claude.ts
|
||||
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
|
||||
]
|
||||
// Filter out any null/undefined tools that might have been added by getters
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +269,8 @@ export const getTools = (permissionContext: ToolPermissionContext): Tools => {
|
||||
feature('COORDINATOR_MODE') &&
|
||||
coordinatorModeModule?.isCoordinatorMode()
|
||||
) {
|
||||
replSimple.push(TaskStopTool, getSendMessageTool())
|
||||
const sendMessageTool = getSendMessageTool()
|
||||
if (sendMessageTool) replSimple.push(TaskStopTool, sendMessageTool)
|
||||
}
|
||||
return filterToolsByDenyRules(replSimple, permissionContext)
|
||||
}
|
||||
@@ -279,7 +282,9 @@ export const getTools = (permissionContext: ToolPermissionContext): Tools => {
|
||||
feature('COORDINATOR_MODE') &&
|
||||
coordinatorModeModule?.isCoordinatorMode()
|
||||
) {
|
||||
simpleTools.push(AgentTool, TaskStopTool, getSendMessageTool())
|
||||
simpleTools.push(AgentTool, TaskStopTool)
|
||||
const sendMessageTool = getSendMessageTool()
|
||||
if (sendMessageTool) simpleTools.push(sendMessageTool)
|
||||
}
|
||||
return filterToolsByDenyRules(simpleTools, permissionContext)
|
||||
}
|
||||
@@ -309,7 +314,11 @@ export const getTools = (permissionContext: ToolPermissionContext): Tools => {
|
||||
}
|
||||
}
|
||||
|
||||
const isEnabled = allowedTools.map(_ => _.isEnabled())
|
||||
// Filter out any null/undefined tools that might have slipped through
|
||||
// (defensive check against initialization timing issues)
|
||||
allowedTools = allowedTools.filter(Boolean)
|
||||
|
||||
const isEnabled = allowedTools.map(_ => typeof _.isEnabled === 'function' ? _.isEnabled() : true)
|
||||
return allowedTools.filter((_, i) => isEnabled[i])
|
||||
}
|
||||
|
||||
@@ -335,8 +344,9 @@ export function assembleToolPool(
|
||||
): Tools {
|
||||
const builtInTools = getTools(permissionContext)
|
||||
|
||||
// Filter out MCP tools that are in the deny list
|
||||
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
|
||||
// Filter out MCP tools that are in the deny list, and filter out any null/undefined
|
||||
// tools that might have been added by MCP client initialization
|
||||
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext).filter(Boolean)
|
||||
|
||||
// Sort each partition for prompt-cache stability, keeping built-ins as a
|
||||
// contiguous prefix. The server's claude_code_system_cache_policy places a
|
||||
|
||||
@@ -211,6 +211,6 @@ export function getCommandName(cmd: CommandBase): string {
|
||||
}
|
||||
|
||||
/** Resolves whether the command is enabled, defaulting to true. */
|
||||
export function isCommandEnabled(cmd: CommandBase): boolean {
|
||||
return cmd.isEnabled?.() ?? true
|
||||
export function isCommandEnabled(cmd: CommandBase | null | undefined): boolean {
|
||||
return cmd?.isEnabled?.() ?? true
|
||||
}
|
||||
|
||||
@@ -24,3 +24,21 @@ export function getToolSchemaCache(): Map<string, CachedSchema> {
|
||||
export function clearToolSchemaCache(): void {
|
||||
TOOL_SCHEMA_CACHE.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Selectively invalidate cache entries for tools not in the provided set.
|
||||
* Used by QueryEngine.updateTools() to avoid clearing schemas for tools that
|
||||
* remain unchanged across concurrent engines in multi-session SDK scenarios.
|
||||
*
|
||||
* @param retainedToolNames - Set of tool names that should keep their cache entries
|
||||
*/
|
||||
export function invalidateRemovedToolSchemas(retainedToolNames: Set<string>): void {
|
||||
for (const key of TOOL_SCHEMA_CACHE.keys()) {
|
||||
// Cache key format: either "toolName" or "toolName:{...schemaJSON...}"
|
||||
// Extract the tool name portion (before the colon if present)
|
||||
const toolName = key.includes(':') ? key.split(':')[0] : key
|
||||
if (!retainedToolNames.has(toolName)) {
|
||||
TOOL_SCHEMA_CACHE.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import {
|
||||
snakeToCamel,
|
||||
camelToSnake,
|
||||
mapKeysToCamel,
|
||||
mapKeysToSnake,
|
||||
} from '../../src/entrypoints/sdk/casing.js'
|
||||
|
||||
describe('snakeToCamel', () => {
|
||||
test('converts snake_case to camelCase', () => {
|
||||
expect(snakeToCamel('session_id')).toBe('sessionId')
|
||||
expect(snakeToCamel('last_modified')).toBe('lastModified')
|
||||
expect(snakeToCamel('parent_tool_use_id')).toBe('parentToolUseId')
|
||||
})
|
||||
|
||||
test('leaves already-camelCase unchanged', () => {
|
||||
expect(snakeToCamel('sessionId')).toBe('sessionId')
|
||||
expect(snakeToCamel('cwd')).toBe('cwd')
|
||||
})
|
||||
|
||||
test('handles empty string', () => {
|
||||
expect(snakeToCamel('')).toBe('')
|
||||
})
|
||||
|
||||
test('handles consecutive underscores correctly', () => {
|
||||
// __proto__ should become Proto (both underscores removed before letter)
|
||||
expect(snakeToCamel('__proto__')).toBe('Proto')
|
||||
expect(snakeToCamel('__typename')).toBe('Typename')
|
||||
expect(snakeToCamel('a__b_c')).toBe('aB_c')
|
||||
})
|
||||
|
||||
test('preserves trailing underscores', () => {
|
||||
expect(snakeToCamel('test_')).toBe('test_')
|
||||
expect(snakeToCamel('test__')).toBe('test__')
|
||||
})
|
||||
})
|
||||
|
||||
describe('camelToSnake', () => {
|
||||
test('converts camelCase to snake_case', () => {
|
||||
expect(camelToSnake('sessionId')).toBe('session_id')
|
||||
expect(camelToSnake('lastModified')).toBe('last_modified')
|
||||
})
|
||||
|
||||
test('leaves already-snake_case unchanged', () => {
|
||||
expect(camelToSnake('session_id')).toBe('session_id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapKeysToCamel', () => {
|
||||
test('converts top-level keys', () => {
|
||||
const input = { session_id: 'abc', last_modified: 123 }
|
||||
const result = mapKeysToCamel(input)
|
||||
expect(result).toEqual({ sessionId: 'abc', lastModified: 123 })
|
||||
})
|
||||
|
||||
test('converts nested object keys', () => {
|
||||
const input = { outer_key: { inner_key: 'value' } }
|
||||
const result = mapKeysToCamel(input)
|
||||
expect(result).toEqual({ outerKey: { innerKey: 'value' } })
|
||||
})
|
||||
|
||||
test('converts arrays of objects', () => {
|
||||
const input = [{ item_name: 'a' }, { item_name: 'b' }]
|
||||
const result = mapKeysToCamel(input)
|
||||
expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }])
|
||||
})
|
||||
|
||||
test('returns null/undefined as-is', () => {
|
||||
expect(mapKeysToCamel(null)).toBeNull()
|
||||
expect(mapKeysToCamel(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns primitives as-is', () => {
|
||||
expect(mapKeysToCamel('hello')).toBe('hello')
|
||||
expect(mapKeysToCamel(42)).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapKeysToSnake', () => {
|
||||
test('converts top-level keys', () => {
|
||||
const input = { sessionId: 'abc', lastModified: 123 }
|
||||
const result = mapKeysToSnake(input)
|
||||
expect(result).toEqual({ session_id: 'abc', last_modified: 123 })
|
||||
})
|
||||
|
||||
test('round-trips with mapKeysToCamel', () => {
|
||||
const original = { session_id: 'abc', last_modified: 123 }
|
||||
const camel = mapKeysToCamel(original)
|
||||
const back = mapKeysToSnake(camel)
|
||||
expect(back).toEqual(original)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,654 @@
|
||||
import { describe, test, expect, vi } from 'bun:test'
|
||||
import {
|
||||
buildPermissionContext,
|
||||
connectSdkMcpServers,
|
||||
createDefaultCanUseTool,
|
||||
createExternalCanUseTool,
|
||||
createOnceOnlyResolve,
|
||||
createPermissionTarget,
|
||||
NO_SESSION_PLACEHOLDER,
|
||||
} from '../../src/entrypoints/sdk/permissions.js'
|
||||
import type { PermissionResolveDecision } from '../../src/entrypoints/sdk/permissions.js'
|
||||
import { getEmptyToolPermissionContext } from '../../src/Tool.js'
|
||||
|
||||
describe('buildPermissionContext', () => {
|
||||
test('returns default mode when no permissionMode specified', () => {
|
||||
const ctx = buildPermissionContext({ cwd: '/tmp' })
|
||||
expect(ctx.mode).toBe('default')
|
||||
})
|
||||
|
||||
test('maps plan mode correctly', () => {
|
||||
const ctx = buildPermissionContext({ cwd: '/tmp', permissionMode: 'plan' })
|
||||
expect(ctx.mode).toBe('plan')
|
||||
})
|
||||
|
||||
test('maps auto-accept to acceptEdits', () => {
|
||||
const ctx = buildPermissionContext({ cwd: '/tmp', permissionMode: 'auto-accept' })
|
||||
expect(ctx.mode).toBe('acceptEdits')
|
||||
})
|
||||
|
||||
test('maps acceptEdits mode', () => {
|
||||
const ctx = buildPermissionContext({ cwd: '/tmp', permissionMode: 'acceptEdits' })
|
||||
expect(ctx.mode).toBe('acceptEdits')
|
||||
})
|
||||
|
||||
test('maps bypass-permissions mode', () => {
|
||||
const ctx = buildPermissionContext({ cwd: '/tmp', permissionMode: 'bypass-permissions' })
|
||||
expect(ctx.mode).toBe('bypassPermissions')
|
||||
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
|
||||
})
|
||||
|
||||
test('maps bypassPermissions mode', () => {
|
||||
const ctx = buildPermissionContext({ cwd: '/tmp', permissionMode: 'bypassPermissions' })
|
||||
expect(ctx.mode).toBe('bypassPermissions')
|
||||
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
|
||||
})
|
||||
|
||||
test('default mode does not have bypass available', () => {
|
||||
const ctx = buildPermissionContext({ cwd: '/tmp' })
|
||||
expect(ctx.isBypassPermissionsModeAvailable).toBe(false)
|
||||
})
|
||||
|
||||
test('allowDangerouslySkipPermissions sets bypass flag', () => {
|
||||
const ctx = buildPermissionContext({
|
||||
cwd: '/tmp',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
})
|
||||
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
|
||||
})
|
||||
|
||||
test('additionalDirectories are added to context', () => {
|
||||
const ctx = buildPermissionContext({
|
||||
cwd: '/tmp',
|
||||
additionalDirectories: ['/dir1', '/dir2'],
|
||||
})
|
||||
expect(ctx.additionalWorkingDirectories.has('/dir1')).toBe(true)
|
||||
expect(ctx.additionalWorkingDirectories.has('/dir2')).toBe(true)
|
||||
})
|
||||
|
||||
test('empty additionalDirectories does nothing', () => {
|
||||
const ctx = buildPermissionContext({ cwd: '/tmp', additionalDirectories: [] })
|
||||
expect(ctx.additionalWorkingDirectories.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDefaultCanUseTool', () => {
|
||||
test('denies all tool uses', async () => {
|
||||
const ctx = getEmptyToolPermissionContext()
|
||||
const canUseTool = createDefaultCanUseTool(ctx)
|
||||
|
||||
const result = await canUseTool(
|
||||
{ name: 'Bash' } as any,
|
||||
{ command: 'rm -rf /' },
|
||||
{} as any,
|
||||
{} as any,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
})
|
||||
|
||||
test('honors forceDecision when provided', async () => {
|
||||
const ctx = getEmptyToolPermissionContext()
|
||||
const canUseTool = createDefaultCanUseTool(ctx)
|
||||
|
||||
const forced = { behavior: 'allow' as const }
|
||||
const result = await canUseTool(
|
||||
{ name: 'Bash' } as any,
|
||||
{},
|
||||
{} as any,
|
||||
{} as any,
|
||||
undefined,
|
||||
forced,
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('allow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createExternalCanUseTool synchronous host response', () => {
|
||||
test('synchronous host response from onPermissionRequest is received', async () => {
|
||||
// Regression test: onPermissionRequest must fire AFTER registerPendingPermission
|
||||
// so a host that responds synchronously finds the entry in the map.
|
||||
const permissionTarget = createPermissionTarget()
|
||||
|
||||
const onPermissionRequest = vi.fn((message: any) => {
|
||||
// Simulate a host that resolves synchronously from the callback
|
||||
const pending = permissionTarget.pendingPermissionPrompts.get(message.tool_use_id)
|
||||
expect(pending).toBeDefined() // Must be registered before this callback fires
|
||||
pending!.resolve({ behavior: 'allow' as const })
|
||||
})
|
||||
|
||||
const canUseTool = createExternalCanUseTool(
|
||||
undefined,
|
||||
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
|
||||
permissionTarget,
|
||||
onPermissionRequest,
|
||||
undefined,
|
||||
50, // short timeout — should NOT fire since host responds immediately
|
||||
)
|
||||
|
||||
const result = await canUseTool(
|
||||
{ name: 'TestTool' } as any,
|
||||
{},
|
||||
{} as any,
|
||||
{} as any,
|
||||
'sync-response-id',
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('allow')
|
||||
expect(onPermissionRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('permission request message includes uuid and session_id matching schema', async () => {
|
||||
// Regression test: permission_request must match SDKMessageSchema contract
|
||||
// which requires uuid and session_id fields (not optional).
|
||||
const permissionTarget = createPermissionTarget()
|
||||
|
||||
const onPermissionRequest = vi.fn((message: any) => {
|
||||
// Verify message shape matches generated schema requirements
|
||||
expect(message.type).toBe('permission_request')
|
||||
expect(message.request_id).toBeDefined()
|
||||
expect(message.tool_name).toBe('TestTool')
|
||||
expect(message.tool_use_id).toBe('shape-test-id')
|
||||
expect(message.input).toBeDefined()
|
||||
expect(message.uuid).toBeDefined() // Required by schema
|
||||
expect(message.session_id).toBeDefined() // Required by schema
|
||||
|
||||
// Resolve to complete the test
|
||||
const pending = permissionTarget.pendingPermissionPrompts.get(message.tool_use_id)
|
||||
pending!.resolve({ behavior: 'allow' as const })
|
||||
})
|
||||
|
||||
const canUseTool = createExternalCanUseTool(
|
||||
undefined,
|
||||
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
|
||||
permissionTarget,
|
||||
onPermissionRequest,
|
||||
undefined,
|
||||
50,
|
||||
'test-session-123', // Provide session_id
|
||||
)
|
||||
|
||||
const result = await canUseTool(
|
||||
{ name: 'TestTool' } as any,
|
||||
{},
|
||||
{} as any,
|
||||
{} as any,
|
||||
'shape-test-id',
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('allow')
|
||||
expect(onPermissionRequest).toHaveBeenCalledTimes(1)
|
||||
// Verify session_id was passed through
|
||||
expect(onPermissionRequest.mock.calls[0][0].session_id).toBe('test-session-123')
|
||||
})
|
||||
|
||||
test('permission request uses no-session placeholder when sessionId not provided', async () => {
|
||||
// When createExternalCanUseTool is called without sessionId,
|
||||
// the permission request should emit 'no-session' placeholder
|
||||
// to explicitly indicate standalone permission prompt context.
|
||||
const permissionTarget = createPermissionTarget()
|
||||
|
||||
const onPermissionRequest = vi.fn((message: any) => {
|
||||
expect(message.session_id).toBe(NO_SESSION_PLACEHOLDER)
|
||||
const pending = permissionTarget.pendingPermissionPrompts.get(message.tool_use_id)
|
||||
pending!.resolve({ behavior: 'allow' as const })
|
||||
})
|
||||
|
||||
// Note: sessionId parameter intentionally omitted
|
||||
const canUseTool = createExternalCanUseTool(
|
||||
undefined,
|
||||
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
|
||||
permissionTarget,
|
||||
onPermissionRequest,
|
||||
undefined,
|
||||
50,
|
||||
// sessionId undefined - should use placeholder
|
||||
)
|
||||
|
||||
const result = await canUseTool(
|
||||
{ name: 'TestTool' } as any,
|
||||
{},
|
||||
{} as any,
|
||||
{} as any,
|
||||
'no-session-test-id',
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('allow')
|
||||
expect(onPermissionRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createExternalCanUseTool race condition', () => {
|
||||
test('handles simultaneous timeout and response correctly', async () => {
|
||||
// Use createPermissionTarget which applies onceOnlyResolve at registration
|
||||
const permissionTarget = createPermissionTarget()
|
||||
|
||||
const onPermissionRequest = vi.fn()
|
||||
const onTimeout = vi.fn()
|
||||
|
||||
// Timeout set to 50ms with 25ms wait to trigger race condition reliably
|
||||
// This gives enough time for the test to be stable on slower systems
|
||||
// while still being fast enough to test the race condition scenario
|
||||
const timeoutMs = 50
|
||||
const canUseTool = createExternalCanUseTool(
|
||||
undefined,
|
||||
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
|
||||
permissionTarget,
|
||||
onPermissionRequest,
|
||||
onTimeout,
|
||||
timeoutMs,
|
||||
)
|
||||
|
||||
const toolUseID = 'test-tool-use-id'
|
||||
|
||||
// Start the canUseTool call
|
||||
const resultPromise = canUseTool(
|
||||
{ name: 'TestTool' } as any,
|
||||
{},
|
||||
{} as any,
|
||||
{} as any,
|
||||
toolUseID,
|
||||
undefined,
|
||||
)
|
||||
|
||||
// Simulate host responding right at timeout threshold
|
||||
// This creates the race condition scenario where both timeout and host
|
||||
// try to resolve the same promise - but onceOnlyResolve ensures only one wins
|
||||
await new Promise(r => setTimeout(r, 25))
|
||||
|
||||
const pending = permissionTarget.pendingPermissionPrompts.get(toolUseID)
|
||||
if (pending) {
|
||||
// This will race with the timeout handler's resolve call
|
||||
pending.resolve({ behavior: 'allow' as const })
|
||||
}
|
||||
|
||||
// Wait for result - should NOT throw "promise already resolved" error
|
||||
// Explicitly wrap in try-catch to verify no error is thrown during race condition
|
||||
let result: PermissionResolveDecision
|
||||
let errorThrown: Error | null = null
|
||||
try {
|
||||
result = await resultPromise
|
||||
} catch (e) {
|
||||
errorThrown = e as Error
|
||||
throw new Error(`Expected no error during race condition, but got: ${errorThrown.message}`)
|
||||
}
|
||||
|
||||
// Explicitly verify no error was thrown
|
||||
expect(errorThrown).toBeNull()
|
||||
|
||||
// Result should be deterministic - either allow or deny, but no error
|
||||
expect(['allow', 'deny']).toContain(result!.behavior)
|
||||
})
|
||||
|
||||
test('once-only resolve wrapper prevents double resolution', async () => {
|
||||
// Use createPermissionTarget which applies onceOnlyResolve at registration
|
||||
const permissionTarget = createPermissionTarget()
|
||||
|
||||
const onPermissionRequest = vi.fn()
|
||||
const onTimeout = vi.fn()
|
||||
|
||||
const canUseTool = createExternalCanUseTool(
|
||||
undefined,
|
||||
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
|
||||
permissionTarget,
|
||||
onPermissionRequest,
|
||||
onTimeout,
|
||||
50, // 50ms timeout
|
||||
)
|
||||
|
||||
const toolUseID = 'test-tool-use-id-race'
|
||||
|
||||
// Start the canUseTool call
|
||||
const resultPromise = canUseTool(
|
||||
{ name: 'TestTool' } as any,
|
||||
{},
|
||||
{} as any,
|
||||
{} as any,
|
||||
toolUseID,
|
||||
undefined,
|
||||
)
|
||||
|
||||
// Respond immediately after starting to simulate very fast host response
|
||||
// This tests that the first response wins, not the timeout
|
||||
const pending = permissionTarget.pendingPermissionPrompts.get(toolUseID)
|
||||
if (pending) {
|
||||
pending.resolve({ behavior: 'allow' as const, updatedInput: { test: true } })
|
||||
}
|
||||
|
||||
// Wait for result
|
||||
const result = await resultPromise
|
||||
|
||||
// Host response should win over timeout since it came first
|
||||
expect(result.behavior).toBe('allow')
|
||||
expect(onTimeout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('host response after timeout is safely ignored (no double-resolve)', async () => {
|
||||
const permissionTarget = createPermissionTarget()
|
||||
const onPermissionRequest = vi.fn()
|
||||
const onTimeout = vi.fn()
|
||||
|
||||
const canUseTool = createExternalCanUseTool(
|
||||
undefined,
|
||||
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
|
||||
permissionTarget,
|
||||
onPermissionRequest,
|
||||
onTimeout,
|
||||
50, // 50ms timeout
|
||||
)
|
||||
|
||||
const toolUseID = 'test-timeout-then-late-response'
|
||||
|
||||
// Start the canUseTool call — this registers a pending permission
|
||||
const resultPromise = canUseTool(
|
||||
{ name: 'TestTool' } as any,
|
||||
{},
|
||||
{} as any,
|
||||
{} as any,
|
||||
toolUseID,
|
||||
undefined,
|
||||
)
|
||||
|
||||
// Grab a reference to the resolve BEFORE timeout fires — simulates host
|
||||
// capturing the callback while the permission prompt is still pending
|
||||
const staleResolve = permissionTarget.pendingPermissionPrompts.get(toolUseID)
|
||||
expect(staleResolve).toBeDefined()
|
||||
|
||||
// Wait LONGER than the 50ms timeout — timeout fires first, resolves with deny
|
||||
const result = await resultPromise
|
||||
|
||||
// Timeout should have denied
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(onTimeout).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Map entry cleaned up by timeout handler — no leaked listener
|
||||
expect(permissionTarget.pendingPermissionPrompts.has(toolUseID)).toBe(false)
|
||||
|
||||
// NOW the host responds late through the stale reference it captured earlier.
|
||||
// This is the critical scenario: host calls resolve({allow}) AFTER timeout
|
||||
// already resolved with {deny}. onceOnlyResolve must silently ignore this.
|
||||
// Wrap in try/catch to explicitly verify no error from double-resolve attempt.
|
||||
let lateResponseError: Error | null = null
|
||||
try {
|
||||
staleResolve!.resolve({ behavior: 'allow' as const, updatedInput: { injected: true } })
|
||||
} catch (e) {
|
||||
lateResponseError = e as Error
|
||||
}
|
||||
|
||||
// No error thrown — onceOnlyResolve silently swallowed the second resolve
|
||||
expect(lateResponseError).toBeNull()
|
||||
|
||||
// Result stays 'deny' — timeout decision is immutable
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect((result as any).updatedInput).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createOnceOnlyResolve', () => {
|
||||
test('only resolves once when called multiple times', () => {
|
||||
let resolvedValue: string | undefined
|
||||
let callCount = 0
|
||||
|
||||
const resolve = (value: string) => {
|
||||
callCount++
|
||||
resolvedValue = value
|
||||
}
|
||||
|
||||
const onceOnlyResolve = createOnceOnlyResolve(resolve)
|
||||
|
||||
// First call should resolve
|
||||
onceOnlyResolve('first')
|
||||
expect(resolvedValue).toBe('first')
|
||||
expect(callCount).toBe(1)
|
||||
|
||||
// Second call should be ignored
|
||||
onceOnlyResolve('second')
|
||||
expect(resolvedValue).toBe('first') // Still 'first', not 'second'
|
||||
expect(callCount).toBe(1) // Still 1, not incremented
|
||||
|
||||
// Third call should also be ignored
|
||||
onceOnlyResolve('third')
|
||||
expect(resolvedValue).toBe('first')
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
|
||||
test('works with Promise resolution', async () => {
|
||||
let resolveFunc: (value: string) => void
|
||||
const promise = new Promise<string>(resolve => {
|
||||
resolveFunc = resolve
|
||||
})
|
||||
|
||||
const onceOnlyResolve = createOnceOnlyResolve(resolveFunc!)
|
||||
|
||||
// Resolve twice rapidly
|
||||
onceOnlyResolve('first')
|
||||
onceOnlyResolve('second')
|
||||
|
||||
// Promise should resolve with 'first' only
|
||||
const result = await promise
|
||||
expect(result).toBe('first')
|
||||
})
|
||||
|
||||
test('handles undefined and null values', () => {
|
||||
let resolvedValue: string | null | undefined = 'initial'
|
||||
|
||||
const resolve = (value: string | null | undefined) => {
|
||||
resolvedValue = value
|
||||
}
|
||||
|
||||
const onceOnlyResolve = createOnceOnlyResolve(resolve)
|
||||
|
||||
onceOnlyResolve(undefined)
|
||||
expect(resolvedValue).toBeUndefined()
|
||||
|
||||
onceOnlyResolve('should not change')
|
||||
expect(resolvedValue).toBeUndefined() // Still undefined
|
||||
|
||||
onceOnlyResolve(null)
|
||||
expect(resolvedValue).toBeUndefined() // Still undefined
|
||||
})
|
||||
|
||||
test('timeout-deny-then-host-allow: raw resolve called exactly once', () => {
|
||||
// This directly proves onceOnlyResolve prevents the raw resolve from being
|
||||
// called a second time — the exact scenario the reviewer asked about:
|
||||
// timeout fires first (deny), then host responds (allow) — raw resolve
|
||||
// must only execute once.
|
||||
let rawCallCount = 0
|
||||
let rawResolvedValue: PermissionResolveDecision | undefined
|
||||
|
||||
const rawResolve = (value: PermissionResolveDecision) => {
|
||||
rawCallCount++
|
||||
rawResolvedValue = value
|
||||
}
|
||||
|
||||
const wrapped = createOnceOnlyResolve(rawResolve)
|
||||
|
||||
// Step 1: Timeout fires first — resolves with deny
|
||||
wrapped({ behavior: 'deny', message: 'Permission resolution timed out' })
|
||||
expect(rawCallCount).toBe(1)
|
||||
expect(rawResolvedValue!.behavior).toBe('deny')
|
||||
|
||||
// Step 2: Host responds late with allow — must be ignored
|
||||
wrapped({ behavior: 'allow' as const, updatedInput: { injected: true } })
|
||||
expect(rawCallCount).toBe(1) // NOT 2 — second call was a no-op
|
||||
expect(rawResolvedValue!.behavior).toBe('deny') // Unchanged
|
||||
expect((rawResolvedValue as any).updatedInput).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPermissionTarget', () => {
|
||||
test('creates permission target with wrapped resolve', () => {
|
||||
const target = createPermissionTarget()
|
||||
expect(target.pendingPermissionPrompts).toBeDefined()
|
||||
expect(target.registerPendingPermission).toBeDefined()
|
||||
})
|
||||
|
||||
test('registerPendingPermission stores wrapped resolve', async () => {
|
||||
const target = createPermissionTarget()
|
||||
const toolUseId = 'test-id'
|
||||
|
||||
// Register should create a promise
|
||||
const promise = target.registerPendingPermission(toolUseId)
|
||||
|
||||
// The resolve should be stored in the map
|
||||
const pending = target.pendingPermissionPrompts.get(toolUseId)
|
||||
expect(pending).toBeDefined()
|
||||
|
||||
// Calling resolve twice should only resolve once (onceOnlyResolve behavior)
|
||||
pending!.resolve({ behavior: 'allow' as const })
|
||||
pending!.resolve({ behavior: 'deny' as const, message: 'should not happen', decisionReason: { type: 'mode', mode: 'default' } })
|
||||
|
||||
// Promise should resolve with 'allow' (first call)
|
||||
const result = await promise
|
||||
expect(result.behavior).toBe('allow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createExternalCanUseTool error handling', () => {
|
||||
test('includes original error message in denial', async () => {
|
||||
const userFn = async () => {
|
||||
throw new Error('Custom error from callback')
|
||||
}
|
||||
|
||||
const permissionTarget = {
|
||||
registerPendingPermission: async () => ({ behavior: 'deny' as const }),
|
||||
pendingPermissionPrompts: new Map(),
|
||||
}
|
||||
|
||||
const canUseTool = createExternalCanUseTool(
|
||||
userFn,
|
||||
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
|
||||
permissionTarget,
|
||||
)
|
||||
|
||||
const result = await canUseTool(
|
||||
{ name: 'TestTool' } as any,
|
||||
{},
|
||||
{} as any,
|
||||
{} as any,
|
||||
'test-id',
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(result.message).toContain('Custom error from callback')
|
||||
})
|
||||
|
||||
test('throwing onPermissionRequest cleans up pending resolver and denies', async () => {
|
||||
// Regression test: After registerPendingPermission was moved before onPermissionRequest,
|
||||
// a throwing host callback leaves a pending resolver behind in pendingPermissionPrompts.
|
||||
// The callback should be wrapped so the pending entry is deleted and the flow denies cleanly.
|
||||
const permissionTarget = createPermissionTarget()
|
||||
|
||||
const throwingCallback = vi.fn(() => {
|
||||
throw new Error('host boom')
|
||||
})
|
||||
|
||||
const canUseTool = createExternalCanUseTool(
|
||||
undefined,
|
||||
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
|
||||
permissionTarget,
|
||||
throwingCallback,
|
||||
undefined,
|
||||
50,
|
||||
)
|
||||
|
||||
const result = await canUseTool(
|
||||
{ name: 'TestTool' } as any,
|
||||
{},
|
||||
{} as any,
|
||||
{} as any,
|
||||
'throw-id',
|
||||
undefined,
|
||||
)
|
||||
|
||||
// Should deny with error message, NOT throw
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(result.message).toContain('host boom')
|
||||
expect(throwingCallback).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Critical: pending resolver must be cleaned up, not leaked
|
||||
expect(permissionTarget.pendingPermissionPrompts.has('throw-id')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createExternalCanUseTool timeout scenarios', () => {
|
||||
test('emits timeout message when host does not respond', async () => {
|
||||
// Use createPermissionTarget which applies onceOnlyResolve at registration
|
||||
const permissionTarget = createPermissionTarget()
|
||||
|
||||
const onPermissionRequest = vi.fn()
|
||||
const onTimeout = vi.fn()
|
||||
|
||||
const canUseTool = createExternalCanUseTool(
|
||||
undefined,
|
||||
async () => ({ behavior: 'deny' as const, message: 'fallback' }),
|
||||
permissionTarget,
|
||||
onPermissionRequest,
|
||||
onTimeout,
|
||||
50, // 50ms timeout for fast test
|
||||
)
|
||||
|
||||
const result = await canUseTool(
|
||||
{ name: 'TestTool' } as any,
|
||||
{},
|
||||
{} as any,
|
||||
{} as any,
|
||||
'test-id',
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
// When timeout occurs, the implementation calls onTimeout and falls through to fallback
|
||||
expect(result.message).toBe('fallback')
|
||||
expect(onTimeout).toHaveBeenCalled()
|
||||
expect(onTimeout.mock.calls[0][0].type).toBe('permission_timeout')
|
||||
expect(onTimeout.mock.calls[0][0].tool_name).toBe('TestTool')
|
||||
expect(onTimeout.mock.calls[0][0].timed_out_after_ms).toBe(50)
|
||||
})
|
||||
|
||||
test('fallback is used when no onPermissionRequest callback', async () => {
|
||||
const permissionTarget = createPermissionTarget()
|
||||
|
||||
const canUseTool = createExternalCanUseTool(
|
||||
undefined,
|
||||
async () => ({ behavior: 'deny' as const, message: 'fallback denial' }),
|
||||
permissionTarget,
|
||||
// No onPermissionRequest callback
|
||||
)
|
||||
|
||||
const result = await canUseTool(
|
||||
{ name: 'TestTool' } as any,
|
||||
{},
|
||||
{} as any,
|
||||
{} as any,
|
||||
'test-id',
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(result.message).toBe('fallback denial')
|
||||
})
|
||||
})
|
||||
|
||||
describe('connectSdkMcpServers error handling', () => {
|
||||
test('returns empty arrays for undefined config', async () => {
|
||||
const result = await connectSdkMcpServers(undefined)
|
||||
|
||||
expect(result.clients).toEqual([])
|
||||
expect(result.tools).toEqual([])
|
||||
})
|
||||
|
||||
test('returns empty arrays for empty config', async () => {
|
||||
const result = await connectSdkMcpServers({})
|
||||
|
||||
expect(result.clients).toEqual([])
|
||||
expect(result.tools).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import {
|
||||
runWithSdkContext,
|
||||
getSessionId,
|
||||
regenerateSessionId,
|
||||
switchSession,
|
||||
getSessionProjectDir,
|
||||
getCwdState,
|
||||
setCwdState,
|
||||
getOriginalCwd,
|
||||
setOriginalCwd,
|
||||
getParentSessionId,
|
||||
} from '../../src/bootstrap/state.js'
|
||||
import type { SessionId } from '../../src/entrypoints/agentSdkTypes.js'
|
||||
|
||||
// Snapshot global state before each test so we can restore it
|
||||
let originalSessionId: SessionId
|
||||
let originalCwd: string
|
||||
let originalOriginalCwd: string
|
||||
let originalSessionProjectDir: string | null
|
||||
|
||||
describe('SDK context isolation', () => {
|
||||
beforeEach(() => {
|
||||
originalSessionId = getSessionId()
|
||||
originalCwd = getCwdState()
|
||||
originalOriginalCwd = getOriginalCwd()
|
||||
originalSessionProjectDir = getSessionProjectDir()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore global state after each test
|
||||
switchSession(originalSessionId, originalSessionProjectDir)
|
||||
setCwdState(originalCwd)
|
||||
setOriginalCwd(originalOriginalCwd)
|
||||
})
|
||||
|
||||
describe('setCwdState', () => {
|
||||
test('writes to global STATE outside of SDK context', () => {
|
||||
setCwdState('/global/path')
|
||||
expect(getCwdState()).toBe('/global/path')
|
||||
})
|
||||
|
||||
test('writes to SDK context inside runWithSdkContext', () => {
|
||||
const ctx = {
|
||||
sessionId: 'test-session-1' as SessionId,
|
||||
sessionProjectDir: null,
|
||||
cwd: '/initial',
|
||||
originalCwd: '/initial',
|
||||
}
|
||||
|
||||
runWithSdkContext(ctx, () => {
|
||||
setCwdState('/sdk/path')
|
||||
// Context-aware getter should read from context
|
||||
expect(getCwdState()).toBe('/sdk/path')
|
||||
})
|
||||
|
||||
// Global state should be unchanged
|
||||
expect(getCwdState()).toBe(originalCwd)
|
||||
})
|
||||
|
||||
test('does not leak between concurrent contexts', async () => {
|
||||
const ctxA = {
|
||||
sessionId: 'session-a' as SessionId,
|
||||
sessionProjectDir: null,
|
||||
cwd: '/a',
|
||||
originalCwd: '/a',
|
||||
}
|
||||
const ctxB = {
|
||||
sessionId: 'session-b' as SessionId,
|
||||
sessionProjectDir: null,
|
||||
cwd: '/b',
|
||||
originalCwd: '/b',
|
||||
}
|
||||
|
||||
const results = await Promise.all([
|
||||
new Promise<string>(resolve => {
|
||||
runWithSdkContext(ctxA, async () => {
|
||||
setCwdState('/a/modified')
|
||||
// Small delay to allow interleaving
|
||||
await Bun.sleep(1)
|
||||
resolve(getCwdState())
|
||||
})
|
||||
}),
|
||||
new Promise<string>(resolve => {
|
||||
runWithSdkContext(ctxB, async () => {
|
||||
await Bun.sleep(1)
|
||||
setCwdState('/b/modified')
|
||||
resolve(getCwdState())
|
||||
})
|
||||
}),
|
||||
])
|
||||
|
||||
expect(results[0]).toBe('/a/modified')
|
||||
expect(results[1]).toBe('/b/modified')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setOriginalCwd', () => {
|
||||
test('writes to global STATE outside of SDK context', () => {
|
||||
setOriginalCwd('/global/original')
|
||||
expect(getOriginalCwd()).toBe('/global/original')
|
||||
})
|
||||
|
||||
test('writes to SDK context inside runWithSdkContext', () => {
|
||||
const ctx = {
|
||||
sessionId: 'test-session-2' as SessionId,
|
||||
sessionProjectDir: null,
|
||||
cwd: '/cwd',
|
||||
originalCwd: '/initial',
|
||||
}
|
||||
|
||||
runWithSdkContext(ctx, () => {
|
||||
setOriginalCwd('/sdk/original')
|
||||
expect(getOriginalCwd()).toBe('/sdk/original')
|
||||
})
|
||||
|
||||
// Global state should be unchanged
|
||||
expect(getOriginalCwd()).toBe(originalOriginalCwd)
|
||||
})
|
||||
})
|
||||
|
||||
describe('regenerateSessionId', () => {
|
||||
test('updates global STATE outside of SDK context', () => {
|
||||
const beforeId = getSessionId()
|
||||
const newId = regenerateSessionId()
|
||||
expect(newId).not.toBe(beforeId)
|
||||
expect(getSessionId()).toBe(newId)
|
||||
})
|
||||
|
||||
test('updates SDK context inside runWithSdkContext', () => {
|
||||
const ctx = {
|
||||
sessionId: 'ctx-session-before' as SessionId,
|
||||
sessionProjectDir: '/some/dir',
|
||||
cwd: '/cwd',
|
||||
originalCwd: '/cwd',
|
||||
}
|
||||
|
||||
let newId: SessionId
|
||||
runWithSdkContext(ctx, () => {
|
||||
newId = regenerateSessionId()
|
||||
expect(getSessionId()).toBe(newId)
|
||||
// sessionProjectDir should be reset to null
|
||||
expect(getSessionProjectDir()).toBeNull()
|
||||
})
|
||||
|
||||
// Global state should be unchanged
|
||||
expect(getSessionId()).toBe(originalSessionId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('switchSession', () => {
|
||||
test('updates global STATE outside of SDK context', () => {
|
||||
const newSessionId = 'switched-global' as SessionId
|
||||
switchSession(newSessionId, '/global/project')
|
||||
expect(getSessionId()).toBe(newSessionId)
|
||||
expect(getSessionProjectDir()).toBe('/global/project')
|
||||
})
|
||||
|
||||
test('updates SDK context inside runWithSdkContext', () => {
|
||||
const ctx = {
|
||||
sessionId: 'before-switch' as SessionId,
|
||||
sessionProjectDir: null,
|
||||
cwd: '/cwd',
|
||||
originalCwd: '/cwd',
|
||||
}
|
||||
|
||||
runWithSdkContext(ctx, () => {
|
||||
switchSession('after-switch' as SessionId, '/sdk/project')
|
||||
expect(getSessionId()).toBe('after-switch')
|
||||
expect(getSessionProjectDir()).toBe('/sdk/project')
|
||||
})
|
||||
|
||||
// Global state should be unchanged
|
||||
expect(getSessionId()).toBe(originalSessionId)
|
||||
expect(getSessionProjectDir()).toBe(originalSessionProjectDir)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parentSessionId isolation', () => {
|
||||
test('regenerateSessionId({ setCurrentAsParent: true }) writes to SDK context, not global STATE', () => {
|
||||
const ctx = {
|
||||
sessionId: 'parent-test-1' as SessionId,
|
||||
sessionProjectDir: null,
|
||||
cwd: '/cwd',
|
||||
originalCwd: '/cwd',
|
||||
}
|
||||
|
||||
runWithSdkContext(ctx, () => {
|
||||
regenerateSessionId({ setCurrentAsParent: true })
|
||||
// Inside context: parentSessionId should reflect the context's value
|
||||
expect(getParentSessionId()).toBe('parent-test-1')
|
||||
})
|
||||
|
||||
// Outside context: global STATE.parentSessionId should NOT be polluted
|
||||
expect(getParentSessionId()).toBeUndefined()
|
||||
})
|
||||
|
||||
test('sequential SDK contexts do not overwrite each other\'s parentSessionId', () => {
|
||||
const ctxA = {
|
||||
sessionId: '11111111-1111-4111-8111-111111111111' as SessionId,
|
||||
sessionProjectDir: null,
|
||||
cwd: 'C:/a',
|
||||
originalCwd: 'C:/a',
|
||||
}
|
||||
const ctxB = {
|
||||
sessionId: '22222222-2222-4222-8222-222222222222' as SessionId,
|
||||
sessionProjectDir: null,
|
||||
cwd: 'C:/b',
|
||||
originalCwd: 'C:/b',
|
||||
}
|
||||
|
||||
let afterA: SessionId | undefined
|
||||
let afterB: SessionId | undefined
|
||||
|
||||
runWithSdkContext(ctxA, () => {
|
||||
regenerateSessionId({ setCurrentAsParent: true })
|
||||
afterA = getParentSessionId()
|
||||
})
|
||||
|
||||
runWithSdkContext(ctxB, () => {
|
||||
regenerateSessionId({ setCurrentAsParent: true })
|
||||
afterB = getParentSessionId()
|
||||
})
|
||||
|
||||
// Each context sees its own parentSessionId
|
||||
expect(afterA).toBe('11111111-1111-4111-8111-111111111111')
|
||||
expect(afterB).toBe('22222222-2222-4222-8222-222222222222')
|
||||
|
||||
// Global STATE should remain clean
|
||||
expect(getParentSessionId()).toBeUndefined()
|
||||
})
|
||||
|
||||
test('parallel SDK contexts each see their own parentSessionId', async () => {
|
||||
const ctxA = {
|
||||
sessionId: 'parallel-parent-a' as SessionId,
|
||||
sessionProjectDir: null,
|
||||
cwd: '/a',
|
||||
originalCwd: '/a',
|
||||
}
|
||||
const ctxB = {
|
||||
sessionId: 'parallel-parent-b' as SessionId,
|
||||
sessionProjectDir: null,
|
||||
cwd: '/b',
|
||||
originalCwd: '/b',
|
||||
}
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
new Promise<SessionId | undefined>(resolve => {
|
||||
runWithSdkContext(ctxA, async () => {
|
||||
regenerateSessionId({ setCurrentAsParent: true })
|
||||
await Bun.sleep(1)
|
||||
resolve(getParentSessionId())
|
||||
})
|
||||
}),
|
||||
new Promise<SessionId | undefined>(resolve => {
|
||||
runWithSdkContext(ctxB, async () => {
|
||||
await Bun.sleep(1)
|
||||
regenerateSessionId({ setCurrentAsParent: true })
|
||||
resolve(getParentSessionId())
|
||||
})
|
||||
}),
|
||||
])
|
||||
|
||||
expect(resultA).toBe('parallel-parent-a')
|
||||
expect(resultB).toBe('parallel-parent-b')
|
||||
})
|
||||
|
||||
test('non-SDK CLI path: regenerateSessionId still writes to global STATE', () => {
|
||||
// Outside any SDK context, setCurrentAsParent should work as before
|
||||
const beforeId = getSessionId()
|
||||
regenerateSessionId({ setCurrentAsParent: true })
|
||||
expect(getParentSessionId()).toBe(beforeId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('end-to-end: parallel sessions', () => {
|
||||
test('independent sessions do not interfere with each other', async () => {
|
||||
const ctx1 = {
|
||||
sessionId: 'parallel-1' as SessionId,
|
||||
sessionProjectDir: null,
|
||||
cwd: '/session1',
|
||||
originalCwd: '/session1',
|
||||
}
|
||||
const ctx2 = {
|
||||
sessionId: 'parallel-2' as SessionId,
|
||||
sessionProjectDir: null,
|
||||
cwd: '/session2',
|
||||
originalCwd: '/session2',
|
||||
}
|
||||
|
||||
const [result1, result2] = await Promise.all([
|
||||
new Promise<{ sessionId: string; cwd: string }>(resolve => {
|
||||
runWithSdkContext(ctx1, async () => {
|
||||
setCwdState('/session1/new-cwd')
|
||||
const newId = regenerateSessionId()
|
||||
await Bun.sleep(1)
|
||||
resolve({ sessionId: getSessionId(), cwd: getCwdState() })
|
||||
// Assign to suppress unused-var lint
|
||||
void newId
|
||||
})
|
||||
}),
|
||||
new Promise<{ sessionId: string; cwd: string }>(resolve => {
|
||||
runWithSdkContext(ctx2, async () => {
|
||||
await Bun.sleep(1)
|
||||
switchSession('parallel-2-switched' as SessionId)
|
||||
setCwdState('/session2/new-cwd')
|
||||
resolve({ sessionId: getSessionId(), cwd: getCwdState() })
|
||||
})
|
||||
}),
|
||||
])
|
||||
|
||||
// Session 1 should see its own state
|
||||
expect(result1.cwd).toBe('/session1/new-cwd')
|
||||
// Session 2 should see its own state
|
||||
expect(result2.sessionId).toBe('parallel-2-switched')
|
||||
expect(result2.cwd).toBe('/session2/new-cwd')
|
||||
|
||||
// Global state should be untouched
|
||||
expect(getSessionId()).toBe(originalSessionId)
|
||||
expect(getCwdState()).toBe(originalCwd)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import {
|
||||
assertValidSessionId,
|
||||
mapMessageToSDK,
|
||||
acquireEnvMutex,
|
||||
releaseEnvMutex,
|
||||
resetEnvMutexForTesting,
|
||||
} from '../../src/entrypoints/sdk/shared.js'
|
||||
|
||||
describe('assertValidSessionId', () => {
|
||||
test('accepts valid UUID v4', () => {
|
||||
expect(() => assertValidSessionId('00000000-0000-0000-0000-000000000000')).not.toThrow()
|
||||
expect(() => assertValidSessionId('550e8400-e29b-41d4-a716-446655440000')).not.toThrow()
|
||||
})
|
||||
|
||||
test('rejects non-UUID string', () => {
|
||||
expect(() => assertValidSessionId('not-a-uuid')).toThrow('Invalid session ID')
|
||||
})
|
||||
|
||||
test('rejects empty string', () => {
|
||||
expect(() => assertValidSessionId('')).toThrow('Invalid session ID')
|
||||
})
|
||||
|
||||
test('rejects UUID with wrong format', () => {
|
||||
expect(() => assertValidSessionId('00000000-0000-0000-0000')).toThrow('Invalid session ID')
|
||||
})
|
||||
|
||||
test('rejects path traversal attempts', () => {
|
||||
expect(() => assertValidSessionId('../../etc/passwd')).toThrow('Invalid session ID')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapMessageToSDK', () => {
|
||||
test('preserves type field from message', () => {
|
||||
const result = mapMessageToSDK({ type: 'assistant', content: 'hello' })
|
||||
expect(result.type).toBe('assistant')
|
||||
})
|
||||
|
||||
test('defaults to unknown when type is missing', () => {
|
||||
const result = mapMessageToSDK({ content: 'hello' })
|
||||
expect(result.type).toBe('unknown')
|
||||
})
|
||||
|
||||
test('spreads all fields through', () => {
|
||||
const msg = {
|
||||
type: 'result',
|
||||
session_id: 'test-123',
|
||||
subtype: 'success',
|
||||
cost_usd: 0.01,
|
||||
}
|
||||
const result = mapMessageToSDK(msg)
|
||||
expect((result as any).session_id).toBe('test-123')
|
||||
expect((result as any).subtype).toBe('success')
|
||||
expect((result as any).cost_usd).toBe(0.01)
|
||||
})
|
||||
|
||||
test('preserves nested objects', () => {
|
||||
const msg = {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hello world' }],
|
||||
},
|
||||
}
|
||||
const result = mapMessageToSDK(msg)
|
||||
expect((result as any).message.content[0].text).toBe('Hello world')
|
||||
})
|
||||
|
||||
test('throws TypeError for null input', () => {
|
||||
expect(() => mapMessageToSDK(null as any)).toThrow(TypeError)
|
||||
expect(() => mapMessageToSDK(null as any)).toThrow('expected non-null object')
|
||||
})
|
||||
|
||||
test('throws TypeError for non-object input', () => {
|
||||
expect(() => mapMessageToSDK('string' as any)).toThrow(TypeError)
|
||||
expect(() => mapMessageToSDK(42 as any)).toThrow(TypeError)
|
||||
})
|
||||
|
||||
test('throws TypeError for invalid type field', () => {
|
||||
expect(() => mapMessageToSDK({ type: 123 })).toThrow(TypeError)
|
||||
expect(() => mapMessageToSDK({ type: 123 })).toThrow("'type' field must be string")
|
||||
})
|
||||
})
|
||||
|
||||
describe.serial('env mutex timeout', () => {
|
||||
beforeEach(() => {
|
||||
resetEnvMutexForTesting()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetEnvMutexForTesting()
|
||||
})
|
||||
|
||||
test('acquireEnvMutex returns timeout result when mutex is locked', async () => {
|
||||
// First acquire locks the mutex
|
||||
const firstResult = await acquireEnvMutex()
|
||||
expect(firstResult.acquired).toBe(true)
|
||||
|
||||
// Second acquire with timeout should return timeout result
|
||||
const secondResult = await acquireEnvMutex({ timeoutMs: 100 })
|
||||
expect(secondResult.acquired).toBe(false)
|
||||
expect(secondResult.reason).toBe('timeout')
|
||||
|
||||
// Clean up
|
||||
releaseEnvMutex()
|
||||
})
|
||||
|
||||
test('acquireEnvMutex succeeds before timeout', async () => {
|
||||
await acquireEnvMutex()
|
||||
|
||||
// Release after 50ms
|
||||
setTimeout(releaseEnvMutex, 50)
|
||||
|
||||
// Second acquire with 200ms timeout should succeed
|
||||
const result = await acquireEnvMutex({ timeoutMs: 200 })
|
||||
expect(result.acquired).toBe(true)
|
||||
|
||||
releaseEnvMutex()
|
||||
})
|
||||
|
||||
test('acquireEnvMutex without timeout waits indefinitely (default behavior)', async () => {
|
||||
await acquireEnvMutex()
|
||||
|
||||
// Release after short delay
|
||||
setTimeout(releaseEnvMutex, 50)
|
||||
|
||||
// No timeout option - should wait and succeed
|
||||
const result = await acquireEnvMutex()
|
||||
expect(result.acquired).toBe(true)
|
||||
|
||||
releaseEnvMutex()
|
||||
})
|
||||
|
||||
test('mutex remains functional after timeout', async () => {
|
||||
// First acquire locks it
|
||||
await acquireEnvMutex()
|
||||
|
||||
// Second acquire with timeout fails
|
||||
const result2 = await acquireEnvMutex({ timeoutMs: 50 })
|
||||
expect(result2.acquired).toBe(false)
|
||||
|
||||
// Release the first
|
||||
releaseEnvMutex()
|
||||
|
||||
// Third acquire should succeed (mutex not permanently locked)
|
||||
const result3 = await acquireEnvMutex({ timeoutMs: 100 })
|
||||
expect(result3.acquired).toBe(true)
|
||||
|
||||
releaseEnvMutex()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
getToolSchemaCache,
|
||||
clearToolSchemaCache,
|
||||
invalidateRemovedToolSchemas,
|
||||
} from '../../src/utils/toolSchemaCache.js'
|
||||
|
||||
describe('invalidateRemovedToolSchemas', () => {
|
||||
beforeEach(() => {
|
||||
clearToolSchemaCache()
|
||||
})
|
||||
|
||||
test('removes entries for tools not in retained set', () => {
|
||||
const cache = getToolSchemaCache()
|
||||
// Simulate cached tool schemas with different key formats
|
||||
cache.set('Read', { name: 'Read', description: 'Read file', input_schema: {} })
|
||||
cache.set('Write', { name: 'Write', description: 'Write file', input_schema: {} })
|
||||
cache.set('Bash', { name: 'Bash', description: 'Run command', input_schema: {} })
|
||||
cache.set('Bash:{\"type\":\"object\"}', {
|
||||
name: 'Bash',
|
||||
description: 'Run command with schema',
|
||||
input_schema: { type: 'object' },
|
||||
})
|
||||
|
||||
// Keep Read and Bash, remove Write
|
||||
invalidateRemovedToolSchemas(new Set(['Read', 'Bash']))
|
||||
|
||||
expect(cache.has('Read')).toBe(true)
|
||||
expect(cache.has('Bash')).toBe(true)
|
||||
expect(cache.has('Bash:{\"type\":\"object\"}')).toBe(true) // Schema variant preserved
|
||||
expect(cache.has('Write')).toBe(false)
|
||||
})
|
||||
|
||||
test('preserves schema variants for retained tools', () => {
|
||||
const cache = getToolSchemaCache()
|
||||
cache.set('Tool', { name: 'Tool', description: 'Basic', input_schema: {} })
|
||||
cache.set('Tool:{\"type\":\"object\",\"properties\":{}}', {
|
||||
name: 'Tool',
|
||||
description: 'With schema',
|
||||
input_schema: { type: 'object', properties: {} },
|
||||
})
|
||||
cache.set('Tool:{\"type\":\"array\"}', {
|
||||
name: 'Tool',
|
||||
description: 'Array schema',
|
||||
input_schema: { type: 'array' },
|
||||
})
|
||||
|
||||
invalidateRemovedToolSchemas(new Set(['Tool']))
|
||||
|
||||
// All Tool variants should be preserved
|
||||
expect(cache.size).toBe(3)
|
||||
expect(cache.has('Tool')).toBe(true)
|
||||
expect(cache.has('Tool:{\"type\":\"object\",\"properties\":{}}')).toBe(true)
|
||||
expect(cache.has('Tool:{\"type\":\"array\"}')).toBe(true)
|
||||
})
|
||||
|
||||
test('handles empty retained set (clears all)', () => {
|
||||
const cache = getToolSchemaCache()
|
||||
cache.set('A', { name: 'A', description: 'Tool A', input_schema: {} })
|
||||
cache.set('B', { name: 'B', description: 'Tool B', input_schema: {} })
|
||||
|
||||
invalidateRemovedToolSchemas(new Set())
|
||||
|
||||
expect(cache.size).toBe(0)
|
||||
})
|
||||
|
||||
test('handles empty cache gracefully', () => {
|
||||
clearToolSchemaCache()
|
||||
invalidateRemovedToolSchemas(new Set(['Read', 'Write']))
|
||||
expect(getToolSchemaCache().size).toBe(0)
|
||||
})
|
||||
|
||||
test('no-op when all tools are retained', () => {
|
||||
const cache = getToolSchemaCache()
|
||||
cache.set('A', { name: 'A', description: 'Tool A', input_schema: {} })
|
||||
cache.set('B', { name: 'B', description: 'Tool B', input_schema: {} })
|
||||
|
||||
invalidateRemovedToolSchemas(new Set(['A', 'B']))
|
||||
|
||||
expect(cache.size).toBe(2)
|
||||
expect(cache.get('A')?.description).toBe('Tool A')
|
||||
expect(cache.get('B')?.description).toBe('Tool B')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user