diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index c89af500a..88f85fb29 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -39,6 +39,13 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [ icon: "ShieldCheck", colorTier: "safe", }, + { + id: "auto", + label: "Auto mode", + description: "Uses a model classifier to review permission prompts automatically", + icon: "ShieldQuestionMark", + colorTier: "moderate", + }, { id: "acceptEdits", label: "Accept File Edits", diff --git a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index 4a698c17e..fd32340e7 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -140,6 +140,14 @@ function extractStringLogArgs(calls: unknown[][]): string[] { return calls.flatMap((args) => args.filter((arg): arg is string => typeof arg === "string")); } +function restoreEnvValue(key: string, previousValue: string | undefined): void { + if (previousValue === undefined) { + delete process.env[key]; + return; + } + process.env[key] = previousValue; +} + async function collectUntilTerminal( stream: AsyncGenerator, ): Promise { @@ -165,6 +173,112 @@ afterEach(() => { sdkQueryFactory.mockReset(); }); +test("exposes and applies auto permission mode", async () => { + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + sdkQueryFactory.mockImplementation(() => queryMock); + + const session = await createSession(); + + try { + await expect(session.getAvailableModes()).resolves.toEqual( + expect.arrayContaining([ + { + id: "auto", + label: "Auto mode", + description: "Uses a model classifier to review permission prompts automatically", + }, + ]), + ); + + await session.setMode("auto"); + + expect(queryMock.setPermissionMode).toHaveBeenCalledWith("auto"); + expect(await session.getCurrentMode()).toBe("auto"); + } finally { + await session.close(); + } +}); + +test("rejects auto mode when Claude Code uses Bedrock", async () => { + const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK; + process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + + const session = await createSession(); + + try { + await expect(session.setMode("auto")).rejects.toThrow( + "Claude Auto mode requires the Anthropic API and is not supported when Claude Code uses Bedrock", + ); + expect(sdkQueryFactory).not.toHaveBeenCalled(); + } finally { + restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock); + await session.close(); + } +}); + +test("allows launch env to disable inherited Bedrock transport for auto mode", async () => { + const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK; + process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + sdkQueryFactory.mockImplementation(() => queryMock); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdkQueryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession( + { + provider: "claude", + cwd: process.cwd(), + }, + { env: { CLAUDE_CODE_USE_BEDROCK: "0" } }, + ); + + try { + await session.setMode("auto"); + + expect(queryMock.setPermissionMode).toHaveBeenCalledWith("auto"); + expect(await session.getCurrentMode()).toBe("auto"); + } finally { + restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock); + await session.close(); + } +}); + +test("fails an auto mode turn when Claude Code uses Vertex", async () => { + const previousVertex = process.env.CLAUDE_CODE_USE_VERTEX; + process.env.CLAUDE_CODE_USE_VERTEX = "true"; + sdkQueryFactory.mockImplementation(() => { + throw new Error("query should not start"); + }); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdkQueryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "auto", + }); + + try { + const events = await collectUntilTerminal(streamSession(session, "hello")); + const failure = events.find( + (event): event is Extract => + event.type === "turn_failed", + ); + + expect(failure?.error).toContain( + "Claude Auto mode requires the Anthropic API and is not supported when Claude Code uses Vertex", + ); + expect(sdkQueryFactory).not.toHaveBeenCalled(); + } finally { + restoreEnvValue("CLAUDE_CODE_USE_VERTEX", previousVertex); + await session.close(); + } +}); + test("logs redacted query summary and never leaks sentinel secrets", async () => { const envSecret = "PASEO_ENV_SENTINEL_SECRET"; const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET"; diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 04b999f52..ededd5b60 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -196,6 +196,11 @@ const DEFAULT_MODES: AgentMode[] = [ label: "Always Ask", description: "Prompts for permission the first time a tool is used", }, + { + id: "auto", + label: "Auto mode", + description: "Uses a model classifier to review permission prompts automatically", + }, { id: "acceptEdits", label: "Accept File Edits", @@ -660,6 +665,41 @@ function isPermissionMode(value: string | undefined): value is PermissionMode { return typeof value === "string" && VALID_CLAUDE_MODES.has(value); } +function isTruthyEnvValue(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return ( + normalized !== undefined && + normalized.length > 0 && + normalized !== "0" && + normalized !== "false" && + normalized !== "no" && + normalized !== "off" + ); +} + +function detectIneligibleAutoModeTransport(env: NodeJS.ProcessEnv): "Bedrock" | "Vertex" | null { + if (isTruthyEnvValue(env.CLAUDE_CODE_USE_BEDROCK)) { + return "Bedrock"; + } + if (isTruthyEnvValue(env.CLAUDE_CODE_USE_VERTEX)) { + return "Vertex"; + } + return null; +} + +function assertClaudeAutoModeEligible(mode: PermissionMode, env: NodeJS.ProcessEnv): void { + if (mode !== "auto") { + return; + } + const transport = detectIneligibleAutoModeTransport(env); + if (transport === null) { + return; + } + throw new Error( + `Claude Auto mode requires the Anthropic API and is not supported when Claude Code uses ${transport}. Select another permission mode or unset the ${transport === "Bedrock" ? "CLAUDE_CODE_USE_BEDROCK" : "CLAUDE_CODE_USE_VERTEX"} environment variable.`, + ); +} + function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial { if (!isMetadata(metadata)) { return {}; @@ -1735,6 +1775,7 @@ class ClaudeAgentSession implements AgentSession { } const normalized = isPermissionMode(modeId) ? modeId : "default"; + assertClaudeAutoModeEligible(normalized, this.buildSdkEnv(this.config.extra?.claude)); const previousMode = this.currentMode; const activeQuery = await this.ensureQuery(); await activeQuery.setPermissionMode(normalized); @@ -2261,11 +2302,8 @@ class ClaudeAgentSession implements AgentSession { .join("\n\n"); } - private async buildOptions(): Promise { - const { thinking, effort } = this.resolveThinkingConfig(); - const appendedSystemPrompt = this.buildAppendedSystemPrompt(); - const extraClaudeOptions = this.config.extra?.claude; - const sdkEnv = createProviderEnv({ + private buildSdkEnv(extraClaudeOptions: Partial | undefined): NodeJS.ProcessEnv { + return createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings, overlays: [ @@ -2278,6 +2316,14 @@ class ClaudeAgentSession implements AgentSession { this.launchEnv, ], }); + } + + private async buildOptions(): Promise { + const { thinking, effort } = this.resolveThinkingConfig(); + const appendedSystemPrompt = this.buildAppendedSystemPrompt(); + const extraClaudeOptions = this.config.extra?.claude; + const sdkEnv = this.buildSdkEnv(extraClaudeOptions); + assertClaudeAutoModeEligible(this.currentMode, sdkEnv); const claudeBinary = await this.resolveBinary(); this.logger.debug(