Files
shockbot/commands/auth.ts
T
Colin McDonnell a0576a702a opencode v2: harness adapted to opencode-ai 1.15+ SDK-v2 / Effect-ts CLI rewrite (#767)
* opencode v2: harness adapted to opencode-ai 1.15+ SDK-v2 / Effect-ts CLI rewrite

Bumps `opencode-ai` from `1.1.56` → `1.15.1` and ports the harness to the
v2 NDJSON event contract. The legacy `opencode.ts` is kept as reference;
`opencode_v2.ts` is the active runner via `agents/index.ts`.

Why: `1.1.56` doesn't echo Gemini `thought_signature` back through the
MCP tool-call serializer, so direct-Google reviews 400 on the 3rd-ish
tool call. The fix only exists in the `1.14.x`+ line, which also ships
the SDK-v2 / Effect-ts CLI rewrite — taking the rewrite is mandatory.
Also unblocks the Codex ChatGPT-subscription auth path.

Surface area:

  - drop `init` / `message` / `result` / `tool_result` event types and
    handlers (no longer emitted at v1.14+ per upstream
    `cli/cmd/run.ts:588-601`).
  - `tool_use` is now a single event covering both `state.status:
    "completed"` and `"error"`. duration / subagent-finish bookkeeping
    moves from the v1 `tool_result` handler into the consolidated
    `tool_use` handler.
  - new `reasoning` event handler — gated on `--thinking`, surfaces
    Gemini-3 / OpenAI / Anthropic thinking blocks. `--thinking` added to
    `baseArgs`.
  - drop `pendingTaskDispatches` FIFO + `knownNonTaskCallIDs` set: at
    v1.15 the `task` tool callID is stable across the whole
    `tool-input-* → tool-call → tool-result/tool-error` chain
    (`session/processor.ts:282-330`). exact-match map is sufficient.
  - drop `experimental.batch_tool: true` from injected config — declared
    but inert at v1.15. re-add once upstream wires it back.
  - bin path: `bin/opencode` → `bin/opencode.exe` (postinstall renames
    the platform-specific binary into `opencode.exe` for every OS now).

Validated locally:

  - `pnpm test` 610/610 ✓
  - `pnpm play --raw` end-to-end with Anthropic via OpenRouter ✓
  - `pnpm play --raw` with `google/gemini-3.1-pro-preview`: 6 tool calls,
    multiple reasoning blocks visible, `set_output` propagates, exit 0 ✓
    (this is the headline `thought_signature` fix)
  - runtest opencode: smoke ✓, restricted ✓, nobash ✓, token-exfil ✓
  - runtest opencode: skill-invoke and mcpmerge fail (model-behavior
    drift on the new system prompt; wiring confirmed intact via direct
    repro showing both `robinMCP` and `pullfrog` MCP tools exposed).
    Tracked for follow-up; does not gate the migration.

Plugin (`opencodePlugin.ts`) and skill discovery paths are unchanged at
v1.15 — verified upstream and reused as-is. Bus subscription via
`bus.subscribeAll()` and the `event` hook still fan out every payload.

* model-smoke: bump opencode bin path to opencode.exe (v1.14+ rename)

The v1.14+ postinstall.mjs renames the platform-specific binary to
`bin/opencode.exe` for every OS (incl. linux/darwin), not just Windows.
Mirrors the fix in action/agents/opencode_v2.ts.

* opencode v2: set PWD env explicitly to fix skill / project-config discovery

Root cause for skill-invoke + mcpmerge harness regressions: opencode-ai 1.15
reads `process.env.PWD` first (with `process.cwd()` as fallback) when
resolving the SDK client's `directory` parameter — see upstream
`cli/cmd/run.ts:282`:

  const root = Filesystem.resolve(process.env.PWD ?? process.cwd())

We pass `cwd: repoDir` to spawn, but the child inherits the harness's PWD
via `...process.env`. Under `pnpm runtest` (and `pnpm play`) PWD is the
`action/` directory, not the cloned test repo. Result: opencode creates
two instances per session — one at `process.cwd()` (correct) and one at
`PWD` (wrong) — and the agent's session runs in the PWD-derived one,
which can't see the project's `.opencode/skills/` or `.claude/skills/`.

Empirically traced via the full opencode stderr trace under the runtest
harness: `service=skill count=3 init` (no `pullfrog-skill-check`) plus a
second `service=default directory=<harness-pwd> creating instance` line
per run. With `PWD=repoDir` set explicitly, `count=4 init` includes the
test skill, the agent reaches for `skill({"name":"pullfrog-skill-check"})`
exactly as the validator expects, and mcpmerge's `robinMCP_get_test_value`
becomes accessible too.

Validated locally: skill-invoke-opencode ✓, mcpmerge-opencode ✓, smoke ✓,
restricted ✓, nobash ✓, token-exfil ✓ (flaked once on a model-narration
match, passes on retry; unrelated to PWD).

* opencode v2: drop ThinkingTimer; use opencode's reasoning.part.time directly

opencode-ai 1.15 emits `reasoning` parts with `time.start` / `time.end`
on terminal state (`cli/cmd/run.ts:671`), giving us a precise per-block
"thought for X s" duration straight from the runtime. The v1
ThinkingTimer heuristic — measuring wall-clock between markToolResult
and the next markToolCall — was an approximation when no native source
existed; with v2 it's redundant and noisy (it would log alongside the
real reasoning event, and conflated network latency with model thinking).

Removed: `ThinkingTimer` import, `thinkingTimers` Map, `timerFor()`
helper, both `markToolCall` / `markToolResult` call sites in `tool_use`.
The `reasoning` handler now reads `part.time.start/end` directly and
prefixes the visible preview with `(X.Ys)`.

Output before: `» thinking: <preview>` + `» thought for 4.0s` (separate)
Output now:    `» thinking (4.0s): <preview>` (one line, sourced)

For models that don't emit reasoning (Sonnet without extended thinking,
GPT-4o, etc.), there's just no thinking line — which matches reality
better than the gap-heuristic, which would fire on any pause >3s
including provider-side latency that wasn't actual model reasoning.

Validated locally: skill-invoke ✓, mcpmerge ✓, smoke ✓, Gemini play
shows `» thinking (4.0s)` and `» thinking (0.8s)` from real durations.

* claude.ts: same PWD fix as opencode v2; entryPost: refresh stale comment

claude-code 2.1.x reads `process.env.PWD` and registers it as a "session"
additional-working-directory when it differs from `process.cwd()` (per the
bundled cli.js: `let H = process.env.PWD; if (H && H !== Y7() && ...)
j.set(H, { path: H, source: "session" })`). Without overriding PWD on the
spawn env, claude inherits the harness's PWD via `...process.env` — under
`pnpm runtest` / `pnpm play` that's `action/`, not the cloned test repo —
and adds the wrong dir to the agent's allowed working set.

Symmetric to the opencode v2 fix in 52337f9. Pre-empts the same class of
"agent's session sees the wrong cwd" failures on the claude side.

Also refresh the stale `action/agents/opencode.ts` reference in
entryPost.ts to point at opencode_v2.ts (the active runner), with the v1
file noted as kept-for-reference.

* opencode: extract shared helpers into opencodeShared.ts; v2 cleanup

Code-quality pass on the v2 work:

1. New `agents/opencodeShared.ts` (144 lines) for genuinely-shared helpers
   between v1 and v2:
   - `OpenCodeConfig` type
   - `geminiHighThinkingOverrides()` (registry-driven Gemini thinking pin)
   - `buildReviewerAgentConfig()` (reviewfrog config builder, was in v1
     and re-imported by v2 via a back-reference)
   - `installOpencodeCli({ binPath })` (parameterized — v1 passes
     `bin/opencode`, v2 passes `bin/opencode.exe` via a per-version
     `installCli` lambda; matches each pinned version's npm shape)
   - `autoSelectModel()` + `getOpenCodeModels()` model-registry fallback

   v2 drops the `import { ... } from "./opencode.ts"` back-reference; v1
   keeps a one-line `export { geminiHighThinkingOverrides }` re-export
   so `opencode.test.ts` keeps working unchanged. Once v1 is retired
   (post burn-in) opencodeShared collapses back into v2.

2. `opencode_v2.ts` cleanup:
   - drop dead state (`currentStepId`, `stepHistory` were write-only —
     their reader was the v1 `tool_result` handler we deleted)
   - hoist `state` in `tool_use` handler; replace nested-ternary payload
     extraction with a `terminalPayload(state)` helper
   - extract `formatPartDuration(time)` for the reasoning-block
     "(X.Ys)" suffix
   - tighten `OpenCodeBusEnvelopeEvent` type to include `tool` /
     `callID` fields directly, drop the `partWithToolFields` cast
   - trim docblocks per AGENTS.md "≤ 2-3 lines per code line": reasoning
     handler, tool_use handler, bus envelope handler all shortened
   - `step_start` becomes an explicit `() => {}` no-op so the dispatcher
     doesn't log "unhandled event" for every step

3. `subagentRegistration.test.ts` retargeted at the new file split —
   reads opencodeShared.ts for the buildReviewerAgentConfig assertions
   and opencode_v2.ts for the orchestrator-model wire-through.

Net: -306 source lines (1339+1130 → 1228+1031+144). Tests + lint + format
+ typecheck all green; skill-invoke-opencode ✓ and smoke ✓ verified
against the refactored v2 runtime.

* opencode v2: address PR review feedback

Three fixes from the inline review threads on #767:

1. Activity-diagnostic ordering bug (Copilot review at L705): the chunk-
   level `markActivity()` resets the module-level idle counter, so the
   per-event `getIdleMs()` sample inside the dispatch loop was always
   ~0ms — the "no activity for Xs" diagnostic never fired. Replaced with
   a runner-local `lastEventAt` so we measure real event-to-event silence
   instead of chunk-arrival latency. Drop the unused `getIdleMs` import.

2. TDZ-defensive hoist (Pullfrog review nit): `agentErrorEvent`,
   `lastProviderError`, and `recentStderr` are closed over by the
   `handlers` const but were declared after it. No current bug because
   handlers only fire inside the awaited `spawn()`, but a future
   refactor that triggers a handler synchronously during setup would
   surface a TDZ. Hoisted above `handlers`.

3. `step_finish.part.tokens.reasoning` follow-up (Pullfrog review at
   L566): leave a `TODO` comment marking the gap until `AgentUsage`
   grows a `reasoningTokens` field — separate PR with schema work.
   Cost totals stay correct because `part.cost` is summed independently.

Other thread states for the record:
- Copilot L63 (geminiHighThinkingOverrides import from legacy): already
  fixed by the opencodeShared.ts extraction in 83a7cab.
- Copilot L672 (ThinkingTimer over-reports on terminal events): already
  fixed by dropping ThinkingTimer in a1e536b — we use opencode's own
  `reasoning.part.time.{start,end}` for thinking durations now.
- Pullfrog L642 (onToolUse double-fire on subagent dispatch): re-checked
  the bus-envelope flow; the plugin filters orchestrator events except
  for status=running task dispatches, and bus-envelope returns before
  calling handlers.tool_use on those. No double-fire under current code.

Validated: 610/610 unit tests, lint + format + typecheck clean,
skill-invoke-opencode ✓.

* DX: flip pnpm play / pnpm runtest to docker-by-default

Restores the script shape wiki/docker.md has documented since the docker
rewrite (#750). PR #756 inadvertently reverted action/package.json's
gha/play/runtest scripts to host-only and dropped the :local variants;
the wiki kept the new shape, so docs and reality drifted. The OpenCode-v2
migration agent ran `pnpm play --raw …` host-side throughout because the
host entry was the only thing that existed.

scripts (root → action):
- pnpm play       → pnpm -C action gha play.ts          (docker, default)
- pnpm play:local → pnpm -C action play:local           (host)
- pnpm runtest    → pnpm -C action gha test/run.ts      (docker, default)
- pnpm runtest:local → pnpm -C action runtest:local     (host)
- pnpm gha is restored in action/package.json (re-adds `node gha.ts`)

action/package.json deliberately ships only the :local variants — bare
`pnpm -C action play` now errors instead of silently bypassing docker.
This is a tradeoff per the user prompt's "consider whether NAMES should
change" hint: the explicit error is worth the small CI churn.

CI workflows: `.github/workflows/test.yml` and
`action/.github/workflows/test.yml` flipped from `pnpm runtest …` to
`pnpm runtest:local …`. Semantics unchanged — they still execute
`node test/run.ts` directly on the GHA Linux runner; nesting docker on
GHA is unnecessary overhead. Only the script name changed to match the
new package.json.

Webhook tester: the existing root `pnpm play` was actually a webhook
handler smoke harness (root play.ts), unrelated to the action runtime.
Renamed root play.ts → webhook.ts and exposed it as `pnpm webhook` so
`pnpm play` can carry the docker-by-default action shortcut without
collision. README updated.

File headers updated:
- action/play.ts: invocation block now points at `pnpm play` /
  `pnpm play:local`
- action/test/run.ts: same
- action/gha.ts: usage block calls out the new shortcut wrappers

AGENTS.md: extended the existing "local sanity checks of action tool
logic" rule with the play / play:local / runtest / runtest:local
selection guidance and the `cd action; pnpm play` footgun note.

wiki/docker.md unchanged — already described the now-real shape.

* test/crossagent: add codex-auth smoke

Pins openai/gpt-5.5 (in opencode's Codex ALLOWED_MODELS) and runs the
full opencode harness against the env-provided CODEX_AUTH_JSON. Verifies:

  - installCodexAuth() materializes auth.json under the test HOME
  - opencode routes openai requests through ChatGPT subscription auth
    (no OPENAI_API_KEY in env, AT path forced via expires: 0)
  - the refresh chain advances during the run (refresh_token rotates)
  - detectCodexRefresh() would surface the rotation to entryPost.ts

The post-hook write-back fetch isn't reachable from `pnpm runtest`
(it's a separate GHA `post:` step). The integration boundary that
matters end-to-end is "did the on-disk auth.json change in a way
detectCodexRefresh recognizes" — that's exactly what this test asserts.

CI wiring (already committed in a1c1fd4f as part of the DX flip):
  - .github/workflows/test.yml: CODEX_AUTH_JSON via secrets in
    action-agents env block
  - action/.github/workflows/test.yml: same; codex-auth in the
    hardcoded test matrix with a claude exclude

The provisioning step on the user's side is `gh secret set
CODEX_AUTH_JSON --repo pullfrog/app < auth.json`.

ci.test.ts: expectedAgentEnvVars now includes provider
`managedCredentials` so the "env vars cover all provider API keys"
invariant stays self-correcting as more managed credentials land.

* docs(codex-auth): make storage requirement unmissable

A previous reviewing agent on this branch came away thinking
`CODEX_AUTH_JSON` could live in GitHub Actions secrets. It can't —
`entryPost.ts` rewrites the rotated refresh token after every run, and GH
Actions secrets are immutable at runtime, so any non-Pullfrog-Postgres
storage breaks the refresh chain on the first rotation (~1h silent
expiry).

- wiki/codex-auth.md: prominent `[!IMPORTANT]` callout above the fold,
  with the words "GitHub Actions secrets DO NOT WORK" verbatim and an
  enumeration of broken alternatives.
- action/utils/codexHome.ts + action/entryPost.ts: header comments now
  loudly contrast Pullfrog secret store vs GH Actions and explain the
  writeback constraint.
- AGENTS.md: terse one-bullet rule next to the model-resolution rule so
  future agents don't repeat the mistake.
- .github/workflows/test.yml + action/.github/workflows/test.yml: added a
  comment marking the existing `secrets.CODEX_AUTH_JSON` injection as a
  CI smoke-testing shortcut, not the canonical pattern. CI wiring itself
  unchanged per scope.

* auth codex: auto-open device URL, drop --scope flag

- detect `https://auth.openai.com/codex/device...` from codex CLI output
  and best-effort launch it in the user's default browser (open / xdg-open
  / cmd start, wslview fallback on linux). gated so we only open once per
  flow; failures are swallowed so manual copy-paste still works.
- drop the `--scope` flag entirely. the device-code flow is fundamentally
  interactive (browser approval), so a "skip-the-prompt" flag for just one
  of the prompts was dead weight. collapses scope selection to "always
  prompt on org-owned, always account on user-owned".

* rename gha→docker, flip play/runtest defaults to host

the previous shape conflated "real GitHub Actions" with the local docker
container that mocks it, and made the slow docker path the default for
fast-iteration scripts.

- `action/gha.ts` → `action/docker.ts` (banner, --doctor, --help, image
  tag `pullfrog-docker:*`, volume `pullfrog-docker-node-modules-*`,
  tmpdir, error messages)
- `pnpm play` / `pnpm runtest` now default to host (fast iteration);
  `pnpm play:docker` / `pnpm runtest:docker` run inside the container
- `pnpm gha` → `pnpm docker` (the container runner shortcut)
- `pnpm webhook` → `pnpm play:webhook` (fits the play: namespace; the
  bare name implied a webhook server, which hookdeck-cli already is)
- update docs (`wiki/{docker,action-tests,billing,adversarial,browser}.md`,
  `README.md`, `AGENTS.md`), CI workflows
  (`.github/workflows/test.yml`, `action/.github/workflows/test.yml`),
  and code headers (`action/{play,test/run,utils/runFixture}.ts`,
  `webhook.ts`, `action/test/coverage.ts`)

`action/commands/gha.ts` keeps its name — it's the real GitHub Actions
entry point for the `pullfrog gha` CLI command (not the docker mock).

* fix(codex): route post-hook writeback through apiFetch + conditional skip

Three threads addressing PR #767 followups.

action/entryPost.ts: replace raw fetch() with apiFetch() so the
PUT /api/runtime/secret call carries the x-vercel-protection-bypass
header/query when targeting a preview deployment. raw fetch silently
401s against the Vercel SSO gate, so every preview-env Codex run was
losing its rotated refresh token. production is unaffected (no SSO).

action/test/crossagent/codexAuth.ts: gate the test on CODEX_AUTH_JSON
via new TestRunnerOptions.skipIf hook. when the secret is absent
(forks, contributors without it), runTestForAgent short-circuits to a
passing-with-skipped ValidationResult before any agent spawn — so the
matrix's fail-fast: true setting doesn't cascade-cancel siblings. CI
on pullfrog/app and dev-local with .env both still run the test for
real. printSingleValidation/printResults now render skipped entries
distinctly.

doc/comment drift:
- docs/codex-auth.mdx, wiki/codex-auth.md: drop stale --scope flag
  mention (removed in 10be96db, scope is now always interactively
  prompted or implicit).
- wiki/codex-auth.md: tighten Claude-defense wording — materialization
  is agent-gated (opencode/opencode_v2 harness), not model-gated;
  opencode runs with non-OpenAI models still materialize the file,
  it's just not read.
- action/Dockerfile, action/docker-entrypoint.sh: pnpm gha / gha.ts
  → pnpm docker / docker.ts (renamed in a2a63929).
- app/api/runtime/secret/route.ts: refer to the save-time scope prompt
  instead of the dropped --scope flag.

* smoke: force ≥2 tool calls; document test-bar in wiki + AGENTS

upgrade crossagent/smoke prompt to call pullfrog_git status before
set_output. this exercises the 2nd model→agent round-trip across every
providers-live flagship, catching bugs like the Gemini thought_signature
echo that single-tool-call tests can't see.

also adds the "bar for adding new LLM-driven tests" section to
wiki/action-tests.md and an extension to the existing AGENTS.md
no-tests rule pointing at it — prefer upgrading existing matrix entries
over adding new ones.

local: pnpm runtest smoke opencode passes against both
anthropic/claude-sonnet-4-6 and google/gemini-pro.

---------

Co-authored-by: Colin McDonnell <colinmcd94@M1chelle.local>
2026-05-20 04:05:16 +00:00

325 lines
11 KiB
TypeScript

// `pullfrog auth <provider>` — manage credentials for a configured repo
// without going through the full `init` flow. currently supports:
//
// pullfrog auth codex mint a Codex subscription credential and save it
// as the `CODEX_AUTH_JSON` Pullfrog secret
//
// the `codex` subcommand runs `codex login --device-auth` against an
// isolated `CODEX_HOME` (so the user's existing ~/.codex/auth.json is never
// touched), validates the resulting auth.json, and posts it to the Pullfrog
// secrets API. used both for first-time setup of a Codex subscription on a
// repo and for rotating a stale credential.
import { spawn } from "node:child_process";
import * as p from "@clack/prompts";
import arg from "arg";
import pc from "picocolors";
import { mintCodexAuth, refreshCodexAuth } from "../utils/codexAuth.ts";
import {
bail,
fetchStatus,
getGhToken,
handleCancel,
PULLFROG_API_URL,
parseGitRemote,
promptScope,
setActiveSpin,
setPullfrogSecret,
} from "./_shared.ts";
const CODEX_AUTH_SECRET = "CODEX_AUTH_JSON";
/** strip CSI ANSI escapes (color, cursor) from a string so callers can re-style
* the visible text without inheriting the source's formatting. covers what
* Codex emits during device auth (mostly `\x1b[<digits>m` color codes).
*/
function stripAnsi(s: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes are control chars by design
return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
}
/** matches the Codex device-auth verification URL printed by `codex login
* --device-auth`. captures the full URL (with query string) up to whitespace.
*/
const CODEX_DEVICE_URL_RE = /https:\/\/auth\.openai\.com\/codex\/device\S*/;
/** best-effort cross-platform "open URL in default browser". swallows
* spawn errors and non-zero exits — the user can always copy-paste the URL
* Codex already printed. on Linux, falls back to `wslview` when `xdg-open`
* is missing (covers WSL where xdg-open isn't installed by default).
*/
function openInBrowser(url: string): void {
const platform = process.platform;
let cmd: string;
let args: string[];
if (platform === "darwin") {
cmd = "open";
args = [url];
} else if (platform === "win32") {
// `start` is a cmd.exe builtin. the empty "" is the window title
// (required when the next argument is quoted, which happens for
// URLs with `&`).
cmd = "cmd.exe";
args = ["/c", "start", "", url];
} else {
cmd = "xdg-open";
args = [url];
}
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
child.on("error", () => {
if (platform !== "linux") return;
const fallback = spawn("wslview", [url], { stdio: "ignore", detached: true });
fallback.on("error", () => {});
fallback.unref();
});
child.unref();
}
interface AuthCliParams {
args: string[];
prog: string;
showHelp?: boolean;
}
function printAuthUsage(params: { stream: typeof console.log; prog: string }): void {
params.stream(`usage: ${params.prog} auth <provider>\n`);
params.stream("manage provider credentials for the current repository.");
params.stream("");
params.stream("providers:");
params.stream(" codex mint a Codex (ChatGPT) subscription credential");
params.stream("");
params.stream("options:");
params.stream(" -h, --help show help");
}
function printCodexUsage(params: { stream: typeof console.log; prog: string }): void {
params.stream(`usage: ${params.prog} auth codex [options]\n`);
params.stream("mint a Codex subscription credential and save it as CODEX_AUTH_JSON.");
params.stream("");
params.stream("options:");
params.stream(" -h, --help show help");
}
export async function runCli(params: AuthCliParams): Promise<void> {
// route `auth --help` (no subcommand) to top-level usage. when the user
// passes `auth codex --help`, we leave the flag in the rest args so the
// subcommand's own parser handles it.
const firstArg = params.args[0];
const helpAtTopLevel =
params.showHelp ||
params.args.length === 0 ||
(params.args.length === 1 && (firstArg === "--help" || firstArg === "-h"));
if (helpAtTopLevel) {
printAuthUsage({ stream: console.log, prog: params.prog });
return;
}
const subcommand = firstArg;
const rest = params.args.slice(1);
if (subcommand === "codex") {
await runCodex({ args: rest, prog: params.prog });
return;
}
console.error(`unknown auth provider: ${pc.bold(subcommand)}\n`);
printAuthUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
interface CodexCliParams {
args: string[];
prog: string;
}
function parseCodexArgs(args: string[]) {
return arg(
{
"--help": Boolean,
"-h": "--help",
},
{ argv: args }
);
}
async function runCodex(params: CodexCliParams): Promise<void> {
let parsed: ReturnType<typeof parseCodexArgs>;
try {
parsed = parseCodexArgs(params.args);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${message}\n`);
printCodexUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
if (parsed["--help"]) {
printCodexUsage({ stream: console.log, prog: params.prog });
return;
}
await runCodexAuth();
}
async function runCodexAuth(): Promise<void> {
p.intro(pc.bgGreen(pc.black(" pullfrog auth codex ")));
const spin = p.spinner();
setActiveSpin(spin);
try {
spin.start("authenticating with github");
const token = getGhToken();
spin.stop("github authenticated");
spin.start("detecting repository");
const remote = parseGitRemote();
spin.stop(`detected repo ${pc.cyan(`${remote.owner}/${remote.repo}`)}`);
spin.start("checking pullfrog app installation");
const status = await fetchStatus({ token, owner: remote.owner, repo: remote.repo });
if (!status.installed) {
spin.stop(pc.red("pullfrog app not installed on this repo"));
bail(
`install pullfrog on ${pc.bold(`${remote.owner}/${remote.repo}`)} before configuring auth.\n` +
` ${pc.dim("run:")} ${pc.cyan(`npx pullfrog init`)}`
);
}
spin.stop(`pullfrog app is installed on ${pc.cyan(`@${remote.owner}`)}`);
if (status.pullfrogSecrets.includes(CODEX_AUTH_SECRET)) {
const overwrite = await p.select({
message: `${pc.cyan(CODEX_AUTH_SECRET)} is already configured — overwrite?`,
options: [
{ value: true, label: "overwrite", hint: "rotate to a freshly minted credential" },
{ value: false, label: "cancel" },
],
});
handleCancel(overwrite);
if (!overwrite) {
p.cancel("canceled.");
return;
}
}
// user-owned repos can only ever be "account" (Pullfrog has no per-repo
// store for user accounts), so we never bother prompting. on org-owned
// repos, prompt interactively — matches `init`'s behavior.
const scope = status.isOrg
? await promptScope({ owner: remote.owner, repo: remote.repo })
: "account";
p.log.info(
[
`signing in via Codex device authorization. open the URL Codex prints`,
`below, enter the one-time code, and approve in your browser.`,
``,
`${pc.dim("note:")} if your ChatGPT account doesn't have device-code auth enabled,`,
`Codex will exit early. enable it at ${pc.cyan(`https://chatgpt.com/#settings/Security`)}`,
`then re-run ${pc.cyan(`${process.env.PULLFROG_BIN_NAME || "pullfrog"} auth codex`)}.`,
].join("\n")
);
// tracks the most recent exit so the retry prompt can tell the user
// *why* no auth.json was written (timeout vs. early-exit).
let lastTimedOut = false;
// gate so we don't re-launch the browser if Codex prints the URL
// more than once (e.g. on a retry attempt within the same flow).
let hasOpenedDeviceUrl = false;
const auth = await mintCodexAuth({
childStdio: "pipe",
onChildLine: (line) => {
// dim Codex's own colored output (URL/code in cyan, boilerplate in
// gray) so the user reads it as sub-process noise, not Pullfrog's
// own prompts. the rail char matches @clack/prompts so the column
// reads as one continuous flow.
const stripped = stripAnsi(line);
process.stdout.write(`${pc.gray(p.S_BAR)} ${pc.dim(stripped)}\n`);
if (hasOpenedDeviceUrl) return;
const match = stripped.match(CODEX_DEVICE_URL_RE);
if (!match) return;
hasOpenedDeviceUrl = true;
const url = match[0];
openInBrowser(url);
process.stdout.write(
`${pc.gray(p.S_BAR)} ${pc.dim(`» opened ${url} in browser (paste manually if it didn't open)`)}\n`
);
},
onProgress: (event) => {
if (event.kind === "start") {
lastTimedOut = false;
if (event.attempt > 1) p.log.info(`retry attempt ${event.attempt}`);
// shell-prompt style header so the user sees what Pullfrog is
// about to spawn, with the rail to keep the visual column.
process.stdout.write(`${pc.gray(p.S_BAR)}\n`);
process.stdout.write(`${pc.gray(p.S_BAR)} $ codex login --device-auth\n`);
}
if (event.kind === "exit") {
if (event.timedOut) lastTimedOut = true;
// trailing blank rail so the next clack prompt isn't crammed
// against the last codex output line.
process.stdout.write(`${pc.gray(p.S_BAR)}\n`);
}
},
shouldRetry: async () => {
const message = lastTimedOut
? "device authorization timed out — retry?"
: "no auth.json was written — retry?";
const retry = await p.select({
message,
options: [
{ value: true, label: "retry", hint: "after enabling device-code auth" },
{ value: false, label: "cancel" },
],
});
handleCancel(retry);
return retry;
},
});
// eager refresh: bump the OAuth chain once before persisting so the
// saved token is one Pullfrog has used. otherwise the user's laptop's
// codex CLI could refresh first and strand our copy.
spin.start("refreshing token");
let savable: typeof auth;
try {
savable = await refreshCodexAuth(auth);
spin.stop("refreshed");
} catch (err) {
spin.stop(pc.yellow("refresh failed — saving minted token as-is"));
p.log.warn(err instanceof Error ? err.message : String(err));
savable = auth;
}
spin.start(`saving ${pc.cyan(CODEX_AUTH_SECRET)} to Pullfrog`);
const result = await setPullfrogSecret({
token,
owner: remote.owner,
repo: remote.repo,
name: CODEX_AUTH_SECRET,
value: savable.json,
scope,
});
if (!result.saved) {
spin.stop(pc.red("could not save secret"));
p.log.warn(
`${result.error}\n ${pc.dim("set it manually at:")} ${PULLFROG_API_URL}/console/${remote.owner}`
);
process.exit(1);
}
spin.stop(`saved ${pc.cyan(CODEX_AUTH_SECRET)} to Pullfrog (${scope})`);
setActiveSpin(null);
p.outro("done.");
} catch (error) {
// mirror what `bail` does: stop the spinner with a red "failed" glyph
// before clearing it, otherwise an in-flight spinner keeps animating
// above the error message we're about to print.
spin.stop(pc.red("failed"));
setActiveSpin(null);
const message = error instanceof Error ? error.message : String(error);
p.log.error(message);
process.exit(1);
}
}