diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 92f0e4205b639..4e1e323a746da 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1049,130 +1049,139 @@ describe("ClaudeAdapterV2 session permissions", () => { }); }); -describe("ClaudeAdapterV2 Auto-accept edits", () => { - it.effect("asks before a command instead of allowing it", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-accept-edits-", - }); - let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator, - queryRunner: { - allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"), - open: (input) => - Effect.sync(() => { - openedOptions = input.options; - return { - messages: Stream.never, - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "auto-accept-edits", - interactionMode: "default", - cwd: "/workspace", - }); - const threadId = ThreadId.make("thread-claude-accept-edits"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-accept-edits"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ +describe("ClaudeAdapterV2 Auto and Auto-accept edits", () => { + it.effect.each([ + { runtimeMode: "auto-accept-edits", permissionMode: "acceptEdits" }, + { runtimeMode: "auto", permissionMode: "auto" }, + ] as const)( + "asks before a command in $runtimeMode instead of allowing it", + ({ runtimeMode, permissionMode }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-accept-edits-", + }); + let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"), + open: (input) => + Effect.sync(() => { + openedOptions = input.options; + return { + messages: Stream.never, + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }; + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode, + interactionMode: "default", + cwd: "/workspace", + }); + const threadId = ThreadId.make("thread-claude-accept-edits"); + const runtime = yield* adapter.openSession({ threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-accept-edits"), - text: "Run node.", - attachments: [], + providerSessionId: ProviderSessionId.make("provider-session-claude-accept-edits"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, runtimePolicy, - }), - ); - assert.equal(openedOptions?.permissionMode, "acceptEdits"); - const canUseTool = openedOptions?.canUseTool; - assert.isFunction(canUseTool); + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-accept-edits"), + text: "Run node.", + attachments: [], + runtimePolicy, + }), + ); + assert.equal(openedOptions?.permissionMode, permissionMode); + const canUseTool = openedOptions?.canUseTool; + assert.isFunction(canUseTool); - const requestEvent = yield* runtime.events.pipe( - Stream.takeUntil( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "approval_request", - ), - Stream.runCollect, - Effect.forkScoped, - ); - const command = { command: "node -e 'console.log(42)'" }; - const decision = yield* Effect.promise(() => - canUseTool!("Bash", command, { - signal: new AbortController().signal, - toolUseID: "tool-bash-accept-edits", - requestId: "request-bash-accept-edits", - description: "Check the Node runtime", - }), - ).pipe(Effect.forkScoped); - const first = yield* Effect.raceFirst( - Fiber.join(requestEvent).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 command ran without asking"); - if (first.type !== "request") return; - const event = first.event.find((event) => event.type === "runtime_request.updated"); - assert.equal(event?.runtimeRequest.kind, "command"); - const approval = first.event.findLast((event) => event.type === "turn_item.updated"); - assert.equal( - approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined, - command.command, - ); + const requestEvent = yield* runtime.events.pipe( + Stream.takeUntil( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + ), + Stream.runCollect, + Effect.forkScoped, + ); + const command = { command: "node -e 'console.log(42)'" }; + const decision = yield* Effect.promise(() => + canUseTool!("Bash", command, { + signal: new AbortController().signal, + toolUseID: "tool-bash-accept-edits", + requestId: "request-bash-accept-edits", + description: "Check the Node runtime", + }), + ).pipe(Effect.forkScoped); + const first = yield* Effect.raceFirst( + Fiber.join(requestEvent).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 command ran without asking"); + if (first.type !== "request") return; + const event = first.event.find((event) => event.type === "runtime_request.updated"); + assert.equal(event?.runtimeRequest.kind, "command"); + const approval = first.event.findLast((event) => event.type === "turn_item.updated"); + assert.equal( + approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined, + command.command, + ); - yield* runtime.respondToRuntimeRequest({ - requestId: event!.runtimeRequest.id, - decision: "accept", - }); - assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, + yield* runtime.respondToRuntimeRequest({ + requestId: event!.runtimeRequest.id, + decision: "accept", + }); + assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), - ), ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 4ea7b6f8008e4..b8e6f6e20f5c6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3262,12 +3262,11 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy( readOnlyTools !== undefined && readOnlyPolicyAllowsGlobalReads(runtimePolicy) ? readOnlyTools : undefined; - // acceptEdits approves edits before the callback; the callback must ask for everything else. + // acceptEdits and auto approve routine actions before the callback; the callback must ask for the rest. const installPermissionCallback = downgraded || (runtimePolicy.approvalPolicy === undefined - ? runtimePolicy.runtimeMode === "approval-required" || - runtimePolicy.runtimeMode === "auto-accept-edits" + ? runtimePolicy.runtimeMode !== "full-access" : runtimePolicy.approvalPolicy !== "never"); if (permissionMode === "plan") {