diff --git a/server/__tests__/pty-run.test.js b/server/__tests__/pty-run.test.js index e3a492da..f5a5f819 100644 --- a/server/__tests__/pty-run.test.js +++ b/server/__tests__/pty-run.test.js @@ -374,7 +374,27 @@ describe("pty-run", () => { if (args[0] === "new-session") newSessionArgv = args; return ""; }); - pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" }); + const prevHome = process.env.CLAUDE_HOME; + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bypass-home-")); + process.env.CLAUDE_HOME = home; + try { + // An account that never accepted the bypass warning: no flag, so its + // first session does not open on that warning (the Chat UI can't answer). + pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" }); + assert.ok(!newSessionArgv.includes("--allow-dangerously-skip-permissions")); + // ...unless the run asks for bypass itself: then the warning is wanted. + pty.spawnRun({ laneId: 4, cwd: "/tmp/repo", permissionMode: "bypassPermissions" }); + assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions")); + fs.writeFileSync( + path.join(home, "settings.json"), + JSON.stringify({ skipDangerousModePermissionPrompt: true }) + ); + pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" }); + } finally { + if (prevHome === undefined) delete process.env.CLAUDE_HOME; + else process.env.CLAUDE_HOME = prevHome; + fs.rmSync(home, { recursive: true, force: true }); + } assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions")); // ...as an OPTION only: the starting mode stays the requested one, and // `--dangerously-skip-permissions` (which would enable it) is never passed. diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index 5d60e9c9..c0601580 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -165,6 +165,24 @@ function promptFileArgv(id, argv) { return ["sh", "-c", READ_PROMPT, "sh", file, ...argv.slice(0, -1)]; } +/** + * Whether the account a pane runs as has accepted Claude Code's bypass + * warning — Claude writes `skipDangerousModePermissionPrompt: true` to its + * user settings once it is. A member's (S3) is read from their home, owner- + * checked; the host's from CLAUDE_HOME. Unreadable reads as "not yet". + */ +function bypassAccepted(member) { + const file = member + ? path.join(member.home, ".claude", "settings.json") + : path.join(require("./claude-home").getClaudeHome(), "settings.json"); + try { + const text = require("./run-as").readFileSafe(file, "utf8", { max: 1 << 20 }); + return JSON.parse(text).skipDangerousModePermissionPrompt === true; + } catch { + return false; + } +} + function buildClaudeArgv({ model, permissionMode, @@ -177,6 +195,7 @@ function buildClaudeArgv({ addDirs = [], systemPromptFile = null, proxyArgv = [], + offerBypass = true, platform = process.platform, }) { const argv = ["claude"]; @@ -210,7 +229,11 @@ function buildClaudeArgv({ // plan/auto/manual/acceptEdits, so a run started in any non-bypass mode could // never reach bypass from inside the pane, while a real terminal could. // The starting mode is still whatever --permission-mode says above. - argv.push("--allow-dangerously-skip-permissions"); + // Only once the account has accepted Claude Code's bypass warning + // (`offerBypass`, see bypassAccepted): before that the flag alone puts the + // warning in front of a brand-new account's first session — one the Chat UI + // cannot answer — although nobody asked for bypass. + if (offerBypass) argv.push("--allow-dangerously-skip-permissions"); if (model) argv.push("--model", model); if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort); if (resumeSessionId) argv.push("--resume", resumeSessionId); @@ -512,6 +535,11 @@ function spawnRun(args) { resumeSessionId, forkSession: forkSession === true, newSessionId, + // Asking for bypass is consent to see its warning; otherwise only an + // account that already accepted it gets bypass in its shift+tab cycle. + offerBypass: + permissionMode === "bypassPermissions" || + (entry.launcher === "claude" && bypassAccepted(require("./run-as").accountForLane(laneId))), worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null, resumeLast, bypass: bypass === true,