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)
This commit is contained in:
committed by
pullfrog[bot]
parent
ddbc610569
commit
a78b1542da
+3
-1
@@ -6,7 +6,9 @@ import {
|
||||
} from "../models.ts";
|
||||
import { getApiUrl } from "./apiUrl.ts";
|
||||
|
||||
const knownApiKeys: Set<string> = new Set(Object.values(providers).flatMap((p) => [...p.envVars]));
|
||||
const knownApiKeys: Set<string> = new Set(
|
||||
Object.values(providers).flatMap((p) => [...p.envVars, ...(p.managedCredentials ?? [])])
|
||||
);
|
||||
|
||||
/** marker prefix on the throw message for the catch-side reclassification path */
|
||||
const MISSING_KEY_MARKER = "no API key found";
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectCodexRefresh } from "./codexHome.ts";
|
||||
|
||||
// installCodexAuth touches the filesystem (mkdir + writeFile) — leaving it
|
||||
// untested here per AGENTS.md guidance ("be highly dubious of any test that
|
||||
// relies on mocks"). The conversion math is what we actually want to
|
||||
// protect; the disk write is one writeFileSync call.
|
||||
|
||||
describe("detectCodexRefresh", () => {
|
||||
const original = "rt_original_chain";
|
||||
|
||||
it("returns Codex-shape JSON when openai.refresh advanced", () => {
|
||||
const authFileContent = JSON.stringify({
|
||||
openai: {
|
||||
type: "oauth",
|
||||
refresh: "rt_new_chain",
|
||||
access: "at_new",
|
||||
expires: 9_999_999_999_999,
|
||||
accountId: "acc_123",
|
||||
},
|
||||
});
|
||||
const result = detectCodexRefresh({ authFileContent, originalRefresh: original });
|
||||
expect(result).not.toBeNull();
|
||||
const parsed = JSON.parse(result ?? "{}");
|
||||
expect(parsed.auth_mode).toBe("chatgpt");
|
||||
expect(parsed.tokens.refresh_token).toBe("rt_new_chain");
|
||||
expect(parsed.tokens.access_token).toBe("at_new");
|
||||
expect(parsed.tokens.account_id).toBe("acc_123");
|
||||
expect(typeof parsed.last_refresh).toBe("string");
|
||||
});
|
||||
|
||||
it("omits account_id when accountId is absent from OpenCode shape", () => {
|
||||
const authFileContent = JSON.stringify({
|
||||
openai: {
|
||||
type: "oauth",
|
||||
refresh: "rt_new",
|
||||
access: "at_new",
|
||||
expires: 0,
|
||||
},
|
||||
});
|
||||
const result = detectCodexRefresh({ authFileContent, originalRefresh: original });
|
||||
const parsed = JSON.parse(result ?? "{}");
|
||||
expect("account_id" in parsed.tokens).toBe(false);
|
||||
});
|
||||
|
||||
it("returns null when refresh token unchanged (no rotation happened)", () => {
|
||||
const authFileContent = JSON.stringify({
|
||||
openai: { type: "oauth", refresh: original, access: "at_same", expires: 0 },
|
||||
});
|
||||
expect(detectCodexRefresh({ authFileContent, originalRefresh: original })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when openai entry is missing", () => {
|
||||
const authFileContent = JSON.stringify({
|
||||
anthropic: { type: "oauth", refresh: "rt_other", access: "at_other", expires: 0 },
|
||||
});
|
||||
expect(detectCodexRefresh({ authFileContent, originalRefresh: original })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when openai is api-key type (no refresh chain)", () => {
|
||||
const authFileContent = JSON.stringify({
|
||||
openai: { type: "api", key: "sk-something" },
|
||||
});
|
||||
expect(detectCodexRefresh({ authFileContent, originalRefresh: original })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON", () => {
|
||||
expect(
|
||||
detectCodexRefresh({ authFileContent: "{not json", originalRefresh: original })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object content", () => {
|
||||
expect(
|
||||
detectCodexRefresh({ authFileContent: '"a string"', originalRefresh: original })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when refresh field is missing", () => {
|
||||
const authFileContent = JSON.stringify({
|
||||
openai: { type: "oauth", access: "at_new", expires: 0 },
|
||||
});
|
||||
expect(detectCodexRefresh({ authFileContent, originalRefresh: original })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
// Codex-to-OpenCode auth bridging for the action runtime.
|
||||
//
|
||||
// `pullfrog auth codex` stores a Codex CLI `auth.json` blob in the Pullfrog
|
||||
// secret store. At runtime the harness injects it as `CODEX_AUTH_JSON` in
|
||||
// process.env (via `dbSecrets` in main.ts). This utility:
|
||||
//
|
||||
// 1. parses + validates that env value
|
||||
// 2. converts Codex's shape `{ auth_mode, tokens: { access_token, refresh_token, ... } }`
|
||||
// into OpenCode's shape `{ openai: { type: "oauth", refresh, access, expires, accountId } }`
|
||||
// 3. materializes it to disk at the runner's REAL `$HOME/.local/share/opencode/auth.json`
|
||||
// (NOT the per-run tmpdir's HOME)
|
||||
// 4. returns the path + the original refresh token so the post-run hook
|
||||
// can detect a refresh and write back to Pullfrog
|
||||
//
|
||||
// Why real $HOME and not ctx.tmpdir-redirected HOME: the broad
|
||||
// `external_directory: { "/tmp/*": "allow" }` rule on OpenCode would expose
|
||||
// auth.json to the agent's filesystem tools if the file lived under
|
||||
// `ctx.tmpdir` = `/tmp/pullfrog-*`. Real `$HOME/.local/share/opencode/...`
|
||||
// falls outside that allow zone, so OpenCode's deny-default protects it
|
||||
// without any new permission rules.
|
||||
//
|
||||
// `expires: 0` forces OpenCode to refresh on first request (we don't trust
|
||||
// the in-blob freshness — the saved token was eager-refreshed once at
|
||||
// `auth codex` time but may have aged since).
|
||||
//
|
||||
// See [wiki/codex-auth.md] for the full data-flow picture.
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
const CODEX_AUTH_ENV = "CODEX_AUTH_JSON";
|
||||
|
||||
interface CodexAuthBlob {
|
||||
auth_mode: "chatgpt";
|
||||
tokens: {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
id_token?: string;
|
||||
account_id?: string;
|
||||
};
|
||||
last_refresh?: string;
|
||||
}
|
||||
|
||||
interface OpenCodeAuthFile {
|
||||
openai: {
|
||||
type: "oauth";
|
||||
refresh: string;
|
||||
access: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InstalledCodexAuth {
|
||||
/** absolute path of the auth.json we wrote — caller passes this to the
|
||||
* post-hook via core.saveState for refresh-detection later. */
|
||||
authPath: string;
|
||||
/** value to set as XDG_DATA_HOME for the OpenCode subprocess. */
|
||||
xdgDataHome: string;
|
||||
/** refresh_token from the env at materialization time. post-hook compares
|
||||
* against the on-disk file after the run to detect whether OpenCode
|
||||
* refreshed during the session. */
|
||||
originalRefresh: string;
|
||||
}
|
||||
|
||||
/** materialize CODEX_AUTH_JSON from env into a disk path OpenCode reads from.
|
||||
* returns null when the env var is absent, malformed, or wrong auth mode —
|
||||
* caller treats null as "no codex auth, fall through to API key flow". */
|
||||
export function installCodexAuth(): InstalledCodexAuth | null {
|
||||
const raw = process.env[CODEX_AUTH_ENV];
|
||||
if (!raw) return null;
|
||||
|
||||
const blob = parseCodexBlob(raw);
|
||||
if (!blob) {
|
||||
log.warning(`» ${CODEX_AUTH_ENV} present but malformed; ignoring`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const xdgDataHome = join(homedir(), ".local", "share");
|
||||
const opencodeDir = join(xdgDataHome, "opencode");
|
||||
const authPath = join(opencodeDir, "auth.json");
|
||||
|
||||
const opencodeAuth: OpenCodeAuthFile = {
|
||||
openai: {
|
||||
type: "oauth",
|
||||
refresh: blob.tokens.refresh_token,
|
||||
access: blob.tokens.access_token,
|
||||
// expires: 0 forces OpenCode's CodexAuthPlugin to refresh on first
|
||||
// request (it checks `expires < Date.now()`). safest default — we
|
||||
// don't carry an `expires_in` from the Codex blob.
|
||||
expires: 0,
|
||||
...(blob.tokens.account_id ? { accountId: blob.tokens.account_id } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
mkdirSync(opencodeDir, { recursive: true });
|
||||
writeFileSync(authPath, `${JSON.stringify(opencodeAuth, null, 2)}\n`, { mode: 0o600 });
|
||||
|
||||
log.info(`» installed Codex auth at ${authPath}`);
|
||||
|
||||
return { authPath, xdgDataHome, originalRefresh: blob.tokens.refresh_token };
|
||||
}
|
||||
|
||||
function parseCodexBlob(raw: string): CodexAuthBlob | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const v = parsed as Record<string, unknown>;
|
||||
if (v.auth_mode !== "chatgpt") return null;
|
||||
const tokens = v.tokens;
|
||||
if (!tokens || typeof tokens !== "object") return null;
|
||||
const t = tokens as Record<string, unknown>;
|
||||
if (typeof t.access_token !== "string" || t.access_token.length === 0) return null;
|
||||
if (typeof t.refresh_token !== "string" || t.refresh_token.length === 0) return null;
|
||||
return {
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
access_token: t.access_token,
|
||||
refresh_token: t.refresh_token,
|
||||
...(typeof t.id_token === "string" ? { id_token: t.id_token } : {}),
|
||||
...(typeof t.account_id === "string" ? { account_id: t.account_id } : {}),
|
||||
},
|
||||
...(typeof v.last_refresh === "string" ? { last_refresh: v.last_refresh } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** convert an on-disk OpenCode auth.json back to the Codex CLI shape so the
|
||||
* post-hook can write it to the Pullfrog secret store. returns null when the
|
||||
* file's `openai` entry is missing, has the wrong type, or hasn't actually
|
||||
* refreshed (refresh token unchanged from `originalRefresh`). */
|
||||
export function detectCodexRefresh(params: {
|
||||
authFileContent: string;
|
||||
originalRefresh: string;
|
||||
}): string | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(params.authFileContent);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const oauth = (parsed as Record<string, unknown>).openai;
|
||||
if (!oauth || typeof oauth !== "object") return null;
|
||||
const o = oauth as Record<string, unknown>;
|
||||
if (o.type !== "oauth") return null;
|
||||
if (typeof o.refresh !== "string" || typeof o.access !== "string") return null;
|
||||
if (o.refresh === params.originalRefresh) return null;
|
||||
|
||||
const codexShape: CodexAuthBlob = {
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
access_token: o.access,
|
||||
refresh_token: o.refresh,
|
||||
...(typeof o.accountId === "string" ? { account_id: o.accountId } : {}),
|
||||
},
|
||||
last_refresh: new Date().toISOString(),
|
||||
};
|
||||
return `${JSON.stringify(codexShape, null, 2)}\n`;
|
||||
}
|
||||
Reference in New Issue
Block a user