utils/codexHome.ts
Rama main · réplica de lectura del repositorio original.
// 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.
//
// The run-context endpoint calls `maybeRotateCodexSecret` (see
// `utils/codexSecretRotation.ts`) inside a Postgres row lock just before
// decrypting + returning dbSecrets. That serializes concurrent rotations
// across the fleet: the first concurrent run rotates the token; the rest
// see the just-written value and skip. Result: the token in
// `process.env.CODEX_AUTH_JSON` is guaranteed fresh for at least the
// rotation safety margin (~50min) when this code runs. No action-side
// pre-flight refresh is needed.
//
// This utility then:
// 1. parses + validates the env value
// 2. decodes the access_token JWT's `exp` claim so opencode knows how
// long to trust the token before its CodexAuthPlugin attempts its
// own mid-run refresh
// 3. converts Codex's shape `{ auth_mode, tokens: { access_token,
// refresh_token, id_token?, account_id? } }` into OpenCode's shape
// `{ openai: { type: "oauth", refresh, access, expires, accountId } }`
// 4. materializes it to disk under a path the MCP-shell mount-namespace
// sandbox can hide from bash: `/var/lib/pullfrog/run-*/opencode/auth.json`
// in CI (sudo-bootstrapped, fail-closed if sudo unavailable),
// `$HOME/.local/share/opencode/auth.json` locally (sandbox is no-op
// locally so the path is irrelevant to security)
// 5. returns the path + the original refresh token so the post-run hook
// can detect a mid-run rotation and write back to Pullfrog
//
// Why `/var/lib/pullfrog/` and not `$HOME` in CI: bash via MCP runs inside a
// mount namespace that overlays tmpfs on `/var/lib/pullfrog/` (see FS_MOUNTS
// in action/mcp/shell.ts), so bash sees an empty dir while opencode's
// internal auth module — which runs in the agent process outside that
// namespace — reads/writes the real file. `$HOME` can't be tmpfs-overlaid
// without breaking the agent's legitimate need to access ~/.npm, ~/.cache,
// etc.
//
// See [wiki/codex-auth.md] for the full data-flow picture.
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { homedir, tmpdir, userInfo } from "node:os";
import { dirname, join } from "node:path";
import type { CredentialEntry } from "@opencode/client";
import { log } from "./cli.ts";
import { type CodexAuthBody, parseCodexAuthBody, stringifyCodexAuthBody } from "./codexOAuth.ts";
import { decodeJwtExpMs } from "./oauthShared.ts";
import { SUBSCRIPTION_DATA_DIR_STATE } from "./oauthWriteback.ts";
import { trustedBinary } from "./sandbox.ts";
import { maskSecret, saveSecretState } from "./secretCommands.ts";
import { parseXaiAuthBody, type XaiAuthBody } from "./xaiOAuth.ts";
const CODEX_AUTH_ENV = "CODEX_AUTH_JSON";
const XAI_AUTH_ENV = "GROK_AUTH_JSON";
const installedSubscriptionPaths = new Map<string, string>();
let installedDataHome: string | undefined;
/** the data dir a subscription was installed into this run, which opencode's XDG_DATA_HOME must name. */
export function getSubscriptionDataHome(): string | undefined {
return installedDataHome;
}
export function clearInstalledSubscription(name: "CODEX_AUTH_JSON" | "GROK_AUTH_JSON") {
const path = installedSubscriptionPaths.get(name);
if (!path || !existsSync(path)) return;
const value: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!value || typeof value !== "object") return;
if (name === "CODEX_AUTH_JSON" && "openai" in value) delete value.openai;
if (name === "GROK_AUTH_JSON" && "xai" in value) delete value.xai;
writeFileSync(path, `${JSON.stringify(value)}\n`, { mode: 0o600 });
installedSubscriptionPaths.delete(name);
}
/** sandbox-hidden home for pullfrog-managed on-disk secrets in CI. bash via
* MCP shell tmpfs-overlays this path; opencode's internal auth module
* bypasses external_directory and reaches the real file. mirrors the
* pattern in action/agents/claude.ts installManagedSettings.
*
* not used for codex auth in local dev — the sandbox is no-op there, so
* the path doesn't matter. local dev keeps the existing $HOME path. */
export const PULLFROG_DATA_DIR = "/var/lib/pullfrog";
interface OpenCodeOAuthEntry {
type: "oauth";
refresh: string;
access: string;
expires: number;
accountId?: string;
}
/** opencode keys auth.json by provider id — `openai` for the Codex chain,
* `xai` for the Grok chain. An account can hold both, so entries are merged
* into whatever is already on disk rather than overwriting the file. */
type OpenCodeAuthFile = Record<string, OpenCodeOAuthEntry>;
/** the server latches `refresh_rejected_at` when OpenAI rejects the refresh —
* the rotation is one-shot, so a rejection is PERMANENT until the user re-runs
* `pullfrog auth codex` (see utils/codexSecretRotation.ts and #1101). the
* latched blob still reaches the runner, and materializing it buys nothing: it
* dies at the first model call, having already displaced an `OPENAI_API_KEY`
* that would have served the run. */
function isRejectedChain(body: CodexAuthBody): boolean {
if (!body.refresh_rejected_at) return false;
log.warning(
`» ${CODEX_AUTH_ENV} was rejected by OpenAI at ${body.refresh_rejected_at} and cannot be ` +
`refreshed — re-run \`npx pullfrog auth codex\`. skipping it for this run.`
);
return true;
}
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;
/** 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 (only happens on long runs
* that span >50min — see wiki/codex-auth.md "Concurrency"). */
originalRefresh: string;
/** id_token from the env at materialization time. OpenCode's auth file has
* no slot for it, so the post-hook has to re-attach this one or the written
* back blob loses it — and the Codex CLI REFUSES an auth.json without
* `tokens.id_token` (measured: `missing field 'id_token'`), so dropping it
* silently disqualifies the account from the codex harness forever. */
originalIdToken: string | undefined;
}
/** the stored blob is masked whole; mask its tokens on their own so a log line carrying one is redacted too */
function maskTokens(tokens: { access_token: string; refresh_token: string }): void {
maskSecret(tokens.access_token);
maskSecret(tokens.refresh_token);
}
/** 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, or
* when this runner cannot host it (`resolveDataHome`) —
* caller treats null as "no codex auth, fall through to API key flow".
*
* The env value is server-side guaranteed fresh by `maybeRotateCodexSecret`
* in the run-context endpoint. We parse + write it here and record the dir
* (`getSubscriptionDataHome`) so every opencode subprocess discovers the
* auth.json; no refresh, no DB interaction. */
export function installCodexAuth(): InstalledCodexAuth | null {
const raw = process.env[CODEX_AUTH_ENV];
if (!raw) return null;
const body = parseCodexAuthBody(raw);
if (!body) {
log.warning(`» ${CODEX_AUTH_ENV} present but malformed; ignoring`);
return null;
}
if (isRejectedChain(body)) return null;
maskTokens(body.tokens);
// decode the access_token's JWT exp so opencode trusts the token until
// its real expiry (no need to refresh on first request). null exp ->
// fall back to "expires: 0" so opencode refreshes immediately on first
// request (the old behavior).
const expiresMs = decodeJwtExpMs(body.tokens.access_token) ?? 0;
const xdgDataHome = resolveDataHome();
if (!xdgDataHome) return null;
const opencodeDir = join(xdgDataHome, "opencode");
const authPath = join(opencodeDir, "auth.json");
writeOpenCodeAuthEntry({
opencodeDir,
authPath,
provider: "openai",
entry: {
type: "oauth",
refresh: body.tokens.refresh_token,
access: body.tokens.access_token,
expires: expiresMs,
...(body.tokens.account_id ? { accountId: body.tokens.account_id } : {}),
},
});
// every opencode subprocess in this run (agent spawn + `opencode models`
// introspection) reads this through `openCodeHomeEnv`.
installedDataHome = xdgDataHome;
log.info(`» installed Codex auth at ${authPath}`);
installedSubscriptionPaths.set(CODEX_AUTH_ENV, authPath);
return {
authPath,
originalRefresh: body.tokens.refresh_token,
originalIdToken: body.tokens.id_token,
};
}
/** merge one provider entry into opencode's auth.json, preserving any other
* provider already there. An account can hold both a Codex and a Grok
* credential, and both install into the same file — a wholesale write would
* silently drop whichever ran first. Unreadable or malformed existing content
* is replaced rather than merged: it is opencode's own cache, not a credential
* we can recover, and a stale half-file would break the run either way. */
function writeOpenCodeAuthEntry(params: {
opencodeDir: string;
authPath: string;
provider: string;
entry: OpenCodeOAuthEntry;
}): void {
let existing: OpenCodeAuthFile = {};
if (existsSync(params.authPath)) {
try {
const parsed: unknown = JSON.parse(readFileSync(params.authPath, "utf8"));
if (parsed && typeof parsed === "object") existing = parsed as OpenCodeAuthFile;
} catch {
log.warning(`» ${params.authPath} was unreadable; rewriting it`);
}
}
const merged: OpenCodeAuthFile = { ...existing, [params.provider]: params.entry };
mkdirSync(params.opencodeDir, { recursive: true });
writeFileSync(params.authPath, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
}
/** the sign-in method V2 files each subscription under, as its own legacy auth.json import does. */
const V2_METHOD_IDS: Record<string, string> = { openai: "chatgpt-browser", xai: "device" };
function installedAuthPath(): string | undefined {
return installedDataHome && join(installedDataHome, "opencode", "auth.json");
}
/**
* The installed subscriptions in `opencode auth export`'s shape. OpenCode V2 keeps credentials in
* SQLite and imports auth.json only in a migration a fresh database never runs, so this is the only
* way they reach it. see wiki/opencode-v2.md.
*/
export function openCodeV2Credentials() {
const authPath = installedAuthPath();
if (!authPath || !existsSync(authPath)) return [];
const file: OpenCodeAuthFile = JSON.parse(readFileSync(authPath, "utf8"));
return Object.entries(file).flatMap(([integrationID, entry]) => {
const methodID = V2_METHOD_IDS[integrationID];
if (!methodID || entry.type !== "oauth") return [];
const metadata = entry.accountId ? { accountID: entry.accountId } : {};
const value = {
type: "oauth",
methodID,
refresh: entry.refresh,
access: entry.access,
expires: entry.expires,
metadata,
};
return [
{
id: `cred_pullfrog_${integrationID}`,
integrationID,
label: "Pullfrog",
active: true,
value,
},
];
});
}
/**
* Write V2's current subscription tokens back into auth.json. V2 rotates them in its database, and
* the post hook reads only auth.json, so a refresh the run does not mirror is a chain lost.
*/
export function mirrorOpenCodeV2Credentials(credentials: CredentialEntry[]): void {
const authPath = installedAuthPath();
if (!authPath) return;
const file: OpenCodeAuthFile = JSON.parse(readFileSync(authPath, "utf8"));
for (const credential of credentials) {
const value = credential.value;
if (!V2_METHOD_IDS[credential.integrationID] || value.type !== "oauth") continue;
if (file[credential.integrationID]?.access === value.access) continue;
const accountId = value.metadata?.accountID;
writeOpenCodeAuthEntry({
opencodeDir: dirname(authPath),
authPath,
provider: credential.integrationID,
entry: {
type: "oauth",
refresh: value.refresh,
access: value.access,
expires: value.expires,
...(typeof accountId === "string" ? { accountId } : {}),
},
});
}
}
export interface InstalledXaiAuth {
/** absolute path of the auth.json we wrote — the post-hook diffs it. */
authPath: string;
/** refresh_token at materialization time. opencode's XaiAuthPlugin rotates
* in-process on a long run, so the post-hook compares against this to decide
* whether anything needs writing back. */
originalRefresh: string;
}
/** materialize GROK_AUTH_JSON from env into opencode's auth.json.
*
* opencode ships xAI Grok OAuth natively (`XaiAuthPlugin`, added upstream
* 2026-05-21, present in our pinned 1.18.5) against the same public
* Grok-CLI OAuth client we mint with, so the stored chain drops straight in.
* The plugin sends the token to `api.x.ai/v1` — it deliberately sets no
* baseURL — so there is no CLI proxy and no client-version header in play.
*
* returns null when the env var is absent or malformed, or when this runner
* cannot host it (`resolveDataHome`); caller treats null as
* "no grok subscription auth, fall through to XAI_API_KEY". */
export function installXaiAuth(): InstalledXaiAuth | null {
const raw = process.env[XAI_AUTH_ENV];
if (!raw) return null;
const body = parseXaiAuthBody(raw);
if (!body) {
log.warning(`» ${XAI_AUTH_ENV} present but malformed; ignoring`);
return null;
}
if (isXaiRejectedChain(body)) return null;
maskTokens(body.tokens);
const xdgDataHome = resolveDataHome();
if (!xdgDataHome) return null;
const opencodeDir = join(xdgDataHome, "opencode");
const authPath = join(opencodeDir, "auth.json");
writeOpenCodeAuthEntry({
opencodeDir,
authPath,
provider: "xai",
// opencode refreshes when `expires` is inside its skew window, and falls
// back to the JWT's own exp when the stored value is absent. Decoding it
// here means the first request uses the token we already hold instead of
// burning a rotation on startup.
entry: {
type: "oauth",
refresh: body.tokens.refresh_token,
access: body.tokens.access_token,
expires: decodeJwtExpMs(body.tokens.access_token) ?? 0,
},
});
installedDataHome = xdgDataHome;
log.info(`» installed Grok auth at ${authPath}`);
installedSubscriptionPaths.set(XAI_AUTH_ENV, authPath);
return { authPath, originalRefresh: body.tokens.refresh_token };
}
/** a fresh dir for an `ask_model` credential file. in CI it sits under the bootstrapped data dir the MCP shell cannot see. */
export function askModelPrivateDir(): string | null {
const base = process.env.CI === "true" ? resolveDataHome() : tmpdir();
return base ? mkdtempSync(join(base, "ask-model-")) : null;
}
/**
* one subscription in a private data dir of its own, for an `ask_model` process that must not
* share the run's auth.json. returns the dir and when the access token expires; nothing is written
* back, so the caller must not let the process refresh it, which would rotate the chain the stored
* credential depends on.
*/
export function installPrivateSubscription(input: {
name: "CODEX_AUTH_JSON" | "GROK_AUTH_JSON";
value: string;
}) {
const codex = input.name === CODEX_AUTH_ENV ? parseCodexAuthBody(input.value) : null;
const xai = codex ? null : parseXaiAuthBody(input.value);
const body = codex ?? xai;
if (!body || (codex && isRejectedChain(codex)) || (xai && isXaiRejectedChain(xai))) return null;
const dataHome = askModelPrivateDir();
if (!dataHome) return null;
maskTokens(body.tokens);
const opencodeDir = join(dataHome, "opencode");
const expires = decodeJwtExpMs(body.tokens.access_token) ?? 0;
writeOpenCodeAuthEntry({
opencodeDir,
authPath: join(opencodeDir, "auth.json"),
provider: codex ? "openai" : "xai",
entry: {
type: "oauth",
refresh: body.tokens.refresh_token,
access: body.tokens.access_token,
expires,
...(codex?.tokens.account_id ? { accountId: codex.tokens.account_id } : {}),
},
});
return { dataHome, expires };
}
/** the server latches `refresh_rejected_at` when xAI rejects the refresh.
* rotation is one-shot, so the rejection is permanent until the user re-runs
* `pullfrog auth grok`. materializing a latched blob buys nothing — it dies at
* the first model call, having already displaced an `XAI_API_KEY` that would
* have served the run. */
function isXaiRejectedChain(body: XaiAuthBody): boolean {
if (!body.refresh_rejected_at) return false;
log.warning(
`» ${XAI_AUTH_ENV} was rejected by xAI at ${body.refresh_rejected_at} and cannot be ` +
`refreshed — re-run \`npx pullfrog auth grok\`. skipping it for this run.`
);
return true;
}
export interface InstalledCodexHome {
/** value to set as CODEX_HOME for the codex subprocess. holds auth.json,
* config.toml and the session rollouts the resume path reads. */
codexHome: string;
/** absolute path of the auth.json we wrote. the codex CLI rewrites this file
* in place when it refreshes, so the post-hook diffs it for a rotation. */
authPath: string;
originalRefresh: string;
}
/**
* materialize CODEX_AUTH_JSON into a CODEX_HOME the codex CLI reads directly.
* unlike {@link installCodexAuth} there is no shape conversion — the stored
* blob IS the codex CLI's own `auth.json`, so it round-trips verbatim.
*
* returns null when the env var is absent, malformed, or carries no
* `tokens.id_token`. that last one is not defensive: the CLI's `TokenData`
* makes `id_token` a required field with a JWT deserializer, so an auth.json
* without it fails to load with `missing field 'id_token'` before any model
* call. callers treat null as "codex CLI can't use this credential".
*/
export function installCodexHome(): InstalledCodexHome | null {
const raw = process.env[CODEX_AUTH_ENV];
if (!raw) return null;
const body = parseCodexAuthBody(raw);
if (!body) {
log.warning(`» ${CODEX_AUTH_ENV} present but malformed; ignoring`);
return null;
}
if (isRejectedChain(body)) return null;
maskTokens(body.tokens);
if (!body.tokens.id_token) {
log.warning(
`» ${CODEX_AUTH_ENV} carries no id_token — the codex CLI cannot load it. ` +
`re-run \`npx pullfrog auth codex\` to mint a complete credential.`
);
return null;
}
const root = resolveDataHome();
if (!root) return null;
const codexHome = join(root, "codex");
const authPath = join(codexHome, "auth.json");
mkdirSync(codexHome, { recursive: true });
writeFileSync(authPath, stringifyCodexAuthBody(body), { mode: 0o600 });
log.info(`» installed Codex auth at ${authPath}`);
return { codexHome, authPath, originalRefresh: body.tokens.refresh_token };
}
let dataHome: string | null | undefined;
/** subscriptions this run held but could not install, so a no-key error can name the real cause. */
export const unhostedSubscriptions = new Set<string>();
/** false for a Codex or Grok subscription on a runner that cannot host it (`resolveDataHome`). */
export function canInstallSubscription(name: string) {
return (name !== CODEX_AUTH_ENV && name !== XAI_AUTH_ENV) || resolveDataHome() !== null;
}
/** pick the XDG_DATA_HOME for codex auth.
*
* - **local dev (CI != true)**: use $HOME. mount-namespace sandbox is no-op
* locally so the file isn't protected from bash either way; codex auth on
* a developer's machine is the developer's responsibility.
* - **CI**: bootstrap /var/lib/pullfrog via sudo and use a fresh dir inside it.
* MCP shell's mount namespace tmpfs-overlays this path, and claude
* managed-settings + opencode external_directory both deny it — three
* independent layers.
*
* **fail closed in CI** when the sudo bootstrap fails — for the credential,
* not the run. falling back to $HOME silently strips two of the three
* protection layers, so the subscription is not installed at all (null) and
* leaves env: a run on any other model proceeds, and one that needs it
* reports no key.
* failing the whole run instead broke every run on a sudo-less self-hosted
* runner that merely HELD a subscription, whatever its model (#1335). */
function resolveDataHome(): string | null {
if (process.env.CI !== "true") return join(homedir(), ".local", "share");
if (dataHome === undefined) dataHome = bootstrapPullfrogDataDir();
return dataHome;
}
function bootstrapPullfrogDataDir(): string | null {
const user = userInfo().username;
// `id -gn $user` resolves the user's primary group name correctly even on
// self-hosted images where the group isn't `<user>:<user>` (e.g., `runner`
// belongs to `runner`, but a self-hosted setup might use `users`, `docker`,
// or a project-specific gid). avoids the brittle "group has same name as
// user" assumption.
let primaryGroup: string;
try {
primaryGroup = execFileSync(trustedBinary("id"), ["-gn", user], {
stdio: "pipe",
encoding: "utf-8",
}).trim();
} catch {
primaryGroup = user;
}
// `-n` (non-interactive) makes sudo fail-fast on locked-down runners
// instead of prompting and timing out.
try {
execFileSync(trustedBinary("sudo"), ["-n", "mkdir", "-p", PULLFROG_DATA_DIR], {
stdio: "pipe",
});
execFileSync(
trustedBinary("sudo"),
["-n", "chown", `${user}:${primaryGroup}`, PULLFROG_DATA_DIR],
{
stdio: "pipe",
}
);
execFileSync(trustedBinary("sudo"), ["-n", "chmod", "700", PULLFROG_DATA_DIR], {
stdio: "pipe",
});
} catch (err) {
log.warning(
`» subscription auth skipped: could not create ${PULLFROG_DATA_DIR} (${err instanceof Error ? err.message : String(err)}). ` +
`it must live outside the agent sandbox's reach, which needs passwordless sudo on this runner; ` +
`runs on a model that needs the subscription will report no key.`
);
// harness routing and key validation read the env vars, not the installed file.
for (const name of [CODEX_AUTH_ENV, XAI_AUTH_ENV]) {
if (process.env[name]) unhostedSubscriptions.add(name);
delete process.env[name];
}
return null;
}
// one per run: concurrent runs on a self-hosted host shared opencode's SQLite db and
// the codex CLI's home here (#1475). the post hook removes it after the writeback; a
// GitHub-hosted VM is discarded anyway, so it skips that post-step npm bootstrap.
const runDir = mkdtempSync(join(PULLFROG_DATA_DIR, "run-"));
// not a credential: this is just the one state writer the secret-command guard allows.
if (process.env.RUNNER_ENVIRONMENT !== "github-hosted") {
saveSecretState(SUBSCRIPTION_DATA_DIR_STATE, runDir);
}
return runDir;
}