diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index cb9aadef97ad0..4879fb8fdced7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4857,8 +4857,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* finish; yield* start("/restricted-worktree"); - assert.equal(harness.getOpenedOptions()?.permissionMode, "default"); - assert.isUndefined(harness.getOpenedOptions()?.allowDangerouslySkipPermissions); + assert.strictEqual(harness.getOpenedOptions(), openedOptions); + assert.deepEqual(harness.permissionModeChanges, ["auto", "default"]); assert.equal(resolveSettings.mock.calls.length, 2); yield* finish; @@ -4955,7 +4955,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.strictEqual(harness.getOpenedOptions(), unrestrictedOptions); assert.deepEqual( harness.permissionModeChanges, - ["auto"], + ["auto", "default"], "A CLI policy downgrade must not be restored to bypass on the next turn", ); assert.equal(resolveSettings.mock.calls.length, 3); @@ -4979,7 +4979,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { `A rejected bypass restore must not fail the ${phase} turn`, ); assert.strictEqual(harness.getOpenedOptions(), unrestrictedOptions); - assert.deepEqual(harness.permissionModeChanges, ["auto", "bypassPermissions", "default"]); + assert.deepEqual(harness.permissionModeChanges, [ + "auto", + "default", + "bypassPermissions", + "default", + ]); assert.equal(harness.offeredMessages.length, ordinal); const restoredDecision = yield* Effect.promise(() => harness.getOpenedOptions()!.canUseTool!( @@ -7348,6 +7353,270 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + { runtimeMode: "auto", modelHasAuto: true, changes: ["auto", "plan"] }, + { runtimeMode: "full-access", modelHasAuto: true, changes: ["bypassPermissions", "plan"] }, + { + runtimeMode: "full-access", + modelHasAuto: true, + changes: ["default", "default"], + launchArgs: "--permission-mode default", + openedMode: "default", + }, + // Like the CLI at launch, a model without Auto runs in default. + { runtimeMode: "auto", modelHasAuto: false, changes: ["auto", "default", "plan"] }, + ] as const)( + "switches $runtimeMode Plan in and out on a process running a background shell (Auto: $modelHasAuto)", + (scenario) => { + const { runtimeMode, modelHasAuto, changes } = scenario; + const launchArgs = "launchArgs" in scenario ? scenario.launchArgs : ""; + return Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + settings: { ...DEFAULT_CLAUDE_SETTINGS, launchArgs }, + setPermissionMode: (mode) => + mode === "auto" && !modelHasAuto + ? Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "setPermissionMode", + cause: new Error("auto mode is not available for this model"), + }), + ) + : Effect.void, + }); + const now = yield* DateTime.now; + const turn = (ordinal: number, interactionMode: "plan" | "default") => { + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-plan-background:${ordinal}`), + text: `Request ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode, + interactionMode, + cwd: "/workspace", + }), + }); + return { ...input, appThread: { ...input.appThread, runtimeMode, interactionMode } }; + }; + const settle = Effect.gen(function* () { + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + }); + yield* harness.runtime.startTurn(turn(1, "plan")); + const originalOptions = harness.getOpenedOptions(); + assert.equal( + originalOptions?.permissionMode, + "openedMode" in scenario ? scenario.openedMode : "plan", + ); + // Leaving Plan for Full access needs the bypass allowance from launch. + assert.equal( + originalOptions?.allowDangerouslySkipPermissions, + runtimeMode === "full-access" ? true : undefined, + ); + yield* harness.offerAndWait(wakeTaskStarted); + yield* settle; + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* harness.runtime.startTurn(turn(2, "default")); + const decision = yield* Effect.promise(() => + originalOptions!.canUseTool!( + "Bash", + { command: "git status" }, + { + signal: new AbortController().signal, + toolUseID: "tool-after-plan", + requestId: "request-after-plan", + }, + ), + ).pipe(Effect.forkScoped); + const first = yield* Effect.raceFirst( + Queue.take(harness.runtimeRequestReceipts).pipe( + Effect.map((event) => ({ type: "request", event }) as const), + ), + Fiber.join(decision).pipe(Effect.map(() => ({ type: "decision" }) as const)), + ); + // Every mode asks about a request Claude forwards once the thread has left Plan. + assert.equal(first.type, "request"); + if (first.type !== "request") return; + yield* harness.runtime.respondToRuntimeRequest({ + requestId: first.event.runtimeRequest.id, + decision: "decline", + }); + yield* Fiber.join(decision); + yield* settle; + yield* harness.runtime.startTurn(turn(3, "plan")); + yield* settle; + + assert.deepEqual(harness.permissionModeChanges, [...changes]); + assert.strictEqual(harness.getOpenedOptions(), originalOptions); + assert.lengthOf(harness.offeredMessages, 3); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + }, + ); + + it.effect("asks for approvals after restoring Claude's own Plan falls back to default", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + setPermissionMode: (mode) => + mode === "auto" + ? Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "setPermissionMode", + cause: new Error("auto mode is not available for this model"), + }), + ) + : Effect.void, + }); + const now = yield* DateTime.now; + const turn = (ordinal: number) => + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-restore-plan:${ordinal}`), + text: `Request ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "auto", + interactionMode: "default", + cwd: "/workspace", + }), + }); + yield* harness.runtime.startTurn(turn(1)); + // Claude enters Plan on its own, and the model no longer offers Auto to return to. + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "status", + status: null, + permissionMode: "plan", + uuid: "restore-enter-plan", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + + yield* harness.runtime.startTurn(turn(2)); + assert.deepEqual(harness.permissionModeChanges, ["auto", "default"]); + const decision = yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!( + "Bash", + { command: "git status" }, + { + signal: new AbortController().signal, + toolUseID: "tool-after-restore", + requestId: "request-after-restore", + }, + ), + ).pipe(Effect.forkScoped); + const first = yield* Effect.raceFirst( + Queue.take(harness.runtimeRequestReceipts).pipe( + Effect.map((event) => ({ type: "request", event }) as const), + ), + Fiber.join(decision).pipe(Effect.map(() => ({ type: "decision" }) as const)), + ); + assert.equal(first.type, "request"); + if (first.type !== "request") return; + yield* harness.runtime.respondToRuntimeRequest({ + requestId: first.event.runtimeRequest.id, + decision: "decline", + }); + yield* Fiber.join(decision); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + + // regression: an Auto turn answered forwarded requests from the mode it asked for, so a + // process that had fallen back to default (its status frame still queued) ran them unasked. + it.effect("asks before a forwarded Bash command in Auto", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-auto-forwarded:1"), + text: "Request 1", + attachments: [], + providerTurnOrdinal: 1, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "auto", + interactionMode: "default", + cwd: "/workspace", + }), + }), + ); + + const decision = yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!( + "Bash", + { command: "git push" }, + { + signal: new AbortController().signal, + toolUseID: "tool-auto-downgrade", + requestId: "request-auto-downgrade", + }, + ), + ).pipe(Effect.forkScoped); + const first = yield* Effect.raceFirst( + Queue.take(harness.runtimeRequestReceipts).pipe( + Effect.map((event) => ({ type: "request", event }) as const), + ), + Fiber.join(decision).pipe( + Effect.map((result) => ({ type: "decision", result }) as const), + ), + ); + assert.equal(first.type, "request", "the forwarded command ran without an approval"); + if (first.type !== "request") return; + assert.equal(first.event.runtimeRequest.kind, "command"); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: first.event.runtimeRequest.id, + decision: "decline", + }); + assert.equal((yield* Fiber.join(decision))?.behavior, "deny"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + // regression: #34549 a model change sent with a steer was shown but never applied to the turn. it.effect("applies a model change sent with a steer to the running turn", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1539b054c6e7c..90f4a990d1e8e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2027,6 +2027,45 @@ function claudeLiveSelectionFlagSettings( }; } +function claudePermissionModeWithLaunchArgs( + permissionMode: PermissionMode, + flags: ReturnType["flags"], +): PermissionMode { + return ( + (flags["permission-mode"] as PermissionMode | null | undefined) ?? + (flags["dangerously-skip-permissions"] === null || + flags["dangerously-skip-permissions"] === "true" + ? "bypassPermissions" + : permissionMode) + ); +} + +// Policy can refuse bypass and some models lack Auto; like the CLI at launch, fall back to +// default, which asks for every approval and which every process accepts. +function setClaudeLivePermissionMode(live: ClaudeLiveQueryContext, mode: PermissionMode) { + return live.query.setPermissionMode(mode).pipe( + Effect.as(mode), + Effect.catchTags({ + ClaudeAgentSdkQueryRunnerError: (error) => + mode === "bypassPermissions" || mode === "auto" + ? live.query.setPermissionMode("default").pipe(Effect.as("default" as const)) + : Effect.fail(error), + }), + ); +} + +// Turns follow the mode Claude accepted. A fallback to default forwards its approvals, so the +// callback must ask for them even when the requested mode would not. +function claudeEffectiveLiveQueryPolicy(live: ClaudeLiveQueryContext): ClaudeRuntimeQueryPolicy { + return live.openedPermissionMode === live.queryPolicy.permissionMode + ? live.queryPolicy + : { + ...live.queryPolicy, + permissionMode: live.openedPermissionMode, + installPermissionCallback: true, + }; +} + // A delegated child is its own SDK session, so Claude Code gives it the main-conversation cache // TTL. When the user sets Claude's subagentPromptCacheTtl, the child follows it like a native // subagent would. A resumed session's first request otherwise goes out before its MCP servers @@ -2140,17 +2179,19 @@ export function makeClaudeQueryOptions(input: { readonly toolSearchOverride?: "true"; }): ClaudeAgentSdkQueryOptions { const compiledSelection = compileClaudeModelSelection(input.modelSelection, input.modelCatalog); + const launchFlags = + input.settings === undefined ? {} : parseCliArgs(input.settings.launchArgs).flags; const { // claudeLaunchArgDirectories reads every --add-dir into additionalDirectories. "add-dir": _launchArgAddDir, - "permission-mode": launchArgPermissionMode, - "dangerously-skip-permissions": launchArgSkipPermissions, + "permission-mode": _launchArgPermissionMode, + "dangerously-skip-permissions": _launchArgSkipPermissions, "append-system-prompt": launchArgAppendSystemPrompt, "append-system-prompt-file": _launchArgAppendSystemPromptFile, // The SDK replaces this flag with options.settings, so it arrives read as sdkSettings. settings: _launchArgSettings, ...extraArgs - } = input.settings === undefined ? {} : parseCliArgs(input.settings.launchArgs).flags; + } = launchFlags; // Claude Code overwrites its CLI append text with the SDK's initialize // appendSystemPrompt, so launch-argument text has to travel in that value. // The CLI puts file text before inline text and trims one trailing newline. @@ -2225,11 +2266,10 @@ export function makeClaudeQueryOptions(input: { // T3 names threads; a supplied title skips Claude's redundant naming request. ...(input.title === undefined ? {} : { title: input.title }), tools: claudeAgentSdkQueryToolsForSdk(selectedTools), - permissionMode: - (launchArgPermissionMode as PermissionMode | null | undefined) ?? - (launchArgSkipPermissions === null || launchArgSkipPermissions === "true" - ? "bypassPermissions" - : (input.permissionMode ?? "default")), + permissionMode: claudePermissionModeWithLaunchArgs( + input.permissionMode ?? "default", + launchFlags, + ), includePartialMessages: true, promptSuggestions: input.promptSuggestions === true, ...(compiledSelection.effort === undefined @@ -3225,9 +3265,15 @@ function readOnlyPolicyAllowsGlobalReads( function permissionModeForClaudeRuntimePolicy( runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, ): PermissionMode { - if (runtimePolicy.interactionMode === "plan") { - return "plan"; - } + return runtimePolicy.interactionMode === "plan" + ? "plan" + : basePermissionModeForClaudeRuntimePolicy(runtimePolicy); +} + +// The mode the thread returns to when it leaves Plan. +function basePermissionModeForClaudeRuntimePolicy( + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, +): PermissionMode { if (runtimePolicy.approvalPolicy === "never") { switch (sandboxPolicyKindForClaudeRuntimePolicy(runtimePolicy)) { case "readOnly": @@ -3321,6 +3367,11 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy( permissionMode, ...(readOnlyTools === undefined ? {} : { tools: readOnlyTools }), ...(allowedTools === undefined ? {} : { allowedTools }), + // Bypass can only be entered live by a process launched with this flag. + ...(basePermissionModeForClaudeRuntimePolicy(runtimePolicy) === "bypassPermissions" && + !availability.bypassDisabled + ? { allowDangerouslySkipPermissions: true } + : {}), // Claude routes Plan-mode writes to the callback even in Auto or Full access. installPermissionCallback: true, }; @@ -3429,18 +3480,22 @@ function claudeBypassAvailability( function requiresClaudeApproval(context: ActiveClaudeTurnContext): boolean { // CLI-forwarded requests need a person's decision even under bypass (ask rules, - // safety checks, or settings fallback). Do not wait for queued init/status updates. + // safety checks, or settings fallback) and Auto (its review could not decide, or Claude fell + // back to default). The callback cannot tell which, and init/status updates may still be queued. const policy = context.queryPolicy; - return policy.installPermissionCallback || policy.permissionMode === "bypassPermissions"; + return ( + policy.installPermissionCallback || + policy.permissionMode === "bypassPermissions" || + policy.permissionMode === "auto" + ); } +// Only what a process fixes at launch; the permission mode and callback policy change live. function claudeRuntimeQueryPolicyKey(policy: ClaudeRuntimeQueryPolicy): string { return JSON.stringify({ - permissionMode: policy.permissionMode, tools: policy.tools, allowedTools: policy.allowedTools, allowDangerouslySkipPermissions: policy.allowDangerouslySkipPermissions, - installPermissionCallback: policy.installPermissionCallback, }); } @@ -5006,7 +5061,7 @@ interface ActiveClaudeSubagent { } interface ClaudeLiveQueryContext { - readonly queryPolicy: ClaudeRuntimeQueryPolicy; + queryPolicy: ClaudeRuntimeQueryPolicy; readonly nativeThreadId: string; readonly settingsCwd: string | null; readonly launchArgSettings: ClaudeSdkSettings | undefined; @@ -5774,7 +5829,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, ) { if ( - permissionModeForClaudeRuntimePolicy(turnInput.runtimePolicy) !== "bypassPermissions" + basePermissionModeForClaudeRuntimePolicy(turnInput.runtimePolicy) !== + "bypassPermissions" ) { return claudeRuntimeQueryPolicyForRuntimePolicy(turnInput.runtimePolicy); } @@ -12547,7 +12603,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existing.selection.queryIdentity !== compiledSelection.queryIdentity && canApplyClaudeSelectionLive(existing.selection, compiledSelection) && (existing.selection.apiModelId === compiledSelection.apiModelId || - existing.openedPermissionMode === queryPolicy.permissionMode) + existing.openedPermissionMode === existing.queryPolicy.permissionMode) ) { yield* applySelectionLive(existing, compiledSelection); } @@ -12559,22 +12615,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( !existing.stopping && existing.selection.queryIdentity === compiledSelection.queryIdentity ) { + // Switch Plan and the permission mode live, so a background agent survives the change. + // Record the mode Claude accepted, as the init frame does for a fresh process. + if (existing.queryPolicy.permissionMode !== queryPolicy.permissionMode) { + existing.permissionMode = yield* setClaudeLivePermissionMode( + existing, + claudePermissionModeWithLaunchArgs( + queryPolicy.permissionMode, + adapterOptions.settings === undefined + ? {} + : parseCliArgs(adapterOptions.settings.launchArgs).flags, + ), + ); + existing.openedPermissionMode = existing.permissionMode; + } + existing.queryPolicy = queryPolicy; // A denied ExitPlanMode leaves Claude in plan mode. Restore only from plan: // Auto/default may reflect a policy restriction that forbids restoring bypass. if (existing.permissionMode === "plan" && existing.openedPermissionMode !== "plan") { - existing.permissionMode = yield* existing.query - .setPermissionMode(existing.openedPermissionMode) - .pipe( - Effect.as(existing.openedPermissionMode), - Effect.catchTags({ - ClaudeAgentSdkQueryRunnerError: (error) => - existing.openedPermissionMode === "bypassPermissions" - ? existing.query - .setPermissionMode("default") - .pipe(Effect.as("default" as const)) - : Effect.fail(error), - }), - ); + existing.permissionMode = yield* setClaudeLivePermissionMode( + existing, + existing.openedPermissionMode, + ); + existing.openedPermissionMode = existing.permissionMode; } return existing; } @@ -12999,7 +13062,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const context: ActiveClaudeTurnContext = { input: turnInput, autoCompactWindow, - queryPolicy: querySession.queryPolicy, + queryPolicy: claudeEffectiveLiveQueryPolicy(querySession), nativeThreadId, nativeTurnId, nativeMessageCursor: null, @@ -13507,7 +13570,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existing.selection.queryIdentity !== steerSelection.queryIdentity && canApplyClaudeSelectionLive(existing.selection, steerSelection) && (existing.selection.apiModelId === steerSelection.apiModelId || - existing.openedPermissionMode === currentTurn.queryPolicy.permissionMode) + existing.openedPermissionMode === existing.queryPolicy.permissionMode) ? steerSelection : undefined; const userMessage = yield* makeClaudeUserMessageWithAttachments({