a78b1542da
* 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)
161 lines
6.6 KiB
TypeScript
161 lines
6.6 KiB
TypeScript
import {
|
|
BEDROCK_MODEL_ID_ENV,
|
|
getModelEnvVars,
|
|
providers,
|
|
resolveDisplayAlias,
|
|
} from "../models.ts";
|
|
import { getApiUrl } from "./apiUrl.ts";
|
|
|
|
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";
|
|
|
|
/** Markdown body used for both the thrown error and the formatted PR comment summary. */
|
|
function buildMissingApiKeyError(params: { owner: string; name: string }): string {
|
|
const githubSecretsUrl = `https://github.com/${params.owner}/${params.name}/settings/secrets/actions`;
|
|
const settingsUrl = `${getApiUrl()}/console/${params.owner}/${params.name}`;
|
|
|
|
return [
|
|
`**${MISSING_KEY_MARKER}** — Pullfrog needs at least one LLM provider API key (e.g. \`ANTHROPIC_API_KEY\`, \`OPENAI_API_KEY\`, \`GEMINI_API_KEY\`) configured as a GitHub Actions secret.`,
|
|
"",
|
|
`[Open repo secrets →](${githubSecretsUrl}) · [Configure model →](${settingsUrl}) · [Setup docs →](https://docs.pullfrog.com/keys) · [Ask in Discord →](https://discord.gg/8y96raFg8e)`,
|
|
].join("\n");
|
|
}
|
|
|
|
function buildBedrockSetupError(params: {
|
|
owner: string;
|
|
name: string;
|
|
missing: string[];
|
|
}): string {
|
|
const githubSecretsUrl = `https://github.com/${params.owner}/${params.name}/settings/secrets/actions`;
|
|
|
|
return `Bedrock model selected but required configuration is missing: ${params.missing.join(", ")}.
|
|
|
|
add the missing secret(s) to your GitHub repository at ${githubSecretsUrl}, then reference them in your workflow's \`env:\` block:
|
|
|
|
AWS_BEARER_TOKEN_BEDROCK: \${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
|
|
AWS_REGION: \${{ secrets.AWS_REGION }}
|
|
${BEDROCK_MODEL_ID_ENV}: \${{ secrets.${BEDROCK_MODEL_ID_ENV} }}
|
|
|
|
\`AWS_BEARER_TOKEN_BEDROCK\` may be substituted with \`AWS_ACCESS_KEY_ID\` + \`AWS_SECRET_ACCESS_KEY\` (and optional \`AWS_SESSION_TOKEN\`) if you prefer access keys.
|
|
|
|
for full setup instructions, see https://docs.pullfrog.com/bedrock`;
|
|
}
|
|
|
|
function hasEnvVar(name: string): boolean {
|
|
const value = process.env[name];
|
|
return typeof value === "string" && value.length > 0;
|
|
}
|
|
|
|
/** check if the user has a BYOK key for the given model's provider (does not throw) */
|
|
export function hasProviderKey(model: string): boolean {
|
|
const requiredVars = getModelEnvVars(model);
|
|
if (requiredVars.length === 0) return true;
|
|
return requiredVars.some((v) => hasEnvVar(v));
|
|
}
|
|
|
|
function validateBedrockSetup(params: { owner: string; name: string }): void {
|
|
const hasAuth =
|
|
hasEnvVar("AWS_BEARER_TOKEN_BEDROCK") ||
|
|
(hasEnvVar("AWS_ACCESS_KEY_ID") && hasEnvVar("AWS_SECRET_ACCESS_KEY"));
|
|
|
|
const missing: string[] = [];
|
|
if (!hasAuth)
|
|
missing.push("AWS_BEARER_TOKEN_BEDROCK (or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)");
|
|
if (!hasEnvVar("AWS_REGION")) missing.push("AWS_REGION");
|
|
if (!hasEnvVar(BEDROCK_MODEL_ID_ENV)) missing.push(BEDROCK_MODEL_ID_ENV);
|
|
|
|
if (missing.length > 0) {
|
|
throw new Error(buildBedrockSetupError({ owner: params.owner, name: params.name, missing }));
|
|
}
|
|
}
|
|
|
|
export function validateAgentApiKey(params: {
|
|
agent: { name: string };
|
|
model: string | undefined;
|
|
owner: string;
|
|
name: string;
|
|
}): void {
|
|
// if a specific model is configured, only check that model's required env vars
|
|
if (params.model) {
|
|
// routing slugs (e.g. bedrock) get a tailored validation path because
|
|
// their auth shape doesn't match the standard "any one envVar present"
|
|
// rule (Bedrock needs auth + region + model-id, with auth being either
|
|
// a bearer token OR an access-key pair).
|
|
const alias = resolveDisplayAlias(params.model);
|
|
if (alias?.routing === "bedrock") {
|
|
validateBedrockSetup({ owner: params.owner, name: params.name });
|
|
return;
|
|
}
|
|
|
|
// upstream `resolveModel` translates `bedrock/byok` into the raw Bedrock
|
|
// model ID (e.g. `us.anthropic.claude-opus-4-6-v1`), which has no `/`
|
|
// and so isn't parseable as `provider/model`. these IDs only reach this
|
|
// function via routing aliases, so re-run the bedrock setup check rather
|
|
// than falling through to `getModelEnvVars` (which would throw inside
|
|
// parseModel). resolveModel itself already enforced BEDROCK_MODEL_ID,
|
|
// but auth + region are still validated here.
|
|
if (!params.model.includes("/")) {
|
|
validateBedrockSetup({ owner: params.owner, name: params.name });
|
|
return;
|
|
}
|
|
|
|
const requiredVars = getModelEnvVars(params.model);
|
|
// free models have no required env vars — skip validation entirely
|
|
if (requiredVars.length === 0) return;
|
|
if (requiredVars.some((v) => hasEnvVar(v))) return;
|
|
|
|
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
|
}
|
|
|
|
// no model configured — auto-select requires at least one known provider key
|
|
const hasAnyKey = [...knownApiKeys].some((k) => hasEnvVar(k));
|
|
if (!hasAnyKey) {
|
|
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect agent-runtime auth failures that should be reformatted as an actionable
|
|
* key-fix CTA before being shown to the user. Covers the two shapes we see:
|
|
* - missing key (validateAgentApiKey throw): contains MISSING_KEY_MARKER
|
|
* - revoked / invalid key (Claude CLI 401 surfaced via api_error_status):
|
|
* "Invalid API key · Fix external API key" + similar provider variants
|
|
*/
|
|
export function isApiKeyAuthError(text: string): boolean {
|
|
if (!text) return false;
|
|
return (
|
|
text.includes(MISSING_KEY_MARKER) ||
|
|
/Invalid API key/i.test(text) ||
|
|
/\bUser not found\b/i.test(text) ||
|
|
/\bInvalid authentication\b/i.test(text)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Friendly Markdown summary for both the missing-key and invalid-key cases.
|
|
* Used in the catch / result-failure paths in `main.ts` to overwrite the raw
|
|
* agent error before it's posted to the PR progress comment.
|
|
*/
|
|
export function formatApiKeyErrorSummary(params: {
|
|
owner: string;
|
|
name: string;
|
|
raw: string;
|
|
}): string {
|
|
if (params.raw.includes(MISSING_KEY_MARKER)) {
|
|
return buildMissingApiKeyError({ owner: params.owner, name: params.name });
|
|
}
|
|
|
|
const githubSecretsUrl = `https://github.com/${params.owner}/${params.name}/settings/secrets/actions`;
|
|
const settingsUrl = `${getApiUrl()}/console/${params.owner}/${params.name}`;
|
|
|
|
return [
|
|
`**Your LLM provider API key was rejected (401).** Rotate the key in your provider dashboard, then update the matching GitHub Actions secret.`,
|
|
"",
|
|
`[Update repo secret →](${githubSecretsUrl}) · [Model settings →](${settingsUrl}) · [Setup docs →](https://docs.pullfrog.com/keys) · [Ask in Discord →](https://discord.gg/8y96raFg8e)`,
|
|
].join("\n");
|
|
}
|