diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b1e47d71141bf..45c842b0b7d0a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2542,6 +2542,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.unbounded< Extract >(); + const runtimeRequestReceipts = + yield* Queue.unbounded< + Extract + >(); + const planReceipts = + yield* Queue.unbounded< + Extract + >(); let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, @@ -2649,6 +2657,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (event.type === "turn.terminal") { yield* Queue.offer(terminalReceipts, event); } + if (event.type === "runtime_request.updated") { + yield* Queue.offer(runtimeRequestReceipts, event); + } + if (event.type === "plan.updated") { + yield* Queue.offer(planReceipts, event); + } if (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") { yield* Queue.offer(systemNoticeReceipts, event); } @@ -2688,6 +2702,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { terminalReceipts, subagentReceipts, systemNoticeReceipts, + runtimeRequestReceipts, + planReceipts, getOpenedOptions: () => openedOptions, terminalEvents, hasPendingBackgroundWork, @@ -2695,6 +2711,152 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const makeWakeHarness = makeWakeHarnessWithOptions(); + describe("Plan-mode tool approval", () => { + const openPlanTurn = Effect.fn("openPlanTurn")(function* ( + runtimeMode: "auto" | "full-access", + approvalPolicy?: "never", + ) { + const harness = yield* makeWakeHarness; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode, + interactionMode: "plan", + cwd: "/workspace", + ...(approvalPolicy === undefined ? {} : { approvalPolicy }), + }); + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-plan-write"), + text: "Investigate before implementing.", + attachments: [], + runtimePolicy, + }); + yield* harness.runtime.startTurn({ + ...input, + appThread: { ...input.appThread, runtimeMode, interactionMode: "plan" }, + }); + assert.equal(harness.getOpenedOptions()?.permissionMode, "plan"); + const canUseTool = harness.getOpenedOptions()?.canUseTool; + assert.isDefined(canUseTool); + return { ...harness, canUseTool: canUseTool! }; + }); + + const requestWrite = Effect.fn("requestPlanWrite")(function* ( + turn: Effect.Success>, + toolName: string, + toolInput: Record, + ) { + const decision = yield* Effect.promise(() => + turn.canUseTool(toolName, toolInput, { + signal: new AbortController().signal, + toolUseID: "tool-plan-write", + requestId: "request-plan-write", + }), + ).pipe(Effect.forkScoped); + const first = yield* Effect.raceFirst( + Queue.take(turn.runtimeRequestReceipts).pipe( + Effect.map((event) => ({ type: "request", event }) as const), + ), + Fiber.join(decision).pipe(Effect.map((result) => ({ type: "decision", result }) as const)), + ); + return { first, decision }; + }); + + it.effect.each([ + { + runtimeMode: "auto", + toolName: "Edit", + toolInput: { + file_path: "/workspace/target.txt", + old_string: "before", + new_string: "after", + }, + kind: "file-change", + decision: "accept", + behavior: "allow", + }, + { + runtimeMode: "full-access", + toolName: "Bash", + toolInput: { command: "echo after > target.txt" }, + kind: "command", + decision: "cancel", + behavior: "deny", + }, + ] as const)( + "asks before $toolName in $runtimeMode Plan, then respects $decision", + ({ runtimeMode, toolName, toolInput, kind, decision, behavior }) => + Effect.gen(function* () { + const turn = yield* openPlanTurn(runtimeMode); + const permission = yield* requestWrite(turn, toolName, toolInput); + assert.equal(permission.first.type, "request", "the planned write bypassed approval"); + if (permission.first.type !== "request") return; + const request = permission.first.event.runtimeRequest; + assert.equal(request.status, "pending"); + assert.equal(request.kind, kind); + yield* turn.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); + const result = yield* Fiber.join(permission.decision); + assert.equal(result?.behavior, behavior); + if (result?.behavior === "allow") { + assert.deepEqual(result.updatedInput, toolInput); + assert.equal(result.toolUseID, "tool-plan-write"); + } + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("denies Plan writes without asking when approvals are never allowed", () => + Effect.gen(function* () { + const turn = yield* openPlanTurn("full-access", "never"); + const permission = yield* requestWrite(turn, "Bash", { + command: "echo after > target.txt", + }); + assert.equal(permission.first.type, "decision", "Never raised an approval request"); + if (permission.first.type !== "decision") return; + assert.equal(permission.first.result?.behavior, "deny"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("still captures ExitPlanMode when Plan approvals are never allowed", () => + Effect.gen(function* () { + const turn = yield* openPlanTurn("full-access", "never"); + const markdown = "# Proposed work\n\nFix the calculation after approval."; + const result = yield* Effect.promise(() => + turn.canUseTool( + "ExitPlanMode", + { plan: markdown }, + { + signal: new AbortController().signal, + toolUseID: "tool-plan-capture", + requestId: "request-plan-capture", + }, + ), + ); + assert.equal(result?.behavior, "deny"); + if (result?.behavior === "deny") + assert.include(result.message, "captured your proposed plan"); + const event = yield* Queue.take(turn.planReceipts); + assert.equal(event.plan.kind, "proposed_plan"); + assert.equal(event.plan.kind === "proposed_plan" ? event.plan.markdown : "", markdown); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + }); + it.effect.each([ { isError: false, title: "Check weather" }, { isError: true, title: "Check weather" }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7dca11461789c..b5f585ddbfaf4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1952,7 +1952,8 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy( permissionMode, ...(readOnlyTools === undefined ? {} : { tools: readOnlyTools }), ...(allowedTools === undefined ? {} : { allowedTools }), - installPermissionCallback, + // Claude routes Plan-mode writes to the callback even in Auto or Full access. + installPermissionCallback: true, }; } @@ -8471,6 +8472,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } satisfies PermissionResult; } + if ( + context.input.runtimePolicy.interactionMode === "plan" && + context.input.runtimePolicy.approvalPolicy === "never" + ) { + return { + behavior: "deny", + message: + "This tool requires approval in Plan mode, but approvals are disabled for this thread.", + toolUseID: callbackOptions.toolUseID, + } satisfies PermissionResult; + } + if (!requiresClaudeApproval(context)) { return { behavior: "allow",