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") { diff --git a/docs/agent-tooling.md b/docs/agent-tooling.md index ec3ae06f3f4..d7bb2cb3cf6 100644 --- a/docs/agent-tooling.md +++ b/docs/agent-tooling.md @@ -52,6 +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`, 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-claude-codex-opencode.ts b/packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts index 218c9c321d8..e7f1f409642 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,11 @@ export function createCodexWrapper(): void { beforeLaunch: buildDefaultAccountResolver( "CODEX_HOME", "default-codex-home", - "SUPERSET_AMBIENT_CODEX_HOME", + { + 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 bda917fac27..fa4ecc38207 100644 --- a/packages/agent-setup/src/agent-wrappers-common.ts +++ b/packages/agent-setup/src/agent-wrappers-common.ts @@ -137,6 +137,25 @@ function buildRealBinaryResolver(): string { `; } +export interface DefaultAccountResolverOptions { + /** Holds the system-default value while a selected account is injected. */ + ambientEnvVar?: string; + /** + * 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[]; +} + /** * 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 +164,20 @@ 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 = [], + readOnlyCommands = [], + } = options; const pointer = `"$SUPERSET_HOME_DIR/state/${pointerName}"`; const restoreSystemDefault = ambientEnvVar ? `if [ -n "\${${ambientEnvVar}}" ]; then @@ -162,8 +189,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, readOnlyCommands) + : ""; + 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 +211,122 @@ export function buildDefaultAccountResolver( ${restoreSystemDefault} fi fi +${guardCredentialCommand} +`; +} + +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 + --) break ;; + -h|--help) superset_command_tokens=""; break ;; + ${words.join("|")}) superset_command_tokens="$superset_command_tokens $superset_arg " ;; + *) superset_command_tokens="$superset_command_tokens . " ;; + esac +done +case "$1" in help) superset_command_tokens="" ;; esac +superset_credential_command="" +${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, +): string { + const notSystemDefault = ambientEnvVar + ? ` && [ "\${${envVar}}" != "\${${ambientEnvVar}}" ]` + : ""; + 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 + 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 + 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 a380745eaa1..e46d0e59817 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,171 @@ exit 0 expect(existsSync(notifyCapturePath)).toBe(false); }); + /** + * 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, envVar] of [ + ["codex", "CODEX_HOME"], + ["claude", "CLAUDE_CONFIG_DIR"], + ]) { + writeFileSync( + path.join(realBinDir, name), + `#!/bin/bash\n[ -t 0 ] || superset_stdin="$(cat)"\nprintf '%s %s stdin=%s\\n' "\${${envVar}:-}" "$*" "$superset_stdin" >> "${ranFile}"\n`, + { mode: 0o755 }, + ); + } + createCodexWrapper(); + createClaudeWrapper(); + 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: ambient, + CLAUDE_CONFIG_DIR: profile, + SUPERSET_DEFAULT_CLAUDE_CONFIG_DIR: profile, + }; + 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") : ""; + /** 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", () => { + 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(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 }); + 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(" "); + }); + + 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", () => { 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..bab7f0cb8a9 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,374 @@ 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"], + readOnlyCommands: ["login status"], + })}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 }; +} + +/** + * 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); + 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, + }, + }; +} + +/** 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(); + 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); + 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 documented for scripts", () => { + 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("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({ 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}`); + } + }); + + 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 { profile, env } = claudeTerminal(); + const login = run(claudeScript(), ["claude", "auth", "login"], env); + 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}`); + }); + + 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( + 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); + 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 "); + } + }); +});