From aa5bd958efa1e12d6367793bac0196681717bea2 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Wed, 25 Mar 2026 10:45:58 -0700 Subject: [PATCH] Fix Claude tmux bypass confirmation --- src/loop/tmux.ts | 24 +++-- tests/loop/tmux.test.ts | 195 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 6 deletions(-) diff --git a/src/loop/tmux.ts b/src/loop/tmux.ts index 0d81c7a..5d61179 100644 --- a/src/loop/tmux.ts +++ b/src/loop/tmux.ts @@ -36,6 +36,9 @@ const RUN_BASE_ENV = "LOOP_RUN_BASE"; const RUN_ID_ENV = "LOOP_RUN_ID"; const CLAUDE_TRUST_PROMPT = "Is this a project you created or one you trust?"; const CLAUDE_BYPASS_PROMPT = "running in Bypass Permissions mode"; +const CLAUDE_BYPASS_MODE = "Bypass Permissions mode"; +const CLAUDE_BYPASS_ACCEPT = "Yes, I accept"; +const CLAUDE_EXIT_OPTION = "No, exit"; const CLAUDE_DEV_CHANNELS_PROMPT = "WARNING: Loading development channels"; const CLAUDE_DEV_CHANNELS_CONFIRM = "I am using this for local development"; const CLAUDE_CHANNEL_SCOPE = "local"; @@ -700,16 +703,25 @@ const runTmuxCommand = ( throw new Error(`${message}${suffix}`); }; +const normalizePaneText = (text: string): string => + text.replace(/\s+/g, " ").trim(); + const detectClaudePrompt = (text: string): "bypass" | "confirm" | undefined => { - if (text.includes(CLAUDE_BYPASS_PROMPT)) { + const normalized = normalizePaneText(text); + if ( + normalized.includes(CLAUDE_BYPASS_PROMPT) || + (normalized.includes(CLAUDE_BYPASS_MODE) && + normalized.includes(CLAUDE_BYPASS_ACCEPT) && + normalized.includes(CLAUDE_EXIT_OPTION)) + ) { return "bypass"; } - if (text.includes(CLAUDE_TRUST_PROMPT)) { + if (normalized.includes(CLAUDE_TRUST_PROMPT)) { return "confirm"; } if ( - text.includes(CLAUDE_DEV_CHANNELS_PROMPT) && - text.includes(CLAUDE_DEV_CHANNELS_CONFIRM) + normalized.includes(CLAUDE_DEV_CHANNELS_PROMPT) && + normalized.includes(CLAUDE_DEV_CHANNELS_CONFIRM) ) { return "confirm"; } @@ -740,7 +752,9 @@ const unblockClaudePane = async ( continue; } if (prompt === "bypass") { - deps.sendKeys(pane, ["Down", "Enter"]); + deps.sendKeys(pane, ["Down"]); + await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS); + deps.sendKeys(pane, ["Enter"]); handledPrompt = true; quietPolls = 0; await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS); diff --git a/tests/loop/tmux.test.ts b/tests/loop/tmux.test.ts index 939df2c..cd20534 100644 --- a/tests/loop/tmux.test.ts +++ b/tests/loop/tmux.test.ts @@ -665,7 +665,11 @@ test("runInTmux auto-confirms Claude startup prompts in paired mode", async () = expect(keyCalls[0]).toEqual({ keys: ["Enter"], pane: "repo-loop-1:0.0" }); expect(keyCalls[1]).toEqual({ - keys: ["Down", "Enter"], + keys: ["Down"], + pane: "repo-loop-1:0.0", + }); + expect(keyCalls[2]).toEqual({ + keys: ["Enter"], pane: "repo-loop-1:0.0", }); expect( @@ -696,6 +700,176 @@ test("runInTmux auto-confirms Claude startup prompts in paired mode", async () = ); }); +test("runInTmux confirms wrapped Claude dev-channel prompts", async () => { + const keyCalls: Array<{ keys: string[]; pane: string }> = []; + let sessionStarted = false; + let pollCount = 0; + const devChannelsPrompt = [ + "WARNING: Loading development channels", + "", + "--dangerously-load-development-channels is for local channel development only.", + "", + "1. I am using this for local", + "development", + ].join("\n"); + const manifest = createRunManifest({ + cwd: "/repo", + mode: "paired", + pid: 1234, + repoId: "repo-123", + runId: "1", + status: "running", + }); + const storage = { + manifestPath: "/repo/.loop/runs/1/manifest.json", + repoId: "repo-123", + runDir: "/repo/.loop/runs/1", + runId: "1", + storageRoot: "/repo/.loop/runs", + transcriptPath: "/repo/.loop/runs/1/transcript.jsonl", + }; + + await runInTmux( + ["--tmux", "--proof", "verify with tests"], + { + capturePane: () => { + pollCount += 1; + if (pollCount === 1) { + return devChannelsPrompt; + } + return ""; + }, + cwd: "/repo", + env: {}, + findBinary: () => true, + getCodexAppServerUrl: () => "ws://127.0.0.1:4500", + getLastCodexThreadId: () => "codex-thread-1", + isInteractive: () => false, + launchArgv: ["bun", "/repo/src/cli.ts"], + log: (): void => undefined, + makeClaudeSessionId: () => "claude-session-1", + preparePairedRun: (nextOpts) => { + nextOpts.codexMcpConfigArgs = [ + "-c", + 'mcp_servers.loop-bridge.command="loop"', + ]; + return { manifest, storage }; + }, + sendKeys: (pane: string, keys: string[]) => { + keyCalls.push({ keys, pane }); + }, + sendText: (): void => undefined, + sleep: () => Promise.resolve(), + startCodexProxy: () => Promise.resolve("ws://127.0.0.1:4600/"), + startPersistentAgentSession: () => Promise.resolve(undefined), + spawn: (args: string[]) => { + if (args[0] === "tmux" && args[1] === "has-session") { + return sessionStarted + ? { exitCode: 0, stderr: "" } + : { exitCode: 1, stderr: "" }; + } + if (args[0] === "tmux" && args[1] === "new-session") { + sessionStarted = true; + } + return { exitCode: 0, stderr: "" }; + }, + updateRunManifest: (_path, update) => update(manifest), + }, + { opts: makePairedOptions(), task: "Ship feature" } + ); + + expect(keyCalls).toContainEqual({ + keys: ["Enter"], + pane: "repo-loop-1:0.0", + }); +}); + +test("runInTmux confirms the current Claude bypass prompt wording", async () => { + const keyCalls: Array<{ keys: string[]; pane: string }> = []; + let sessionStarted = false; + let pollCount = 0; + const bypassPrompt = [ + "Bypass Permissions mode", + "", + "1. No, exit", + "2. Yes, I accept", + ].join("\n"); + const manifest = createRunManifest({ + cwd: "/repo", + mode: "paired", + pid: 1234, + repoId: "repo-123", + runId: "1", + status: "running", + }); + const storage = { + manifestPath: "/repo/.loop/runs/1/manifest.json", + repoId: "repo-123", + runDir: "/repo/.loop/runs/1", + runId: "1", + storageRoot: "/repo/.loop/runs", + transcriptPath: "/repo/.loop/runs/1/transcript.jsonl", + }; + + await runInTmux( + ["--tmux", "--proof", "verify with tests"], + { + capturePane: () => { + pollCount += 1; + if (pollCount === 1) { + return bypassPrompt; + } + return ""; + }, + cwd: "/repo", + env: {}, + findBinary: () => true, + getCodexAppServerUrl: () => "ws://127.0.0.1:4500", + getLastCodexThreadId: () => "codex-thread-1", + isInteractive: () => false, + launchArgv: ["bun", "/repo/src/cli.ts"], + log: (): void => undefined, + makeClaudeSessionId: () => "claude-session-1", + preparePairedRun: (nextOpts) => { + nextOpts.codexMcpConfigArgs = [ + "-c", + 'mcp_servers.loop-bridge.command="loop"', + ]; + return { manifest, storage }; + }, + sendKeys: (pane: string, keys: string[]) => { + keyCalls.push({ keys, pane }); + }, + sendText: (): void => undefined, + sleep: () => Promise.resolve(), + startCodexProxy: () => Promise.resolve("ws://127.0.0.1:4600/"), + startPersistentAgentSession: () => Promise.resolve(undefined), + spawn: (args: string[]) => { + if (args[0] === "tmux" && args[1] === "has-session") { + return sessionStarted + ? { exitCode: 0, stderr: "" } + : { exitCode: 1, stderr: "" }; + } + if (args[0] === "tmux" && args[1] === "new-session") { + sessionStarted = true; + } + return { exitCode: 0, stderr: "" }; + }, + updateRunManifest: (_path, update) => update(manifest), + }, + { opts: makePairedOptions(), task: "Ship feature" } + ); + + expect(keyCalls).toContainEqual({ + keys: ["Down"], + pane: "repo-loop-1:0.0", + }); + expect(keyCalls).toContainEqual({ + keys: ["Enter"], + pane: "repo-loop-1:0.0", + }); +}); + test("runInTmux still confirms Claude trust prompts in paired mode", async () => { const keyCalls: Array<{ keys: string[]; pane: string }> = []; let sessionStarted = false; @@ -1576,6 +1750,25 @@ test("tmux internals build shell command with escaping", () => { ); }); +test("tmux internals launch Claude in bypass mode", () => { + expect( + tmuxInternals.buildClaudeCommand( + "claude-session-1", + "opus", + "loop-bridge-1", + false + ) + ).toContain("--dangerously-skip-permissions"); + expect( + tmuxInternals.buildClaudeCommand( + "claude-session-1", + "opus", + "loop-bridge-1", + false + ) + ).not.toContain("--permission-mode"); +}); + test("tmux internals build run names", () => { expect(tmuxInternals.buildRunName("repo", 3)).toBe("repo-loop-3"); });