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
+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 {
|
||||
|
||||
Reference in New Issue
Block a user