diff --git a/apps/server/src/services/threads/thread-commands.ts b/apps/server/src/services/threads/thread-commands.ts index 7ee9b7691..98536e5f6 100644 --- a/apps/server/src/services/threads/thread-commands.ts +++ b/apps/server/src/services/threads/thread-commands.ts @@ -220,18 +220,6 @@ function resolveProviderSubagentsEnabled( return true; } -function resolveProviderDisallowedTools( - deps: Pick, - providerId: string, -): string[] | undefined { - if (providerId !== "claude-code") return undefined; - const settings = getAppSettings(deps.db); - const disallowedTools: string[] = []; - if (settings.claudeCodeSubagentsDisabled) disallowedTools.push("Task"); - if (settings.claudeCodeWorkflowsDisabled) disallowedTools.push("Workflow"); - return disallowedTools.length > 0 ? disallowedTools : undefined; -} - function resolveProviderWorkflowsEnabled( deps: Pick, providerId: string, @@ -359,7 +347,6 @@ export async function buildThreadStartCommand( }), instructions: runtimeContext.instructions, dynamicTools: runtimeContext.dynamicTools, - disallowedTools: resolveProviderDisallowedTools(deps, args.providerId), injectedSkillSources: runtimeContext.injectedSkillSources, instructionMode: runtimeContext.instructionMode, threadStoragePath: runtimeContext.threadStoragePath, @@ -413,10 +400,6 @@ function buildPreparedTurnSubmitCommandPayload( providerThreadId: args.providerThreadId, instructions: args.runtimeContext.instructions, dynamicTools: args.runtimeContext.dynamicTools, - disallowedTools: resolveProviderDisallowedTools( - args.deps, - args.runtimeContext.providerId, - ), injectedSkillSources: args.runtimeContext.injectedSkillSources, instructionMode: args.runtimeContext.instructionMode, }, diff --git a/apps/server/test/threads/thread-runtime-config.test.ts b/apps/server/test/threads/thread-runtime-config.test.ts index bda42337b..a9d676e85 100644 --- a/apps/server/test/threads/thread-runtime-config.test.ts +++ b/apps/server/test/threads/thread-runtime-config.test.ts @@ -918,7 +918,7 @@ describe("thread runtime config", () => { }); }); - it("disables provider-native subagent tools independently", async () => { + it("carries provider-native feature settings independently", async () => { await withTestHarness(async (harness) => { setAppSettings(harness.db, { ...defaultAppSettings, @@ -971,7 +971,7 @@ describe("thread runtime config", () => { const claudeCode = await build("claude-code"); expect(claudeCode.options.providerSubagentsEnabled).toBe(false); expect(claudeCode.options.workflowsEnabled).toBe(false); - expect(claudeCode.disallowedTools).toEqual(["Task", "Workflow"]); + expect(claudeCode.disallowedTools).toBeUndefined(); }); }); diff --git a/packages/agent-runtime/src/acp/adapter.ts b/packages/agent-runtime/src/acp/adapter.ts index acd019180..2013b0c4f 100644 --- a/packages/agent-runtime/src/acp/adapter.ts +++ b/packages/agent-runtime/src/acp/adapter.ts @@ -41,6 +41,7 @@ import { flattenPromptInputGroups, noPreparedProviderCommandDispatch, } from "../provider-adapter.js"; +import { classifySessionExecutionSettingsChange } from "../execution-options.js"; import { ProviderResponseEncodeError } from "../runtime-json-rpc.js"; import type { ProviderInboundRequest, @@ -1341,6 +1342,8 @@ export function createAcpProviderAdapter( id: providerInfo.id, displayName: providerInfo.displayName, capabilities: providerInfo.capabilities, + approvalRequestPolicy: "runtime", + classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, process: { command: opts.bridgeNodeExecutablePath ?? "node", args: resolveBridgeProcessArgs({ diff --git a/packages/agent-runtime/src/claude-code/adapter.ts b/packages/agent-runtime/src/claude-code/adapter.ts index 4d3f852af..70b5c113f 100644 --- a/packages/agent-runtime/src/claude-code/adapter.ts +++ b/packages/agent-runtime/src/claude-code/adapter.ts @@ -80,6 +80,10 @@ import type { ProviderAdapterFactoryOptions, } from "../provider-adapter.js"; import { noPreparedProviderCommandDispatch } from "../provider-adapter.js"; +import { + classifyClaudeExecutionSettingsChange, + normalizeClaudeExecutionOptions, +} from "../execution-options.js"; import { type JsonRpcMessage, type ProviderInboundRequest, @@ -1042,6 +1046,9 @@ export function createClaudeCodeProviderAdapter( id: providerInfo.id, displayName: providerInfo.displayName, capabilities, + approvalRequestPolicy: "provider", + classifyExecutionSettingsChange: classifyClaudeExecutionSettingsChange, + normalizeExecutionOptions: normalizeClaudeExecutionOptions, process: { command: opts?.bridgeNodeExecutablePath ?? "node", args: resolveBridgeProcessArgs({ @@ -1134,6 +1141,8 @@ export function createClaudeCodeProviderAdapter( : {}), workflowsEnabled: command.options.workflowsEnabled, memoryEnabled: command.options.memoryEnabled, + providerSubagentsEnabled: + command.options.providerSubagentsEnabled, ...(dynamicTools && dynamicTools.length > 0 ? { dynamicTools } : {}), @@ -1204,6 +1213,8 @@ export function createClaudeCodeProviderAdapter( : {}), workflowsEnabled: command.options.workflowsEnabled, memoryEnabled: command.options.memoryEnabled, + providerSubagentsEnabled: + command.options.providerSubagentsEnabled, ...(dynamicTools && dynamicTools.length > 0 ? { dynamicTools } : {}), @@ -1240,6 +1251,14 @@ export function createClaudeCodeProviderAdapter( ...(command.options?.model ? { model: command.options.model } : {}), + ...(command.options?.reasoningLevel + ? { reasoningLevel: command.options.reasoningLevel } + : {}), + workflowsEnabled: command.options.workflowsEnabled, + memoryEnabled: command.options.memoryEnabled, + providerSubagentsEnabled: + command.options.providerSubagentsEnabled, + permissionEscalation: command.options.permissionEscalation, }, }; case "turn/steer": @@ -1261,6 +1280,17 @@ export function createClaudeCodeProviderAdapter( ), } : {}), + ...(command.options?.model + ? { model: command.options.model } + : {}), + ...(command.options?.reasoningLevel + ? { reasoningLevel: command.options.reasoningLevel } + : {}), + workflowsEnabled: command.options.workflowsEnabled, + memoryEnabled: command.options.memoryEnabled, + providerSubagentsEnabled: + command.options.providerSubagentsEnabled, + permissionEscalation: command.options.permissionEscalation, }, }; case "thread/fork": { @@ -1324,6 +1354,8 @@ export function createClaudeCodeProviderAdapter( : {}), workflowsEnabled: command.options.workflowsEnabled, memoryEnabled: command.options.memoryEnabled, + providerSubagentsEnabled: + command.options.providerSubagentsEnabled, ...(dynamicTools && dynamicTools.length > 0 ? { dynamicTools } : {}), diff --git a/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts b/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts index 0d61ebcaf..096607abb 100644 --- a/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts +++ b/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts @@ -65,6 +65,13 @@ interface DeniedReadonlyBashCase { command: string; } +interface AssistantToolUseMessageArgs { + parentToolUseId: string | null; + toolInput: Record; + toolName: string; + toolUseId: string; +} + interface CanUseToolPolicyAllowExpectation { behavior: "allow"; updatedInput: Record; @@ -92,17 +99,21 @@ interface CanUseToolPolicyCase { } interface ControlledClaudeQuery { + applyFlagSettings: ReturnType; close: ReturnType; emit(message: SDKMessage): void; fail(error: Error): void; finish(): void; initializationResult: ReturnType; + setModel: ReturnType; + setPermissionMode: ReturnType; [Symbol.asyncIterator](): AsyncIterator; } interface ClaudeQueryCallOptions { canUseTool?: CanUseTool; env?: Record; + hooks?: BridgeSessionHooks; model?: string; permissionMode?: ClaudePermissionMode; resume?: string; @@ -150,6 +161,7 @@ interface StartBridgeThreadArgs { interface ResumeBridgeThreadArgs { bridge: BridgeJsonRpcTestHarness; + permissionEscalation?: "ask" | "deny"; providerThreadId: string | null; requestId: number; threadId: string; @@ -334,6 +346,7 @@ function createControlledClaudeQuery(): ControlledClaudeQuery { }, }; return { + applyFlagSettings: vi.fn().mockResolvedValue(undefined), close: vi.fn(() => { pushResult({ value: undefined, done: true }); }), @@ -347,24 +360,52 @@ function createControlledClaudeQuery(): ControlledClaudeQuery { pushResult({ value: undefined, done: true }); }, initializationResult: vi.fn(), + setModel: vi.fn().mockResolvedValue(undefined), + setPermissionMode: vi.fn().mockResolvedValue(undefined), [Symbol.asyncIterator]() { return iterator; }, }; } -async function readNextPromptText(call: ClaudeQueryCall): Promise { +async function readNextPrompt(call: ClaudeQueryCall): Promise { const result = await call.prompt[Symbol.asyncIterator]().next(); if (result.done) { throw new Error("Expected Claude prompt input"); } - const content = result.value.message.content; + return result.value; +} + +async function readNextPromptText(call: ClaudeQueryCall): Promise { + const content = (await readNextPrompt(call)).message.content; if (typeof content !== "string") { throw new Error("Expected Claude prompt text content"); } return content; } +async function invokeBridgeHooks( + matchers: + | readonly { + hooks: readonly BridgePreToolUseHook[]; + }[] + | undefined, + input: Parameters[0], + toolUseId?: string, +): Promise>[]> { + const outputs: Awaited>[] = []; + for (const matcher of matchers ?? []) { + for (const hook of matcher.hooks) { + outputs.push( + await hook(input, toolUseId, { + signal: new AbortController().signal, + }), + ); + } + } + return outputs; +} + function createResultUsage(): SdkResultUsage { return { cache_creation: { @@ -407,6 +448,37 @@ function createStaleResumeErrorMessage( }; } +function createAssistantToolUseMessage( + args: AssistantToolUseMessageArgs, +): SDKMessage { + return { + type: "assistant", + message: { + id: `message-${args.toolUseId}`, + type: "message", + role: "assistant", + container: null, + content: [ + { + type: "tool_use", + id: args.toolUseId, + name: args.toolName, + input: args.toolInput, + }, + ], + context_management: null, + model: "claude-sonnet-5", + stop_details: null, + stop_reason: "tool_use", + stop_sequence: null, + usage: createResultUsage(), + }, + parent_tool_use_id: args.parentToolUseId, + uuid: `00000000-0000-4000-8000-${args.toolUseId}`, + session_id: "session-1", + }; +} + function createTempClaudeExecutable(): TempClaudeExecutable { const binDir = mkdtempSync(join(tmpdir(), "bb-claude-path-")); tempDirs.push(binDir); @@ -461,7 +533,7 @@ function sendResumeThread(args: ResumeBridgeThreadArgs): void { baseInstructions: "test", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + permissionEscalation: args.permissionEscalation ?? "ask", permissionMode: "default", approvedPlanPermissionMode: "default", permissionScope: "workspace", @@ -557,7 +629,7 @@ describe("bridge", () => { cwd: "/tmp/worktree", disallowedTools: ["ExitPlanMode", "NotebookEdit", "Task"], instructionMode: "replace", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -582,7 +654,7 @@ describe("bridge", () => { instructionMode: "append", reasoningLevel: "ultracode", workflowsEnabled: true, - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -605,7 +677,7 @@ describe("bridge", () => { instructionMode: "append", reasoningLevel: "high", workflowsEnabled: true, - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -616,6 +688,7 @@ describe("bridge", () => { expect(options.settings).toEqual({ autoMemoryEnabled: true, enableWorkflows: true, + ultracode: false, }); }); @@ -627,14 +700,18 @@ describe("bridge", () => { cwd: "/tmp/worktree", instructionMode: "append", reasoningLevel: "xhigh", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, {}, ); - expect(options.settings).toEqual({ autoMemoryEnabled: true }); + expect(options.settings).toEqual({ + autoMemoryEnabled: true, + enableWorkflows: false, + ultracode: false, + }); }); it("disables Claude auto-memory reads and writes", () => { @@ -644,14 +721,18 @@ describe("bridge", () => { memoryEnabled: false, cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, {}, ); - expect(options.settings).toEqual({ autoMemoryEnabled: false }); + expect(options.settings).toEqual({ + autoMemoryEnabled: false, + enableWorkflows: false, + ultracode: false, + }); }); it("leaves standard sessions on the default Claude tool preset", () => { @@ -662,7 +743,7 @@ describe("bridge", () => { cwd: "/tmp/worktree", instructionMode: "append", reasoningLevel: "xhigh", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -690,7 +771,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", plugins: [{ type: "local", path: "/tmp/bb-skills" }], @@ -711,7 +792,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "deny", + getPermissionEscalation: () => "deny", permissionMode: "dontAsk", permissionScope: "workspace", }, @@ -729,7 +810,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -754,7 +835,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -772,7 +853,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -793,7 +874,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -818,7 +899,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -837,7 +918,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "acceptEdits", permissionScope: "workspace", }, @@ -849,7 +930,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "deny", + getPermissionEscalation: () => "deny", permissionMode: "auto", permissionScope: "workspace", }, @@ -869,7 +950,7 @@ describe("bridge", () => { enabled: true, failIfUnavailable: false, autoAllowBashIfSandboxed: true, - allowUnsandboxedCommands: false, + allowUnsandboxedCommands: true, network: { allowLocalBinding: true }, }); }); @@ -882,7 +963,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "plan", permissionScope: "workspace", }, @@ -905,7 +986,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "deny", + getPermissionEscalation: () => "deny", permissionMode: "auto", permissionScope: "workspace", }, @@ -920,7 +1001,7 @@ describe("bridge", () => { enabled: true, failIfUnavailable: false, autoAllowBashIfSandboxed: true, - allowUnsandboxedCommands: false, + allowUnsandboxedCommands: true, network: { allowLocalBinding: true }, filesystem: { allowWrite: ["/repo/.git/worktrees/bb13", "/repo/.git/objects"], @@ -935,7 +1016,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -947,7 +1028,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "deny", + getPermissionEscalation: () => "deny", permissionMode: "dontAsk", permissionScope: "workspace", }, @@ -1250,6 +1331,23 @@ describe("bridge", () => { messageIncludes: "bb's workspace sandbox allows work inside", }, }, + { + id: "escalation-deny-unsandboxed-bash", + name: "escalation deny blocks unsandboxed Bash retry", + permissionMode: "auto", + permissionEscalation: "deny", + toolName: "Bash", + decisionReason: "dangerouslyDisableSandbox", + input: { + command: "echo hi", + dangerouslyDisableSandbox: true, + description: "Permission boundary test", + }, + expected: { + behavior: "deny", + messageIncludes: "bb's workspace sandbox allows work inside", + }, + }, { id: "full-bypass-allow", name: "full bypass does not rewrite via readonly Bash auto-allow", @@ -1786,6 +1884,7 @@ describe("bridge", () => { }); bridge.sendRequest(collidingId, "turn/start", { + permissionEscalation: "ask", threadId, providerThreadId: null, input: [{ type: "text", text: "colliding turn", mentions: [] }], @@ -1821,6 +1920,7 @@ describe("bridge", () => { // Any params mismatch used to be dropped without a reply, so the caller // learned nothing until its 30s timeout. The reply now names the field. bridge.sendRequest(11, "turn/start", { + permissionEscalation: "ask", threadId: "thread-invalid-params", providerThreadId: null, input: [{ type: "text", text: "hi", mentions: [] }], @@ -2446,7 +2546,7 @@ describe("bridge", () => { } }); - it("rebuilds a live Claude session when enforcement options change", async () => { + it("rebuilds for enforcement changes but applies model changes live", async () => { const bridge = createBridgeJsonRpcTestHarness(handleLine); const queries: ControlledClaudeQuery[] = []; queryMock.mockImplementation(() => { @@ -2525,7 +2625,7 @@ describe("bridge", () => { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, - allowUnsandboxedCommands: false, + allowUnsandboxedCommands: true, }, }); @@ -2545,17 +2645,407 @@ describe("bridge", () => { }); await bridge.waitForResponse(4); - expect(queries).toHaveLength(4); - expect(queries[2]?.close).toHaveBeenCalledTimes(1); - expect(getLatestQueryOptions()).toMatchObject({ - model: "claude-opus-4-1", - permissionMode: "auto", - resume: providerThreadId, + expect(queries).toHaveLength(3); + expect(queries[2]?.close).not.toHaveBeenCalled(); + expect(queries[2]?.setModel).toHaveBeenCalledWith("claude-opus-4-1"); + + bridge.sendRequest(5, "thread/stop", { threadId }); + await bridge.flushWork(); + queries[2]?.finish(); + await bridge.waitForResponse(5); + } finally { + queries.forEach((query) => query.finish()); + bridge.restore(); + } + }); + + it("keeps a live Claude session across an escalation-only resume change", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const queries: ControlledClaudeQuery[] = []; + queryMock.mockImplementation(() => { + const query = createControlledClaudeQuery(); + queries.push(query); + return query; + }); + + try { + const threadId = "thread-resume-escalation-only"; + const providerThreadId = "provider-thread-escalation-only"; + sendResumeThread({ bridge, providerThreadId, requestId: 1, threadId }); + await bridge.waitForResponse(1); + + expect(queries).toHaveLength(1); + + sendResumeThread({ + bridge, + permissionEscalation: "deny", + providerThreadId, + requestId: 2, + threadId, + }); + await bridge.waitForResponse(2); + + expect(queries).toHaveLength(1); + expect(queries[0]?.close).not.toHaveBeenCalled(); + + bridge.sendRequest(3, "thread/stop", { threadId }); + await bridge.flushWork(); + queries[0]?.finish(); + await bridge.waitForResponse(3); + } finally { + queries.forEach((query) => query.finish()); + bridge.restore(); + } + }); + + it("applies turn model, reasoning, memory, workflow, and subagent settings live", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const queries: ControlledClaudeQuery[] = []; + queryMock.mockImplementation(() => { + const query = createControlledClaudeQuery(); + queries.push(query); + return query; + }); + + try { + const threadId = "thread-live-settings"; + bridge.sendRequest(1, "thread/start", { + workflowsEnabled: false, + memoryEnabled: true, + providerSubagentsEnabled: true, + reasoningLevel: "low", + model: "claude-haiku-4-5", + claudeCodeMockCliTraffic: DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG, + baseInstructions: "test", + cwd: "/tmp/worktree", + instructionMode: "append", + permissionEscalation: "ask", + permissionMode: "default", + approvedPlanPermissionMode: "default", + permissionScope: "workspace", + threadId, + }); + await bridge.waitForResponse(1); + + const query = queries[0]; + const call = getLatestQueryCall(); + const hooks = call.options.hooks; + if (!query || !hooks) { + throw new Error("Expected live Claude query and hooks"); + } + + bridge.sendRequest(2, "turn/start", { + workflowsEnabled: true, + memoryEnabled: false, + providerSubagentsEnabled: false, + reasoningLevel: "max", + model: "claude-opus-5[1m]", + permissionEscalation: "ask", + input: [{ type: "text", text: "Use the new live settings" }], + providerThreadId: null, + threadId, + }); + await bridge.waitForResponse(2); + await readNextPrompt(call); + + expect(queries).toHaveLength(1); + expect(query.close).not.toHaveBeenCalled(); + expect(query.setModel).toHaveBeenCalledWith("claude-opus-5[1m]"); + expect(query.applyFlagSettings).toHaveBeenLastCalledWith({ + autoMemoryEnabled: false, + enableWorkflows: true, + effortLevel: "max", + ultracode: false, + }); + + for (const toolName of ["Agent", "Task"]) { + const toolUseId = `tool-disabled-${toolName.toLowerCase()}`; + const disabledSubagentOutputs = await invokeBridgeHooks( + hooks.PreToolUse, + { + hook_event_name: "PreToolUse", + tool_name: toolName, + tool_input: {}, + tool_use_id: toolUseId, + session_id: "session-1", + transcript_path: "/tmp/transcript.jsonl", + cwd: "/tmp/worktree", + }, + toolUseId, + ); + expect(disabledSubagentOutputs).toContainEqual( + expect.objectContaining({ + hookSpecificOutput: expect.objectContaining({ + permissionDecision: "deny", + }), + }), + ); + } + const enabledWorkflowOutputs = await invokeBridgeHooks( + hooks.PreToolUse, + { + hook_event_name: "PreToolUse", + tool_name: "Workflow", + tool_input: {}, + tool_use_id: "tool-enabled-workflow", + session_id: "session-1", + transcript_path: "/tmp/transcript.jsonl", + cwd: "/tmp/worktree", + }, + "tool-enabled-workflow", + ); + expect(enabledWorkflowOutputs).not.toContainEqual( + expect.objectContaining({ + hookSpecificOutput: expect.objectContaining({ + permissionDecision: "deny", + }), + }), + ); + + bridge.sendRequest(3, "turn/start", { + workflowsEnabled: false, + memoryEnabled: true, + providerSubagentsEnabled: true, + reasoningLevel: "xhigh", + model: "claude-opus-5[1m]", + permissionEscalation: "ask", + input: [{ type: "text", text: "Flip the live feature settings" }], + providerThreadId: null, + threadId, + }); + await bridge.waitForResponse(3); + await readNextPrompt(call); + + expect(queries).toHaveLength(1); + expect(query.applyFlagSettings).toHaveBeenLastCalledWith({ + autoMemoryEnabled: true, + enableWorkflows: false, + effortLevel: "xhigh", + ultracode: false, + }); + const enabledSubagentOutputs = await invokeBridgeHooks( + hooks.PreToolUse, + { + hook_event_name: "PreToolUse", + tool_name: "Agent", + tool_input: {}, + tool_use_id: "tool-enabled-agent", + session_id: "session-1", + transcript_path: "/tmp/transcript.jsonl", + cwd: "/tmp/worktree", + }, + "tool-enabled-agent", + ); + expect(enabledSubagentOutputs).not.toContainEqual( + expect.objectContaining({ + hookSpecificOutput: expect.objectContaining({ + permissionDecision: "deny", + }), + }), + ); + const disabledWorkflowOutputs = await invokeBridgeHooks( + hooks.PreToolUse, + { + hook_event_name: "PreToolUse", + tool_name: "Workflow", + tool_input: {}, + tool_use_id: "tool-disabled-workflow", + session_id: "session-1", + transcript_path: "/tmp/transcript.jsonl", + cwd: "/tmp/worktree", + }, + "tool-disabled-workflow", + ); + expect(disabledWorkflowOutputs).toContainEqual( + expect.objectContaining({ + hookSpecificOutput: expect.objectContaining({ + permissionDecision: "deny", + }), + }), + ); + + bridge.sendRequest(4, "thread/stop", { threadId }); + await bridge.flushWork(); + query.finish(); + await bridge.waitForResponse(4); + } finally { + queries.forEach((query) => query.finish()); + bridge.restore(); + } + }); + + it("keeps background subagents on their parent tool escalation when canUseTool omits agent metadata", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const queries: ControlledClaudeQuery[] = []; + queryMock.mockImplementation(() => { + const query = createControlledClaudeQuery(); + queries.push(query); + return query; + }); + + try { + const threadId = "thread-background-escalation"; + bridge.sendRequest(1, "thread/start", { + workflowsEnabled: false, + claudeCodeMockCliTraffic: DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG, + baseInstructions: "test", + cwd: "/tmp/worktree", + instructionMode: "append", + permissionEscalation: "deny", + permissionMode: "acceptEdits", + approvedPlanPermissionMode: "acceptEdits", + permissionScope: "workspace", + threadId, + }); + await bridge.waitForResponse(1); + + const call = getLatestQueryCall(); + const hooks = call.options.hooks; + if (!hooks) { + throw new Error("Expected Claude SDK hooks"); + } + + bridge.sendRequest(2, "turn/start", { + permissionEscalation: "deny", + input: [{ type: "text", text: "Start denied background work" }], + providerThreadId: null, + threadId, + }); + await bridge.waitForResponse(2); + const deniedPrompt = await readNextPrompt(call); + if (!deniedPrompt.uuid) { + throw new Error("Expected denied prompt UUID"); + } + + const denyParentToolUseId = "tool-agent-deny"; + queries[0]?.emit( + createAssistantToolUseMessage({ + parentToolUseId: null, + toolInput: { prompt: "Start denied background work" }, + toolName: "Agent", + toolUseId: denyParentToolUseId, + }), + ); + await bridge.flushWork(); + + bridge.sendRequest(3, "turn/start", { + permissionEscalation: "ask", + input: [{ type: "text", text: "Start interactive background work" }], + providerThreadId: null, + threadId, + }); + await bridge.waitForResponse(3); + const askPrompt = await readNextPrompt(call); + if (!askPrompt.uuid) { + throw new Error("Expected ask prompt UUID"); + } + + const denyToolUseId = "tool-background-deny"; + queries[0]?.emit( + createAssistantToolUseMessage({ + parentToolUseId: denyParentToolUseId, + toolInput: { + command: "echo hi", + dangerouslyDisableSandbox: true, + }, + toolName: "Bash", + toolUseId: denyToolUseId, + }), + ); + await expect( + getLastCanUseTool()( + "Bash", + { command: "echo hi", dangerouslyDisableSandbox: true }, + { + decisionReason: "dangerouslyDisableSandbox", + signal: new AbortController().signal, + toolUseID: denyToolUseId, + }, + ), + ).resolves.toMatchObject({ behavior: "deny" }); + + const askParentToolUseId = "tool-agent-ask"; + queries[0]?.emit( + createAssistantToolUseMessage({ + parentToolUseId: null, + toolInput: { prompt: "Start interactive background work" }, + toolName: "Agent", + toolUseId: askParentToolUseId, + }), + ); + await bridge.flushWork(); + + bridge.sendRequest(4, "turn/start", { + permissionEscalation: "deny", + input: [{ type: "text", text: "Return to denied work" }], + providerThreadId: null, + threadId, + }); + await bridge.waitForResponse(4); + const latestPrompt = await readNextPrompt(call); + if (!latestPrompt.uuid) { + throw new Error("Expected latest prompt UUID"); + } + + const askToolUseId = "tool-background-ask"; + queries[0]?.emit( + createAssistantToolUseMessage({ + parentToolUseId: askParentToolUseId, + toolInput: { + command: "echo hi", + dangerouslyDisableSandbox: true, + }, + toolName: "Bash", + toolUseId: askToolUseId, + }), + ); + + const askResultPromise = getLastCanUseTool()( + "Bash", + { command: "echo hi", dangerouslyDisableSandbox: true }, + { + decisionReason: "dangerouslyDisableSandbox", + signal: new AbortController().signal, + toolUseID: askToolUseId, + }, + ); + await bridge.flushWork(); + + const permissionRequest = bridge.messages.find( + (message) => + message.method === CLAUDE_PERMISSION_REQUEST_APPROVAL_METHOD && + isRecord(message.params) && + message.params.itemId === askToolUseId, + ); + if (permissionRequest?.id === undefined) { + throw new Error("Expected forwarded background permission request"); + } + expect(permissionRequest.params).toMatchObject({ + itemId: askToolUseId, + threadId, + toolName: "Bash", + }); + + handleLine( + JSON.stringify({ + jsonrpc: "2.0", + id: permissionRequest.id, + result: { + kind: "permission_request", + behavior: "deny", + message: "Denied by user", + }, + }), + ); + await expect(askResultPromise).resolves.toMatchObject({ + behavior: "deny", + message: "Denied by user", + toolUseID: askToolUseId, }); bridge.sendRequest(5, "thread/stop", { threadId }); await bridge.flushWork(); - queries[3]?.finish(); + queries[0]?.finish(); await bridge.waitForResponse(5); } finally { queries.forEach((query) => query.finish()); @@ -2799,6 +3289,7 @@ describe("bridge", () => { ).toBe(true); bridge.sendRequest(2, "turn/start", { + permissionEscalation: "ask", input: [{ type: "text", text: inputText }], providerThreadId, threadId, @@ -2854,6 +3345,7 @@ describe("bridge", () => { await bridge.flushWork(); bridge.sendRequest(2, "turn/start", { + permissionEscalation: "ask", input: [{ type: "text", text: "" }], providerThreadId, threadId, @@ -2901,6 +3393,7 @@ describe("bridge", () => { const providerThreadId = getProviderThreadIdFromResult(startResponse); bridge.sendRequest(2, "turn/start", { + permissionEscalation: "ask", input: [ { type: "text", text: "First grouped input" }, { type: "text", text: "\n\n" }, @@ -2951,6 +3444,7 @@ describe("bridge", () => { await startBridgeThread({ bridge, threadId }); bridge.sendRequest(2, "turn/start", { + permissionEscalation: "ask", input: [ { type: "text", text: "First grouped input" }, { type: "text", text: "\n\n" }, @@ -3019,6 +3513,7 @@ describe("bridge", () => { }); bridge.sendRequest(2, "turn/start", { + permissionEscalation: "ask", input: [{ type: "text", text: inputText }], providerThreadId: staleProviderThreadId, threadId, @@ -3177,6 +3672,7 @@ describe("bridge", () => { await startBridgeThread({ bridge, threadId }); bridge.sendRequest(2, "turn/steer", { + permissionEscalation: "ask", expectedTurnId: "turn-1", input: [{ type: "text", text: "Please account for the restart" }], providerThreadId: null, @@ -3213,6 +3709,7 @@ describe("bridge", () => { await startBridgeThread({ bridge, threadId }); bridge.sendRequest(2, "turn/steer", { + permissionEscalation: "ask", expectedTurnId: "turn-1", input: [ { type: "text", text: "First grouped steer" }, @@ -3246,6 +3743,83 @@ describe("bridge", () => { } }); + it.each([ + { grouped: false, method: "turn/start", name: "turn start" }, + { grouped: false, method: "turn/steer", name: "single turn steer" }, + { grouped: true, method: "turn/steer", name: "grouped turn steer" }, + ] as const)( + "keeps the prior escalation when a rejected $name cannot push input", + async (testCase) => { + const threadId = `thread-rejected-${testCase.name.replaceAll(" ", "-")}`; + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const queries: ControlledClaudeQuery[] = []; + queryMock.mockImplementation(() => { + const query = createControlledClaudeQuery(); + queries.push(query); + return query; + }); + + try { + bridge.sendRequest(1, "thread/start", { + workflowsEnabled: false, + claudeCodeMockCliTraffic: DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG, + baseInstructions: "test", + cwd: "/tmp/worktree", + instructionMode: "append", + permissionEscalation: "deny", + permissionMode: "auto", + approvedPlanPermissionMode: "auto", + permissionScope: "workspace", + threadId, + }); + await bridge.waitForResponse(1); + + await getLatestQueryCall().prompt[Symbol.asyncIterator]().return?.(); + + bridge.sendRequest(2, testCase.method, { + permissionEscalation: "ask", + ...(testCase.method === "turn/steer" + ? { expectedTurnId: "turn-1" } + : {}), + input: [{ type: "text", text: "loosen permissions" }], + ...(testCase.grouped + ? { + inputGroups: [ + [{ type: "text", text: "first grouped input" }], + [{ type: "text", text: "second grouped input" }], + ], + } + : {}), + providerThreadId: null, + threadId, + }); + await expect(bridge.waitForResponse(2)).resolves.toMatchObject({ + error: { code: -32000 }, + }); + + await expect( + getLastCanUseTool()( + "Bash", + { command: "echo hi", dangerouslyDisableSandbox: true }, + { + decisionReason: "dangerouslyDisableSandbox", + signal: new AbortController().signal, + toolUseID: `tool-rejected-${testCase.method}`, + }, + ), + ).resolves.toMatchObject({ behavior: "deny" }); + + bridge.sendRequest(3, "thread/stop", { threadId }); + await bridge.flushWork(); + queries[0]?.finish(); + await bridge.waitForResponse(3); + } finally { + queries.forEach((query) => query.finish()); + bridge.restore(); + } + }, + ); + describe("prompt attachment text markers", () => { async function sendTurnAndReadPrompt( bridge: BridgeJsonRpcTestHarness, @@ -3255,6 +3829,7 @@ describe("bridge", () => { ): Promise { await startBridgeThread({ bridge, threadId }); bridge.sendRequest(2, "turn/start", { + permissionEscalation: "ask", input, providerThreadId: null, threadId, diff --git a/packages/agent-runtime/src/claude-code/bridge/__tests__/sdk-session.test.ts b/packages/agent-runtime/src/claude-code/bridge/__tests__/sdk-session.test.ts index 5192ec8cc..201cad8bb 100644 --- a/packages/agent-runtime/src/claude-code/bridge/__tests__/sdk-session.test.ts +++ b/packages/agent-runtime/src/claude-code/bridge/__tests__/sdk-session.test.ts @@ -6,8 +6,11 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; const mockQueryInstance = { + applyFlagSettings: vi.fn(), close: vi.fn(), interrupt: vi.fn(), + setModel: vi.fn(), + setPermissionMode: vi.fn(), [Symbol.asyncIterator]: vi.fn(), }; const { queryMock } = vi.hoisted(() => ({ @@ -34,7 +37,9 @@ interface RejectSdkStreamArgs { error: Error; } -function isClaudeQueryPromptCall(value: unknown): value is ClaudeQueryPromptCall { +function isClaudeQueryPromptCall( + value: unknown, +): value is ClaudeQueryPromptCall { return ( value !== null && typeof value === "object" && @@ -81,6 +86,9 @@ describe("SdkSession", () => { beforeEach(() => { vi.clearAllMocks(); queryMock.mockImplementation(() => mockQueryInstance); + mockQueryInstance.applyFlagSettings.mockResolvedValue(undefined); + mockQueryInstance.setModel.mockResolvedValue(undefined); + mockQueryInstance.setPermissionMode.mockResolvedValue(undefined); // Make the query async iterable return immediately mockQueryInstance[Symbol.asyncIterator].mockReturnValue({ next: vi.fn().mockResolvedValue({ value: undefined, done: true }), @@ -100,12 +108,40 @@ describe("SdkSession", () => { expect(session.getIsProcessing()).toBe(false); }); + it("applies model and mutable flag settings to the live query", async () => { + keepSdkStreamOpen(); + const session = new SdkSession(defaultOptions, vi.fn(), vi.fn()); + session.start(); + + await session.setModel("claude-sonnet-5"); + await session.applyMutableSettings({ + effort: "max", + settings: { + autoMemoryEnabled: false, + enableWorkflows: true, + effortLevel: "max", + ultracode: false, + }, + }); + + expect(mockQueryInstance.setModel).toHaveBeenCalledWith("claude-sonnet-5"); + expect(mockQueryInstance.applyFlagSettings).toHaveBeenCalledWith({ + autoMemoryEnabled: false, + enableWorkflows: true, + effortLevel: "max", + ultracode: false, + }); + session.stop(); + }); + it("resolves pushed input after the SDK prompt iterator yields it", async () => { keepSdkStreamOpen(); const session = new SdkSession(defaultOptions, vi.fn(), vi.fn()); + const promptId = "00000000-0000-0000-0000-000000000001"; session.start(); - const consumed = session.pushInput("hello"); + expect(session.canPushInput()).toBe(true); + const consumed = session.pushInput("hello", promptId); let consumedResolved = false; void consumed.then(() => { consumedResolved = true; @@ -116,6 +152,7 @@ describe("SdkSession", () => { const result = await getLatestPrompt()[Symbol.asyncIterator]().next(); expect(result.done).toBe(false); expect(result.value?.message.content).toBe("hello"); + expect(result.value?.uuid).toBe(promptId); await consumed; expect(consumedResolved).toBe(true); session.stop(); @@ -126,6 +163,7 @@ describe("SdkSession", () => { const consumed = session.pushInput("hello"); session.stop(); + expect(session.canPushInput()).toBe(false); await expect(consumed).rejects.toThrow( "Claude SDK session stopped before input consumed", diff --git a/packages/agent-runtime/src/claude-code/bridge/bridge.ts b/packages/agent-runtime/src/claude-code/bridge/bridge.ts index 4f54ec7e9..b399e7bb2 100644 --- a/packages/agent-runtime/src/claude-code/bridge/bridge.ts +++ b/packages/agent-runtime/src/claude-code/bridge/bridge.ts @@ -25,10 +25,12 @@ import { DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_ENDPOINT, type PendingInteractionGrantedPermissionProfile, type PermissionEscalation, + type ReasoningLevel, } from "@bb/domain"; import { forkSession, type CanUseTool, + type HookCallback, type PermissionResult, type SDKMessage, } from "@anthropic-ai/claude-agent-sdk"; @@ -54,9 +56,12 @@ import { } from "./commands.js"; import { buildReadonlyDenialMessage, + buildMutableFlagSettings, buildSessionOptions, buildWorkspaceWriteDenialMessage, + toSdkEffort, type BuildSessionOptionsArgs, + type PermissionEscalationWorkContext, } from "./session-options.js"; import { startClaudeCodeMockCliTrafficProxy, @@ -111,6 +116,9 @@ const promptInputItemSchema = z.discriminatedUnion("type", [ }), ]); +const CLAUDE_PROVIDER_SUBAGENT_TOOL_NAMES = new Set(["Agent", "Task"]); +const CLAUDE_WORKFLOW_TOOL_NAME = "Workflow"; + interface JsonRpcResponse { jsonrpc: "2.0"; id: string | number; @@ -197,8 +205,26 @@ interface ThreadSession { mockCliTrafficProxy: ClaudeCodeMockCliTrafficProxy | null; pendingToolCalls: Map; pendingInteractiveRequests: Map; + /** Current-turn fallback when Claude supplies no originating-work metadata. */ permissionEscalation: PermissionEscalation | null; + permissionEscalationByAgentId: Map; + /** + * Retained for the session lifetime because background work can wake after + * multiple newer prompts have run. + */ + permissionEscalationByPromptId: Map; + /** + * Retained for the session lifetime so SDK messages from background + * subagents can inherit the policy of the Agent/Task call that launched + * them, even after that parent tool call has completed. + */ + permissionEscalationBySubagentParentToolUseId: Map< + string, + PermissionEscalation | null + >; + permissionEscalationByToolUseId: Map; permissionMode: ClaudePermissionMode; + liveSettings: ClaudeLiveSessionSettings; /** Mode to return to once the user approves a plan. See commands.ts. */ approvedPlanPermissionMode: ClaudePermissionMode; providerThreadId?: string; @@ -216,6 +242,7 @@ interface CreateThreadSessionArgs { mockCliTrafficProxy: ClaudeCodeMockCliTrafficProxy | null; permissionEscalation: PermissionEscalation | null; permissionMode: ClaudePermissionMode; + liveSettings: ClaudeLiveSessionSettings; approvedPlanPermissionMode: ClaudePermissionMode; providerThreadId?: string; sessionConstructionConfig: SessionConstructionConfig; @@ -233,7 +260,24 @@ interface SessionConstructionConfig { claudeCodeMockCliTraffic: ThreadResumeParams["claudeCodeMockCliTraffic"]; config: ThreadResumeParams["config"]; dynamicTools: ThreadResumeParams["dynamicTools"]; - sessionOptions: BuildSessionOptionsArgs; + // Live settings are not part of the comparable construction config: the + // bridge applies them through SDK controls without replacing the session. + sessionOptions: Omit< + BuildSessionOptionsArgs, + | "getPermissionEscalation" + | "memoryEnabled" + | "model" + | "reasoningLevel" + | "workflowsEnabled" + >; +} + +interface ClaudeLiveSessionSettings { + memoryEnabled: boolean; + model?: string; + providerSubagentsEnabled: boolean; + reasoningLevel?: ReasoningLevel; + workflowsEnabled: boolean; } type SessionConstructionParams = @@ -430,13 +474,66 @@ function ignoreInputConsumption(promise: Promise): void { void promise.catch(() => {}); } +function pushPromptInput( + threadSession: ThreadSession, + input: string, + permissionEscalation: PermissionEscalation | null, +): Promise { + const promptId = randomUUID(); + threadSession.permissionEscalationByPromptId.set( + promptId, + permissionEscalation, + ); + return threadSession.session.pushInput(input, promptId).catch((error) => { + threadSession.permissionEscalationByPromptId.delete(promptId); + throw error; + }); +} + function queuePromptInputs( threadSession: ThreadSession, inputs: readonly string[], -): void { + permissionEscalation: PermissionEscalation | null, +): boolean { + if (!threadSession.session.canPushInput()) { + return false; + } for (const input of inputs) { - ignoreInputConsumption(threadSession.session.pushInput(input)); + ignoreInputConsumption( + pushPromptInput(threadSession, input, permissionEscalation), + ); + } + return true; +} + +async function applyLiveSessionSettings( + threadSession: ThreadSession, + next: ClaudeLiveSessionSettings, +): Promise { + const current = threadSession.liveSettings; + if (current.model !== next.model) { + await threadSession.session.setModel(next.model); + } + + if ( + current.memoryEnabled !== next.memoryEnabled || + current.reasoningLevel !== next.reasoningLevel || + current.workflowsEnabled !== next.workflowsEnabled + ) { + await threadSession.session.applyMutableSettings({ + effort: + next.reasoningLevel === undefined + ? undefined + : toSdkEffort(next.reasoningLevel), + settings: buildMutableFlagSettings({ + memoryEnabled: next.memoryEnabled, + reasoningLevel: next.reasoningLevel, + workflowsEnabled: next.workflowsEnabled, + }), + }); } + + threadSession.liveSettings = next; } function sendSdkMessage(threadId: string, message: SDKMessage): void { @@ -476,14 +573,55 @@ function toSessionConstructionConfig( cwd: params.cwd, disallowedTools: params.disallowedTools, instructionMode: params.instructionMode, - memoryEnabled: params.memoryEnabled, - model: params.model, - permissionEscalation: params.permissionEscalation, permissionMode: params.permissionMode, permissionScope: params.permissionScope, plugins: params.plugins, - reasoningLevel: params.reasoningLevel, - workflowsEnabled: params.workflowsEnabled, + }, + }; +} + +function toInitialLiveSessionSettings( + params: SessionConstructionParams, +): ClaudeLiveSessionSettings { + return { + memoryEnabled: params.memoryEnabled ?? true, + ...(params.model !== undefined ? { model: params.model } : {}), + providerSubagentsEnabled: params.providerSubagentsEnabled ?? true, + ...(params.reasoningLevel !== undefined + ? { reasoningLevel: params.reasoningLevel } + : {}), + workflowsEnabled: params.workflowsEnabled, + }; +} + +function withTurnLiveSessionSettings( + current: ClaudeLiveSessionSettings, + params: TurnStartParams | TurnSteerParams, +): ClaudeLiveSessionSettings { + const model = params.model ?? current.model; + const reasoningLevel = params.reasoningLevel ?? current.reasoningLevel; + return { + memoryEnabled: params.memoryEnabled ?? current.memoryEnabled, + ...(model !== undefined ? { model } : {}), + providerSubagentsEnabled: + params.providerSubagentsEnabled ?? current.providerSubagentsEnabled, + ...(reasoningLevel !== undefined ? { reasoningLevel } : {}), + workflowsEnabled: params.workflowsEnabled ?? current.workflowsEnabled, + }; +} + +function withTrackedPermissionEscalation( + params: SessionConstructionParams, + threadIdRef: ThreadIdRef, +): BuildSessionOptionsArgs { + return { + ...toSessionConstructionConfig(params).sessionOptions, + ...toInitialLiveSessionSettings(params), + getPermissionEscalation: (context) => { + const threadSession = sessions.get(threadIdRef.current); + return threadSession + ? resolvePermissionEscalationForWork(threadSession, context) + : null; }, }; } @@ -513,7 +651,12 @@ function createThreadSession(args: CreateThreadSessionArgs): ThreadSession { pendingToolCalls: new Map(), pendingInteractiveRequests: new Map(), permissionEscalation: args.permissionEscalation, + permissionEscalationByAgentId: new Map(), + permissionEscalationByPromptId: new Map(), + permissionEscalationBySubagentParentToolUseId: new Map(), + permissionEscalationByToolUseId: new Map(), permissionMode: args.permissionMode, + liveSettings: args.liveSettings, approvedPlanPermissionMode: args.approvedPlanPermissionMode, ...(args.providerThreadId ? { providerThreadId: args.providerThreadId } @@ -523,6 +666,270 @@ function createThreadSession(args: CreateThreadSessionArgs): ThreadSession { }; } +function getTrackedPermissionEscalation( + values: Map, + key: string | undefined, +): PermissionEscalation | null | undefined { + if (key === undefined || !values.has(key)) { + return undefined; + } + return values.get(key) ?? null; +} + +function resolvePermissionEscalationForWork( + threadSession: ThreadSession, + context: PermissionEscalationWorkContext, +): PermissionEscalation | null { + const toolPermissionEscalation = getTrackedPermissionEscalation( + threadSession.permissionEscalationByToolUseId, + context.toolUseId, + ); + if (toolPermissionEscalation !== undefined) { + return toolPermissionEscalation; + } + + const agentPermissionEscalation = getTrackedPermissionEscalation( + threadSession.permissionEscalationByAgentId, + context.agentId, + ); + if (agentPermissionEscalation !== undefined) { + return agentPermissionEscalation; + } + + const promptPermissionEscalation = getTrackedPermissionEscalation( + threadSession.permissionEscalationByPromptId, + context.promptId, + ); + return promptPermissionEscalation === undefined + ? threadSession.permissionEscalation + : promptPermissionEscalation; +} + +function trackSdkAssistantPermissionEscalation( + threadSession: ThreadSession, + message: SDKMessage, +): void { + if (message.type !== "assistant") { + return; + } + + const parentToolUseId = message.parent_tool_use_id ?? undefined; + const parentPermissionEscalation = getTrackedPermissionEscalation( + threadSession.permissionEscalationBySubagentParentToolUseId, + parentToolUseId, + ); + const permissionEscalation = + parentPermissionEscalation === undefined + ? threadSession.permissionEscalation + : parentPermissionEscalation; + + for (const content of message.message.content) { + if (content.type !== "tool_use") { + continue; + } + threadSession.permissionEscalationByToolUseId.set( + content.id, + permissionEscalation, + ); + if (CLAUDE_PROVIDER_SUBAGENT_TOOL_NAMES.has(content.name)) { + threadSession.permissionEscalationBySubagentParentToolUseId.set( + content.id, + permissionEscalation, + ); + } + } +} + +function buildPermissionEscalationTrackingHooks( + threadIdRef: ThreadIdRef, +): NonNullable { + const trackPermissionRequest: HookCallback = async (input, toolUseId) => { + if ( + input.hook_event_name !== "PermissionRequest" || + toolUseId === undefined + ) { + return { continue: true }; + } + const threadSession = sessions.get(threadIdRef.current); + if (threadSession) { + // Claude can omit agentID from the later canUseTool callback. Preserve + // the work's provenance at the permission boundary, where the hook + // still carries its agent/prompt metadata. + threadSession.permissionEscalationByToolUseId.set( + toolUseId, + resolvePermissionEscalationForWork(threadSession, { + ...(input.agent_id !== undefined ? { agentId: input.agent_id } : {}), + ...(input.prompt_id !== undefined + ? { promptId: input.prompt_id } + : {}), + }), + ); + } + return { continue: true }; + }; + + const trackPreToolUse: HookCallback = async (input) => { + if (input.hook_event_name !== "PreToolUse") { + return { continue: true }; + } + const threadSession = sessions.get(threadIdRef.current); + if (threadSession) { + const permissionEscalation = resolvePermissionEscalationForWork( + threadSession, + { + ...(input.agent_id !== undefined ? { agentId: input.agent_id } : {}), + ...(input.prompt_id !== undefined + ? { promptId: input.prompt_id } + : {}), + }, + ); + threadSession.permissionEscalationByToolUseId.set( + input.tool_use_id, + permissionEscalation, + ); + if (CLAUDE_PROVIDER_SUBAGENT_TOOL_NAMES.has(input.tool_name)) { + threadSession.permissionEscalationBySubagentParentToolUseId.set( + input.tool_use_id, + permissionEscalation, + ); + } + if ( + !threadSession.liveSettings.providerSubagentsEnabled && + CLAUDE_PROVIDER_SUBAGENT_TOOL_NAMES.has(input.tool_name) + ) { + return { + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: + "bb has disabled Claude Code native subagents; use bb delegation instead.", + }, + }; + } + if ( + !threadSession.liveSettings.workflowsEnabled && + input.tool_name === CLAUDE_WORKFLOW_TOOL_NAME + ) { + return { + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: + "bb has disabled the Claude Code Workflow tool.", + }, + }; + } + } + return { continue: true }; + }; + + const trackSubagentStart: HookCallback = async (input) => { + if (input.hook_event_name !== "SubagentStart") { + return { continue: true }; + } + const threadSession = sessions.get(threadIdRef.current); + if (threadSession) { + threadSession.permissionEscalationByAgentId.set( + input.agent_id, + resolvePermissionEscalationForWork(threadSession, { + ...(input.prompt_id !== undefined + ? { promptId: input.prompt_id } + : {}), + }), + ); + } + return { continue: true }; + }; + + const clearSubagent: HookCallback = async (input) => { + if (input.hook_event_name === "SubagentStop") { + sessions + .get(threadIdRef.current) + ?.permissionEscalationByAgentId.delete(input.agent_id); + } + return { continue: true }; + }; + + const clearToolUse: HookCallback = async (input) => { + if ( + input.hook_event_name === "PostToolUse" || + input.hook_event_name === "PostToolUseFailure" || + input.hook_event_name === "PermissionDenied" + ) { + sessions + .get(threadIdRef.current) + ?.permissionEscalationByToolUseId.delete(input.tool_use_id); + } + return { continue: true }; + }; + + return { + PermissionDenied: [{ hooks: [clearToolUse] }], + PermissionRequest: [{ hooks: [trackPermissionRequest] }], + PostToolUse: [{ hooks: [clearToolUse] }], + PostToolUseFailure: [{ hooks: [clearToolUse] }], + PreToolUse: [{ hooks: [trackPreToolUse] }], + SubagentStart: [{ hooks: [trackSubagentStart] }], + SubagentStop: [{ hooks: [clearSubagent] }], + }; +} + +function addPermissionEscalationTrackingHooks( + sessionOptions: SdkSessionOptions, + threadIdRef: ThreadIdRef, +): void { + const existingHooks = sessionOptions.hooks; + const trackingHooks = buildPermissionEscalationTrackingHooks(threadIdRef); + // PreToolUse tracking must run before enforcement hooks so those hooks can + // resolve the tool ID back to the prompt or subagent that originated it. + sessionOptions.hooks = { + ...existingHooks, + PermissionDenied: [ + ...(trackingHooks.PermissionDenied ?? []), + ...(existingHooks?.PermissionDenied ?? []), + ], + PermissionRequest: [ + ...(trackingHooks.PermissionRequest ?? []), + ...(existingHooks?.PermissionRequest ?? []), + ], + PostToolUse: [ + ...(trackingHooks.PostToolUse ?? []), + ...(existingHooks?.PostToolUse ?? []), + ], + PostToolUseFailure: [ + ...(trackingHooks.PostToolUseFailure ?? []), + ...(existingHooks?.PostToolUseFailure ?? []), + ], + PreToolUse: [ + ...(trackingHooks.PreToolUse ?? []), + ...(existingHooks?.PreToolUse ?? []), + ], + SubagentStart: [ + ...(trackingHooks.SubagentStart ?? []), + ...(existingHooks?.SubagentStart ?? []), + ], + SubagentStop: [ + ...(trackingHooks.SubagentStop ?? []), + ...(existingHooks?.SubagentStop ?? []), + ], + }; +} + +function buildTrackedSessionOptions( + params: SessionConstructionParams, + env: NodeJS.ProcessEnv, + threadIdRef: ThreadIdRef, +): SdkSessionOptions { + const sessionOptions = buildSessionOptions( + withTrackedPermissionEscalation(params, threadIdRef), + env, + ); + addPermissionEscalationTrackingHooks(sessionOptions, threadIdRef); + return sessionOptions; +} + function replaceThreadSession(args: ReplaceThreadSessionArgs): void { args.threadSession.closing = true; args.threadSession.mockCliTrafficProxy = null; @@ -550,6 +957,7 @@ function replaceEndedThreadSession( const replacementSession = createThreadSession({ mockCliTrafficProxy: args.threadSession.mockCliTrafficProxy, + liveSettings: args.threadSession.liveSettings, permissionEscalation: args.threadSession.permissionEscalation, // Carries the live mode, so a session replaced after an approved plan // keeps the restored preset instead of dropping back into Plan mode. @@ -614,6 +1022,7 @@ function createOnSdkMessage( threadSession.providerThreadId = providerThreadId; sendThreadIdentity(args.threadIdRef.current, providerThreadId); } + trackSdkAssistantPermissionEscalation(threadSession, message); sendSdkMessage(args.threadIdRef.current, message); }; } @@ -1115,6 +1524,11 @@ function createCanUseTool(threadIdRef: ThreadIdRef): CanUseTool { createForwardUserQuestionRequest(threadIdRef); return async (toolName, input, options) => { + // Claude can dispatch canUseTool while the preceding assistant tool-use + // message is queued for the SDK async iterator. Give the stream consumer + // one turn to record its parent-tool provenance before resolving policy. + await new Promise((resolve) => setImmediate(resolve)); + const threadSession = sessions.get(threadIdRef.current); if (!threadSession) { return { @@ -1168,6 +1582,12 @@ function createCanUseTool(threadIdRef: ThreadIdRef): CanUseTool { }); } + const interactiveRequestPolicy = { + permissionEscalation: resolvePermissionEscalationForWork(threadSession, { + ...(options.agentID !== undefined ? { agentId: options.agentID } : {}), + toolUseId: options.toolUseID, + }), + }; const suggestions = parseClaudeSuggestedPermissionUpdates( options.suggestions, ); @@ -1180,6 +1600,24 @@ function createCanUseTool(threadIdRef: ThreadIdRef): CanUseTool { }; const requestedPermissions = toPendingInteractionPermissionProfile(requestContext); + if ( + toolName === "Bash" && + shouldAutoDenyInteractiveRequest(interactiveRequestPolicy) && + typeof input === "object" && + input !== null && + (input as { dangerouslyDisableSandbox?: unknown }) + .dangerouslyDisableSandbox === true + ) { + // With `allowUnsandboxedCommands` permanently enabled, this deny is the + // only gate on the unsandboxed retry for escalation-denied turns. It must + // run before the session-grant shortcut: grants survive escalation flips + // now that an escalation-only change reuses the session. + return { + behavior: "deny", + message: buildWorkspaceWriteDenialMessage(), + toolUseID: options.toolUseID, + }; + } if ( hasClaudeSessionPermissionGrant({ grants: threadSession.sessionPermissionGrants, @@ -1234,7 +1672,7 @@ function createCanUseTool(threadIdRef: ThreadIdRef): CanUseTool { } if ( - shouldAutoDenyInteractiveRequest(threadSession) || + shouldAutoDenyInteractiveRequest(interactiveRequestPolicy) || threadSession.permissionMode === "dontAsk" ) { const policyMessage = @@ -1309,7 +1747,11 @@ async function handleThreadStart( } const preparedEnv = await prepareSessionEnv(params); - const sessionOptions = buildSessionOptions(params, preparedEnv.env); + const sessionOptions = buildTrackedSessionOptions( + params, + preparedEnv.env, + threadIdRef, + ); const providerThreadId = randomUUID(); sessionOptions.sessionId = providerThreadId; sessionOptions.canUseTool = createCanUseTool(threadIdRef); @@ -1324,6 +1766,7 @@ async function handleThreadStart( const threadSession = createThreadSession({ mockCliTrafficProxy: preparedEnv.mockCliTrafficProxy, + liveSettings: toInitialLiveSessionSettings(params), permissionEscalation: params.permissionEscalation, permissionMode: params.permissionMode, approvedPlanPermissionMode: params.approvedPlanPermissionMode, @@ -1360,6 +1803,11 @@ async function handleThreadResume( sessionConstructionConfig, ) ) { + await applyLiveSessionSettings( + existing, + toInitialLiveSessionSettings(params), + ); + existing.permissionEscalation = params.permissionEscalation; sendResult(id, { threadId, providerThreadId: requestedProviderThreadId, @@ -1377,7 +1825,11 @@ async function handleThreadResume( const preparedEnv = await prepareSessionEnv(params); const threadIdRef = { current: threadId }; - const sessionOptions = buildSessionOptions(params, preparedEnv.env); + const sessionOptions = buildTrackedSessionOptions( + params, + preparedEnv.env, + threadIdRef, + ); sessionOptions.canUseTool = createCanUseTool(threadIdRef); if (params.dynamicTools && params.dynamicTools.length > 0) { const mcpServer = buildBridgeMcpServer( @@ -1389,6 +1841,7 @@ async function handleThreadResume( } const threadSession = createThreadSession({ mockCliTrafficProxy: preparedEnv.mockCliTrafficProxy, + liveSettings: toInitialLiveSessionSettings(params), permissionEscalation: params.permissionEscalation, permissionMode: params.permissionMode, approvedPlanPermissionMode: params.approvedPlanPermissionMode, @@ -1438,7 +1891,11 @@ async function handleThreadFork( const preparedEnv = await prepareSessionEnv(params); const threadIdRef = { current: threadId }; - const sessionOptions = buildSessionOptions(params, preparedEnv.env); + const sessionOptions = buildTrackedSessionOptions( + params, + preparedEnv.env, + threadIdRef, + ); sessionOptions.canUseTool = createCanUseTool(threadIdRef); if (params.dynamicTools && params.dynamicTools.length > 0) { const mcpServer = buildBridgeMcpServer( @@ -1450,6 +1907,7 @@ async function handleThreadFork( } const threadSession = createThreadSession({ mockCliTrafficProxy: preparedEnv.mockCliTrafficProxy, + liveSettings: toInitialLiveSessionSettings(params), permissionEscalation: params.permissionEscalation, permissionMode: params.permissionMode, approvedPlanPermissionMode: params.approvedPlanPermissionMode, @@ -1498,7 +1956,26 @@ async function handleTurnStart( return; } - queuePromptInputs(threadSession, inputs); + if (!threadSession.session.canPushInput()) { + sendError(id, -32000, "Claude SDK input stream is closed"); + return; + } + try { + await applyLiveSessionSettings( + threadSession, + withTurnLiveSessionSettings(threadSession.liveSettings, params), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendError(id, -32000, message); + return; + } + + if (!queuePromptInputs(threadSession, inputs, params.permissionEscalation)) { + sendError(id, -32000, "Claude SDK input stream is closed"); + return; + } + threadSession.permissionEscalation = params.permissionEscalation; sendResult(id, { threadId: params.threadId }); } @@ -1518,14 +1995,41 @@ async function handleTurnSteer( return; } + if (!threadSession.session.canPushInput()) { + sendError(id, -32000, "Claude SDK input stream is closed"); + return; + } + try { + await applyLiveSessionSettings( + threadSession, + withTurnLiveSessionSettings(threadSession.liveSettings, params), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendError(id, -32000, message); + return; + } + if (inputs.length > 1) { - queuePromptInputs(threadSession, inputs); + if ( + !queuePromptInputs(threadSession, inputs, params.permissionEscalation) + ) { + sendError(id, -32000, "Claude SDK input stream is closed"); + return; + } + threadSession.permissionEscalation = params.permissionEscalation; sendResult(id, { threadId: params.threadId }); return; } try { - await threadSession.session.pushInput(inputs[0] ?? ""); + await pushPromptInput( + threadSession, + inputs[0] ?? "", + params.permissionEscalation, + ); + // A failed steer must not change the running turn's escalation. + threadSession.permissionEscalation = params.permissionEscalation; sendResult(id, { threadId: params.threadId }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/agent-runtime/src/claude-code/bridge/commands.ts b/packages/agent-runtime/src/claude-code/bridge/commands.ts index 4922479ca..ef8fb88cd 100644 --- a/packages/agent-runtime/src/claude-code/bridge/commands.ts +++ b/packages/agent-runtime/src/claude-code/bridge/commands.ts @@ -69,6 +69,7 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [ reasoningLevel: bridgeReasoningLevelSchema.optional(), workflowsEnabled: z.boolean(), memoryEnabled: z.boolean().optional(), + providerSubagentsEnabled: z.boolean().optional(), instructionMode: bridgeInstructionModeSchema, dynamicTools: z.array(dynamicToolSchema).optional(), disallowedTools: z.array(z.string()).optional(), @@ -98,6 +99,7 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [ reasoningLevel: bridgeReasoningLevelSchema.optional(), workflowsEnabled: z.boolean(), memoryEnabled: z.boolean().optional(), + providerSubagentsEnabled: z.boolean().optional(), instructionMode: bridgeInstructionModeSchema, dynamicTools: z.array(dynamicToolSchema).optional(), disallowedTools: z.array(z.string()).optional(), @@ -127,6 +129,7 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [ reasoningLevel: bridgeReasoningLevelSchema.optional(), workflowsEnabled: z.boolean(), memoryEnabled: z.boolean().optional(), + providerSubagentsEnabled: z.boolean().optional(), instructionMode: bridgeInstructionModeSchema, dynamicTools: z.array(dynamicToolSchema).optional(), disallowedTools: z.array(z.string()).optional(), @@ -140,7 +143,12 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [ input: z.array(z.unknown()), inputGroups: z.array(z.array(z.unknown()).min(1)).optional(), model: z.string().optional(), + reasoningLevel: bridgeReasoningLevelSchema.optional(), + workflowsEnabled: z.boolean().optional(), + memoryEnabled: z.boolean().optional(), + providerSubagentsEnabled: z.boolean().optional(), config: z.record(z.string(), z.unknown()).optional(), + permissionEscalation: bridgePermissionEscalationSchema, }), }), z.object({ @@ -151,6 +159,12 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [ expectedTurnId: z.string(), input: z.array(z.unknown()), inputGroups: z.array(z.array(z.unknown()).min(1)).optional(), + model: z.string().optional(), + reasoningLevel: bridgeReasoningLevelSchema.optional(), + workflowsEnabled: z.boolean().optional(), + memoryEnabled: z.boolean().optional(), + providerSubagentsEnabled: z.boolean().optional(), + permissionEscalation: bridgePermissionEscalationSchema, }), }), z.object({ diff --git a/packages/agent-runtime/src/claude-code/bridge/sdk-session.ts b/packages/agent-runtime/src/claude-code/bridge/sdk-session.ts index 47bfd1252..12a4200e5 100644 --- a/packages/agent-runtime/src/claude-code/bridge/sdk-session.ts +++ b/packages/agent-runtime/src/claude-code/bridge/sdk-session.ts @@ -37,6 +37,24 @@ export interface SdkSessionOptions { settings?: Options["settings"]; } +export type ClaudeSdkReasoningEffort = + | "low" + | "medium" + | "high" + | "xhigh" + | "max"; + +export interface ClaudeMutableFlagSettings { + autoMemoryEnabled: boolean; + enableWorkflows: boolean; + effortLevel?: ClaudeSdkReasoningEffort; + ultracode: boolean; +} + +interface ClaudeMutableSettingsQueryBoundary { + applyFlagSettings(settings: ClaudeMutableFlagSettings): Promise; +} + type SdkSessionMessageHandler = (message: SDKMessage) => void; type SdkSessionDoneHandler = (error?: unknown) => void; @@ -148,6 +166,10 @@ export class SdkSession { return this.isProcessing; } + canPushInput(): boolean { + return !this.inputDone; + } + /** * Change the permission mode of the live session. Used to leave Plan mode * once the user approves a plan. The new mode is also recorded on the @@ -161,6 +183,31 @@ export class SdkSession { await this.query?.setPermissionMode(mode); } + async setModel(model: string | undefined): Promise { + await this.query?.setModel(model); + this.options.model = model; + } + + async applyMutableSettings(args: { + effort: ClaudeSdkReasoningEffort | undefined; + settings: ClaudeMutableFlagSettings; + }): Promise { + // Claude CLI accepts `max` through apply_flag_settings (and reports max + // from its hook context), but Agent SDK 0.3.197's Settings type omits it. + // Keep the compatibility assertion at this external SDK boundary. + await ( + this.query as ClaudeMutableSettingsQueryBoundary | undefined + )?.applyFlagSettings(args.settings); + this.options.effort = args.effort; + const { effortLevel: _effortLevel, ...sessionSettings } = args.settings; + const currentSettings = + typeof this.options.settings === "object" ? this.options.settings : {}; + this.options.settings = { + ...currentSettings, + ...sessionSettings, + }; + } + start(resumeSessionId?: string): void { if (resumeSessionId) { this.sessionId = resumeSessionId; @@ -237,12 +284,16 @@ export class SdkSession { void this.consumeStream(); } - pushInput(text: string): Promise { + pushInput( + text: string, + promptId?: NonNullable, + ): Promise { const message: SDKUserMessage = { type: "user", message: { role: "user", content: text }, parent_tool_use_id: null, session_id: this.sessionId ?? "", + ...(promptId !== undefined ? { uuid: promptId } : {}), }; if (this.inputDone) { diff --git a/packages/agent-runtime/src/claude-code/bridge/session-options.ts b/packages/agent-runtime/src/claude-code/bridge/session-options.ts index a91f5f71f..83eb18d70 100644 --- a/packages/agent-runtime/src/claude-code/bridge/session-options.ts +++ b/packages/agent-runtime/src/claude-code/bridge/session-options.ts @@ -9,7 +9,11 @@ import type { } from "@bb/domain"; import type { ClaudePermissionMode } from "../interactive-contract.js"; import { buildReadonlyBashUpdatedInput } from "./readonly-bash-policy.js"; -import type { SdkSessionOptions } from "./sdk-session.js"; +import type { + ClaudeMutableFlagSettings, + ClaudeSdkReasoningEffort, + SdkSessionOptions, +} from "./sdk-session.js"; export interface BuildSessionOptionsArgs { additionalWorkspaceWriteRoots?: readonly string[]; @@ -18,7 +22,14 @@ export interface BuildSessionOptionsArgs { disallowedTools?: readonly string[]; instructionMode: InstructionMode; model?: string; - permissionEscalation: PermissionEscalation | null; + /** + * Escalation changes per turn without replacing the session. Hook closures + * resolve the originating prompt or subagent at call time, falling back to + * the current turn when Claude provides no correlation metadata. + */ + getPermissionEscalation: ( + context: PermissionEscalationWorkContext, + ) => PermissionEscalation | null; permissionMode: ClaudePermissionMode; permissionScope: RuntimePermissionScope; plugins?: Options["plugins"]; @@ -27,6 +38,12 @@ export interface BuildSessionOptionsArgs { memoryEnabled?: boolean; } +export interface PermissionEscalationWorkContext { + agentId?: string; + promptId?: string; + toolUseId?: string; +} + interface ResolveExecutableOnPathArgs { executableName: string; pathEnv: string | undefined; @@ -47,6 +64,8 @@ const READONLY_ALLOWED_TOOLS = new Set([ "TodoRead", ]); const READONLY_BASH_TOOL_NAME = "Bash"; +const READONLY_ASK_REASON = + "bb readonly mode requires approval before using tools that can modify state, run commands, access network, or perform non-read actions."; const SUMMARIZED_ADAPTIVE_THINKING = { type: "adaptive", display: "summarized", @@ -59,9 +78,9 @@ const CLAUDE_CODE_EXECUTABLE_ENV = "BB_CLAUDE_CODE_EXECUTABLE"; * dynamic-workflow orchestration). The SDK Settings flag tier is otherwise * unused by BB, so it is owned entirely here. */ -function toSdkEffort( +export function toSdkEffort( reasoningLevel: ReasoningLevel, -): Exclude { +): ClaudeSdkReasoningEffort { if (reasoningLevel === "ultracode") return "xhigh"; // "none" (thinking-off) is a Cursor-only level; Claude Code models never // expose it, so this is a defensive floor that reconciliation never reaches. @@ -74,8 +93,23 @@ function toSdkEffort( function buildFlagSettings(params: BuildSessionOptionsArgs): Settings { return { autoMemoryEnabled: params.memoryEnabled ?? true, - ...(params.workflowsEnabled ? { enableWorkflows: true } : {}), - ...(params.reasoningLevel === "ultracode" ? { ultracode: true } : {}), + enableWorkflows: params.workflowsEnabled, + ultracode: params.reasoningLevel === "ultracode", + }; +} + +export function buildMutableFlagSettings(args: { + memoryEnabled: boolean; + reasoningLevel: ReasoningLevel | undefined; + workflowsEnabled: boolean; +}): ClaudeMutableFlagSettings { + return { + autoMemoryEnabled: args.memoryEnabled, + enableWorkflows: args.workflowsEnabled, + ...(args.reasoningLevel !== undefined + ? { effortLevel: toSdkEffort(args.reasoningLevel) } + : {}), + ultracode: args.reasoningLevel === "ultracode", }; } @@ -97,12 +131,7 @@ function buildReadonlyHooks( return undefined; } - const permissionDecision = - params.permissionEscalation === "deny" ? "deny" : "ask"; - const permissionDecisionReason = - permissionDecision === "deny" - ? buildReadonlyDenialMessage() - : "bb readonly mode requires approval before using tools that can modify state, run commands, access network, or perform non-read actions."; + const getPermissionEscalation = params.getPermissionEscalation; return { PreToolUse: [ @@ -131,12 +160,27 @@ function buildReadonlyHooks( } } + const permissionDecision = + getPermissionEscalation({ + ...(input.agent_id !== undefined + ? { agentId: input.agent_id } + : {}), + ...(input.prompt_id !== undefined + ? { promptId: input.prompt_id } + : {}), + toolUseId: input.tool_use_id, + }) === "deny" + ? "deny" + : "ask"; return { continue: true, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, - permissionDecisionReason, + permissionDecisionReason: + permissionDecision === "deny" + ? buildReadonlyDenialMessage() + : READONLY_ASK_REASON, }, }; }, @@ -175,7 +219,10 @@ function buildWorkspaceWriteSandbox( // back to bb's own `canUseTool` gating instead of running wide open. failIfUnavailable: false, autoAllowBashIfSandboxed: true, - allowUnsandboxedCommands: params.permissionEscalation === "ask", + // Sandbox settings are session-fixed while escalation changes per turn; + // the unsandboxed retry stays enabled and `canUseTool` auto-denies it on + // escalation-denied turns. + allowUnsandboxedCommands: true, // The bb CLI needs loopback to reach the local server, and // escalation-denied turns have no unsandboxed-retry path around a block. // macOS-only and coarse (all localhost ports, binding on all interfaces); diff --git a/packages/agent-runtime/src/codex/adapter.ts b/packages/agent-runtime/src/codex/adapter.ts index dc8303b70..8b324145f 100644 --- a/packages/agent-runtime/src/codex/adapter.ts +++ b/packages/agent-runtime/src/codex/adapter.ts @@ -56,6 +56,7 @@ import type { ProviderExecutionContext, } from "../provider-adapter.js"; import { flattenPromptInputGroups } from "../provider-adapter.js"; +import { classifySessionExecutionSettingsChange } from "../execution-options.js"; import type { JsonRpcMessage, ProviderInboundRequest, @@ -1850,6 +1851,8 @@ export function createCodexProviderAdapter( id: providerInfo.id, displayName: providerInfo.displayName, capabilities, + approvalRequestPolicy: "runtime", + classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, // Codex app-server connections are owned by the runtime process manager. // BB runs live Codex threads on thread-scoped app-server processes, while // provider-only probes can still use a provider-scoped maintenance process. diff --git a/packages/agent-runtime/src/execution-options.test.ts b/packages/agent-runtime/src/execution-options.test.ts new file mode 100644 index 000000000..6b2a209de --- /dev/null +++ b/packages/agent-runtime/src/execution-options.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import type { RuntimeThreadExecutionOptions } from "@bb/domain"; +import { + classifyClaudeExecutionSettingsChange, + classifySessionExecutionSettingsChange, + normalizeClaudeExecutionOptions, +} from "./execution-options.js"; + +const baseOptions = { + model: "claude-opus-5[1m]", + serviceTier: "default", + reasoningLevel: "high", + workflowsEnabled: true, + memoryEnabled: true, + providerSubagentsEnabled: true, + permissionMode: "auto", + permissionScope: "workspace", + approvalReviewer: "automatic", + permissionEscalation: "ask", +} satisfies RuntimeThreadExecutionOptions; + +describe("execution setting classification", () => { + it("classifies Claude turn-mutable settings as live", () => { + const liveChanges: RuntimeThreadExecutionOptions[] = [ + { ...baseOptions, model: "claude-sonnet-5" }, + { ...baseOptions, reasoningLevel: "max" }, + { ...baseOptions, workflowsEnabled: false }, + { ...baseOptions, memoryEnabled: false }, + { ...baseOptions, providerSubagentsEnabled: false }, + { ...baseOptions, permissionEscalation: "deny" }, + ]; + + for (const next of liveChanges) { + expect( + classifyClaudeExecutionSettingsChange({ + current: baseOptions, + next, + }), + ).toBe("live"); + } + }); + + it("ignores Claude service-tier drift because Claude does not support tiers", () => { + expect( + classifyClaudeExecutionSettingsChange({ + current: baseOptions, + next: { ...baseOptions, serviceTier: "fast" }, + }), + ).toBe("unchanged"); + }); + + it("normalizes Claude's unsupported fast service tier to default", () => { + expect( + normalizeClaudeExecutionOptions({ + ...baseOptions, + serviceTier: "fast", + }), + ).toEqual(baseOptions); + expect(normalizeClaudeExecutionOptions(baseOptions)).toBe(baseOptions); + }); + + it("keeps Claude construction-time settings session-scoped", () => { + const sessionChanges: RuntimeThreadExecutionOptions[] = [ + { ...baseOptions, claudeCodePermissionMode: "plan" }, + { + ...baseOptions, + claudeCodeMockCliTraffic: { + enabled: true, + endpoint: "http://127.0.0.1:19001", + }, + }, + { + ...baseOptions, + permissionMode: "full", + permissionScope: "full", + approvalReviewer: null, + permissionEscalation: null, + }, + ]; + + for (const next of sessionChanges) { + expect( + classifyClaudeExecutionSettingsChange({ + current: baseOptions, + next, + }), + ).toBe("session"); + } + }); + + it("keeps setting changes session-scoped for adapters without live controls", () => { + expect( + classifySessionExecutionSettingsChange({ + current: baseOptions, + next: { ...baseOptions, model: "another-model" }, + }), + ).toBe("session"); + }); +}); diff --git a/packages/agent-runtime/src/execution-options.ts b/packages/agent-runtime/src/execution-options.ts index f3bc2abb0..c0f461918 100644 --- a/packages/agent-runtime/src/execution-options.ts +++ b/packages/agent-runtime/src/execution-options.ts @@ -1,4 +1,8 @@ import type { ProviderAdapter } from "./provider-adapter.js"; +import type { + ClassifyProviderExecutionSettingsChangeArgs, + ProviderExecutionSettingsChange, +} from "./provider-adapter.js"; import type { AgentRuntimeExecutionOptions, AgentRuntimeSkillRoot, @@ -86,6 +90,67 @@ export function sameExecutionSettings( ); } +export function classifySessionExecutionSettingsChange( + args: ClassifyProviderExecutionSettingsChangeArgs, +): ProviderExecutionSettingsChange { + return sameExecutionSettings({ left: args.current, right: args.next }) + ? "unchanged" + : "session"; +} + +function sameClaudeSessionSettings( + args: ClassifyProviderExecutionSettingsChangeArgs, +): boolean { + const currentMockCliTraffic = + args.current.claudeCodeMockCliTraffic ?? + DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG; + const nextMockCliTraffic = + args.next.claudeCodeMockCliTraffic ?? + DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG; + return ( + args.current.claudeCodePermissionMode === + args.next.claudeCodePermissionMode && + currentMockCliTraffic.enabled === nextMockCliTraffic.enabled && + currentMockCliTraffic.endpoint === nextMockCliTraffic.endpoint && + args.current.permissionMode === args.next.permissionMode && + args.current.permissionScope === args.next.permissionScope && + args.current.approvalReviewer === args.next.approvalReviewer + ); +} + +function sameClaudeLiveSettings( + args: ClassifyProviderExecutionSettingsChangeArgs, +): boolean { + return ( + args.current.model === args.next.model && + args.current.reasoningLevel === args.next.reasoningLevel && + args.current.workflowsEnabled === args.next.workflowsEnabled && + (args.current.memoryEnabled ?? true) === + (args.next.memoryEnabled ?? true) && + (args.current.providerSubagentsEnabled ?? true) === + (args.next.providerSubagentsEnabled ?? true) && + args.current.permissionEscalation === args.next.permissionEscalation + ); +} + +export function classifyClaudeExecutionSettingsChange( + args: ClassifyProviderExecutionSettingsChangeArgs, +): ProviderExecutionSettingsChange { + if (!sameClaudeSessionSettings(args)) { + return "session"; + } + return sameClaudeLiveSettings(args) ? "unchanged" : "live"; +} + +export function normalizeClaudeExecutionOptions( + options: AgentRuntimeExecutionOptions, +): AgentRuntimeExecutionOptions { + if (options.serviceTier !== "fast") { + return options; + } + return { ...options, serviceTier: "default" }; +} + export function toProviderExecutionContext( args: ToProviderExecutionContextArgs, ): ProviderExecutionContext { diff --git a/packages/agent-runtime/src/pi/adapter.ts b/packages/agent-runtime/src/pi/adapter.ts index ac2b4d974..aae12805b 100644 --- a/packages/agent-runtime/src/pi/adapter.ts +++ b/packages/agent-runtime/src/pi/adapter.ts @@ -23,6 +23,7 @@ import type { import { threadScope, toPositiveNumber, turnScope } from "@bb/domain"; import { decodeNormalizedProviderToolCallRequest } from "../shared/provider-tool-call-contract.js"; import { resolveBridgeProcessArgs } from "../shared/bridge-path.js"; +import { classifySessionExecutionSettingsChange } from "../execution-options.js"; import { bashArgsSchema, textBlockSchema } from "../shared/tool-arg-schemas.js"; import { buildEditDiff, @@ -1252,6 +1253,8 @@ export function createPiProviderAdapter( id: providerInfo.id, displayName: providerInfo.displayName, capabilities, + approvalRequestPolicy: "runtime", + classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, process: { command: opts?.bridgeNodeExecutablePath ?? "node", args: resolveBridgeProcessArgs({ diff --git a/packages/agent-runtime/src/provider-adapter.ts b/packages/agent-runtime/src/provider-adapter.ts index a400d402a..1ea16ba43 100644 --- a/packages/agent-runtime/src/provider-adapter.ts +++ b/packages/agent-runtime/src/provider-adapter.ts @@ -10,6 +10,7 @@ import type { ProviderCapabilities, ReasoningLevel, RuntimePermissionPolicy, + RuntimeThreadExecutionOptions, ServiceTier, ThreadEvent, } from "@bb/domain"; @@ -245,6 +246,13 @@ export function noPreparedProviderCommandDispatch( return null; } +export type ProviderExecutionSettingsChange = "unchanged" | "live" | "session"; + +export interface ClassifyProviderExecutionSettingsChangeArgs { + current: RuntimeThreadExecutionOptions; + next: RuntimeThreadExecutionOptions; +} + // --------------------------------------------------------------------------- // ProviderAdapter — internal extension contract // --------------------------------------------------------------------------- @@ -253,6 +261,30 @@ export interface ProviderAdapter { id: string; displayName: string; capabilities: ProviderCapabilities; + /** + * Selects where approval escalation is enforced. `runtime` adapters emit + * every approval request and rely on the runtime's current thread policy. + * `provider` adapters enforce the policy before forwarding a request, so a + * forwarded approval is already known to require user input and must not be + * reclassified against mutable thread settings. + */ + approvalRequestPolicy: "runtime" | "provider"; + /** + * Normalizes provider-specific execution options before validation, + * comparison, persistence, and command construction. Providers may use this + * to collapse accepted no-op values onto their effective setting. + */ + normalizeExecutionOptions?( + options: RuntimeThreadExecutionOptions, + ): RuntimeThreadExecutionOptions; + /** + * Classifies execution-setting drift for this provider. `live` settings are + * carried by the next turn command; `session` settings require rebuilding + * the provider session. + */ + classifyExecutionSettingsChange( + args: ClassifyProviderExecutionSettingsChangeArgs, + ): ProviderExecutionSettingsChange; process: { command: string; args: string[]; env?: Record }; buildCommandPlan(command: AdapterCommand): ProviderCommandPlan; diff --git a/packages/agent-runtime/src/runtime-provider-requests.ts b/packages/agent-runtime/src/runtime-provider-requests.ts index b954b715e..d608be465 100644 --- a/packages/agent-runtime/src/runtime-provider-requests.ts +++ b/packages/agent-runtime/src/runtime-provider-requests.ts @@ -55,8 +55,7 @@ export interface HandleRuntimeProviderRequestArgs extends RuntimeProviderRequest ) => string | null; } -interface ResolveRuntimeProviderRequestTurnIdArgs - extends HandleRuntimeProviderRequestArgs { +interface ResolveRuntimeProviderRequestTurnIdArgs extends HandleRuntimeProviderRequestArgs { requestKind: RuntimeProviderRequestKind; resolvedThreadId: string; turnId: string | null; @@ -298,13 +297,17 @@ function handleInteractiveProviderRequest( payload: interactiveReq.payload, }; - const executionOptions = args.getThreadExecutionOptions(resolvedThreadId); const isApprovalRequest = isApprovalPendingInteractionPayload( interactiveReq.payload, ); + const runtimeOwnsApprovalPolicy = + args.providerProcess.adapter.approvalRequestPolicy === "runtime"; + const executionOptions = runtimeOwnsApprovalPolicy + ? args.getThreadExecutionOptions(resolvedThreadId) + : undefined; const shouldAutoDenyApprovalRequest = isApprovalRequest && - ((executionOptions + ((runtimeOwnsApprovalPolicy && executionOptions ? shouldAutoDenyInteractiveRequest(executionOptions) : false) || !args.onInteractiveRequest); diff --git a/packages/agent-runtime/src/runtime.interactive-requests.test.ts b/packages/agent-runtime/src/runtime.interactive-requests.test.ts index 0824149ca..1cbb6e38f 100644 --- a/packages/agent-runtime/src/runtime.interactive-requests.test.ts +++ b/packages/agent-runtime/src/runtime.interactive-requests.test.ts @@ -298,7 +298,9 @@ rl.on("line", (line) => { resolveThreadId: () => "t1", }); - const parsed = parseJsonRpcLine((await readChildStdoutLine(child)).trim()); + const parsed = parseJsonRpcLine( + (await readChildStdoutLine(child)).trim(), + ); if (parsed.kind !== "response") { throw new Error(`Expected JSON-RPC response, got ${parsed.kind}`); } @@ -473,6 +475,83 @@ rl.on("line", (line) => { await runtime.shutdown(); }); + it("does not reclassify provider-filtered approvals against mutable thread settings", async () => { + const child = spawn(process.execPath, [ + "-e", + "process.stdin.pipe(process.stdout)", + ]); + const adapter = { + ...createInteractiveRequestAdapter( + join(tmpDir, "unused-provider-filtered-approval.cjs"), + ), + approvalRequestPolicy: "provider" as const, + }; + const onInteractiveRequest = vi.fn(async () => ({ + decision: "allow_once" as const, + grantedPermissions: null, + })); + const rawRequest = { + jsonrpc: "2.0", + id: 78, + method: "request_interaction", + params: { + threadId: "prov-1", + turnId: "turn-1", + itemId: "item-provider-filtered", + kind: "command_approval", + command: "git push", + cwd: "/tmp/project", + reason: "Needs approval", + }, + } satisfies JsonRpcMessage; + + try { + handleRuntimeProviderRequest({ + getActiveTurnId: () => null, + getThreadExecutionOptions: () => ({ + ...fullRuntimeOptions, + permissionMode: "auto", + permissionScope: "workspace", + approvalReviewer: "automatic", + permissionEscalation: "deny", + }), + onInteractiveRequest, + onToolCall: async () => ({ + contentItems: [{ type: "inputText", text: "tool result" }], + success: true, + }), + parsedId: rawRequest.id, + parsedMethod: rawRequest.method, + providerProcess: { + adapter, + child, + interactiveRequestScope: "scope-provider-filtered", + }, + rawRequest, + resolveThreadId: () => "t1", + }); + + const parsed = parseJsonRpcLine( + (await readChildStdoutLine(child)).trim(), + ); + if (parsed.kind !== "response") { + throw new Error(`Expected JSON-RPC response, got ${parsed.kind}`); + } + expect(parsed.parsed).toMatchObject({ + jsonrpc: "2.0", + id: 78, + result: { + resolution: { + decision: "allow_once", + }, + }, + }); + expect(onInteractiveRequest).toHaveBeenCalledTimes(1); + } finally { + child.kill(); + } + }); + it("routes user-question interactive requests through the handler when permission escalation is deny", async () => { const child = spawn(process.execPath, [ "-e", @@ -555,7 +634,9 @@ rl.on("line", (line) => { resolveThreadId: () => "t1", }); - const parsed = parseJsonRpcLine((await readChildStdoutLine(child)).trim()); + const parsed = parseJsonRpcLine( + (await readChildStdoutLine(child)).trim(), + ); if (parsed.kind !== "response") { throw new Error(`Expected JSON-RPC response, got ${parsed.kind}`); } @@ -646,7 +727,9 @@ rl.on("line", (line) => { resolveThreadId: () => "t1", }); - const parsed = parseJsonRpcLine((await readChildStdoutLine(child)).trim()); + const parsed = parseJsonRpcLine( + (await readChildStdoutLine(child)).trim(), + ); if (parsed.kind !== "response") { throw new Error(`Expected JSON-RPC response, got ${parsed.kind}`); } diff --git a/packages/agent-runtime/src/runtime.lifecycle.test.ts b/packages/agent-runtime/src/runtime.lifecycle.test.ts index 608e62ed3..17475b7eb 100644 --- a/packages/agent-runtime/src/runtime.lifecycle.test.ts +++ b/packages/agent-runtime/src/runtime.lifecycle.test.ts @@ -9,6 +9,10 @@ import type { } from "./provider-adapter.js"; import { promptTextInput } from "./test/prompt-input.js"; import { createAgentRuntimeWithAdapters } from "./runtime.js"; +import { + classifyClaudeExecutionSettingsChange, + normalizeClaudeExecutionOptions, +} from "./execution-options.js"; import { fakeProviderScriptPath } from "./test/index.js"; import { createFakeAdapter, @@ -499,6 +503,86 @@ rl.on("line", (line) => { await runtime.shutdown(); }); + it("skips session reconfigure when the adapter classifies settings as live", async () => { + const recordedCommands: AdapterCommand[] = []; + const runtime = createAgentRuntimeWithAdapters({ + workspacePath: tmpDir, + onEvent: () => undefined, + onToolCall: async () => ({ + contentItems: [{ type: "inputText", text: "ok" }], + success: true, + }), + adapterFactory: () => ({ + ...createRecordingAdapter({ recordedCommands, scriptPath }), + classifyExecutionSettingsChange: + classifyClaudeExecutionSettingsChange, + normalizeExecutionOptions: normalizeClaudeExecutionOptions, + }), + }); + + await runtime.startThread({ + environmentId: "env-1", + threadId: "t1", + projectId: "p1", + providerId: "fake", + instructions: "Initial instructions", + options: { + ...fullRuntimeOptions, + memoryEnabled: true, + permissionMode: "auto", + permissionScope: "workspace", + approvalReviewer: "automatic", + permissionEscalation: "ask", + providerSubagentsEnabled: true, + serviceTier: "fast", + }, + }); + + await runtime.runTurn({ + clientRequestId: "creq_222222224h", + threadId: "t1", + input: [promptTextInput({ text: "follow up" })], + instructions: "Initial instructions", + options: { + ...fullRuntimeOptions, + memoryEnabled: false, + model: "test-model-2", + permissionMode: "auto", + permissionScope: "workspace", + approvalReviewer: "automatic", + permissionEscalation: "deny", + providerSubagentsEnabled: false, + reasoningLevel: "high", + serviceTier: "fast", + workflowsEnabled: true, + }, + }); + + expect( + recordedCommands.some((command) => command.type === "thread/resume"), + ).toBe(false); + expect( + findLastRecordedCommand(recordedCommands, "thread/start"), + ).toMatchObject({ + options: { serviceTier: "default" }, + }); + expect( + findLastRecordedCommand(recordedCommands, "turn/start"), + ).toMatchObject({ + options: { + memoryEnabled: false, + model: "test-model-2", + permissionEscalation: "deny", + providerSubagentsEnabled: false, + reasoningLevel: "high", + serviceTier: "default", + workflowsEnabled: true, + }, + }); + + await runtime.shutdown(); + }); + it("passes the workspace cwd when resuming a thread", async () => { const recordedCommands: AdapterCommand[] = []; const runtime = createAgentRuntimeWithAdapters({ diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index b4bdb3c25..3ef9ffa5f 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -13,13 +13,13 @@ import type { import type { HostDaemonAcpLaunchSpec } from "@bb/host-daemon-contract"; import type { AdapterCommand, + ProviderAdapter, ProviderAdapterFactory, ProviderCommandPlan, ProviderRequestCommandPlan, } from "./provider-adapter.js"; import { assertProviderSupportsExecutionOptions, - sameExecutionSettings, toProviderExecutionContext, } from "./execution-options.js"; import { @@ -83,6 +83,13 @@ interface RunThreadOperationArgs { work: () => Promise; } +function normalizeExecutionOptions(args: { + adapter: ProviderAdapter; + options: AgentRuntimeExecutionOptions; +}): AgentRuntimeExecutionOptions { + return args.adapter.normalizeExecutionOptions?.(args.options) ?? args.options; +} + interface ReapIdleProviderSessionCandidate { idleSinceMs: number; providerThreadId: string; @@ -819,19 +826,24 @@ function createAgentRuntimeInternal( // instructions) must never force a thread/resume, because a resume can // replace the live CLI session and kill its running background tasks. // Fresh instructions apply when the next session is constructed. - if ( - sameExecutionSettings({ - left: currentConfig.options, - right: nextOptions, - }) - ) { - return; - } - const proc = requireProviderProcess({ processKey: currentConfig.processKey, providerId: currentConfig.providerId, }); + const settingsChange = proc.adapter.classifyExecutionSettingsChange({ + current: currentConfig.options, + next: nextOptions, + }); + if (settingsChange !== "session") { + // Live settings ride on the next turn command; record them without + // replacing the session (which would kill its background tasks). + setThreadRuntimeConfig(args.threadId, { + ...currentConfig, + options: nextOptions, + }); + return; + } + const providerSkillRoots = currentConfig.skillRoots; const envVars = buildThreadShellEnvironment({ baseShellEnv: options.shellEnv, @@ -1104,10 +1116,14 @@ function createAgentRuntimeInternal( }); const proc = requireProviderProcess({ processKey, providerId }); + const effectiveExecOpts = normalizeExecutionOptions({ + adapter: proc.adapter, + options: execOpts, + }); const providerSkillRoots = skillRootsForProvider(providerId); assertProviderSupportsExecutionOptions({ adapter: proc.adapter, - options: execOpts, + options: effectiveExecOpts, providerId, }); threadIdentityRegistry.registerThreadProvider({ @@ -1122,7 +1138,7 @@ function createAgentRuntimeInternal( environmentId, instructionMode, instructions, - options: execOpts, + options: effectiveExecOpts, processKey, projectId, providerId, @@ -1143,7 +1159,7 @@ function createAgentRuntimeInternal( const providerExecutionContext = toProviderExecutionContext({ envVars, - execOpts, + execOpts: effectiveExecOpts, instructions, skillRoots: providerSkillRoots, }); @@ -1227,7 +1243,7 @@ function createAgentRuntimeInternal( input, ...(inputGroups !== undefined ? { inputGroups } : {}), clientRequestId, - options: execOpts, + options: effectiveExecOpts, instructions, }); } @@ -1266,10 +1282,14 @@ function createAgentRuntimeInternal( }); const proc = requireProviderProcess({ processKey, providerId }); + const effectiveExecOpts = normalizeExecutionOptions({ + adapter: proc.adapter, + options: execOpts, + }); const providerSkillRoots = skillRootsForProvider(providerId); assertProviderSupportsExecutionOptions({ adapter: proc.adapter, - options: execOpts, + options: effectiveExecOpts, providerId, }); threadIdentityRegistry.registerThreadProvider({ @@ -1284,7 +1304,7 @@ function createAgentRuntimeInternal( environmentId, instructionMode, instructions, - options: execOpts, + options: effectiveExecOpts, processKey, projectId, providerId, @@ -1315,7 +1335,7 @@ function createAgentRuntimeInternal( providerThreadId ?? requireProviderThreadId(threadId), options: toProviderExecutionContext({ envVars, - execOpts, + execOpts: effectiveExecOpts, instructions, skillRoots: providerSkillRoots, }), @@ -1381,21 +1401,28 @@ function createAgentRuntimeInternal( return runThreadOperation({ threadId, work: async () => { + const pid = resolveProviderForThread(threadId); + const currentProc = requireProviderProcessForThread(threadId); + const effectiveExecOpts = normalizeExecutionOptions({ + adapter: currentProc.adapter, + options: execOpts, + }); await restartCodexThreadForNextTurnIfNeeded({ threadId, - options: execOpts, + options: effectiveExecOpts, instructions, }); - const pid = resolveProviderForThread(threadId); + // An account restart replaces a thread-scoped Codex process, so + // resolve the process again before constructing the turn command. const proc = requireProviderProcessForThread(threadId); assertProviderSupportsExecutionOptions({ adapter: proc.adapter, - options: execOpts, + options: effectiveExecOpts, providerId: pid, }); await reconfigureThreadIfNeeded({ threadId, - options: execOpts, + options: effectiveExecOpts, }); const adapterCommand: AdapterCommand = { @@ -1407,7 +1434,7 @@ function createAgentRuntimeInternal( clientRequestId, options: toProviderExecutionContext({ envVars: {}, - execOpts, + execOpts: effectiveExecOpts, instructions, }), }; @@ -1459,10 +1486,14 @@ function createAgentRuntimeInternal( threadId, work: async () => { const pid = resolveProviderForThread(threadId); - const proc = requireProviderProcessForThread(threadId); - assertProviderSupportsExecutionOptions({ - adapter: proc.adapter, + const currentProc = requireProviderProcessForThread(threadId); + const effectiveExecOpts = normalizeExecutionOptions({ + adapter: currentProc.adapter, options: execOpts, + }); + assertProviderSupportsExecutionOptions({ + adapter: currentProc.adapter, + options: effectiveExecOpts, providerId: pid, }); @@ -1479,12 +1510,15 @@ function createAgentRuntimeInternal( await restartCodexThreadForNextTurnIfNeeded({ threadId, - options: execOpts, + options: effectiveExecOpts, instructions, }); + // An account restart replaces a thread-scoped Codex process, so + // resolve the process again before constructing the steer command. + const proc = requireProviderProcessForThread(threadId); await reconfigureThreadIfNeeded({ threadId, - options: execOpts, + options: effectiveExecOpts, }); const adapterCommand: AdapterCommand = { @@ -1497,7 +1531,7 @@ function createAgentRuntimeInternal( clientRequestId, options: toProviderExecutionContext({ envVars: {}, - execOpts, + execOpts: effectiveExecOpts, instructions, }), }; diff --git a/packages/agent-runtime/src/test/fake-adapter.ts b/packages/agent-runtime/src/test/fake-adapter.ts index cc4fb0c07..d2ed295b3 100644 --- a/packages/agent-runtime/src/test/fake-adapter.ts +++ b/packages/agent-runtime/src/test/fake-adapter.ts @@ -30,6 +30,7 @@ import type { } from "../runtime-json-rpc.js"; import { ProviderResponseEncodeError } from "../runtime-json-rpc.js"; import { parseAvailableModelList } from "../shared/available-models.js"; +import { classifySessionExecutionSettingsChange } from "../execution-options.js"; import { decodeNormalizedProviderToolCallRequest } from "../shared/provider-tool-call-contract.js"; type FakeUserQuestionCapability = ProviderCapabilities["supportsUserQuestion"]; @@ -462,6 +463,7 @@ export function createFakeAdapter( const supportsUserQuestion = options.supportsUserQuestion ?? false; return { + approvalRequestPolicy: "runtime", buildCommandPlan, capabilities: { supportsArchive: true, @@ -471,6 +473,7 @@ export function createFakeAdapter( supportsFork: true, supportedPermissionModes: ["accept-edits", "auto", "full"], }, + classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, decodeToolCallRequest, decodeInteractiveRequest: supportsUserQuestion ? decodeInteractiveRequest diff --git a/packages/agent-runtime/src/test/runtime-test-harness.ts b/packages/agent-runtime/src/test/runtime-test-harness.ts index b0b59be07..b83df193d 100644 --- a/packages/agent-runtime/src/test/runtime-test-harness.ts +++ b/packages/agent-runtime/src/test/runtime-test-harness.ts @@ -7,6 +7,7 @@ import type { ProviderCommandPlan, } from "../provider-adapter.js"; import { noPreparedProviderCommandDispatch } from "../provider-adapter.js"; +import { classifySessionExecutionSettingsChange } from "../execution-options.js"; import { ProviderRequestDecodeError } from "../runtime-json-rpc.js"; import { parseAvailableModelList } from "../shared/available-models.js"; import type { AgentRuntimeExecutionOptions } from "../types.js"; @@ -205,6 +206,7 @@ export function createWarningEventAdapter(scriptPath: string): ProviderAdapter { return { id: "warning-fake", displayName: "Warning Fake", + approvalRequestPolicy: "runtime", capabilities: { supportsArchive: false, supportsRename: false, @@ -213,6 +215,7 @@ export function createWarningEventAdapter(scriptPath: string): ProviderAdapter { supportsFork: false, supportedPermissionModes: ["accept-edits", "auto", "full"], }, + classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, process: { command: "node", args: buildNodeScriptArgs(scriptPath), @@ -317,6 +320,7 @@ export function createStartedEventAdapter(scriptPath: string): ProviderAdapter { return { id: "started-fake", displayName: "Started Fake", + approvalRequestPolicy: "runtime", capabilities: { supportsArchive: false, supportsRename: false, @@ -325,6 +329,7 @@ export function createStartedEventAdapter(scriptPath: string): ProviderAdapter { supportsFork: false, supportedPermissionModes: ["accept-edits", "auto", "full"], }, + classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, process: { command: "node", args: buildNodeScriptArgs(scriptPath), diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index 51eff4ac0..e8343f005 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -36,7 +36,7 @@ import { providerCliStatusResponseSchema, } from "./local.js"; -export const HOST_DAEMON_PROTOCOL_VERSION = 96 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 97 as const; export { BRANCH_LIST_LIMIT_MAX, diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 59a463aa6..3c047a370 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1051,10 +1051,10 @@ describe("host-daemon local schemas", () => { }); describe("host-daemon command schemas", () => { - // Version 96 adds provider-native skill discovery roots. An older daemon - // returns an incomplete skill catalog, so it must update before connecting. - it("uses protocol version 96 for provider-native skill discovery", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(96); + // Version 97 moves Claude workflow/subagent enforcement from the session + // payload into live adapter controls, so an older daemon must update. + it("uses protocol version 97 for live Claude feature settings", () => { + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(97); }); it("binds Plan cancellation to a required turn id and typed result", () => {