Files
shockbot/utils/codexAuth.ts
T
Colin McDonnell a78b1542da feat: pullfrog auth codex + fresh-branch (#757)
* feat: pullfrog auth codex + fresh-branch

Add `pullfrog auth codex` standalone command for minting Codex
(ChatGPT) subscription credentials and saving them as the
`CODEX_AUTH_JSON` Pullfrog secret.

Codex device-auth runs in a subprocess with an isolated `CODEX_HOME`
(temp dir) so the user's `~/.codex/auth.json` is never touched. The
spawned `codex login --device-auth` output is captured line-by-line,
ANSI-stripped, and re-rendered with a `$ codex login --device-auth`
header above dimmed sub-output on the @clack/prompts rail so the user
visually understands they're seeing a sub-process.

Companion `pnpm fresh-branch` script: from inside `.worktrees/<name>`,
creates a schema-only Neon branch named `dev/<git-branch>`, patches the
worktree's `.env` (DATABASE_URL, DATABASE_URL_UNPOOLED, NEON_DEV_BRANCH),
then runs `prisma migrate reset --force` so migrations apply cleanly
against a data-free copy. Refuses to run from the primary checkout or
on protected branch names.

Other:
- bump CLI/account/repo secret value limit 4096 -> 49152 chars (matches
  GitHub Actions' 48KB cap; auth.json is ~4-5KB)
- extract shared CLI helpers (gh/pullfrog API, secret save) into
  `action/commands/_shared.ts`

* fix(auth): address PR review + add CodexAuthCallout, default account scope

Review fixes:
- handle 'error' event from `codex` spawn (ENOENT) so missing PATH bails
  with an actionable "install codex CLI" message instead of an unhandled
  Node error
- escalate SIGTERM -> SIGKILL after 5s grace when killing a stuck codex
  child so the CLI can't get pinned indefinitely
- stop the spinner with a red "failed" glyph in the catch path before
  clearing activeSpin, mirroring `bail` (no orphan spinner above errors)
- enforce 48 KB secret value cap by *bytes* (Buffer.byteLength) not
  UTF-16 code units, across all 3 secret routes; matches GH Actions'
  byte-based limit
- preserve existing blank lines + comments when fresh-branch rewrites
  worktree .env (no more cosmetic reformat on every run)

Scope:
- default to `account` scope on org-owned repos too — never silently
  prompt for repo scope. Pullfrog has no per-GitHub-user secret store,
  so account is right for both user and org owners; `--scope repo` is
  the explicit opt-in for repo-only.

UI:
- new CodexAuthCallout (sibling to ClaudeCodeOAuthCallout); surfaces
  `pullfrog auth codex` for ChatGPT subscribers when an OpenAI provider
  model is selected. wired into AgentSettings.tsx (model-costs surface)
  and OnboardingCard.tsx (first-time setup). no paste button — the CLI
  handles minting + saving end-to-end.

* auth/codex: rename to neon-fresh-branch, address PR review

- rename `pnpm fresh-branch` → `pnpm neon-fresh-branch` (and the script
  file) to disambiguate from git branches.
- `--scope` help text now explains the default (account) and when to
  pass `repo`.
- move `_shared.ts` import up with the rest in `action/commands/auth.ts`
  and push the `stripAnsi` helper below the import block.
- `sanitizeBranchName` no longer slices: slicing after trim could
  reintroduce a trailing `-`/`/`. callers slice the raw input first,
  then sanitize.
- DRY the `start` branch of the codex progress callback (single
  header path, optional retry log).
- thread a `timedOut` flag from `runDeviceAuth` → `ProgressEvent.exit`
  so the retry prompt can say "device authorization timed out — retry?"
  instead of the generic "no auth.json was written" line when the
  per-attempt timeout fires.
- drop the redundant `mkdirSync` after `mkdtempSync` in `codexAuth.ts`.

* untrack .scratch/ (committed screenshot fixture by mistake)

* auth codex: prompt for scope on orgs (mirrors init)

* revert worktree.ts: out of scope for this PR

* anneal: trim _shared.ts dead exports, collapse CodexSpawnError, inline packageBin

* codex auth: wire end-to-end runtime consumer

CODEX_AUTH_JSON is now actually usable: the action runtime materializes
it as OpenCode's auth.json at the runner's real $HOME/.local/share/opencode,
OpenCode routes openai requests through the ChatGPT subscription via the
embedded CodexAuthPlugin, and a GitHub Actions post: hook detects any
refresh-chain rotation during the run and PUTs it back to Pullfrog via a
new JWT-authenticated PUT /api/runtime/secret endpoint.

Key decisions:

- Write to the real $HOME (not the per-run tmpdir-redirected HOME) so the
  file lives outside OpenCode's `/tmp/*` permission allow zone — its
  existing deny-default protects it without any new permission rule.
- Materialization gated on agent === opencode (Codex auth is OpenAI-only,
  Claude never sees the file).
- Defense-in-depth on Claude: deny Read/Grep/Edit/Glob + sandbox.denyRead
  for ~/.local/share/opencode/auth.json in managedSettings (covers Bash
  file-reading commands too per Claude Code permissions docs).
- New `provider.managedCredentials` field on the provider config — CLI-only
  credentials authored via `pullfrog auth <provider>`. Counted for
  hasAnyKey/log-redaction but never surfaced as a paste option in init.
  CODEX_AUTH_JSON is the first member; OPENAI_API_KEY stays in envVars.
- Eager refresh on `pullfrog auth codex`: one OAuth round-trip before
  setPullfrogSecret so Pullfrog's copy is the freshest in the chain
  (avoids the user's laptop refreshing first and stranding our copy).
- Post-hook approach for write-back so it survives cancellation, timeouts,
  and unhandled errors in the main step. State is ferried via
  core.saveState since apiToken is run-scoped and not in env.
- Server-side write-back endpoint is allowlist-gated to CODEX_AUTH_JSON
  only — never a generic secret-write surface. Looks up the secret at
  repo scope first, falls back to account scope. 404s on create
  (refresh-only, never auto-provision).

* codex auth: documentation + wiki cross-links

* debug: log dbSecrets keys + CODEX_AUTH_JSON presence (temporary)

* debug: surface install path + parse failure preview

* remove debug log lines (E2E verified)

* hide CodexAuthCallout until opencode-ai bump (1.1.56's allowed-models set excludes gpt-5.5)
2026-05-16 05:06:24 +00:00

284 lines
10 KiB
TypeScript

import { spawn } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/**
* minted Codex subscription credential. raw `auth.json` body that Codex CLI /
* OpenCode plugins consume. validated to be `auth_mode: "chatgpt"` with a
* refresh token before being returned. caller is responsible for storing it
* (typically as the `CODEX_AUTH_JSON` Pullfrog secret).
*/
export interface CodexAuth {
/** raw JSON body of the minted `auth.json`; safe to persist verbatim. */
json: string;
/** parsed for caller convenience; mirrors the shape Codex CLI writes. */
parsed: CodexAuthJson;
}
export interface CodexAuthJson {
auth_mode: "chatgpt";
tokens: {
access_token: string;
id_token?: string;
refresh_token: string;
account_id?: string;
};
last_refresh?: string;
}
/** OAuth client id Codex CLI and OpenCode both use against `auth.openai.com`.
* Same chain — a refresh token minted via `codex login --device-auth` can be
* refreshed against this client_id. */
const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
interface OAuthTokenResponse {
access_token: string;
refresh_token: string;
id_token?: string;
expires_in?: number;
}
/** force one refresh round-trip against the OAuth provider so the saved
* credential carries the freshest refresh token. used right after `codex
* login --device-auth` and again any time we want to bump the chain before
* persisting (avoids the user's laptop refreshing first and burning ours). */
export async function refreshCodexAuth(auth: CodexAuth): Promise<CodexAuth> {
const response = await fetch(CODEX_OAUTH_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: auth.parsed.tokens.refresh_token,
client_id: CODEX_OAUTH_CLIENT_ID,
}).toString(),
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`Codex token refresh failed: ${response.status} ${body}`);
}
const tokens = (await response.json()) as OAuthTokenResponse;
const idToken = tokens.id_token ?? auth.parsed.tokens.id_token;
const accountId = auth.parsed.tokens.account_id;
const refreshed: CodexAuthJson = {
auth_mode: "chatgpt",
tokens: {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
...(idToken ? { id_token: idToken } : {}),
...(accountId ? { account_id: accountId } : {}),
},
last_refresh: new Date().toISOString(),
};
return { json: `${JSON.stringify(refreshed, null, 2)}\n`, parsed: refreshed };
}
export type ProgressEvent =
| { kind: "start"; attempt: number }
| { kind: "exit"; exitCode: number; signal: NodeJS.Signals | null; timedOut: boolean }
| { kind: "retry"; reason: "user-request" | "no-auth-written" }
| { kind: "cancel" };
interface RunOptions {
/** abort the whole flow when true is returned. polled before each retry. */
shouldRetry: () => Promise<boolean>;
/** observe progress for UI rendering. */
onProgress?: (event: ProgressEvent) => void;
/**
* pass-through control over the child's stdio. `inherit` streams Codex's
* own UI directly to the user's terminal. `pipe` is what `pullfrog auth
* codex` uses so it can re-render each line with a Pullfrog-styled rail
* + dim formatting via `onChildLine`.
*/
childStdio?: "inherit" | "pipe";
/**
* called once per line of Codex's stdout/stderr when `childStdio` is
* "pipe". raw line text is passed through unmodified (including any ANSI
* escapes Codex emitted); the caller is responsible for stripping/styling.
*/
onChildLine?: (line: string, stream: "stdout" | "stderr") => void;
/** how long a single device-auth attempt is allowed to run. */
perAttemptTimeoutMs?: number;
}
/**
* mint a fresh Codex subscription credential by running `codex login
* --device-auth` against an isolated `CODEX_HOME`. the user's global
* `~/.codex/auth.json` is never touched; on success or failure, the
* temporary home is cleaned up.
*
* the caller controls retry behavior via `shouldRetry`: when device auth
* exits without writing `auth.json` (most commonly because the user needed
* to enable device-code auth on their ChatGPT account first), the function
* invokes `shouldRetry()` to decide whether to spin up another attempt.
*/
export async function mintCodexAuth(options: RunOptions): Promise<CodexAuth> {
// mkdtempSync already creates the dir with the default 0o700 perms on
// posix; an extra mkdirSync would just be ceremony.
const codexHome = mkdtempSync(join(tmpdir(), "pullfrog-codex-"));
try {
// device auth requires file-backed credentials; otherwise Codex routes the
// refresh token into the OS keyring and we can't observe / persist it.
writeFileSync(join(codexHome, "config.toml"), 'cli_auth_credentials_store = "file"\n', {
mode: 0o600,
});
const authPath = join(codexHome, "auth.json");
let attempt = 1;
while (true) {
options.onProgress?.({ kind: "start", attempt });
const result = await runDeviceAuth({
codexHome,
timeoutMs: options.perAttemptTimeoutMs ?? 15 * 60 * 1000,
childStdio: options.childStdio ?? "inherit",
onChildLine: options.onChildLine,
});
options.onProgress?.({
kind: "exit",
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
});
const auth = readAuthIfPresent(authPath);
if (auth) return auth;
if (!(await options.shouldRetry())) {
options.onProgress?.({ kind: "cancel" });
throw new Error("Codex login did not produce auth.json (no retry requested)");
}
options.onProgress?.({ kind: "retry", reason: "no-auth-written" });
attempt += 1;
}
} finally {
rmSync(codexHome, { recursive: true, force: true });
}
}
interface DeviceAuthResult {
exitCode: number;
signal: NodeJS.Signals | null;
/** true if the attempt was killed by our per-attempt timeout (vs. exited
* naturally or was interrupted by the user). lets callers distinguish
* "user walked away" from "user closed the device flow early". */
timedOut: boolean;
}
interface DeviceAuthInput {
codexHome: string;
timeoutMs: number;
childStdio: "inherit" | "pipe";
onChildLine?: ((line: string, stream: "stdout" | "stderr") => void) | undefined;
}
/** how long to wait between SIGTERM and SIGKILL when killing a stuck `codex`
* subprocess. Codex usually exits cleanly on SIGTERM, but if it ignores it we
* don't want the CLI pinned forever. */
const SIGTERM_GRACE_MS = 5_000;
/** spawn `codex login --device-auth` with stdin closed so Codex doesn't hang
* waiting for input. by default inherits stdout/stderr so the user sees the
* device URL + one-time code Codex prints; when `pipe`d, lines are forwarded
* to `onChildLine` so the caller can re-style them. on per-attempt timeout,
* sends SIGTERM and escalates to SIGKILL after a short grace.
*/
function runDeviceAuth(input: DeviceAuthInput): Promise<DeviceAuthResult> {
return new Promise((resolve, reject) => {
const child = spawn("codex", ["login", "--device-auth"], {
env: { ...process.env, CODEX_HOME: input.codexHome },
stdio: ["ignore", input.childStdio, input.childStdio],
});
if (input.childStdio === "pipe") {
const onLine = input.onChildLine ?? (() => {});
if (child.stdout) pipeLines(child.stdout, (line) => onLine(line, "stdout"));
if (child.stderr) pipeLines(child.stderr, (line) => onLine(line, "stderr"));
}
let killTimer: NodeJS.Timeout | null = null;
let timedOut = false;
const timeoutTimer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
// give Codex a grace window to exit cleanly on SIGTERM. if it ignores
// it, force SIGKILL so we don't pin the CLI on a stuck child.
killTimer = setTimeout(() => child.kill("SIGKILL"), SIGTERM_GRACE_MS);
}, input.timeoutMs);
// `spawn` emits 'error' (not 'close') when the binary can't be found
// (ENOENT) or otherwise fails to start. without a listener, Node crashes
// the process with an unhandled 'error' event.
child.on("error", (err) => {
clearTimeout(timeoutTimer);
if (killTimer) clearTimeout(killTimer);
const errno = err as NodeJS.ErrnoException;
const message =
errno.code === "ENOENT"
? "codex CLI not found on PATH. install it with `npm i -g @openai/codex` or see https://developers.openai.com/codex/cli for other install options."
: `failed to spawn codex: ${errno.message}`;
reject(new Error(message));
});
child.on("close", (code, signal) => {
clearTimeout(timeoutTimer);
if (killTimer) clearTimeout(killTimer);
resolve({ exitCode: code ?? 1, signal, timedOut });
});
});
}
/** byte-stream → newline-delimited line callback. emits any final partial
* line on stream end so trailing content (e.g. a prompt with no newline)
* still surfaces to the renderer.
*/
function pipeLines(stream: NodeJS.ReadableStream, onLine: (line: string) => void): void {
let buffer = "";
stream.on("data", (chunk: Buffer | string) => {
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
let idx = buffer.indexOf("\n");
while (idx !== -1) {
const line = buffer.slice(0, idx).replace(/\r$/, "");
buffer = buffer.slice(idx + 1);
onLine(line);
idx = buffer.indexOf("\n");
}
});
stream.on("end", () => {
if (buffer.length > 0) {
onLine(buffer);
buffer = "";
}
});
}
function readAuthIfPresent(authPath: string): CodexAuth | null {
let raw: string;
try {
raw = readFileSync(authPath, "utf8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isCodexAuthJson(parsed)) return null;
return { json: raw, parsed };
}
function isCodexAuthJson(value: unknown): value is CodexAuthJson {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
if (v.auth_mode !== "chatgpt") return false;
const tokens = v.tokens;
if (!tokens || typeof tokens !== "object") return false;
const t = tokens as Record<string, unknown>;
if (typeof t.access_token !== "string" || t.access_token.length === 0) return false;
if (typeof t.refresh_token !== "string" || t.refresh_token.length === 0) return false;
return true;
}