From 643ba76e226b8aca1c98b6813eb85019d124bb47 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Fri, 30 Oct 2026 20:29:50 -0400 Subject: [PATCH 1/4] fix(agent-setup): ask before a sign-in changes the selected account In a workspace terminal the codex and claude wrappers point every launch at the account selected for new agents. That included `codex login`, `codex logout`, `claude auth login` and `claude auth logout`. A bare `codex login` signed in to the selected home instead of ~/.codex, and so did `env -u CODEX_HOME codex login`, because the resolver filled an unset CODEX_HOME from the pointer file. Current Codex releases log out the target home's stored login before the browser step, so this ended the selected account's login even when the sign-in was cancelled. buildDefaultAccountResolver now takes each wrapper's sign-in commands and matches them against the leading words of the arguments. For those commands an unset value stays unset. When the value is the injected selection (it equals its SUPERSET_DEFAULT_* twin and, for Codex, is not the ambient home), the wrapper names the home and asks on a terminal. Without a terminal it exits 2 and prints how to run the command on that account or without it. Agent runs, `codex login status`, --help, explicit homes and logins with no selection are unchanged. Fixes #9172 Co-Authored-By: Claude Opus 5.5 --- docs/agent-tooling.md | 4 + .../agent-wrappers-claude-codex-opencode.ts | 6 +- .../agent-setup/src/agent-wrappers-common.ts | 76 +++++- .../agent-setup/src/agent-wrappers.test.ts | 43 ++++ .../src/default-account-resolver.test.ts | 218 +++++++++++++++++- 5 files changed, 341 insertions(+), 6 deletions(-) diff --git a/docs/agent-tooling.md b/docs/agent-tooling.md index ec3ae06f3f4..efaa9f7115b 100644 --- a/docs/agent-tooling.md +++ b/docs/agent-tooling.md @@ -52,6 +52,10 @@ and agent launch by `packages/host-service/src/trpc/router/usage/default-account publishes the selection to `$SUPERSET_HOME_DIR/state/default-*` pointer files. The agent wrappers re-read those at every launch (`buildDefaultAccountResolver` in agent-setup), so a switch reaches existing terminals the next time the agent starts; a value the user exported by hand always wins. +Sign-in commands (`codex login`/`logout`, `claude auth login`/`logout`) keep an unset value unset. +Before one changes the login of the selected account, the wrapper names that account and asks for +confirmation, or refuses when there is no terminal: current Codex releases log the old login out +before they sign in. Paradev never touches the credential stores; the provider CLIs own every login end to end. Because those CLIs read *everything* from their active config dir, a second dir would otherwise diff --git a/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts b/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts index 218c9c321d8..7d10ce93489 100644 --- a/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts +++ b/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts @@ -301,6 +301,7 @@ export function createClaudeWrapper(): void { beforeLaunch: buildDefaultAccountResolver( "CLAUDE_CONFIG_DIR", "default-claude-config-dir", + { credentialCommands: ["auth login", "auth logout"] }, ), }); createWrapper("claude", script); @@ -320,7 +321,10 @@ export function createCodexWrapper(): void { beforeLaunch: buildDefaultAccountResolver( "CODEX_HOME", "default-codex-home", - "SUPERSET_AMBIENT_CODEX_HOME", + { + ambientEnvVar: "SUPERSET_AMBIENT_CODEX_HOME", + credentialCommands: ["login", "logout"], + }, ), }, ); diff --git a/packages/agent-setup/src/agent-wrappers-common.ts b/packages/agent-setup/src/agent-wrappers-common.ts index bda917fac27..d70c9c595f6 100644 --- a/packages/agent-setup/src/agent-wrappers-common.ts +++ b/packages/agent-setup/src/agent-wrappers-common.ts @@ -137,6 +137,13 @@ function buildRealBinaryResolver(): string { `; } +export interface DefaultAccountResolverOptions { + /** Holds the system-default value while a selected account is injected. */ + ambientEnvVar?: string; + /** Leading words of the commands that sign in or out, e.g. "auth login". */ + credentialCommands?: readonly string[]; +} + /** * Shell block that re-resolves the Usage-tab default account at launch. * The PTY env is frozen at terminal spawn, so an account switch would @@ -145,12 +152,16 @@ function buildRealBinaryResolver(): string { * only, and a value the user exported by hand — one that differs from what * Superset injected at spawn — always wins. A missing pointer file (older * host build) changes nothing; an empty one means the system default. + * + * A credential command leaves an unset value unset, and it changes the + * login of the selected account only after the user confirms on a terminal. */ export function buildDefaultAccountResolver( envVar: string, pointerName: string, - ambientEnvVar?: string, + options: DefaultAccountResolverOptions = {}, ): string { + const { ambientEnvVar, credentialCommands = [] } = options; const pointer = `"$SUPERSET_HOME_DIR/state/${pointerName}"`; const restoreSystemDefault = ambientEnvVar ? `if [ -n "\${${ambientEnvVar}}" ]; then @@ -162,8 +173,19 @@ export function buildDefaultAccountResolver( fi` : `unset ${envVar} unset SUPERSET_DEFAULT_${envVar}`; - return `if [ -n "$SUPERSET_TERMINAL_ID" ] && [ -n "$SUPERSET_HOME_DIR" ] \\ - && { [ -z "\${${envVar}}" ] || [ "\${${envVar}}" = "\${SUPERSET_DEFAULT_${envVar}}" ]; } \\ + const guardsCredentials = credentialCommands.length > 0; + const matchCredentialCommand = guardsCredentials + ? buildCredentialCommandMatch(credentialCommands) + : ""; + const keepUnsetForCredentials = guardsCredentials + ? ` \\ + && { [ -n "\${${envVar}}" ] || [ -z "$superset_credential_command" ]; }` + : ""; + const guardCredentialCommand = guardsCredentials + ? buildCredentialCommandGuard(envVar, ambientEnvVar) + : ""; + return `${matchCredentialCommand}if [ -n "$SUPERSET_TERMINAL_ID" ] && [ -n "$SUPERSET_HOME_DIR" ] \\ + && { [ -z "\${${envVar}}" ] || [ "\${${envVar}}" = "\${SUPERSET_DEFAULT_${envVar}}" ]; }${keepUnsetForCredentials} \\ && [ -f ${pointer} ]; then superset_default_account="$(cat ${pointer} 2>/dev/null)" if [ -n "$superset_default_account" ] && [ -d "$superset_default_account" ]; then @@ -173,7 +195,55 @@ export function buildDefaultAccountResolver( ${restoreSystemDefault} fi fi +${guardCredentialCommand} +`; +} + +/** Sets `superset_credential_command` when the arguments start with one. */ +function buildCredentialCommandMatch(commands: readonly string[]): string { + const pattern = commands.map((command) => `"${command}"`).join("|"); + return `superset_command_words="" +for superset_arg in "$@"; do + case "$superset_arg" in + -h|--help) superset_command_words=""; break ;; + -*) break ;; + esac + superset_command_words="\${superset_command_words:+$superset_command_words }$superset_arg" +done +superset_credential_command="" +case "$superset_command_words" in + ${pattern}) superset_credential_command="\${0##*/} $superset_command_words" ;; +esac +`; +} +function buildCredentialCommandGuard( + envVar: string, + ambientEnvVar?: string, +): string { + const notSystemDefault = ambientEnvVar + ? ` && [ "\${${envVar}}" != "\${${ambientEnvVar}}" ]` + : ""; + const unsetHint = `To run it without the selected account: env -u ${envVar} %s\\n`; + return `if [ -n "$superset_credential_command" ] && [ -n "\${${envVar}}" ] \\ + && [ "\${${envVar}}" = "\${SUPERSET_DEFAULT_${envVar}}" ]${notSystemDefault}; then + if [ -t 0 ] && [ -t 2 ]; then + printf 'Paradev: %s will change the login in %s, the account you made the default for new agents. Continue? [y/N] ' \\ + "$superset_credential_command" "\${${envVar}}" >&2 + read -r superset_reply + case "$superset_reply" in + [yY]|[yY][eE][sS]) ;; + *) + printf 'Paradev: cancelled. ${unsetHint}' "$superset_credential_command" >&2 + exit 1 + ;; + esac + else + printf 'Paradev: %s would change the login in %s, the account you made the default for new agents.\\nTo use that account, confirm in a terminal or run: SUPERSET_DEFAULT_${envVar}= ${envVar}=%q %s\\n${unsetHint}' \\ + "$superset_credential_command" "\${${envVar}}" "\${${envVar}}" "$superset_credential_command" "$superset_credential_command" >&2 + exit 2 + fi +fi `; } diff --git a/packages/agent-setup/src/agent-wrappers.test.ts b/packages/agent-setup/src/agent-wrappers.test.ts index a380745eaa1..b8ca660ca30 100644 --- a/packages/agent-setup/src/agent-wrappers.test.ts +++ b/packages/agent-setup/src/agent-wrappers.test.ts @@ -70,6 +70,7 @@ const { buildCopilotWrapperExecLine, buildWrapperScript, createClaudeSettingsJson, + createClaudeWrapper, createCodexHooksJson, createCodexWrapper, COPILOT_HOOK_MARKER, @@ -921,6 +922,48 @@ exit 0 expect(existsSync(notifyCapturePath)).toBe(false); }); + it("refuses a login on the selected account without a terminal", () => { + const realBinDir = path.join(TEST_ROOT, "real-bin"); + const ranFile = path.join(TEST_ROOT, "real-cli-ran.txt"); + const profile = path.join(TEST_ROOT, "selected-profile"); + mkdirSync(realBinDir, { recursive: true }); + mkdirSync(profile, { recursive: true }); + for (const name of ["codex", "claude"]) { + writeFileSync( + path.join(realBinDir, name), + `#!/bin/bash\nprintf '%s\\n' "$*" >> "${ranFile}"\n`, + { mode: 0o755 }, + ); + } + createCodexWrapper(); + createClaudeWrapper(); + + const env = { + ...process.env, + PATH: `${TEST_BIN_DIR}:${realBinDir}:${process.env.PATH || ""}`, + SUPERSET_TERMINAL_ID: "terminal-1", + CODEX_HOME: profile, + SUPERSET_DEFAULT_CODEX_HOME: profile, + SUPERSET_AMBIENT_CODEX_HOME: path.join(TEST_ROOT, "codex-default"), + CLAUDE_CONFIG_DIR: profile, + SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: profile, + }; + const codex = spawnSync(path.join(TEST_BIN_DIR, "codex"), ["login"], { + env, + encoding: "utf-8", + }); + const claude = spawnSync( + path.join(TEST_BIN_DIR, "claude"), + ["auth", "login"], + { env, encoding: "utf-8" }, + ); + + expect([codex.status, claude.status]).toEqual([2, 2]); + expect(codex.stderr).toContain(profile); + expect(claude.stderr).toContain(profile); + expect(existsSync(ranFile)).toBe(false); + }); + it("creates mastracode wrapper passthrough", () => { createMastraWrapper(); diff --git a/packages/agent-setup/src/default-account-resolver.test.ts b/packages/agent-setup/src/default-account-resolver.test.ts index 83d5a4d0bdb..3f814a24162 100644 --- a/packages/agent-setup/src/default-account-resolver.test.ts +++ b/packages/agent-setup/src/default-account-resolver.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -40,7 +40,7 @@ function resolveCodexWithTwin(env: Record): string { const script = `${buildDefaultAccountResolver( "CODEX_HOME", "default-codex-home", - "SUPERSET_AMBIENT_CODEX_HOME", + { ambientEnvVar: "SUPERSET_AMBIENT_CODEX_HOME" }, )}printf "%s|%s" "\${CODEX_HOME:-}" "\${SUPERSET_DEFAULT_CODEX_HOME:-}"`; const cleanEnv: Record = { PATH: process.env.PATH ?? "" }; for (const [key, value] of Object.entries(env)) { @@ -193,3 +193,217 @@ describe("buildDefaultAccountResolver", () => { ).toBe(""); }); }); + +function withPath( + env: Record, +): Record { + const cleanEnv: Record = { PATH: process.env.PATH ?? "" }; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) cleanEnv[key] = value; + } + return cleanEnv; +} + +function codexScript(): string { + return `${buildDefaultAccountResolver("CODEX_HOME", "default-codex-home", { + ambientEnvVar: "SUPERSET_AMBIENT_CODEX_HOME", + credentialCommands: ["login", "logout"], + })}printf "ran with %s" "\${CODEX_HOME:-}"`; +} + +function claudeScript(): string { + return `${buildDefaultAccountResolver( + "CLAUDE_CONFIG_DIR", + "default-claude-config-dir", + { credentialCommands: ["auth login", "auth logout"] }, + )}printf "ran with %s" "\${CLAUDE_CONFIG_DIR:-}"`; +} + +/** Runs `script` as the wrapper would, with stdin and stderr not a terminal. */ +function run( + script: string, + argv: string[], + env: Record, +) { + return spawnSync("bash", ["-c", script, ...argv], { + env: withPath(env), + encoding: "utf-8", + }); +} + +/** Runs `script` on a pseudo-terminal and types `answer` at the prompt. */ +async function runOnTerminal( + script: string, + argv: string[], + env: Record, + answer: string, +): Promise<{ status: number; output: string }> { + const decoder = new TextDecoder(); + let output = ""; + let answered = false; + let onClose: (() => void) | undefined; + const closed = new Promise((resolve) => { + onClose = resolve; + }); + const proc = Bun.spawn(["bash", "-c", script, ...argv], { + env: withPath(env), + terminal: { + data(terminal, chunk) { + output += decoder.decode(chunk, { stream: true }); + if (!answered && output.includes("[y/N]")) { + answered = true; + terminal.write(`${answer}\n`); + } + }, + exit: () => onClose?.(), + }, + }); + const status = await proc.exited; + await closed; + proc.terminal?.close(); + return { status, output }; +} + +/** A workspace terminal spawned while `profile` was the selected Codex home. */ +function codexTerminal() { + const { home, profile } = makeHome(null); + const ambient = join(home, "codex-default"); + writeFileSync(join(home, "state", "default-codex-home"), profile); + return { + home, + profile, + ambient, + env: { + SUPERSET_TERMINAL_ID: "t1", + SUPERSET_HOME_DIR: home, + CODEX_HOME: profile, + SUPERSET_DEFAULT_CODEX_HOME: profile, + SUPERSET_AMBIENT_CODEX_HOME: ambient, + }, + }; +} + +describe("buildDefaultAccountResolver credential commands", () => { + it("refuses codex login and logout on the selected home without a terminal", () => { + const { profile, env } = codexTerminal(); + for (const command of ["login", "logout"]) { + const result = run(codexScript(), ["codex", command], env); + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(`codex ${command}`); + expect(result.stderr).toContain(profile); + } + }); + + it("runs the override the refusal suggests", () => { + const { profile, env } = codexTerminal(); + const result = run(codexScript(), ["codex", "login"], { + ...env, + SUPERSET_DEFAULT_CODEX_HOME: "", + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(`ran with ${profile}`); + }); + + it("refuses an injected selection outside workspace terminals too", () => { + const { env } = codexTerminal(); + const result = run(codexScript(), ["codex", "login"], { + ...env, + SUPERSET_TERMINAL_ID: undefined, + }); + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + }); + + it("lets codex login status and help read the selected home", () => { + const { profile, env } = codexTerminal(); + for (const args of [ + ["login", "status"], + ["login", "--help"], + ]) { + const result = run(codexScript(), ["codex", ...args], env); + expect(result.status).toBe(0); + expect(result.stdout).toBe(`ran with ${profile}`); + } + }); + + it("keeps an unset CODEX_HOME unset for codex login", () => { + const { env } = codexTerminal(); + const result = run(codexScript(), ["codex", "login"], { + ...env, + CODEX_HOME: undefined, + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe("ran with "); + }); + + it("runs codex login on a home the user named", () => { + const { env } = codexTerminal(); + const result = run(codexScript(), ["codex", "login"], { + ...env, + CODEX_HOME: "/tmp/user-picked-this", + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe("ran with /tmp/user-picked-this"); + }); + + it("runs codex login on the default home when no account is selected", () => { + const { home, ambient, env } = codexTerminal(); + writeFileSync(join(home, "state", "default-codex-home"), ""); + const result = run(codexScript(), ["codex", "login"], { + ...env, + CODEX_HOME: ambient, + SUPERSET_DEFAULT_CODEX_HOME: ambient, + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(`ran with ${ambient}`); + }); + + it("still points agent runs at the selected home", () => { + const { profile, env } = codexTerminal(); + for (const args of [[], ["exec", "log in to the app"]]) { + const result = run(codexScript(), ["codex", ...args], { + ...env, + CODEX_HOME: undefined, + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(`ran with ${profile}`); + } + }); + + it("refuses claude auth login on the selected config dir, not auth status", () => { + const { home, profile } = makeHome(null); + writeFileSync(join(home, "state", "default-claude-config-dir"), profile); + const env = { + SUPERSET_TERMINAL_ID: "t1", + SUPERSET_HOME_DIR: home, + CLAUDE_CONFIG_DIR: profile, + SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: profile, + }; + const login = run(claudeScript(), ["claude", "auth", "login"], env); + expect(login.status).toBe(2); + expect(login.stdout).toBe(""); + expect(login.stderr).toContain(profile); + const status = run(claudeScript(), ["claude", "auth", "status"], env); + expect(status.status).toBe(0); + expect(status.stdout).toBe(`ran with ${profile}`); + }); + + it("asks on a terminal and continues only on yes", async () => { + const { profile, env } = codexTerminal(); + const yes = await runOnTerminal( + codexScript(), + ["codex", "login"], + env, + "y", + ); + const [prompt, afterPrompt = ""] = yes.output.split("[y/N]"); + expect(prompt).toContain(profile); + expect(afterPrompt).toContain(`ran with ${profile}`); + expect(yes.status).toBe(0); + const no = await runOnTerminal(codexScript(), ["codex", "login"], env, "n"); + expect(no.output).toContain("[y/N]"); + expect(no.output).not.toContain("ran with"); + expect(no.status).toBe(1); + }); +}); From c195a2729acc8268a4d66d7a1a715912157f8dba Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Sat, 31 Oct 2026 00:15:01 -0400 Subject: [PATCH 2/4] fix(agent-setup): find sign-in commands after root options The match stopped at the first argument that starts with "-", so a sign-in written with a root option before the subcommand (`codex -c key=value login`, `codex --enable hooks logout`, `claude --verbose auth login`) was not recognised. It ran on the selected account without asking, and `env -u CODEX_HOME codex -c key=value login` got CODEX_HOME filled in from the pointer file again. Codex takes its global options before any subcommand, and the wrapper's own exec line puts some there. The wrappers now look for the command words as whole arguments anywhere before `--`: `login` or `logout` for Codex, `auth` and then `login` or `logout` for Claude. `codex login status` stays read-only, and a launch that asks for help (-h, --help, or help first) is left alone. Because only whole arguments count, a one-argument prompt such as `claude "auth login"` starts a chat instead of stopping at the guard. Co-Authored-By: Claude Opus 5.5 --- .../agent-wrappers-claude-codex-opencode.ts | 1 + .../agent-setup/src/agent-wrappers-common.ts | 75 ++++++++++-- .../agent-setup/src/agent-wrappers.test.ts | 111 +++++++++++++++--- .../src/default-account-resolver.test.ts | 95 +++++++++++++-- 4 files changed, 242 insertions(+), 40 deletions(-) diff --git a/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts b/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts index 7d10ce93489..e7f1f409642 100644 --- a/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts +++ b/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts @@ -324,6 +324,7 @@ export function createCodexWrapper(): void { { ambientEnvVar: "SUPERSET_AMBIENT_CODEX_HOME", credentialCommands: ["login", "logout"], + readOnlyCommands: ["login status"], }, ), }, diff --git a/packages/agent-setup/src/agent-wrappers-common.ts b/packages/agent-setup/src/agent-wrappers-common.ts index d70c9c595f6..acce1ff62cd 100644 --- a/packages/agent-setup/src/agent-wrappers-common.ts +++ b/packages/agent-setup/src/agent-wrappers-common.ts @@ -140,8 +140,20 @@ function buildRealBinaryResolver(): string { export interface DefaultAccountResolverOptions { /** Holds the system-default value while a selected account is injected. */ ambientEnvVar?: string; - /** Leading words of the commands that sign in or out, e.g. "auth login". */ + /** + * Commands that sign in or out, as their words: "login", "auth login". + * Each word has to be a whole argument, in that order, before any `--`. + * Other arguments may come before or between the words, so a root option + * ahead of the subcommand (`codex -c key=value login`) still matches, and + * a one-argument prompt ("auth login") does not. A launch that asks for + * help (`-h`, `--help`, or `help` first) never matches. + */ credentialCommands?: readonly string[]; + /** + * A credential command followed directly by words that make it only read + * the login, e.g. "login status". Such a launch is not a credential command. + */ + readOnlyCommands?: readonly string[]; } /** @@ -161,7 +173,11 @@ export function buildDefaultAccountResolver( pointerName: string, options: DefaultAccountResolverOptions = {}, ): string { - const { ambientEnvVar, credentialCommands = [] } = options; + const { + ambientEnvVar, + credentialCommands = [], + readOnlyCommands = [], + } = options; const pointer = `"$SUPERSET_HOME_DIR/state/${pointerName}"`; const restoreSystemDefault = ambientEnvVar ? `if [ -n "\${${ambientEnvVar}}" ]; then @@ -175,7 +191,7 @@ export function buildDefaultAccountResolver( unset SUPERSET_DEFAULT_${envVar}`; const guardsCredentials = credentialCommands.length > 0; const matchCredentialCommand = guardsCredentials - ? buildCredentialCommandMatch(credentialCommands) + ? buildCredentialCommandMatch(credentialCommands, readOnlyCommands) : ""; const keepUnsetForCredentials = guardsCredentials ? ` \\ @@ -199,21 +215,54 @@ ${guardCredentialCommand} `; } -/** Sets `superset_credential_command` when the arguments start with one. */ -function buildCredentialCommandMatch(commands: readonly string[]): string { - const pattern = commands.map((command) => `"${command}"`).join("|"); - return `superset_command_words="" +const CREDENTIAL_COMMAND_WORD = /^[a-z]+$/; + +/** + * Sets `superset_credential_command` (e.g. "codex login") when the arguments + * hold one of `commands`. Each argument before `--` adds one token to + * `superset_command_tokens`, wrapped in spaces: a command word stays itself + * and anything else becomes ".". The patterns below therefore only ever + * match whole arguments, never text inside a prompt or an option value. + */ +function buildCredentialCommandMatch( + commands: readonly string[], + readOnlyCommands: readonly string[], +): string { + const wordsOf = (command: string) => command.split(" "); + const words = [ + ...new Set([...commands, ...readOnlyCommands].flatMap(wordsOf)), + ]; + for (const word of words) { + if (!CREDENTIAL_COMMAND_WORD.test(word)) { + throw new Error(`Unsupported credential command word: "${word}"`); + } + } + const inOrder = (command: string) => + `*${wordsOf(command) + .map((word) => `" ${word} "`) + .join("*")}*`; + const adjacent = (command: string) => `*" ${wordsOf(command).join(" ")} "*`; + const matches = commands.map((command) => { + const readOnly = readOnlyCommands + .filter((other) => other.startsWith(`${command} `)) + .map((other) => `\n ${adjacent(other)}) ;;`) + .join(""); + return `case "$superset_command_tokens" in${readOnly} + ${inOrder(command)}) superset_credential_command="\${0##*/} ${command}" ;; +esac`; + }); + return `superset_command_tokens="" for superset_arg in "$@"; do case "$superset_arg" in - -h|--help) superset_command_words=""; break ;; - -*) break ;; + --) break ;; + -h|--help) superset_command_tokens=""; break ;; + ${words.join("|")}) superset_command_tokens="$superset_command_tokens $superset_arg " ;; + *) superset_command_tokens="$superset_command_tokens . " ;; esac - superset_command_words="\${superset_command_words:+$superset_command_words }$superset_arg" done +case "$1" in help) superset_command_tokens="" ;; esac superset_credential_command="" -case "$superset_command_words" in - ${pattern}) superset_credential_command="\${0##*/} $superset_command_words" ;; -esac +${matches.join("\n")} `; } diff --git a/packages/agent-setup/src/agent-wrappers.test.ts b/packages/agent-setup/src/agent-wrappers.test.ts index b8ca660ca30..a592b585931 100644 --- a/packages/agent-setup/src/agent-wrappers.test.ts +++ b/packages/agent-setup/src/agent-wrappers.test.ts @@ -922,46 +922,121 @@ exit 0 expect(existsSync(notifyCapturePath)).toBe(false); }); - it("refuses a login on the selected account without a terminal", () => { + /** + * Generated codex and claude wrappers over stub CLIs that record the home + * they got and their arguments, in a workspace terminal where `profile` + * is the selected account for both. + */ + function setUpSelectedAccountWrappers() { const realBinDir = path.join(TEST_ROOT, "real-bin"); const ranFile = path.join(TEST_ROOT, "real-cli-ran.txt"); const profile = path.join(TEST_ROOT, "selected-profile"); + const ambient = path.join(TEST_ROOT, "codex-default"); mkdirSync(realBinDir, { recursive: true }); mkdirSync(profile, { recursive: true }); - for (const name of ["codex", "claude"]) { + for (const [name, envVar] of [ + ["codex", "CODEX_HOME"], + ["claude", "CLAUDE_CONFIG_DIR"], + ]) { writeFileSync( path.join(realBinDir, name), - `#!/bin/bash\nprintf '%s\\n' "$*" >> "${ranFile}"\n`, + `#!/bin/bash\nprintf '%s %s\\n' "\${${envVar}:-}" "$*" >> "${ranFile}"\n`, { mode: 0o755 }, ); } createCodexWrapper(); createClaudeWrapper(); - - const env = { + const env: Record = { ...process.env, PATH: `${TEST_BIN_DIR}:${realBinDir}:${process.env.PATH || ""}`, SUPERSET_TERMINAL_ID: "terminal-1", CODEX_HOME: profile, SUPERSET_DEFAULT_CODEX_HOME: profile, - SUPERSET_AMBIENT_CODEX_HOME: path.join(TEST_ROOT, "codex-default"), + SUPERSET_AMBIENT_CODEX_HOME: ambient, CLAUDE_CONFIG_DIR: profile, SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: profile, }; - const codex = spawnSync(path.join(TEST_BIN_DIR, "codex"), ["login"], { - env, - encoding: "utf-8", - }); - const claude = spawnSync( - path.join(TEST_BIN_DIR, "claude"), - ["auth", "login"], - { env, encoding: "utf-8" }, + const runCli = ( + name: string, + args: string[], + overrides: Record = {}, + ) => { + const merged: Record = {}; + for (const [key, value] of Object.entries({ ...env, ...overrides })) { + if (value !== undefined) merged[key] = value; + } + return spawnSync(path.join(TEST_BIN_DIR, name), args, { + env: merged, + encoding: "utf-8", + }); + }; + const ran = () => + existsSync(ranFile) ? readFileSync(ranFile, "utf-8") : ""; + return { ambient, profile, runCli, ran }; + } + + it("refuses every sign-in on the selected account without a terminal", () => { + const { profile, runCli, ran } = setUpSelectedAccountWrappers(); + for (const [name, ...args] of [ + ["codex", "login"], + ["codex", "logout"], + ["codex", "-c", "model=x", "login"], + ["codex", "--enable", "hooks", "logout"], + ["claude", "auth", "login"], + ["claude", "auth", "logout"], + ["claude", "--verbose", "auth", "login"], + ["claude", "-d", "auth", "logout"], + ]) { + const result = runCli(name, args); + expect({ name, args, status: result.status }).toEqual({ + name, + args, + status: 2, + }); + expect(result.stderr).toContain(profile); + } + expect(ran()).toBe(""); + }); + + it("keeps an unset CODEX_HOME unset for a wrapped login with root options", () => { + const { profile, runCli, ran } = setUpSelectedAccountWrappers(); + mkdirSync(path.join(TEST_SUPERSET_HOME_DIR, "state"), { recursive: true }); + writeFileSync( + path.join(TEST_SUPERSET_HOME_DIR, "state", "default-codex-home"), + profile, ); + const result = runCli("codex", ["-c", "model=x", "login"], { + CODEX_HOME: undefined, + }); + expect(result.status).toBe(0); + expect(ran()).toStartWith(" "); + }); - expect([codex.status, claude.status]).toEqual([2, 2]); - expect(codex.stderr).toContain(profile); - expect(claude.stderr).toContain(profile); - expect(existsSync(ranFile)).toBe(false); + it("runs a wrapped codex login on the system default without asking", () => { + const { ambient, runCli, ran } = setUpSelectedAccountWrappers(); + const result = runCli("codex", ["login"], { + CODEX_HOME: ambient, + SUPERSET_DEFAULT_CODEX_HOME: ambient, + }); + expect(result.status).toBe(0); + expect(ran()).toStartWith(`${ambient} `); + }); + + it("lets a wrapped codex login status read the selected account", () => { + const { profile, runCli, ran } = setUpSelectedAccountWrappers(); + for (const args of [ + ["login", "status"], + ["-c", "model=x", "login", "status"], + ]) { + const result = runCli("codex", args); + expect({ args, status: result.status }).toEqual({ args, status: 0 }); + } + const [plain, withOption, ...rest] = ran().split("\n").filter(Boolean); + expect(rest).toEqual([]); + expect(plain).toStartWith(`${profile} `); + expect(plain).toContain(" login status"); + expect(withOption).toStartWith(`${profile} `); + expect(withOption).toContain(" -c model=x login status"); }); it("creates mastracode wrapper passthrough", () => { diff --git a/packages/agent-setup/src/default-account-resolver.test.ts b/packages/agent-setup/src/default-account-resolver.test.ts index 3f814a24162..ff59520283f 100644 --- a/packages/agent-setup/src/default-account-resolver.test.ts +++ b/packages/agent-setup/src/default-account-resolver.test.ts @@ -208,6 +208,7 @@ function codexScript(): string { return `${buildDefaultAccountResolver("CODEX_HOME", "default-codex-home", { ambientEnvVar: "SUPERSET_AMBIENT_CODEX_HOME", credentialCommands: ["login", "logout"], + readOnlyCommands: ["login status"], })}printf "ran with %s" "\${CODEX_HOME:-}"`; } @@ -283,6 +284,22 @@ function codexTerminal() { }; } +/** A workspace terminal spawned while `profile` was the selected Claude dir. */ +function claudeTerminal() { + const { home, profile } = makeHome(null); + writeFileSync(join(home, "state", "default-claude-config-dir"), profile); + return { + home, + profile, + env: { + SUPERSET_TERMINAL_ID: "t1", + SUPERSET_HOME_DIR: home, + CLAUDE_CONFIG_DIR: profile, + SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: profile, + }, + }; +} + describe("buildDefaultAccountResolver credential commands", () => { it("refuses codex login and logout on the selected home without a terminal", () => { const { profile, env } = codexTerminal(); @@ -315,14 +332,58 @@ describe("buildDefaultAccountResolver credential commands", () => { expect(result.stdout).toBe(""); }); + it("refuses codex sign-ins with options before or after the subcommand", () => { + const { profile, env } = codexTerminal(); + for (const args of [ + ["-c", "model=x", "login"], + ["--config", "model=x", "logout"], + ["--enable", "hooks", "logout"], + ["-p", "work", "login"], + ["login", "--with-api-key"], + // An option value that reads "status" is not `codex login status`. + ["login", "--experimental_issuer", "status"], + ]) { + const result = run(codexScript(), ["codex", ...args], env); + expect({ args, status: result.status }).toEqual({ args, status: 2 }); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(profile); + } + }); + + it("keeps an unset CODEX_HOME unset when root options come first", () => { + const { env } = codexTerminal(); + const result = run(codexScript(), ["codex", "-c", "model=x", "login"], { + ...env, + CODEX_HOME: undefined, + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe("ran with "); + }); + it("lets codex login status and help read the selected home", () => { const { profile, env } = codexTerminal(); for (const args of [ ["login", "status"], + ["-c", "model=x", "login", "status"], ["login", "--help"], + ["-c", "model=x", "logout", "-h"], + ["help", "login"], ]) { const result = run(codexScript(), ["codex", ...args], env); - expect(result.status).toBe(0); + expect({ args, status: result.status }).toEqual({ args, status: 0 }); + expect(result.stdout).toBe(`ran with ${profile}`); + } + }); + + it("matches command words only as whole arguments before --", () => { + const { profile, env } = codexTerminal(); + for (const args of [ + ["exec", "--", "login"], + ["exec", "please login"], + ["-c", "login=1"], + ]) { + const result = run(codexScript(), ["codex", ...args], env); + expect({ args, status: result.status }).toEqual({ args, status: 0 }); expect(result.stdout).toBe(`ran with ${profile}`); } }); @@ -372,14 +433,7 @@ describe("buildDefaultAccountResolver credential commands", () => { }); it("refuses claude auth login on the selected config dir, not auth status", () => { - const { home, profile } = makeHome(null); - writeFileSync(join(home, "state", "default-claude-config-dir"), profile); - const env = { - SUPERSET_TERMINAL_ID: "t1", - SUPERSET_HOME_DIR: home, - CLAUDE_CONFIG_DIR: profile, - SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: profile, - }; + const { profile, env } = claudeTerminal(); const login = run(claudeScript(), ["claude", "auth", "login"], env); expect(login.status).toBe(2); expect(login.stdout).toBe(""); @@ -389,6 +443,29 @@ describe("buildDefaultAccountResolver credential commands", () => { expect(status.stdout).toBe(`ran with ${profile}`); }); + it("refuses claude auth login and logout with options before them", () => { + const { profile, env } = claudeTerminal(); + for (const args of [ + ["--verbose", "auth", "login"], + ["-d", "auth", "logout"], + ["--settings", "/dev/null", "auth", "login", "--console"], + ]) { + const result = run(claudeScript(), ["claude", ...args], env); + expect({ args, status: result.status }).toEqual({ args, status: 2 }); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(profile); + } + }); + + it("starts a claude chat whose one-argument prompt reads auth login", () => { + const { profile, env } = claudeTerminal(); + for (const args of [["auth login"], ["-p", "auth login"]]) { + const result = run(claudeScript(), ["claude", ...args], env); + expect({ args, status: result.status }).toEqual({ args, status: 0 }); + expect(result.stdout).toBe(`ran with ${profile}`); + } + }); + it("asks on a terminal and continues only on yes", async () => { const { profile, env } = codexTerminal(); const yes = await runOnTerminal( From c5345cfc4863f4e2f25972a4ba46488cb414dae7 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Sat, 31 Oct 2026 00:17:45 -0400 Subject: [PATCH 3/4] fix(agent-setup): print nothing to run when refusing, ask through /dev/tty Without a terminal the refusal printed two runnable lines: one ran the same sign-in on the selected account with no confirmation, the other sent it to the CLI's default home. In a workspace most callers without a terminal are agents' tool calls, so an agent could follow either line and end the login the guard is there to protect. The refusal now names the home and says a person has to confirm in a terminal, and prints no command. docs/agent-tooling.md describes the override for scripts. The question also needed stdin to be a terminal, so a sign-in that pipes its input was refused inside an interactive terminal. That includes the Usage card's re-sign command for an API-billed Codex profile, which pipes the key into `codex login --with-api-key`. The wrapper now asks whenever stderr is a terminal, and reads the answer from /dev/tty when stdin is not one. After a "no", the hint points at the system default: for Codex that is the preserved ambient home rather than the CLI's own ~/.codex. The hint keeps the original arguments, so `--with-api-key` and `--device-auth` are no longer dropped. Co-Authored-By: Claude Opus 5.5 --- docs/agent-tooling.md | 13 ++- .../agent-setup/src/agent-wrappers-common.ts | 62 ++++++++++---- .../agent-setup/src/agent-wrappers.test.ts | 52 +++++++++++- .../src/default-account-resolver.test.ts | 84 ++++++++++++++++++- 4 files changed, 189 insertions(+), 22 deletions(-) diff --git a/docs/agent-tooling.md b/docs/agent-tooling.md index efaa9f7115b..d7bb2cb3cf6 100644 --- a/docs/agent-tooling.md +++ b/docs/agent-tooling.md @@ -52,10 +52,15 @@ and agent launch by `packages/host-service/src/trpc/router/usage/default-account publishes the selection to `$SUPERSET_HOME_DIR/state/default-*` pointer files. The agent wrappers re-read those at every launch (`buildDefaultAccountResolver` in agent-setup), so a switch reaches existing terminals the next time the agent starts; a value the user exported by hand always wins. -Sign-in commands (`codex login`/`logout`, `claude auth login`/`logout`) keep an unset value unset. -Before one changes the login of the selected account, the wrapper names that account and asks for -confirmation, or refuses when there is no terminal: current Codex releases log the old login out -before they sign in. +Sign-in commands (`codex login`/`logout`, `claude auth login`/`logout`, root options before the +subcommand included) keep an unset value unset. Before one changes the login of the selected +account, the wrapper names that account and asks for confirmation on the terminal, because current +Codex releases log the old login out before they sign in. The answer comes from `/dev/tty` when +stdin carries the CLI's input (`printf '%s' "$KEY" | codex login --with-api-key`). With no terminal +to ask on, the wrapper exits 2 and runs nothing. A script that means to change the selected +account's login empties the `SUPERSET_DEFAULT_*` marker for that one command, e.g. +`SUPERSET_DEFAULT_CODEX_HOME= CODEX_HOME= codex login`. Only launches through the wrappers are +asked about; the real binary run by path, or a sign-in from inside a running session, is not. Paradev never touches the credential stores; the provider CLIs own every login end to end. Because those CLIs read *everything* from their active config dir, a second dir would otherwise diff --git a/packages/agent-setup/src/agent-wrappers-common.ts b/packages/agent-setup/src/agent-wrappers-common.ts index acce1ff62cd..fa4ecc38207 100644 --- a/packages/agent-setup/src/agent-wrappers-common.ts +++ b/packages/agent-setup/src/agent-wrappers-common.ts @@ -266,6 +266,17 @@ ${matches.join("\n")} `; } +/** + * Stops a credential command that would change the selected account's login + * until the user confirms on a terminal. stderr has to be a terminal, so the + * question is seen; the answer comes from stdin, or from /dev/tty when stdin + * carries the CLI's own input (`printf '%s' "$KEY" | codex login + * --with-api-key`). Without a terminal the command does not run, and the + * refusal names the home but no command to run instead: in a workspace, the + * callers without a terminal are mostly agents' tool calls. A + * pseudo-terminal counts as a terminal, so an agent that drives one can + * still answer. After a "no", the hint points at the system default. + */ function buildCredentialCommandGuard( envVar: string, ambientEnvVar?: string, @@ -273,25 +284,48 @@ function buildCredentialCommandGuard( const notSystemDefault = ambientEnvVar ? ` && [ "\${${envVar}}" != "\${${ambientEnvVar}}" ]` : ""; - const unsetHint = `To run it without the selected account: env -u ${envVar} %s\\n`; + const cancelled = + "Paradev: cancelled. To run it on the system default instead:"; + const unsetHint = `printf '${cancelled} env -u ${envVar} %s' "\${0##*/}" >&2`; + const systemDefaultHint = ambientEnvVar + ? `if [ -n "\${${ambientEnvVar}}" ]; then + printf '${cancelled} ${envVar}=%q %s' "\${${ambientEnvVar}}" "\${0##*/}" >&2 + else + ${unsetHint} + fi` + : unsetHint; return `if [ -n "$superset_credential_command" ] && [ -n "\${${envVar}}" ] \\ && [ "\${${envVar}}" = "\${SUPERSET_DEFAULT_${envVar}}" ]${notSystemDefault}; then - if [ -t 0 ] && [ -t 2 ]; then - printf 'Paradev: %s will change the login in %s, the account you made the default for new agents. Continue? [y/N] ' \\ + superset_reply_from="" + if [ -t 2 ]; then + if [ -t 0 ]; then + superset_reply_from="stdin" + elif (: /dev/null; then + superset_reply_from="tty" + fi + fi + if [ -z "$superset_reply_from" ]; then + printf 'Paradev: %s would change the login in %s, the account you made the default for new agents. Nothing was run: a person has to confirm this in a terminal.\\n' \\ "$superset_credential_command" "\${${envVar}}" >&2 - read -r superset_reply - case "$superset_reply" in - [yY]|[yY][eE][sS]) ;; - *) - printf 'Paradev: cancelled. ${unsetHint}' "$superset_credential_command" >&2 - exit 1 - ;; - esac - else - printf 'Paradev: %s would change the login in %s, the account you made the default for new agents.\\nTo use that account, confirm in a terminal or run: SUPERSET_DEFAULT_${envVar}= ${envVar}=%q %s\\n${unsetHint}' \\ - "$superset_credential_command" "\${${envVar}}" "\${${envVar}}" "$superset_credential_command" "$superset_credential_command" >&2 exit 2 fi + printf 'Paradev: %s will change the login in %s, the account you made the default for new agents. Continue? [y/N] ' \\ + "$superset_credential_command" "\${${envVar}}" >&2 + superset_reply="" + if [ "$superset_reply_from" = "tty" ]; then + read -r superset_reply &2 + printf '\\n' >&2 + exit 1 + ;; + esac fi `; } diff --git a/packages/agent-setup/src/agent-wrappers.test.ts b/packages/agent-setup/src/agent-wrappers.test.ts index a592b585931..e46d0e59817 100644 --- a/packages/agent-setup/src/agent-wrappers.test.ts +++ b/packages/agent-setup/src/agent-wrappers.test.ts @@ -940,7 +940,7 @@ exit 0 ]) { writeFileSync( path.join(realBinDir, name), - `#!/bin/bash\nprintf '%s %s\\n' "\${${envVar}:-}" "$*" >> "${ranFile}"\n`, + `#!/bin/bash\n[ -t 0 ] || superset_stdin="$(cat)"\nprintf '%s %s stdin=%s\\n' "\${${envVar}:-}" "$*" "$superset_stdin" >> "${ranFile}"\n`, { mode: 0o755 }, ); } @@ -972,7 +972,38 @@ exit 0 }; const ran = () => existsSync(ranFile) ? readFileSync(ranFile, "utf-8") : ""; - return { ambient, profile, runCli, ran }; + /** Runs `command` under bash on a pseudo-terminal, answering the prompt. */ + const runOnTerminal = async (command: string, answer: string) => { + const decoder = new TextDecoder(); + let output = ""; + let answered = false; + let onClose: (() => void) | undefined; + const closed = new Promise((resolve) => { + onClose = resolve; + }); + const merged: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) merged[key] = value; + } + const proc = Bun.spawn(["bash", "-c", command], { + env: merged, + terminal: { + data(terminal, chunk) { + output += decoder.decode(chunk, { stream: true }); + if (!answered && output.includes("[y/N]")) { + answered = true; + terminal.write(`${answer}\n`); + } + }, + exit: () => onClose?.(), + }, + }); + const status = await proc.exited; + await closed; + proc.terminal?.close(); + return { status, output }; + }; + return { ambient, profile, runCli, ran, runOnTerminal }; } it("refuses every sign-in on the selected account without a terminal", () => { @@ -994,10 +1025,27 @@ exit 0 status: 2, }); expect(result.stderr).toContain(profile); + expect(result.stderr).not.toContain("SUPERSET_DEFAULT_"); + expect(result.stderr).not.toContain("env -u"); } expect(ran()).toBe(""); }); + it("asks before a piped API-key login on the selected account", async () => { + const { profile, ran, runOnTerminal } = setUpSelectedAccountWrappers(); + // The shape of the Usage card's re-sign command for an API-billed + // profile: the key arrives on stdin, so only /dev/tty can answer. + const command = `printf '%s' sk-test | "${path.join(TEST_BIN_DIR, "codex")}" login --with-api-key`; + const no = await runOnTerminal(command, "n"); + expect(no.output).toContain(profile); + expect(no.status).toBe(1); + expect(ran()).toBe(""); + const yes = await runOnTerminal(command, "y"); + expect(yes.status).toBe(0); + expect(ran()).toStartWith(`${profile} `); + expect(ran()).toContain(" login --with-api-key stdin=sk-test\n"); + }); + it("keeps an unset CODEX_HOME unset for a wrapped login with root options", () => { const { profile, runCli, ran } = setUpSelectedAccountWrappers(); mkdirSync(path.join(TEST_SUPERSET_HOME_DIR, "state"), { recursive: true }); diff --git a/packages/agent-setup/src/default-account-resolver.test.ts b/packages/agent-setup/src/default-account-resolver.test.ts index ff59520283f..bab7f0cb8a9 100644 --- a/packages/agent-setup/src/default-account-resolver.test.ts +++ b/packages/agent-setup/src/default-account-resolver.test.ts @@ -265,6 +265,25 @@ async function runOnTerminal( return { status, output }; } +/** + * Runs `script` on a pseudo-terminal with `input` piped into its stdin, so + * only stderr and /dev/tty are the terminal, and types `answer` at the prompt. + */ +function runPipedOnTerminal( + script: string, + argv: string[], + env: Record, + input: string, + answer: string, +): Promise<{ status: number; output: string }> { + return runOnTerminal( + `printf "%s" "$1" | bash -c "$0" "\${@:2}"`, + [script, input, ...argv], + env, + answer, + ); +} + /** A workspace terminal spawned while `profile` was the selected Codex home. */ function codexTerminal() { const { home, profile } = makeHome(null); @@ -309,10 +328,14 @@ describe("buildDefaultAccountResolver credential commands", () => { expect(result.stdout).toBe(""); expect(result.stderr).toContain(`codex ${command}`); expect(result.stderr).toContain(profile); + expect(result.stderr).toContain("confirm this in a terminal"); + // Nothing a caller could run to skip the question or sign in elsewhere. + expect(result.stderr).not.toContain("CODEX_HOME="); + expect(result.stderr).not.toContain("env -u"); } }); - it("runs the override the refusal suggests", () => { + it("runs the override documented for scripts", () => { const { profile, env } = codexTerminal(); const result = run(codexScript(), ["codex", "login"], { ...env, @@ -438,6 +461,8 @@ describe("buildDefaultAccountResolver credential commands", () => { expect(login.status).toBe(2); expect(login.stdout).toBe(""); expect(login.stderr).toContain(profile); + expect(login.stderr).not.toContain("CLAUDE_CONFIG_DIR="); + expect(login.stderr).not.toContain("env -u"); const status = run(claudeScript(), ["claude", "auth", "status"], env); expect(status.status).toBe(0); expect(status.stdout).toBe(`ran with ${profile}`); @@ -478,9 +503,64 @@ describe("buildDefaultAccountResolver credential commands", () => { expect(prompt).toContain(profile); expect(afterPrompt).toContain(`ran with ${profile}`); expect(yes.status).toBe(0); - const no = await runOnTerminal(codexScript(), ["codex", "login"], env, "n"); + for (const answer of ["n", ""]) { + const no = await runOnTerminal( + codexScript(), + ["codex", "login"], + env, + answer, + ); + expect({ answer, status: no.status }).toEqual({ answer, status: 1 }); + expect(no.output).toContain("[y/N]"); + expect(no.output).not.toContain("ran with"); + } + }); + + it("points at the system default after a no", async () => { + const { ambient, env } = codexTerminal(); + const codex = await runOnTerminal( + codexScript(), + ["codex", "-c", "model=x", "login", "--device-auth"], + env, + "n", + ); + expect(codex.output).toContain( + `CODEX_HOME=${ambient} codex -c model=x login --device-auth`, + ); + const claude = await runOnTerminal( + claudeScript(), + ["claude", "auth", "login", "--console"], + claudeTerminal().env, + "n", + ); + expect(claude.output).toContain( + "env -u CLAUDE_CONFIG_DIR claude auth login --console", + ); + }); + + it("asks on the terminal when stdin carries input for the CLI", async () => { + const { profile, env } = codexTerminal(); + const script = `${codexScript()}\nprintf " stdin=%s" "$(cat)"`; + const argv = ["codex", "login", "--with-api-key"]; + const yes = await runPipedOnTerminal(script, argv, env, "sk-test", "y"); + const [prompt, afterPrompt = ""] = yes.output.split("[y/N]"); + expect(prompt).toContain(profile); + expect(afterPrompt).toContain(`ran with ${profile} stdin=sk-test`); + expect(yes.status).toBe(0); + const no = await runPipedOnTerminal(script, argv, env, "sk-test", "n"); expect(no.output).toContain("[y/N]"); expect(no.output).not.toContain("ran with"); expect(no.status).toBe(1); }); + + it("leaves sign-ins alone when nothing is selected", () => { + for (const [script, argv] of [ + [codexScript(), ["codex", "login"]], + [claudeScript(), ["claude", "auth", "login"]], + ] as const) { + const result = run(script, [...argv], {}); + expect({ argv, status: result.status }).toEqual({ argv, status: 0 }); + expect(result.stdout).toBe("ran with "); + } + }); }); From da6e8d903580ab5fde71e28be6b2da6f05083935 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Sat, 31 Oct 2026 00:19:04 -0400 Subject: [PATCH 4/4] fix(desktop): re-sign the system-default Codex API login in its own home The Usage card's API re-sign command for the system-default Codex login pointed at "${CODEX_HOME:-$HOME/.codex}". In a Paradev terminal with an account selected, CODEX_HOME holds that account, so the command signed the selected profile in with the key and marked it API-billed. With the wrapper guard it stopped at a question about the selected home instead. It now prefers the ambient home such a terminal preserves (SUPERSET_AMBIENT_CODEX_HOME), which is the home the Usage tab calls the system default, and behaves as before in any other shell. Co-Authored-By: Claude Opus 5.5 --- .../utils/apiBilling/fixtures/shell-checks.ts | 59 ++++++++++++++++++- .../switchSignInCommand.test.ts | 4 +- .../switchSignInCommand.ts | 8 ++- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/apiBilling/fixtures/shell-checks.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/apiBilling/fixtures/shell-checks.ts index 609b139644f..843b3762f17 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/apiBilling/fixtures/shell-checks.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/apiBilling/fixtures/shell-checks.ts @@ -1,11 +1,12 @@ import { afterEach, expect, it } from "bun:test"; // biome-ignore lint/style/noRestrictedImports: shell integration test executes the generated command import { spawnSync } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; // biome-ignore lint/style/noRestrictedImports: shell integration test uses a temporary directory import { tmpdir } from "node:os"; // biome-ignore lint/style/noRestrictedImports: shell integration test uses filesystem paths import { join } from "node:path"; +import { switchSignInCommand } from "../../switchSignInCommand"; import { apiBillingLoginCommand } from "../apiBilling"; const dirs: string[] = []; @@ -40,3 +41,59 @@ for (const exitCode of [0, 1]) ).catch(() => null); expect(marker).toBe(exitCode === 0 ? "codex" : null); }); + +/** + * Runs the system-default Codex API re-sign against a stub codex that + * records the CODEX_HOME it got, in a shell with `env` set, and returns + * where the login and the API-billing marker landed. + */ +async function reSignSystemDefault( + env: (dir: string) => Record, +) { + const dir = await mkdtemp(join(tmpdir(), "superset-api-login-")); + dirs.push(dir); + await Promise.all( + [".codex", "selected", "ambient"].map((name) => mkdir(join(dir, name))), + ); + await writeFile( + join(dir, "codex"), + `#!/bin/sh\ncat >/dev/null\nprintf '%s' "$CODEX_HOME" > '${dir}/ran-with'\n`, + { mode: 0o755 }, + ); + const command = switchSignInCommand({ + agent: "codex", + credentialKind: "api_key", + selection: null, + }); + const result = spawnSync("/bin/bash", ["-c", command], { + cwd: dir, + env: { HOME: dir, PATH: `${dir}:/usr/bin:/bin`, ...env(dir) }, + input: "fake-test-key\n", + encoding: "utf8", + }); + expect(result.status, result.stderr.toString()).toBe(0); + const read = (path: string) => readFile(path, "utf8").catch(() => null); + const markedIn: string[] = []; + for (const name of [".codex", "selected", "ambient"]) { + if (await read(join(dir, name, ".superset-api-billing"))) { + markedIn.push(name); + } + } + return { dir, ranWith: await read(join(dir, "ran-with")), markedIn }; +} + +it("re-signs the ambient Codex home, not the selected one, in a Paradev terminal", async () => { + const { dir, ranWith, markedIn } = await reSignSystemDefault((home) => ({ + CODEX_HOME: join(home, "selected"), + SUPERSET_DEFAULT_CODEX_HOME: join(home, "selected"), + SUPERSET_AMBIENT_CODEX_HOME: join(home, "ambient"), + })); + expect(ranWith).toBe(join(dir, "ambient")); + expect(markedIn).toEqual(["ambient"]); +}); + +it("re-signs the CLI's own Codex home outside a Paradev terminal", async () => { + const { dir, ranWith, markedIn } = await reSignSystemDefault(() => ({})); + expect(ranWith).toBe(join(dir, ".codex")); + expect(markedIn).toEqual([".codex"]); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/switchSignInCommand/switchSignInCommand.test.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/switchSignInCommand/switchSignInCommand.test.ts index 1178370568d..1817f802f88 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/switchSignInCommand/switchSignInCommand.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/switchSignInCommand/switchSignInCommand.test.ts @@ -112,7 +112,7 @@ describe("switchSignInCommand", () => { ); }); - it("marks the system-default Codex home wherever the CLI resolves it", () => { + it("marks the system-default Codex home, not the account a terminal injects", () => { expect( switchSignInCommand({ agent: "codex", @@ -121,7 +121,7 @@ describe("switchSignInCommand", () => { }), ).toContain( // biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion - 'CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" codex login --with-api-key && printf codex > "${CODEX_HOME:-$HOME/.codex}"/.superset-api-billing', + 'CODEX_HOME="${SUPERSET_AMBIENT_CODEX_HOME:-${CODEX_HOME:-$HOME/.codex}}" codex login --with-api-key && printf codex > "${SUPERSET_AMBIENT_CODEX_HOME:-${CODEX_HOME:-$HOME/.codex}}"/.superset-api-billing', ); }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/switchSignInCommand/switchSignInCommand.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/switchSignInCommand/switchSignInCommand.ts index 9800b4d430b..102f70863fc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/switchSignInCommand/switchSignInCommand.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/switchSignInCommand/switchSignInCommand.ts @@ -28,15 +28,17 @@ interface SwitchSignInLogin { */ export function switchSignInCommand(login: SwitchSignInLogin): string { if (login.credentialKind === "api_key") { - // The system-default Codex home is wherever the CLI resolves it; the - // default Claude dir is never API-billed (discovery excludes ~/.claude). + // The system-default Codex home is the ambient one a Paradev terminal + // preserves next to the selected account it injects as CODEX_HOME, and + // wherever the CLI resolves it in any other shell. The default Claude + // dir is never API-billed (discovery excludes ~/.claude). const dir = login.selection !== null ? quoteShellToken(login.selection) : login.agent === "claude" ? '"$HOME/.claude"' : // biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, resolved by the user's shell - '"${CODEX_HOME:-$HOME/.codex}"'; + '"${SUPERSET_AMBIENT_CODEX_HOME:-${CODEX_HOME:-$HOME/.codex}}"'; return apiBillingLoginCommand(login.agent, dir); } if (login.agent === "claude") {