diff --git a/action.yml b/action.yml index e44a807..0061bf2 100644 --- a/action.yml +++ b/action.yml @@ -36,6 +36,11 @@ outputs: runs: using: "node24" main: "entry.ts" + # Always-run post step persists best-effort state that must survive + # cancellation, timeouts, and unhandled errors in the main step. Today's + # only consumer: Codex auth.json refresh write-back. See wiki/codex-auth.md. + post: "entryPost.ts" + post-if: "always()" branding: icon: "code" diff --git a/agents/claude.ts b/agents/claude.ts index c2e2095..f128094 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -802,6 +802,14 @@ const MANAGED_SETTINGS_PATH = `${MANAGED_SETTINGS_DIR}/managed-settings.json`; // allowManagedPermissionRulesOnly prevents malicious PRs from adding allow rules that override // our deny rules — safe in CI because --dangerously-skip-permissions makes allow/ask irrelevant. // allowManagedHooksOnly prevents malicious project hooks from bypassing deny rules. +// Codex auth.json (Pullfrog-stored ChatGPT subscription credential) lives at +// `~/.local/share/opencode/auth.json` when the opencode harness materialized +// it. Claude shouldn't be running OpenAI models — they route to opencode — +// but defense-in-depth: deny the file regardless. Per Claude Code permissions +// docs, Read(...) deny ALSO blocks file-reading Bash commands (cat, head, +// tail, sed) and survives bypassPermissions mode. See wiki/codex-auth.md. +const CODEX_AUTH_DENY_PATH = "~/.local/share/opencode/auth.json"; + const managedSettings = { allowManagedPermissionRulesOnly: true, allowManagedHooksOnly: true, @@ -815,11 +823,15 @@ const managedSettings = { "Edit(//sys/**)", "Glob(//proc/**)", "Glob(//sys/**)", + `Read(${CODEX_AUTH_DENY_PATH})`, + `Grep(${CODEX_AUTH_DENY_PATH})`, + `Edit(${CODEX_AUTH_DENY_PATH})`, + `Glob(${CODEX_AUTH_DENY_PATH})`, ], }, sandbox: { filesystem: { - denyRead: ["/proc", "/sys"], + denyRead: ["/proc", "/sys", CODEX_AUTH_DENY_PATH], }, }, }; diff --git a/agents/opencode.ts b/agents/opencode.ts index 5dc3a9d..7d4de1e 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -15,12 +15,14 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { performance } from "node:perf_hooks"; +import * as core from "@actions/core"; import { pullfrogMcpName } from "../external.ts"; import { BEDROCK_MODEL_ID_ENV, modelAliases } from "../models.ts"; import type { ToolState } from "../toolState.ts"; import { getIdleMs, markActivity } from "../utils/activity.ts"; import { type AgentDiagnostic, formatAgentHangBody } from "../utils/agentHangReport.ts"; import { formatJsonValue, log } from "../utils/cli.ts"; +import { installCodexAuth } from "../utils/codexHome.ts"; import { installFromNpmTarball } from "../utils/install.ts"; import { findProviderErrorMatch } from "../utils/providerErrors.ts"; import { addSkill, installBundledSkills } from "../utils/skills.ts"; @@ -1244,12 +1246,20 @@ export const opencode = agent({ installBundledSkills({ home: homeEnv.HOME }); + // materialize CODEX_AUTH_JSON (Pullfrog-stored Codex subscription + // credential) into the runner's REAL $HOME/.local/share/opencode/auth.json + // so OpenCode's CodexAuthPlugin picks it up and routes openai requests + // through the ChatGPT subscription instead of needing OPENAI_API_KEY. + // see action/utils/codexHome.ts and wiki/codex-auth.md. + const codexAuth = installCodexAuth(); + // base args shared between initial run and continue runs const baseArgs = ["run", "--format", "json", "--print-logs"]; // OPENCODE_PERMISSION has absolute highest precedence (merged after managed/MDM configs). // external_directory gates ALL native filesystem tools (Read, Write, Edit, Glob, Grep, etc.) // for paths outside the project root. last-match-wins: deny everything, then allow /tmp. + // auth.json sits under real $HOME (outside /tmp/*), so deny-default protects it. const permissionOverride = JSON.stringify({ external_directory: { "*": "deny", "/tmp/*": "allow" }, }); @@ -1263,6 +1273,28 @@ export const opencode = agent({ process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY, }; + if (codexAuth) { + // point OpenCode at the real-home XDG dir so it reads auth.json from + // where we wrote it (not the tmpdir-redirected default). + env.XDG_DATA_HOME = codexAuth.xdgDataHome; + // remove OPENAI_API_KEY so OpenCode's provider merge unambiguously + // picks the OAuth path. with both set, the merge order in opencode + // makes the effective key ambiguous. + delete env.OPENAI_API_KEY; + // hand the post-hook everything it needs to detect + persist refresh. + // post-hook runs in a fresh node process, so we have to ferry apiToken + // explicitly — env is preserved across main/post but our run-context + // JWT is computed at runtime and not put in env. see action/entryPost.ts. + core.saveState( + "codex_writeback", + JSON.stringify({ + apiToken: ctx.apiToken, + authPath: codexAuth.authPath, + originalRefresh: codexAuth.originalRefresh, + }) + ); + } + const repoDir = process.cwd(); log.debug(`» starting Pullfrog (OpenCode): ${cliPath} ${baseArgs.join(" ")}`); diff --git a/agents/shared.ts b/agents/shared.ts index 4d13165..cf1273f 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -154,6 +154,13 @@ export interface AgentRunContext { */ onActivityTimeout?: (() => void) | undefined; onToolUse?: ((event: AgentToolUseEvent) => void) | undefined; + /** + * Pullfrog API JWT scoped to this run. agents only need this when they + * have to write state back to Pullfrog mid-run (today: opencode.ts uses + * it to seed the post-hook's writeback envelope for Codex auth refresh). + * empty string when the run wasn't context-resolved (e.g. local dry-runs). + */ + apiToken: string; } export interface Agent { diff --git a/cli.ts b/cli.ts index 145e70b..30e340a 100644 --- a/cli.ts +++ b/cli.ts @@ -1,6 +1,7 @@ import { basename } from "node:path"; import arg from "arg"; import pc from "picocolors"; +import { runCli as runAuthCli } from "./commands/auth.ts"; import { runCli as runGhaCli } from "./commands/gha.ts"; import { runCli as runInitCli } from "./commands/init.ts"; @@ -13,6 +14,7 @@ function printMainUsage(stream: typeof console.log): void { stream(`usage: ${PROG} \n`); stream("commands:"); stream(" init set up pullfrog on the current repository"); + stream(" auth manage provider credentials for the current repository"); stream(""); stream("global options:"); stream(" -h, --help show help"); @@ -85,6 +87,15 @@ async function run(): Promise { return; } + if (command === "auth") { + await runAuthCli({ + args: commandArgs, + prog: PROG, + showHelp: globalParsed["--help"] === true, + }); + return; + } + if (globalParsed["--help"]) { printMainUsage(console.log); process.exit(0); diff --git a/commands/_shared.ts b/commands/_shared.ts new file mode 100644 index 0000000..7846ac1 --- /dev/null +++ b/commands/_shared.ts @@ -0,0 +1,229 @@ +// shared helpers used by `init` and `auth` subcommands. these were originally +// inlined in `init.ts`; pulled out so `auth.ts` can reuse them without +// duplicating gh-auth/pullfrog-api/secret-save logic. + +import { execFileSync } from "node:child_process"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; + +export const PULLFROG_API_URL = (process.env.PULLFROG_API_URL || "https://pullfrog.com").replace( + /\/+$/, + "" +); + +// active spinner reference so bail/cancel can stop it before exiting. shared +// across init/auth subcommands via this module's singleton scope; whichever +// command starts a spinner sets this so handleCancel/bail can clean up. +let activeSpin: ReturnType | null = null; + +export function setActiveSpin(spin: ReturnType | null): void { + activeSpin = spin; +} + +export function bail(msg: string): never { + if (activeSpin) { + activeSpin.stop(pc.red("failed")); + activeSpin = null; + } + p.cancel(msg); + process.exit(1); +} + +export function handleCancel(value: T | symbol): asserts value is T { + if (p.isCancel(value)) { + if (activeSpin) { + activeSpin.stop(pc.red("canceled.")); + activeSpin = null; + } + p.cancel("canceled."); + process.exit(0); + } +} + +export function getGhToken(): string { + let token: string; + try { + token = execFileSync("gh", ["auth", "token"], { encoding: "utf-8" }).trim(); + } catch { + bail( + `gh cli not found or not authenticated.\n` + + ` ${pc.dim("install:")} https://cli.github.com\n` + + ` ${pc.dim("then:")} gh auth login` + ); + } + if (!token) { + bail( + `gh cli returned an empty token. try re-authenticating:\n` + + ` ${pc.dim("run:")} gh auth login` + ); + } + return token; +} + +export function parseGitRemote(): { owner: string; repo: string } { + let url: string; + try { + url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" }).trim(); + } catch { + bail("not a git repository or no 'origin' remote found."); + } + + const match = url.match(/github\.com(?::\d+)?[:/]+([^/]+)\/(.+?)(?:\.git)?(?:\/)?$/); + if (!match) bail(`could not parse github owner/repo from remote: ${url}`); + return { owner: match[1], repo: match[2] }; +} + +// ── Pullfrog API ── + +type SecretsApiData = { + error?: string; + appSlug?: string; + installationId?: number | null; + repositorySelection?: string | null; + isOrg?: boolean; + accessible?: boolean; + repoSecrets?: string[]; + orgSecrets?: string[]; + pullfrogSecrets?: string[]; + repoStatus?: string | null; + repoModel?: string | null; + hasRuns?: boolean; +}; + +type SecretsInfo = { + isOrg: boolean; + installationId: number | null; + secretsAccessible: boolean; + repoSecrets: string[]; + orgSecrets: string[]; + pullfrogSecrets: string[]; + model: string | null; + hasRuns: boolean; +}; + +type InstallationNotFound = { + appSlug: string; + installationId: number | null; + repositorySelection: "all" | "selected" | null; + isOrg: boolean; +}; + +type StatusResult = + | ({ installed: true } & SecretsInfo) + | ({ installed: false } & InstallationNotFound); + +type ApiResult> = { + ok: boolean; + status: number; + data: T; +}; + +async function pullfrogApi>(ctx: { + path: string; + token: string; + method?: string; + body?: Record; +}): Promise> { + const headers: Record = { authorization: `Bearer ${ctx.token}` }; + if (ctx.body) headers["content-type"] = "application/json"; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30_000); + try { + const response = await fetch(`${PULLFROG_API_URL}${ctx.path}`, { + method: ctx.method || "GET", + headers, + body: ctx.body ? JSON.stringify(ctx.body) : null, + signal: controller.signal, + }); + const data = (await response.json().catch(() => ({}))) as T; + return { ok: response.ok, status: response.status, data }; + } finally { + clearTimeout(timeout); + } +} + +export async function fetchStatus(ctx: { + token: string; + owner: string; + repo: string; +}): Promise { + const result = await pullfrogApi({ + path: `/api/cli/secrets?owner=${encodeURIComponent(ctx.owner)}&repo=${encodeURIComponent(ctx.repo)}`, + token: ctx.token, + }); + + if (!result.ok) { + const errorMsg = result.data.error || ""; + if (result.status === 401) bail("invalid or expired github token."); + if (result.status === 404) { + const sel = result.data.repositorySelection; + if (!result.data.appSlug) bail("server did not return appSlug"); + return { + installed: false, + appSlug: result.data.appSlug, + installationId: + typeof result.data.installationId === "number" ? result.data.installationId : null, + repositorySelection: sel === "all" || sel === "selected" ? sel : null, + isOrg: result.data.isOrg === true, + }; + } + bail(errorMsg || `secrets check failed (${result.status})`); + } + + return { + installed: true, + isOrg: result.data.isOrg === true, + installationId: + typeof result.data.installationId === "number" ? result.data.installationId : null, + secretsAccessible: result.data.accessible !== false, + repoSecrets: result.data.repoSecrets || [], + orgSecrets: result.data.orgSecrets || [], + pullfrogSecrets: result.data.pullfrogSecrets || [], + model: result.data.repoModel ?? null, + hasRuns: result.data.hasRuns === true, + }; +} + +// ── secret save ── + +export type SecretScope = "account" | "repo"; + +type PullfrogSecretResult = { saved: boolean; error: string }; + +export async function setPullfrogSecret(ctx: { + token: string; + owner: string; + repo: string; + name: string; + value: string; + scope: SecretScope; +}): Promise { + const result = await pullfrogApi<{ success?: boolean; error?: string }>({ + path: "/api/cli/secrets", + token: ctx.token, + method: "POST", + body: { + owner: ctx.owner, + repo: ctx.repo, + name: ctx.name, + value: ctx.value, + scope: ctx.scope, + }, + }); + if (result.ok && result.data.success === true) { + return { saved: true, error: "" }; + } + return { saved: false, error: result.data.error || `api returned ${result.status}` }; +} + +export async function promptScope(ctx: { owner: string; repo: string }): Promise { + const scope = await p.select({ + message: "secret scope", + options: [ + { value: "account", label: `${ctx.owner} organization`, hint: "shared across repos" }, + { value: "repo", label: `${ctx.owner}/${ctx.repo} only` }, + ], + }); + handleCancel(scope); + return scope; +} diff --git a/commands/auth.ts b/commands/auth.ts new file mode 100644 index 0000000..6d70df1 --- /dev/null +++ b/commands/auth.ts @@ -0,0 +1,301 @@ +// `pullfrog auth ` — manage credentials for a configured repo +// without going through the full `init` flow. currently supports: +// +// pullfrog auth codex mint a Codex subscription credential and save it +// as the `CODEX_AUTH_JSON` Pullfrog secret +// +// the `codex` subcommand runs `codex login --device-auth` against an +// isolated `CODEX_HOME` (so the user's existing ~/.codex/auth.json is never +// touched), validates the resulting auth.json, and posts it to the Pullfrog +// secrets API. used both for first-time setup of a Codex subscription on a +// repo and for rotating a stale credential. + +import * as p from "@clack/prompts"; +import arg from "arg"; +import pc from "picocolors"; +import { mintCodexAuth, refreshCodexAuth } from "../utils/codexAuth.ts"; +import { + bail, + fetchStatus, + getGhToken, + handleCancel, + PULLFROG_API_URL, + parseGitRemote, + promptScope, + type SecretScope, + setActiveSpin, + setPullfrogSecret, +} from "./_shared.ts"; + +const CODEX_AUTH_SECRET = "CODEX_AUTH_JSON"; + +/** strip CSI ANSI escapes (color, cursor) from a string so callers can re-style + * the visible text without inheriting the source's formatting. covers what + * Codex emits during device auth (mostly `\x1b[m` color codes). + */ +function stripAnsi(s: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes are control chars by design + return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); +} + +interface AuthCliParams { + args: string[]; + prog: string; + showHelp?: boolean; +} + +function printAuthUsage(params: { stream: typeof console.log; prog: string }): void { + params.stream(`usage: ${params.prog} auth \n`); + params.stream("manage provider credentials for the current repository."); + params.stream(""); + params.stream("providers:"); + params.stream(" codex mint a Codex (ChatGPT) subscription credential"); + params.stream(""); + params.stream("options:"); + params.stream(" -h, --help show help"); +} + +function printCodexUsage(params: { stream: typeof console.log; prog: string }): void { + params.stream(`usage: ${params.prog} auth codex [options]\n`); + params.stream("mint a Codex subscription credential and save it as CODEX_AUTH_JSON."); + params.stream(""); + params.stream("options:"); + params.stream(" --scope where to store the secret in Pullfrog. on"); + params.stream(" org-owned repos you're prompted to choose"); + params.stream(" interactively; user-owned repos always use"); + params.stream(" `account`. pass this flag to skip the prompt."); + params.stream(" -h, --help show help"); +} + +export async function runCli(params: AuthCliParams): Promise { + // route `auth --help` (no subcommand) to top-level usage. when the user + // passes `auth codex --help`, we leave the flag in the rest args so the + // subcommand's own parser handles it. + const firstArg = params.args[0]; + const helpAtTopLevel = + params.showHelp || + params.args.length === 0 || + (params.args.length === 1 && (firstArg === "--help" || firstArg === "-h")); + if (helpAtTopLevel) { + printAuthUsage({ stream: console.log, prog: params.prog }); + return; + } + + const subcommand = firstArg; + const rest = params.args.slice(1); + + if (subcommand === "codex") { + await runCodex({ args: rest, prog: params.prog }); + return; + } + + console.error(`unknown auth provider: ${pc.bold(subcommand)}\n`); + printAuthUsage({ stream: console.error, prog: params.prog }); + process.exit(1); +} + +interface CodexCliParams { + args: string[]; + prog: string; +} + +function parseCodexArgs(args: string[]) { + return arg( + { + "--help": Boolean, + "--scope": String, + "-h": "--help", + }, + { argv: args } + ); +} + +async function runCodex(params: CodexCliParams): Promise { + let parsed: ReturnType; + try { + parsed = parseCodexArgs(params.args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`${message}\n`); + printCodexUsage({ stream: console.error, prog: params.prog }); + process.exit(1); + } + + if (parsed["--help"]) { + printCodexUsage({ stream: console.log, prog: params.prog }); + return; + } + + const rawScope = parsed["--scope"]; + let explicitScope: SecretScope | null = null; + if (rawScope !== undefined) { + if (rawScope === "account" || rawScope === "repo") { + explicitScope = rawScope; + } else { + console.error(`invalid --scope: ${rawScope} (must be "account" or "repo")\n`); + printCodexUsage({ stream: console.error, prog: params.prog }); + process.exit(1); + } + } + + await runCodexAuth({ explicitScope }); +} + +interface RunCodexAuthCtx { + explicitScope: SecretScope | null; +} + +async function runCodexAuth(ctx: RunCodexAuthCtx): Promise { + p.intro(pc.bgGreen(pc.black(" pullfrog auth codex "))); + + const spin = p.spinner(); + setActiveSpin(spin); + + try { + spin.start("authenticating with github"); + const token = getGhToken(); + spin.stop("github authenticated"); + + spin.start("detecting repository"); + const remote = parseGitRemote(); + spin.stop(`detected repo ${pc.cyan(`${remote.owner}/${remote.repo}`)}`); + + spin.start("checking pullfrog app installation"); + const status = await fetchStatus({ token, owner: remote.owner, repo: remote.repo }); + if (!status.installed) { + spin.stop(pc.red("pullfrog app not installed on this repo")); + bail( + `install pullfrog on ${pc.bold(`${remote.owner}/${remote.repo}`)} before configuring auth.\n` + + ` ${pc.dim("run:")} ${pc.cyan(`npx pullfrog init`)}` + ); + } + spin.stop(`pullfrog app is installed on ${pc.cyan(`@${remote.owner}`)}`); + + if (status.pullfrogSecrets.includes(CODEX_AUTH_SECRET)) { + const overwrite = await p.select({ + message: `${pc.cyan(CODEX_AUTH_SECRET)} is already configured — overwrite?`, + options: [ + { value: true, label: "overwrite", hint: "rotate to a freshly minted credential" }, + { value: false, label: "cancel" }, + ], + }); + handleCancel(overwrite); + if (!overwrite) { + p.cancel("canceled."); + return; + } + } + + // user-owned repos can only ever be "account" (Pullfrog has no per-repo + // store for user accounts), so we never bother prompting. on org-owned + // repos, default to interactive prompt — matches `init`'s behavior — + // unless the caller passed `--scope` to skip it. + let scope: SecretScope; + if (ctx.explicitScope) { + scope = ctx.explicitScope; + } else if (status.isOrg) { + scope = await promptScope({ owner: remote.owner, repo: remote.repo }); + } else { + scope = "account"; + } + + p.log.info( + [ + `signing in via Codex device authorization. open the URL Codex prints`, + `below, enter the one-time code, and approve in your browser.`, + ``, + `${pc.dim("note:")} if your ChatGPT account doesn't have device-code auth enabled,`, + `Codex will exit early. enable it at ${pc.cyan(`https://chatgpt.com/#settings/Security`)}`, + `then re-run ${pc.cyan(`${process.env.PULLFROG_BIN_NAME || "pullfrog"} auth codex`)}.`, + ].join("\n") + ); + + // tracks the most recent exit so the retry prompt can tell the user + // *why* no auth.json was written (timeout vs. early-exit). + let lastTimedOut = false; + const auth = await mintCodexAuth({ + childStdio: "pipe", + onChildLine: (line) => { + // dim Codex's own colored output (URL/code in cyan, boilerplate in + // gray) so the user reads it as sub-process noise, not Pullfrog's + // own prompts. the rail char matches @clack/prompts so the column + // reads as one continuous flow. + process.stdout.write(`${pc.gray(p.S_BAR)} ${pc.dim(stripAnsi(line))}\n`); + }, + onProgress: (event) => { + if (event.kind === "start") { + lastTimedOut = false; + if (event.attempt > 1) p.log.info(`retry attempt ${event.attempt}`); + // shell-prompt style header so the user sees what Pullfrog is + // about to spawn, with the rail to keep the visual column. + process.stdout.write(`${pc.gray(p.S_BAR)}\n`); + process.stdout.write(`${pc.gray(p.S_BAR)} $ codex login --device-auth\n`); + } + if (event.kind === "exit") { + if (event.timedOut) lastTimedOut = true; + // trailing blank rail so the next clack prompt isn't crammed + // against the last codex output line. + process.stdout.write(`${pc.gray(p.S_BAR)}\n`); + } + }, + shouldRetry: async () => { + const message = lastTimedOut + ? "device authorization timed out — retry?" + : "no auth.json was written — retry?"; + const retry = await p.select({ + message, + options: [ + { value: true, label: "retry", hint: "after enabling device-code auth" }, + { value: false, label: "cancel" }, + ], + }); + handleCancel(retry); + return retry; + }, + }); + + // eager refresh: bump the OAuth chain once before persisting so the + // saved token is one Pullfrog has used. otherwise the user's laptop's + // codex CLI could refresh first and strand our copy. + spin.start("refreshing token"); + let savable: typeof auth; + try { + savable = await refreshCodexAuth(auth); + spin.stop("refreshed"); + } catch (err) { + spin.stop(pc.yellow("refresh failed — saving minted token as-is")); + p.log.warn(err instanceof Error ? err.message : String(err)); + savable = auth; + } + + spin.start(`saving ${pc.cyan(CODEX_AUTH_SECRET)} to Pullfrog`); + const result = await setPullfrogSecret({ + token, + owner: remote.owner, + repo: remote.repo, + name: CODEX_AUTH_SECRET, + value: savable.json, + scope, + }); + if (!result.saved) { + spin.stop(pc.red("could not save secret")); + p.log.warn( + `${result.error}\n ${pc.dim("set it manually at:")} ${PULLFROG_API_URL}/console/${remote.owner}` + ); + process.exit(1); + } + spin.stop(`saved ${pc.cyan(CODEX_AUTH_SECRET)} to Pullfrog (${scope})`); + + setActiveSpin(null); + p.outro("done."); + } catch (error) { + // mirror what `bail` does: stop the spinner with a red "failed" glyph + // before clearing it, otherwise an in-flight spinner keeps animating + // above the error message we're about to print. + spin.stop(pc.red("failed")); + setActiveSpin(null); + const message = error instanceof Error ? error.message : String(error); + p.log.error(message); + process.exit(1); + } +} diff --git a/entryPost.ts b/entryPost.ts new file mode 100644 index 0000000..a7ad738 --- /dev/null +++ b/entryPost.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// +// GitHub Actions `post:` entry point. Runs after the main step regardless of +// exit status (cancellation, timeout, unhandled error) — that's the contract +// we need for credential persistence: if OpenCode refreshed the Codex +// auth.json during the run, the refreshed token must land back in Pullfrog +// even when the main step died unexpectedly. +// +// Today's only job: detect a Codex auth refresh by diffing the on-disk +// auth.json against the original refresh token (saved to GH Actions state +// by action/agents/opencode.ts), convert OpenCode's auth shape back to +// Codex CLI shape, and PUT it to /api/runtime/secret. +// +// Silent no-op when the main step didn't materialize Codex auth (no state +// saved). Best-effort: failures are logged but never throw — the workflow +// is already done, and a missed refresh write-back means the user re-runs +// `pullfrog auth codex` next time the chain breaks. +// +// See wiki/codex-auth.md for the full flow. + +import { existsSync, readFileSync } from "node:fs"; +import * as core from "@actions/core"; +import { getApiUrl } from "./utils/apiUrl.ts"; +import { detectCodexRefresh } from "./utils/codexHome.ts"; + +async function main(): Promise { + const raw = core.getState("codex_writeback"); + if (!raw) { + core.info("codex post-hook: no writeback state — skipping"); + return; + } + + let state: { apiToken: string; authPath: string; originalRefresh: string }; + try { + state = JSON.parse(raw) as typeof state; + } catch (err) { + core.warning(`codex post-hook: malformed writeback state — ${err}`); + return; + } + if (!state.apiToken || !state.authPath || !state.originalRefresh) { + core.warning("codex post-hook: incomplete writeback state — skipping"); + return; + } + + if (!existsSync(state.authPath)) { + core.info(`codex post-hook: ${state.authPath} not found — nothing to write back`); + return; + } + + let authFileContent: string; + try { + authFileContent = readFileSync(state.authPath, "utf8"); + } catch (err) { + core.warning(`codex post-hook: cannot read ${state.authPath} — ${err}`); + return; + } + + const refreshedCodexJson = detectCodexRefresh({ + authFileContent, + originalRefresh: state.originalRefresh, + }); + if (!refreshedCodexJson) { + core.info("codex post-hook: refresh chain unchanged — no writeback needed"); + return; + } + + const url = `${getApiUrl()}/api/runtime/secret`; + try { + const response = await fetch(url, { + method: "PUT", + headers: { + authorization: `Bearer ${state.apiToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ name: "CODEX_AUTH_JSON", value: refreshedCodexJson }), + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + core.warning(`codex post-hook: writeback returned ${response.status}: ${body}`); + return; + } + core.info("codex post-hook: refreshed CODEX_AUTH_JSON persisted to Pullfrog"); + } catch (err) { + core.warning(`codex post-hook: writeback failed — ${err}`); + } +} + +main().catch((err) => { + // never throw — post-hook failure must not fail the workflow + core.warning(`codex post-hook: unexpected error — ${err}`); +}); diff --git a/external.ts b/external.ts index 052b4ee..2349b6a 100644 --- a/external.ts +++ b/external.ts @@ -30,6 +30,7 @@ export function formatMcpToolRef(agentId: AgentId, toolName: string): string { export type { ModelAlias, ModelProvider, ProviderConfig } from "./models.ts"; export { getModelEnvVars, + getModelManagedCredentials, getModelProvider, getProviderDisplayName, modelAliases, diff --git a/internal/index.ts b/internal/index.ts index caff525..c9e20da 100644 --- a/internal/index.ts +++ b/internal/index.ts @@ -17,6 +17,7 @@ export type { } from "../external.ts"; export { getModelEnvVars, + getModelManagedCredentials, getModelProvider, getProviderDisplayName, modelAliases, diff --git a/main.ts b/main.ts index 81aba18..9ea308c 100644 --- a/main.ts +++ b/main.ts @@ -958,6 +958,7 @@ export async function main(): Promise { todoTracker, stopScript: runContext.repoSettings.stopScript, toolState, + apiToken: runContext.apiToken, onActivityTimeout: onInnerActivityTimeout, onToolUse: (event) => { const wasTracked = recordDiffReadFromToolUse({ diff --git a/models.ts b/models.ts index ec42f77..ab5bf71 100644 --- a/models.ts +++ b/models.ts @@ -74,6 +74,11 @@ interface ModelDef { export interface ProviderConfig { displayName: string; envVars: readonly string[]; + /** credentials authored only via `pullfrog auth ` — never + * user-facing in `init`, never documented as a manual GHA secret. counted + * for hasAnyKey / log-redaction purposes but excluded from any prompt / + * paste flow. CLI-managed magic. see wiki/codex-auth.md. */ + managedCredentials?: readonly string[]; models: Record; } @@ -110,6 +115,7 @@ export const providers = { openai: provider({ displayName: "OpenAI", envVars: ["OPENAI_API_KEY"], + managedCredentials: ["CODEX_AUTH_JSON"], models: { gpt: { displayName: "GPT", @@ -511,6 +517,16 @@ export function getModelEnvVars(slug: string): string[] { return providerConfig.envVars.slice(); } +/** managed credentials are authored only via `pullfrog auth ` — they + * count as "configured" for hasAnyKey-style UI checks but are never offered as + * a manual-paste option in `init` or the AgentSettings env-var button row. + * see `provider.managedCredentials` and wiki/codex-auth.md. */ +export function getModelManagedCredentials(slug: string): string[] { + const parsed = parseModel(slug); + const providerConfig = (providers as Record)[parsed.provider]; + return providerConfig?.managedCredentials?.slice() ?? []; +} + // ── derived flat list ────────────────────────────────────────────────────────── export const modelAliases: ModelAlias[] = Object.entries(providers).flatMap( diff --git a/utils/apiKeys.ts b/utils/apiKeys.ts index f34e464..58049db 100644 --- a/utils/apiKeys.ts +++ b/utils/apiKeys.ts @@ -6,7 +6,9 @@ import { } from "../models.ts"; import { getApiUrl } from "./apiUrl.ts"; -const knownApiKeys: Set = new Set(Object.values(providers).flatMap((p) => [...p.envVars])); +const knownApiKeys: Set = 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"; diff --git a/utils/codexAuth.ts b/utils/codexAuth.ts new file mode 100644 index 0000000..207c286 --- /dev/null +++ b/utils/codexAuth.ts @@ -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 { + 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; + /** 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 { + // 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 { + 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; + if (v.auth_mode !== "chatgpt") return false; + const tokens = v.tokens; + if (!tokens || typeof tokens !== "object") return false; + const t = tokens as Record; + 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; +} diff --git a/utils/codexHome.test.ts b/utils/codexHome.test.ts new file mode 100644 index 0000000..141d3c8 --- /dev/null +++ b/utils/codexHome.test.ts @@ -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(); + }); +}); diff --git a/utils/codexHome.ts b/utils/codexHome.ts new file mode 100644 index 0000000..9a2f929 --- /dev/null +++ b/utils/codexHome.ts @@ -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; + if (v.auth_mode !== "chatgpt") return null; + const tokens = v.tokens; + if (!tokens || typeof tokens !== "object") return null; + const t = tokens as Record; + 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).openai; + if (!oauth || typeof oauth !== "object") return null; + const o = oauth as Record; + 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`; +}