diff --git a/packages/app/e2e/browser/claude-bypass-permissions.real.spec.ts b/packages/app/e2e/browser/claude-bypass-permissions.real.spec.ts new file mode 100644 index 0000000000..5d65c7e5a8 --- /dev/null +++ b/packages/app/e2e/browser/claude-bypass-permissions.real.spec.ts @@ -0,0 +1,83 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "../support/fixtures"; +import { submitMessage } from "../support/helpers/composer"; +import { cleanupRewindFlow, type AgentHandle, launchAgent } from "../support/helpers/rewind-flow"; + +const FILE_NAME = "BYPASS.md"; +const FILE_CONTENT = "PASEO_BYPASS_PLAYWRIGHT_OK\n"; +const COMPLETION = "BYPASS_WRITE_COMPLETE"; + +function configureClaudeToAskForFileTools(handle: AgentHandle): void { + const settingsDirectory = path.join(handle.cwd, ".claude"); + mkdirSync(settingsDirectory); + writeFileSync( + path.join(settingsDirectory, "settings.local.json"), + `${JSON.stringify({ permissions: { ask: ["Bash", "Write", "Edit"] } }, null, 2)}\n`, + ); +} + +async function switchClaudeToBypassPermissions(handle: AgentHandle): Promise { + const modeControl = handle.page.getByRole("button", { name: /^Select agent mode \(/ }); + await expect(modeControl).toHaveAccessibleName("Select agent mode (Always ask)"); + await modeControl.click(); + + const modePicker = handle.page.getByTestId("combobox-desktop-container").last(); + await expect(modePicker).toBeVisible(); + await modePicker.getByText("Bypass", { exact: true }).click(); + await expect(modeControl).toHaveAccessibleName("Select agent mode (Bypass)"); +} + +async function askClaudeToWriteWithoutApproval(handle: AgentHandle): Promise { + await submitMessage( + handle.page, + `Call the Write tool exactly once to create ${FILE_NAME} with exactly ${JSON.stringify( + FILE_CONTENT, + )}. Do not use Bash or another tool. After Write succeeds, reply exactly ${COMPLETION}.`, + ); +} + +async function expectBypassWriteToComplete(handle: AgentHandle): Promise { + await expect( + handle.page.getByTestId("assistant-message").filter({ hasText: COMPLETION }).last(), + ).toBeVisible({ timeout: 120_000 }); + await expect(handle.page.getByTestId("permission-request-question")).toHaveCount(0); + await expect.poll(() => existsSync(path.join(handle.cwd, FILE_NAME))).toBe(true); + await expect + .poll(() => readFileSync(path.join(handle.cwd, FILE_NAME), "utf8")) + .toBe(FILE_CONTENT); +} + +test.describe("real Claude bypass permissions", () => { + test.setTimeout(240_000); + + test("honors Bypass when project policy asks to approve Write", async ({ page }, testInfo) => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-claude-bypass-"))); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ + page, + provider: "claude", + cwd, + mode: "full-access", + providerConfig: { modeId: "default" }, + }); + configureClaudeToAskForFileTools(handle); + await switchClaudeToBypassPermissions(handle); + await askClaudeToWriteWithoutApproval(handle); + await expectBypassWriteToComplete(handle); + await page.screenshot({ path: testInfo.outputPath("claude-bypass-write-complete.png") }); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index fb9726e127..7b2ed75499 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -4571,9 +4571,23 @@ class ClaudeAgentSession implements AgentSession { input, options, ): Promise => { - const requestId = `permission-${randomUUID()}`; const kind = resolvePermissionKind(toolName, input); const requestInput = normalizeClaudeAskUserQuestionRequestInput(toolName, input); + + // Supplying canUseTool gives Paseo a host UI for interactive modes, but the SDK can + // still call it for ordinary tools while bypassPermissions is active. Honor the mode + // contract here so a Bypass session never turns Write/Bash into a blocking UI card. + // A human steer keeps precedence: tools from the superseded turn must be denied until + // Claude consumes the steer instead of being allowed to race it. + if ( + kind === "tool" && + this.currentMode === "bypassPermissions" && + this.permissionClearingSteerUuids.size === 0 + ) { + return { behavior: "allow", updatedInput: input }; + } + + const requestId = `permission-${randomUUID()}`; const metadata: AgentMetadata = {}; if (options.toolUseID) { metadata.toolUseId = options.toolUseID;