Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down Expand Up @@ -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<string, string>,
) {
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"]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
9 changes: 9 additions & 0 deletions docs/agent-tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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"],
},
),
},
);
Expand Down
159 changes: 156 additions & 3 deletions packages/agent-setup/src/agent-wrappers-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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/tty) 2>/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 </dev/tty
else
read -r superset_reply
fi
case "$superset_reply" in
[yY]|[yY][eE][sS]) ;;
*)
${systemDefaultHint}
printf ' %q' "$@" >&2
printf '\\n' >&2
exit 1
;;
esac
fi
`;
}

Expand Down
Loading
Loading