d3b5340583
* fix: 9 unaddressed log-audit / run-audit findings Co-authored-by: Cursor <cursoragent@cursor.com> #815 entryPost stdlib-only imports; #823 MCP timeoutMs on checkout_pr/shell; #816 FREE_FALLBACK → opencode/big-pickle; #822 chunk GraphQL nodes ≤100; #817/#821 vip_audit 404 skip paths; #813 longer serializable retries; #818 run-context handler-entered log; #805 audit severity template. * fix: update footer test for big-pickle fallback slug Co-authored-by: Cursor <cursoragent@cursor.com> * fix: anneal round 1 — ghaCore getState casing, post-hook timeout Match @actions/core STATE_ key semantics (no uppercasing), cap postApiFetch at 30s, trim serializable retries to stay under GitHub's 10s webhook window, log Clerk failures in getUserTokenByGithubLogin. Co-authored-by: Cursor <cursoragent@cursor.com> * revert: drop run-context handler log (#818 deferred) The #692 client-side fix is already on main; residual SyntaxError hits are old action pins. Per-request log added noise without fixing anything. Co-authored-by: Cursor <cursoragent@cursor.com> * document per-issue Closes syntax for audit PRs GitHub only auto-closes the first issue when numbers are comma-separated; /audits and AGENTS.md now require Closes before each issue number. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: anneal round 2 — outreach privacy, alert resilience, vertex cleanup Filter private repos from VIP authority output, harden console alert lines against DB failures, drop spoofable changesets body check, and unset GOOGLE_APPLICATION_CREDENTIALS after vertex credential cleanup. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop codexHome re-export of detectCodexRefresh Import detectCodexRefresh directly from codexRefreshDetect.ts everywhere; rename the unit test file to match. codexHome.ts stays install-only. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: drop deprecated minimax-m2.5-free; add paid minimax-m2.5 Remove the deprecated free MiniMax promo from the catalog, docs, and tests. BYOK fallback and picker copy stay on opencode/big-pickle. Add opencode/minimax-m2.5 and openrouter/minimax-m2.5 for Zen BYOK and Router. Pin #816 regressions with freeFallbackCatalog and runErrorRenderer unit tests. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: hidden minimax-m2.5-free fallback for stored slugs Re-add opencode/minimax-m2.5-free as a hidden fallback alias to big-pickle so repos with the legacy slug still resolve as free. Drop live Zen API experiment tests in freeFallbackCatalog.test.ts. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
141 lines
5.3 KiB
TypeScript
141 lines
5.3 KiB
TypeScript
// Codex-to-OpenCode auth bridging for the action runtime.
|
|
//
|
|
// `pullfrog auth codex` stores a Codex CLI `auth.json` blob in the Pullfrog
|
|
// per-org secret store (production Postgres) — NOT a GitHub Actions secret.
|
|
// This is non-negotiable: the OAuth refresh chain rotates on every use, and
|
|
// `entryPost.ts` writes the rotated chain back via `PUT /api/runtime/secret`
|
|
// after each run. GH Actions secrets are immutable at runtime, so a token
|
|
// stashed there silently expires on the first refresh (~1h). See
|
|
// wiki/codex-auth.md for the full constraint.
|
|
//
|
|
// At runtime, `CODEX_AUTH_JSON` lands in process.env via `runContext.dbSecrets`
|
|
// merged in main.ts — sourced from Pullfrog Postgres through the OIDC-validated
|
|
// run-context endpoint, never from `${{ secrets.CODEX_AUTH_JSON }}` in
|
|
// workflow yaml. 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 } : {}),
|
|
};
|
|
}
|