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
@@ -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"
|
||||
|
||||
+13
-1
@@ -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],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(" ")}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} <command>\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<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "auth") {
|
||||
await runAuthCli({
|
||||
args: commandArgs,
|
||||
prog: PROG,
|
||||
showHelp: globalParsed["--help"] === true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (globalParsed["--help"]) {
|
||||
printMainUsage(console.log);
|
||||
process.exit(0);
|
||||
|
||||
@@ -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<typeof p.spinner> | null = null;
|
||||
|
||||
export function setActiveSpin(spin: ReturnType<typeof p.spinner> | 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<T>(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<T = Record<string, unknown>> = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: T;
|
||||
};
|
||||
|
||||
async function pullfrogApi<T = Record<string, unknown>>(ctx: {
|
||||
path: string;
|
||||
token: string;
|
||||
method?: string;
|
||||
body?: Record<string, unknown>;
|
||||
}): Promise<ApiResult<T>> {
|
||||
const headers: Record<string, string> = { 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<StatusResult> {
|
||||
const result = await pullfrogApi<SecretsApiData>({
|
||||
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<PullfrogSecretResult> {
|
||||
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<SecretScope> {
|
||||
const scope = await p.select<SecretScope>({
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// `pullfrog auth <provider>` — 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[<digits>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 <provider>\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 <account|repo> 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<void> {
|
||||
// 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<void> {
|
||||
let parsed: ReturnType<typeof parseCodexArgs>;
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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}`);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,7 @@ export type {
|
||||
} from "../external.ts";
|
||||
export {
|
||||
getModelEnvVars,
|
||||
getModelManagedCredentials,
|
||||
getModelProvider,
|
||||
getProviderDisplayName,
|
||||
modelAliases,
|
||||
|
||||
@@ -958,6 +958,7 @@ export async function main(): Promise<MainResult> {
|
||||
todoTracker,
|
||||
stopScript: runContext.repoSettings.stopScript,
|
||||
toolState,
|
||||
apiToken: runContext.apiToken,
|
||||
onActivityTimeout: onInnerActivityTimeout,
|
||||
onToolUse: (event) => {
|
||||
const wasTracked = recordDiffReadFromToolUse({
|
||||
|
||||
@@ -74,6 +74,11 @@ interface ModelDef {
|
||||
export interface ProviderConfig {
|
||||
displayName: string;
|
||||
envVars: readonly string[];
|
||||
/** credentials authored only via `pullfrog auth <provider>` — 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<string, ModelDef>;
|
||||
}
|
||||
|
||||
@@ -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 <provider>` — 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<string, ProviderConfig>)[parsed.provider];
|
||||
return providerConfig?.managedCredentials?.slice() ?? [];
|
||||
}
|
||||
|
||||
// ── derived flat list ──────────────────────────────────────────────────────────
|
||||
|
||||
export const modelAliases: ModelAlias[] = Object.entries(providers).flatMap(
|
||||
|
||||
+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