From fafff5d257f8b769e02ec49fb0b17a11aea1e071 Mon Sep 17 00:00:00 2001 From: Garrett Fincke Date: Fri, 6 Nov 2026 12:07:15 -0500 Subject: [PATCH 1/7] fix(server): switching Plan keeps Claude's background agents running Co-Authored-By: Claude --- .../Adapters/ClaudeAdapterV2.test.ts | 85 ++++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 66 +++++++++----- 2 files changed, 127 insertions(+), 24 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d7d03c779b4bb..81d6455cafaa3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4121,8 +4121,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; @@ -4219,7 +4219,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); @@ -4243,7 +4243,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!( @@ -5911,6 +5916,78 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + { runtimeMode: "auto", modelHasAuto: true, changes: ["auto", "plan"] }, + { runtimeMode: "full-access", modelHasAuto: true, changes: ["bypassPermissions", "plan"] }, + // 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)", + ({ runtimeMode, modelHasAuto, changes }) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + 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, "plan"); + yield* harness.offerAndWait(wakeTaskStarted); + yield* settle; + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* harness.runtime.startTurn(turn(2, "default")); + 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("re-sends the model on the turn after a live selection change fails", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 10edb77c5011a..0a58db43a0c36 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1683,6 +1683,20 @@ function claudeLiveSelectionFlagSettings( }; } +// 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), + }), + ); +} + // 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. An explicit CLAUDE_CODE_PROMPT_CACHE_TTL on the instance still wins. @@ -2800,9 +2814,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": @@ -2896,6 +2916,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, }; @@ -3008,13 +3033,12 @@ function requiresClaudeApproval(context: ActiveClaudeTurnContext): boolean { return policy.installPermissionCallback || policy.permissionMode === "bypassPermissions"; } +// 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, }); } @@ -4423,7 +4447,7 @@ interface ActiveClaudeSubagent { } interface ClaudeLiveQueryContext { - readonly queryPolicy: ClaudeRuntimeQueryPolicy; + queryPolicy: ClaudeRuntimeQueryPolicy; readonly nativeThreadId: string; readonly settingsCwd: string | null; readonly launchArgSettings: ClaudeSdkSettings | undefined; @@ -5169,7 +5193,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, ) { if ( - permissionModeForClaudeRuntimePolicy(turnInput.runtimePolicy) !== "bypassPermissions" + basePermissionModeForClaudeRuntimePolicy(turnInput.runtimePolicy) !== + "bypassPermissions" ) { return claudeRuntimeQueryPolicyForRuntimePolicy(turnInput.runtimePolicy); } @@ -10849,22 +10874,23 @@ 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, + queryPolicy.permissionMode, + ); + 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, + ); } return existing; } From 126974fa83eabbaf7fc1f90b11279a354ac775ce Mon Sep 17 00:00:00 2001 From: Garrett Fincke Date: Fri, 6 Nov 2026 13:14:44 -0500 Subject: [PATCH 2/7] fix(server): a refused live mode switch keeps asking for approvals Co-Authored-By: Claude --- .../Adapters/ClaudeAdapterV2.test.ts | 40 ++++++++++++++++--- .../Adapters/ClaudeAdapterV2.ts | 13 ++++-- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 81d6455cafaa3..a021c3e84dcb3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -5917,13 +5917,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect.each([ - { runtimeMode: "auto", modelHasAuto: true, changes: ["auto", "plan"] }, - { runtimeMode: "full-access", modelHasAuto: true, changes: ["bypassPermissions", "plan"] }, - // Like the CLI at launch, a model without Auto runs in default. - { runtimeMode: "auto", modelHasAuto: false, changes: ["auto", "default", "plan"] }, + { runtimeMode: "auto", modelHasAuto: true, changes: ["auto", "plan"], asks: false }, + { + runtimeMode: "full-access", + modelHasAuto: true, + changes: ["bypassPermissions", "plan"], + asks: true, + }, + // Like the CLI at launch, a model without Auto runs in default, which asks. + { runtimeMode: "auto", modelHasAuto: false, changes: ["auto", "default", "plan"], asks: true }, ] as const)( "switches $runtimeMode Plan in and out on a process running a background shell (Auto: $modelHasAuto)", - ({ runtimeMode, modelHasAuto, changes }) => + ({ runtimeMode, modelHasAuto, changes, asks }) => Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarnessWithOptions({ @@ -5967,6 +5972,31 @@ describe("ClaudeAdapterV2 background wake turns", () => { 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)), + ); + assert.equal(first.type, asks ? "request" : "decision"); + if (first.type === "request") { + 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; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0a58db43a0c36..02fede6a962d1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -10877,13 +10877,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // 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( + const accepted = yield* setClaudeLivePermissionMode( existing, queryPolicy.permissionMode, ); - existing.openedPermissionMode = existing.permissionMode; + existing.permissionMode = accepted; + existing.openedPermissionMode = accepted; + // The default fallback forwards its approvals, so the callback must ask for them. + existing.queryPolicy = + accepted === queryPolicy.permissionMode + ? queryPolicy + : { ...queryPolicy, permissionMode: accepted, installPermissionCallback: true }; + } else { + existing.queryPolicy = queryPolicy; } - 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") { From e12a4477fe17c91739f6f23755c394421add1350 Mon Sep 17 00:00:00 2001 From: Garrett Fincke Date: Fri, 6 Nov 2026 13:31:22 -0500 Subject: [PATCH 3/7] test(server): Full access Plan launches with the bypass allowance Co-Authored-By: Claude --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index a021c3e84dcb3..8c4d5c7233c64 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -5967,6 +5967,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.runtime.startTurn(turn(1, "plan")); const originalOptions = harness.getOpenedOptions(); assert.equal(originalOptions?.permissionMode, "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); From 2f3e8e725919ede324f3a465556eff7b7cc5e53e Mon Sep 17 00:00:00 2001 From: Garrett Fincke Date: Fri, 6 Nov 2026 13:42:22 -0500 Subject: [PATCH 4/7] fix(server): turns follow the permission mode Claude accepted Co-Authored-By: Claude --- .../Adapters/ClaudeAdapterV2.test.ts | 84 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 28 ++++--- 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 8c4d5c7233c64..4a2ce985265f6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6023,6 +6023,90 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + 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, + ), + ), + ), + ), + ); + it.effect("re-sends the model on the turn after a live selection change fails", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 02fede6a962d1..35c655ad51ce1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1697,6 +1697,18 @@ function setClaudeLivePermissionMode(live: ClaudeLiveQueryContext, mode: Permiss ); } +// 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. An explicit CLAUDE_CODE_PROMPT_CACHE_TTL on the instance still wins. @@ -10877,20 +10889,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // 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) { - const accepted = yield* setClaudeLivePermissionMode( + existing.permissionMode = yield* setClaudeLivePermissionMode( existing, queryPolicy.permissionMode, ); - existing.permissionMode = accepted; - existing.openedPermissionMode = accepted; - // The default fallback forwards its approvals, so the callback must ask for them. - existing.queryPolicy = - accepted === queryPolicy.permissionMode - ? queryPolicy - : { ...queryPolicy, permissionMode: accepted, installPermissionCallback: true }; - } else { - existing.queryPolicy = queryPolicy; + 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") { @@ -10898,6 +10903,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existing, existing.openedPermissionMode, ); + existing.openedPermissionMode = existing.permissionMode; } return existing; } @@ -11222,7 +11228,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const context: ActiveClaudeTurnContext = { input: turnInput, autoCompactWindow, - queryPolicy: querySession.queryPolicy, + queryPolicy: claudeEffectiveLiveQueryPolicy(querySession), nativeThreadId, nativeTurnId, nativeMessageCursor: null, From 152a16933b4084753016a566dfa94eaf1102e2c5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 23:41:07 +0000 Subject: [PATCH 5/7] fix(server): preserve claude launch permission overrides live --- .../Adapters/ClaudeAdapterV2.test.ts | 23 ++++++++++-- .../Adapters/ClaudeAdapterV2.ts | 37 ++++++++++++++----- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 96a38f347d090..c88d85f93f5b8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6474,14 +6474,25 @@ describe("ClaudeAdapterV2 background wake turns", () => { changes: ["bypassPermissions", "plan"], asks: true, }, + { + runtimeMode: "full-access", + modelHasAuto: true, + changes: ["default", "default"], + asks: true, + launchArgs: "--permission-mode default", + openedMode: "default", + }, // Like the CLI at launch, a model without Auto runs in default, which asks. { runtimeMode: "auto", modelHasAuto: false, changes: ["auto", "default", "plan"], asks: true }, ] as const)( "switches $runtimeMode Plan in and out on a process running a background shell (Auto: $modelHasAuto)", - ({ runtimeMode, modelHasAuto, changes, asks }) => - Effect.scoped( + (scenario) => { + const { runtimeMode, modelHasAuto, changes, asks } = 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( @@ -6516,7 +6527,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); yield* harness.runtime.startTurn(turn(1, "plan")); const originalOptions = harness.getOpenedOptions(); - assert.equal(originalOptions?.permissionMode, "plan"); + 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, @@ -6570,7 +6584,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), + ); + }, ); it.effect("asks for approvals after restoring Claude's own Plan falls back to default", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index e66e164ecff68..a369dab26a41c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1710,6 +1710,19 @@ 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) { @@ -1831,17 +1844,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. @@ -1890,11 +1905,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 @@ -11306,7 +11320,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (existing.queryPolicy.permissionMode !== queryPolicy.permissionMode) { existing.permissionMode = yield* setClaudeLivePermissionMode( existing, - queryPolicy.permissionMode, + claudePermissionModeWithLaunchArgs( + queryPolicy.permissionMode, + adapterOptions.settings === undefined + ? {} + : parseCliArgs(adapterOptions.settings.launchArgs).flags, + ), ); existing.openedPermissionMode = existing.permissionMode; } From 8a261611f2ce0b822eb62504857da2d13a3fe530 Mon Sep 17 00:00:00 2001 From: Garrett Fincke Date: Wed, 11 Nov 2026 19:23:26 -0500 Subject: [PATCH 6/7] fix(server): Auto asks before running a request Claude forwards An Auto turn answered forwarded permission requests from the mode it asked for, so a process that had fallen back to default ran them without an approval. The callback cannot tell which mode Claude is in and mode frames can still be queued, so Auto now asks like bypass does. Co-Authored-By: Claude --- .../Adapters/ClaudeAdapterV2.test.ts | 128 +++++++++++++++--- .../Adapters/ClaudeAdapterV2.ts | 9 +- 2 files changed, 116 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index c88d85f93f5b8..30c2a3e04db1d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6467,27 +6467,21 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect.each([ - { runtimeMode: "auto", modelHasAuto: true, changes: ["auto", "plan"], asks: false }, - { - runtimeMode: "full-access", - modelHasAuto: true, - changes: ["bypassPermissions", "plan"], - asks: true, - }, + { runtimeMode: "auto", modelHasAuto: true, changes: ["auto", "plan"] }, + { runtimeMode: "full-access", modelHasAuto: true, changes: ["bypassPermissions", "plan"] }, { runtimeMode: "full-access", modelHasAuto: true, changes: ["default", "default"], - asks: true, launchArgs: "--permission-mode default", openedMode: "default", }, - // Like the CLI at launch, a model without Auto runs in default, which asks. - { runtimeMode: "auto", modelHasAuto: false, changes: ["auto", "default", "plan"], asks: true }, + // 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, asks } = scenario; + const { runtimeMode, modelHasAuto, changes } = scenario; const launchArgs = "launchArgs" in scenario ? scenario.launchArgs : ""; return Effect.scoped( Effect.gen(function* () { @@ -6558,14 +6552,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), Fiber.join(decision).pipe(Effect.map(() => ({ type: "decision" }) as const)), ); - assert.equal(first.type, asks ? "request" : "decision"); - if (first.type === "request") { - yield* harness.runtime.respondToRuntimeRequest({ - requestId: first.event.runtimeRequest.id, - decision: "decline", - }); - yield* Fiber.join(decision); - } + // 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; @@ -6672,6 +6666,102 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: an Auto turn answered forwarded requests from the mode it asked for, so a + // process that had fallen back to default ran them unasked. + it.effect.each([ + { name: "a status frame reports default mid-turn", downgrade: "status", planFirst: false }, + { name: "a delayed init frame reports default", downgrade: "init", planFirst: false }, + { name: "a process reused from Plan reports default", downgrade: "status", planFirst: true }, + { name: "the downgrade frame is still queued", downgrade: "queued", planFirst: false }, + ] as const)("asks before a forwarded Bash command in Auto when $name", (scenario) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + 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-auto-downgrade:${ordinal}`), + text: `Request ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "auto", + interactionMode, + cwd: "/workspace", + }), + }); + return { + ...input, + appThread: { ...input.appThread, runtimeMode: "auto" as const, interactionMode }, + }; + }; + if (scenario.planFirst) { + yield* harness.runtime.startTurn(turn(1, "plan")); + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + } + const openedOptions = harness.getOpenedOptions(); + yield* harness.runtime.startTurn(turn(scenario.planFirst ? 2 : 1, "default")); + if (scenario.planFirst) { + assert.strictEqual(harness.getOpenedOptions(), openedOptions); + assert.deepEqual(harness.permissionModeChanges, ["auto"]); + } + if (scenario.downgrade !== "queued") { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: scenario.downgrade, + ...(scenario.downgrade === "status" ? { status: null } : {}), + permissionMode: "default", + uuid: "auto-downgrade", + session_id: WAKE_NATIVE_SESSION, + }), + ); + } + + 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.scoped( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index a369dab26a41c..ef49e3e47d14b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3091,9 +3091,14 @@ 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. From 52ccec2d88f44c48d24505faf4d731d960410632 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 03:58:01 +0000 Subject: [PATCH 7/7] fix(server): live model switches compare against the mode the process was asked for A Plan switch now updates the live process's opened mode, so comparing it with the next turn's mode refused a model change made while leaving Plan, and comparing it with the turn's effective policy let a steer skip the replacement an Auto fallback needs. Also trims the Auto approval regression test to the one case that exercises it. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 52 ++++--------------- .../Adapters/ClaudeAdapterV2.ts | 4 +- 2 files changed, 12 insertions(+), 44 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b1105fc6d9d49..4879fb8fdced7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7554,60 +7554,28 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); // regression: an Auto turn answered forwarded requests from the mode it asked for, so a - // process that had fallen back to default ran them unasked. - it.effect.each([ - { name: "a status frame reports default mid-turn", downgrade: "status", planFirst: false }, - { name: "a delayed init frame reports default", downgrade: "init", planFirst: false }, - { name: "a process reused from Plan reports default", downgrade: "status", planFirst: true }, - { name: "the downgrade frame is still queued", downgrade: "queued", planFirst: false }, - ] as const)("asks before a forwarded Bash command in Auto when $name", (scenario) => + // 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; - const turn = (ordinal: number, interactionMode: "plan" | "default") => { - const input = makeClaudeTestTurnInput({ + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make(`attempt-auto-downgrade:${ordinal}`), - text: `Request ${ordinal}`, + attemptId: RunAttemptId.make("attempt-auto-forwarded:1"), + text: "Request 1", attachments: [], - providerTurnOrdinal: ordinal, + providerTurnOrdinal: 1, runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "auto", - interactionMode, + interactionMode: "default", cwd: "/workspace", }), - }); - return { - ...input, - appThread: { ...input.appThread, runtimeMode: "auto" as const, interactionMode }, - }; - }; - if (scenario.planFirst) { - yield* harness.runtime.startTurn(turn(1, "plan")); - yield* harness.offerAndWait(turnOneResult); - yield* Queue.take(harness.terminalReceipts); - } - const openedOptions = harness.getOpenedOptions(); - yield* harness.runtime.startTurn(turn(scenario.planFirst ? 2 : 1, "default")); - if (scenario.planFirst) { - assert.strictEqual(harness.getOpenedOptions(), openedOptions); - assert.deepEqual(harness.permissionModeChanges, ["auto"]); - } - if (scenario.downgrade !== "queued") { - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "system", - subtype: scenario.downgrade, - ...(scenario.downgrade === "status" ? { status: null } : {}), - permissionMode: "default", - uuid: "auto-downgrade", - session_id: WAKE_NATIVE_SESSION, - }), - ); - } + }), + ); const decision = yield* Effect.promise(() => harness.getOpenedOptions()!.canUseTool!( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 11c39ba9d7c8a..90f4a990d1e8e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -12603,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); } @@ -13570,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({