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:
Colin McDonnell
2026-05-16 05:06:24 +00:00
committed by pullfrog[bot]
parent ddbc610569
commit a78b1542da
16 changed files with 1244 additions and 2 deletions
+91
View File
@@ -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}`);
});