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>
This commit is contained in:
Colin McDonnell
2026-05-20 04:05:16 +00:00
committed by pullfrog[bot]
parent 4d1fd5ea1a
commit a0576a702a
30 changed files with 1658 additions and 312 deletions
+10
View File
@@ -30,6 +30,7 @@ jobs:
agent: [claude, opencode]
test:
[
codex-auth,
mcpmerge,
nobash,
restricted,
@@ -41,6 +42,8 @@ jobs:
exclude:
- agent: claude
test: skill-invoke-opencode
- agent: claude
test: codex-auth
- agent: opencode
test: skill-invoke-claude
env:
@@ -59,6 +62,13 @@ jobs:
AWS_REGION: us-east-1
BEDROCK_MODEL_ID: us.anthropic.claude-opus-4-6-v1
PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }}
# CI smoke-testing shortcut only — production stores this in Pullfrog's
# per-org secret store (Postgres), set via `pullfrog auth codex`. GH
# Actions secrets are immutable at runtime so the post-hook can't write
# back the rotated refresh token; CI accepts the staleness and we
# manually re-provision when smoke tests start failing. Do not copy this
# pattern into user-facing workflows. See wiki/codex-auth.md.
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
+5 -5
View File
@@ -1,11 +1,11 @@
# pullfrog GHA-like test container.
#
# baked once at image build time, used by `pnpm gha`. all runtime cost
# (apt-get, useradd, sudoers wiring) is paid here so each `gha` invocation
# baked once at image build time, used by `pnpm docker`. all runtime cost
# (apt-get, useradd, sudoers wiring) is paid here so each `docker` invocation
# is a single `docker run` with no in-container setup.
#
# rebuild is content-hash gated by gha.ts (Dockerfile + docker-entrypoint.sh).
# bump anything in this file or the entrypoint and the next `pnpm gha` rebuilds.
# rebuild is content-hash gated by docker.ts (Dockerfile + docker-entrypoint.sh).
# bump anything in this file or the entrypoint and the next `pnpm docker` rebuilds.
FROM ubuntu:24.04
@@ -59,7 +59,7 @@ RUN userdel -r ubuntu 2>/dev/null || true \
&& echo "testuser ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/testuser \
&& chmod 0440 /etc/sudoers.d/testuser
# layout matching the bind mount + named volume targets in gha.ts.
# layout matching the bind mount + named volume targets in docker.ts.
RUN mkdir -p /app/action /app/action/node_modules /tmp/home/.config /tmp/home/.cache \
&& chown -R testuser:testuser /app /tmp/home
+11 -2
View File
@@ -949,9 +949,20 @@ export const claude = agent({
// bedrock run; if the user has set the env var manually for some other
// reason (e.g. always-Bedrock org policy), `...process.env` already
// carries it through and we don't disturb it.
const repoDir = process.cwd();
// PWD must match the spawn cwd (see opencode_v2.ts for the analogous fix).
// 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"})`). Inheriting harness PWD via
// `...process.env` ends up adding the wrong dir to the agent's allowed
// working set under `pnpm runtest` / `pnpm play`, which silently confuses
// path-relative tools.
const env: Record<string, string | undefined> = {
...process.env,
...homeEnv,
PWD: repoDir,
};
if (isBedrockRoute) {
env.CLAUDE_CODE_USE_BEDROCK = "1";
@@ -967,8 +978,6 @@ export const claude = agent({
delete env.ANTHROPIC_API_KEY;
}
const repoDir = process.cwd();
log.info(`» effort: ${effort}`);
log.debug(`» starting Pullfrog (Claude Code): node ${baseArgs.join(" ")}`);
log.debug(`» working directory: ${repoDir}`);
+4 -1
View File
@@ -1,5 +1,8 @@
import { claude } from "./claude.ts";
import { opencode } from "./opencode.ts";
// v2 harness — adapted to opencode-ai >=1.14.x SDK-v2 / Effect-ts CLI rewrite.
// The legacy v1 module (`./opencode.ts`) is kept around for reference + fast
// revert; the active runner is the v2 module below.
import { opencode } from "./opencode_v2.ts";
import type { Agent } from "./shared.ts";
export type { Agent, AgentUsage } from "./shared.ts";
+16 -127
View File
@@ -11,13 +11,12 @@
* the agent process itself gets full env (needs LLM API keys, PATH, etc.).
* security is enforced at the tool layer, not the process layer.
*/
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 { BEDROCK_MODEL_ID_ENV } from "../models.ts";
import type { ToolState } from "../toolState.ts";
import {
getIdleMs,
@@ -29,7 +28,6 @@ import {
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";
import {
@@ -47,12 +45,19 @@ import {
PULLFROG_OPENCODE_PLUGIN_FILENAME,
PULLFROG_OPENCODE_PLUGIN_SOURCE,
} from "./opencodePlugin.ts";
import {
autoSelectModel,
buildReviewerAgentConfig,
geminiHighThinkingOverrides,
installOpencodeCli,
type OpenCodeConfig,
} from "./opencodeShared.ts";
import {
buildLearningsReflectionPrompt,
runPostRunRetryLoop,
shouldRunReflection,
} from "./postRun.ts";
import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts";
import { REVIEWER_AGENT_NAME } from "./reviewer.ts";
import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "./sessionLabeler.ts";
import {
type AgentResult,
@@ -62,29 +67,13 @@ import {
logTokenTable,
MAX_STDERR_LINES,
} from "./shared.ts";
import { deriveSubagentModels } from "./subagentModels.ts";
async function installOpencodeCli(): Promise<string> {
return await installFromNpmTarball({
packageName: "opencode-ai",
version: getDevDependencyVersion("opencode-ai"),
executablePath: "bin/opencode",
installDependencies: true,
});
}
// re-export for the existing test (`./opencode.test.ts`) — once v1 is
// retired this module collapses and the test imports from opencodeShared.
export { geminiHighThinkingOverrides } from "./opencodeShared.ts";
// ── config ─────────────────────────────────────────────────────────────────────
type OpenCodeConfig = {
mcp?: Record<string, unknown>;
permission?: Record<string, unknown>;
provider?: Record<string, unknown>;
agent?: Record<string, unknown>;
experimental?: Record<string, unknown>;
model?: string;
enabled_providers?: string[];
[key: string]: unknown;
};
// v1.4-era npm package shipped a per-platform binary directly at this path.
const installCli = () => installOpencodeCli({ binPath: "bin/opencode" });
// NOTE: OpenCode's per-call `max_tokens` defaults to 32_000. We previously
// overrode this via `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX = 5000` in #616
@@ -106,22 +95,6 @@ type OpenCodeConfig = {
// top-level `limit.output` config field has no read site (silently dropped
// on merge in session/llm.ts), so the env var is the only working knob.
/**
* Build the `provider.google.models[id].options` map that pins every direct-Google
* Gemini alias to `thinkingLevel: "high"`. Sourced from the model registry so
* adding/renaming a Google alias in `action/models.ts` flows through automatically.
*/
export function geminiHighThinkingOverrides(): Record<string, { options: object }> {
return Object.fromEntries(
modelAliases
.filter((a) => a.provider === "google")
.map((a) => [
a.resolve.replace(/^google\//, ""),
{ options: { thinkingConfig: { thinkingLevel: "high" } } },
])
);
}
function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
const config: OpenCodeConfig = {
permission: {
@@ -171,90 +144,6 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
return JSON.stringify(config);
}
/**
* Read-only `reviewfrog` subagent for lens-based review.
*
* Non-mutative + non-recursive — enforced by the prose system prompt in
* reviewer.ts.
*
* Per-subagent `model:` override is driven by the registry in
* `action/models.ts` via each alias's `subagentModel` field — see
* `deriveSubagentModels` for the reverse-lookup. Currently wired:
* Anthropic opus → sonnet, OpenAI gpt-pro → gpt and gpt → gpt-5.4,
* Google gemini-pro → gemini-flash. Other providers (xai, deepseek,
* moonshot) and already-cheap tiers inherit (no override) — either the
* absolute savings are too small to justify or there's no clean
* cheaper-but-capable sibling.
*/
function buildReviewerAgentConfig(orchestratorModel: string | undefined): Record<string, unknown> {
const overrides = deriveSubagentModels(orchestratorModel);
return {
[REVIEWER_AGENT_NAME]: {
description:
"Read-only review subagent for lens-based code review (correctness, security, billing-subsystem, etc.). " +
"Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.",
mode: "subagent",
prompt: REVIEWER_SYSTEM_PROMPT,
...(overrides.reviewer !== undefined ? { model: overrides.reviewer } : {}),
},
};
}
// ── model auto-select fallback ──────────────────────────────────────────────────
//
// steps 12 of model resolution (PULLFROG_MODEL env, slug resolution) are handled
// by resolveModel() in utils/agent.ts before the agent runs. this fallback only
// handles step 3: auto-select via `opencode models`.
function getOpenCodeModels(cliPath: string): string[] {
try {
const output = execFileSync(cliPath, ["models"], {
encoding: "utf-8",
timeout: 30_000,
env: process.env,
});
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
} catch (error) {
log.debug(
`» failed to run \`opencode models\`: ${error instanceof Error ? error.message : String(error)}`
);
return [];
}
}
const AUTO_SELECT_WARNING =
"select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this.";
function autoSelectModel(cliPath: string): string | undefined {
const availableModels = getOpenCodeModels(cliPath);
const availableSet = new Set(availableModels);
if (availableSet.size > 0) {
log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`);
// skip hidden aliases (internal subagent-tier targets like opencode/gpt-5.4) —
// they should never surface as a user-facing orchestrator pick. mirrors the
// selectable-list filter in components/ModelSelector.tsx and action/commands/init.ts.
const match =
modelAliases.find((a) => !a.hidden && a.preferred && availableSet.has(a.resolve)) ??
modelAliases.find((a) => !a.hidden && availableSet.has(a.resolve));
if (match) {
log.info(
`» model: ${match.resolve} (auto-selected${match.preferred ? " — preferred" : ""} curated match)`
);
log.warning(`» model auto-selected. ${AUTO_SELECT_WARNING}`);
return match.resolve;
}
log.info(
`» opencode has ${availableSet.size} models but none match curated aliases — letting OpenCode auto-select`
);
}
log.warning(`» no model resolved. letting OpenCode auto-select. ${AUTO_SELECT_WARNING}`);
return undefined;
}
// ── NDJSON event types ─────────────────────────────────────────────────────────
interface OpenCodeInitEvent {
@@ -1220,9 +1109,9 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
export const opencode = agent({
name: "opencode",
install: installOpencodeCli,
install: installCli,
run: async (ctx) => {
const cliPath = await installOpencodeCli();
const cliPath = await installCli();
const rawModel = ctx.payload.proxyModel ?? ctx.resolvedModel ?? autoSelectModel(cliPath);
+144
View File
@@ -0,0 +1,144 @@
// Shared helpers for the OpenCode agent harnesses (`./opencode.ts` v1 and
// `./opencode_v2.ts` v2). Pure config / model-registry / install glue —
// nothing here touches the NDJSON event loop, which differs between v1 and v2.
//
// Once v1 is deleted post-burn-in this module collapses back into v2; until
// then it keeps both runners synchronized so a config drift can't make v1 a
// silently-broken fallback.
import { execFileSync } from "node:child_process";
import { modelAliases } from "../models.ts";
import { log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { getDevDependencyVersion } from "../utils/version.ts";
import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts";
import { deriveSubagentModels } from "./subagentModels.ts";
// ── config ─────────────────────────────────────────────────────────────────────
export type OpenCodeConfig = {
mcp?: Record<string, unknown>;
permission?: Record<string, unknown>;
provider?: Record<string, unknown>;
agent?: Record<string, unknown>;
experimental?: Record<string, unknown>;
model?: string;
enabled_providers?: string[];
[key: string]: unknown;
};
/**
* Build the `provider.google.models[id].options` map that pins every direct-Google
* Gemini alias to `thinkingLevel: "high"`. Sourced from the model registry so
* adding/renaming a Google alias in `action/models.ts` flows through automatically.
*/
export function geminiHighThinkingOverrides(): Record<string, { options: object }> {
return Object.fromEntries(
modelAliases
.filter((a) => a.provider === "google")
.map((a) => [
a.resolve.replace(/^google\//, ""),
{ options: { thinkingConfig: { thinkingLevel: "high" } } },
])
);
}
/**
* Read-only `reviewfrog` subagent for lens-based review. Non-mutative +
* non-recursive — enforced by the system prompt in reviewer.ts.
*
* Per-subagent `model:` override is driven by the registry in
* `action/models.ts` via each alias's `subagentModel` field. Currently wired:
* Anthropic opus → sonnet, OpenAI gpt-pro → gpt and gpt → gpt-5.4, Google
* gemini-pro → gemini-flash. Other providers inherit (no override).
*/
export function buildReviewerAgentConfig(
orchestratorModel: string | undefined
): Record<string, unknown> {
const overrides = deriveSubagentModels(orchestratorModel);
return {
[REVIEWER_AGENT_NAME]: {
description:
"Read-only review subagent for lens-based code review (correctness, security, billing-subsystem, etc.). " +
"Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.",
mode: "subagent",
prompt: REVIEWER_SYSTEM_PROMPT,
...(overrides.reviewer !== undefined ? { model: overrides.reviewer } : {}),
},
};
}
// ── install ────────────────────────────────────────────────────────────────────
/**
* Install the opencode-ai npm tarball and return the path to the executable.
*
* The bin path differs by version: v1.4.x and earlier shipped `bin/opencode`;
* v1.14+ renames the platform-specific binary to `bin/opencode.exe` for every
* OS via the postinstall script. Callers pass the binPath that matches their
* pinned version so a v1↔v2 swap can't silently install the wrong file.
*/
export async function installOpencodeCli(params: { binPath: string }): Promise<string> {
return await installFromNpmTarball({
packageName: "opencode-ai",
version: getDevDependencyVersion("opencode-ai"),
executablePath: params.binPath,
installDependencies: true,
});
}
// ── model auto-select fallback ──────────────────────────────────────────────────
//
// steps 12 of model resolution (PULLFROG_MODEL env, slug resolution) happen
// in resolveModel() in utils/agent.ts before the agent runs. this is step 3:
// auto-select via `opencode models`.
const AUTO_SELECT_WARNING =
"select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this.";
function getOpenCodeModels(cliPath: string): string[] {
try {
const output = execFileSync(cliPath, ["models"], {
encoding: "utf-8",
timeout: 30_000,
env: process.env,
});
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
} catch (error) {
log.debug(
`» failed to run \`opencode models\`: ${error instanceof Error ? error.message : String(error)}`
);
return [];
}
}
export function autoSelectModel(cliPath: string): string | undefined {
const availableModels = getOpenCodeModels(cliPath);
const availableSet = new Set(availableModels);
if (availableSet.size > 0) {
log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`);
// skip hidden aliases (internal subagent-tier targets like
// opencode/gpt-5.4) — they should never surface as a user-facing
// orchestrator pick. mirrors the selectable-list filter in
// components/ModelSelector.tsx and action/commands/init.ts.
const match =
modelAliases.find((a) => !a.hidden && a.preferred && availableSet.has(a.resolve)) ??
modelAliases.find((a) => !a.hidden && availableSet.has(a.resolve));
if (match) {
log.info(
`» model: ${match.resolve} (auto-selected${match.preferred ? " — preferred" : ""} curated match)`
);
log.warning(`» model auto-selected. ${AUTO_SELECT_WARNING}`);
return match.resolve;
}
log.info(
`» opencode has ${availableSet.size} models but none match curated aliases — letting OpenCode auto-select`
);
}
log.warning(`» no model resolved. letting OpenCode auto-select. ${AUTO_SELECT_WARNING}`);
return undefined;
}
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -3,7 +3,8 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
const claudeSource = readFileSync(join(__dirname, "claude.ts"), "utf-8");
const opencodeSource = readFileSync(join(__dirname, "opencode.ts"), "utf-8");
const opencodeSharedSource = readFileSync(join(__dirname, "opencodeShared.ts"), "utf-8");
const opencodeV2Source = readFileSync(join(__dirname, "opencode_v2.ts"), "utf-8");
/**
* The Claude Code `--agents` JSON and OpenCode `agent` config block are the
@@ -25,16 +26,16 @@ describe("subagent registration source asserts", () => {
});
});
describe("opencode.ts buildReviewerAgentConfig", () => {
describe("opencodeShared.ts buildReviewerAgentConfig", () => {
it("registers reviewfrog with mode: subagent", () => {
expect(opencodeSource).toMatch(/\[REVIEWER_AGENT_NAME\]:[^}]*mode:\s*"subagent"/s);
expect(opencodeSharedSource).toMatch(/\[REVIEWER_AGENT_NAME\]:[^}]*mode:\s*"subagent"/s);
});
it("uses deriveSubagentModels for the reviewer model override", () => {
expect(opencodeSource).toMatch(/deriveSubagentModels\(/);
expect(opencodeSource).toMatch(/overrides\.reviewer/);
expect(opencodeSharedSource).toMatch(/deriveSubagentModels\(/);
expect(opencodeSharedSource).toMatch(/overrides\.reviewer/);
});
it("passes orchestrator model to buildReviewerAgentConfig", () => {
expect(opencodeSource).toMatch(/buildReviewerAgentConfig\(model\)/);
it("v2 runner passes orchestrator model to buildReviewerAgentConfig", () => {
expect(opencodeV2Source).toMatch(/buildReviewerAgentConfig\(model\)/);
});
});
});
+58 -35
View File
@@ -10,6 +10,7 @@
// 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";
@@ -22,7 +23,6 @@ import {
PULLFROG_API_URL,
parseGitRemote,
promptScope,
type SecretScope,
setActiveSpin,
setPullfrogSecret,
} from "./_shared.ts";
@@ -38,6 +38,43 @@ function stripAnsi(s: string): string {
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;
@@ -60,10 +97,6 @@ function printCodexUsage(params: { stream: typeof console.log; prog: string }):
params.stream("mint a Codex subscription credential and save it as CODEX_AUTH_JSON.");
params.stream("");
params.stream("options:");
params.stream(" --scope <account|repo> where to store the secret in Pullfrog. on");
params.stream(" org-owned repos you're prompted to choose");
params.stream(" interactively; user-owned repos always use");
params.stream(" `account`. pass this flag to skip the prompt.");
params.stream(" -h, --help show help");
}
@@ -103,7 +136,6 @@ function parseCodexArgs(args: string[]) {
return arg(
{
"--help": Boolean,
"--scope": String,
"-h": "--help",
},
{ argv: args }
@@ -126,26 +158,10 @@ async function runCodex(params: CodexCliParams): Promise<void> {
return;
}
const rawScope = parsed["--scope"];
let explicitScope: SecretScope | null = null;
if (rawScope !== undefined) {
if (rawScope === "account" || rawScope === "repo") {
explicitScope = rawScope;
} else {
console.error(`invalid --scope: ${rawScope} (must be "account" or "repo")\n`);
printCodexUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
await runCodexAuth();
}
await runCodexAuth({ explicitScope });
}
interface RunCodexAuthCtx {
explicitScope: SecretScope | null;
}
async function runCodexAuth(ctx: RunCodexAuthCtx): Promise<void> {
async function runCodexAuth(): Promise<void> {
p.intro(pc.bgGreen(pc.black(" pullfrog auth codex ")));
const spin = p.spinner();
@@ -188,16 +204,10 @@ async function runCodexAuth(ctx: RunCodexAuthCtx): Promise<void> {
// 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, default to interactive prompt — matches `init`'s behavior
// unless the caller passed `--scope` to skip it.
let scope: SecretScope;
if (ctx.explicitScope) {
scope = ctx.explicitScope;
} else if (status.isOrg) {
scope = await promptScope({ owner: remote.owner, repo: remote.repo });
} else {
scope = "account";
}
// repos, prompt interactively — matches `init`'s behavior.
const scope = status.isOrg
? await promptScope({ owner: remote.owner, repo: remote.repo })
: "account";
p.log.info(
[
@@ -213,6 +223,9 @@ async function runCodexAuth(ctx: RunCodexAuthCtx): Promise<void> {
// 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) => {
@@ -220,7 +233,17 @@ async function runCodexAuth(ctx: RunCodexAuthCtx): Promise<void> {
// 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.
process.stdout.write(`${pc.gray(p.S_BAR)} ${pc.dim(stripAnsi(line))}\n`);
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") {
+2 -2
View File
@@ -44,8 +44,8 @@ fi
# this idempotent and fast (~1.5s when nothing changed).
#
# the lockfile lives IN the shared node_modules volume so concurrent
# `pnpm gha` invocations (e.g. `pnpm play` in one terminal and
# `pnpm runtest` in another) serialize their install instead of racing.
# `pnpm docker` invocations (e.g. `pnpm play:docker` in one terminal and
# `pnpm runtest:docker` in another) serialize their install instead of racing.
# `flock -w 120` waits up to 2min before giving up — well under any
# real-world install time but short enough to surface true deadlocks.
mkdir -p /app/action/node_modules
+51 -35
View File
@@ -1,11 +1,21 @@
// run any node script inside the pullfrog GHA-like container.
// run any node script inside the pullfrog local docker container that
// mocks the GHA `ubuntu-24.04` runner environment. NOT a real GitHub
// Actions runner — for the real thing, see `.github/workflows/*.yml`
// and `action/commands/gha.ts` (the action's GHA entry point).
//
// usage:
// pnpm gha <script> [args…] # run script in container
// pnpm gha --shell # interactive bash (requires TTY)
// pnpm gha --build [--no-cache] # force-rebuild image
// pnpm gha --clean # prune orphan images/volumes
// pnpm gha --doctor # versions of every baked tool
// pnpm docker <script> [args…] # run script in container
// pnpm docker --shell # interactive bash (requires TTY)
// pnpm docker --build [--no-cache] # force-rebuild image
// pnpm docker --clean # prune orphan images/volumes
// pnpm docker --doctor # versions of every baked tool
//
// the action's two main entrypoints default to the host (fast iteration).
// `:docker` suffix wraps this script:
// pnpm play [args…] # host (this is the fast default)
// pnpm play:docker [args…] # === pnpm docker play.ts [args…]
// pnpm runtest [filters…] # host
// pnpm runtest:docker [filters…] # === pnpm docker test/run.ts [filters…]
//
// the container is a baked ubuntu:24.04 image (see Dockerfile) with the
// same toolset as GHA `ubuntu-24.04` runners. host env passes through
@@ -66,7 +76,7 @@ const HOST_ONLY_VARS = new Set([
"COLORTERM",
"ITERM_PROFILE",
"ITERM_SESSION_ID",
// outer-CI workflow-run identifiers — when `pnpm runtest smoke` runs inside
// outer-CI workflow-run identifiers — when the test suite runs inside
// pullfrog/app's CI, these refer to pullfrog/app's run, NOT the test repo
// the harness is acting against (e.g. pullfrog/test-repo). Anything inside
// the action that uses them as keys to look up state on the test repo (most
@@ -97,11 +107,11 @@ type Args = {
};
/**
* parses gha-level flags up to (but not including) the first positional
* parses docker-level flags up to (but not including) the first positional
* argument. anything after the first positional, or after a literal `--`,
* passes through verbatim to the inner script. this prevents
* `pnpm gha test/run.ts --build` from intercepting `--build` as a
* gha flag.
* `pnpm docker test/run.ts --build` from intercepting `--build` as a
* docker flag.
*/
function parseArgs(argv: string[]): Args {
const out: Args = {
@@ -140,18 +150,24 @@ function parseArgs(argv: string[]): Args {
}
function showHelp(): void {
process.stdout.write(`Usage: pnpm gha <script> [args…]
pnpm gha --shell
pnpm gha --build [--no-cache]
pnpm gha --clean
pnpm gha --doctor
process.stdout.write(`Usage: pnpm docker <script> [args…]
pnpm docker --shell
pnpm docker --build [--no-cache]
pnpm docker --clean
pnpm docker --doctor
Run a node script inside the pullfrog GHA-like container. Mirrors the
GitHub Actions ubuntu-24.04 runner toolset (gh, jq, python3, sudo, +
Run a node script inside the pullfrog local docker container that mocks
the GHA ubuntu-24.04 runner toolset (gh, jq, python3, sudo, +
build-essential / wget / xz / file). Host env passes through verbatim.
The host is reachable from inside the container at host.docker.internal
(useful for scripts that hit your local dev server).
The action's two main entrypoints have host (fast) and docker variants:
pnpm play [args] # host the fast default
pnpm play:docker [args] # === pnpm docker play.ts [args]
pnpm runtest [filters] # host
pnpm runtest:docker [filters] # === pnpm docker test/run.ts [filters]
Options:
--build rebuild the current image (otherwise rebuilt automatically
when Dockerfile or docker-entrypoint.sh content changes).
@@ -160,7 +176,7 @@ Options:
useful when an apt mirror or base image changed.
--shell drop into an interactive bash inside the container.
requires a TTY.
--clean prune orphaned pullfrog-gha:* images and node_modules
--clean prune orphaned pullfrog-docker:* images and node_modules
volumes whose hash doesn't match the current Dockerfile.
--doctor print version info for tools inside the container (node,
pnpm, gh, jq, git, python3, ssh, ). useful for diagnosing
@@ -169,24 +185,24 @@ Options:
Pass-through:
Anything after the first positional argument (or after a literal \`--\`)
goes to the inner script verbatim. so \`pnpm gha test/run.ts --build\`
passes \`--build\` to test/run.ts, not to gha.
goes to the inner script verbatim. so \`pnpm docker test/run.ts --build\`
passes \`--build\` to test/run.ts, not to docker.
Examples:
pnpm gha play.ts
pnpm gha play.ts --raw '{"prompt":"hi"}'
pnpm gha test/run.ts smoke
pnpm gha --shell
pnpm gha --build # build image, then exit
pnpm gha --build --no-cache # rebuild from scratch
pnpm gha --clean # reclaim disk from old image hashes
pnpm gha --doctor # fidelity audit
pnpm docker play.ts
pnpm docker play.ts --raw '{"prompt":"hi"}'
pnpm docker test/run.ts smoke
pnpm docker --shell
pnpm docker --build # build image, then exit
pnpm docker --build --no-cache # rebuild from scratch
pnpm docker --clean # reclaim disk from old image hashes
pnpm docker --doctor # fidelity audit
`);
}
function ensureDocker(): void {
if (platform() === "win32") {
fail("pnpm gha is not supported on native windows. use wsl2.");
fail("pnpm docker is not supported on native windows. use wsl2.");
}
const probe = spawnSync("docker", ["info"], { stdio: "ignore" });
if (probe.status !== 0) {
@@ -208,15 +224,15 @@ function imageRefFor(ctx: { dockerfile: string; entrypoint: string }): ImageRef
.digest("hex")
.slice(0, 12);
return {
tag: `pullfrog-gha:${hash}`,
tag: `pullfrog-docker:${hash}`,
// version the volume by image hash so a stale node_modules cache from
// an old image (e.g. different node major) can't poison a new image.
volumeName: `pullfrog-gha-node-modules-${hash}`,
volumeName: `pullfrog-docker-node-modules-${hash}`,
};
}
/**
* remove pullfrog-gha:* images and pullfrog-gha-node-modules-* volumes
* remove pullfrog-docker:* images and pullfrog-docker-node-modules-* volumes
* whose hash doesn't match the current Dockerfile + entrypoint. each
* Dockerfile/entrypoint edit creates a fresh hash and orphans the prior
* pair; without periodic cleanup these accumulate (~600MB image + ~200MB
@@ -228,7 +244,7 @@ function cleanOrphans(currentRef: ImageRef): void {
});
const images = (imgList.stdout ?? "")
.split("\n")
.filter((s) => s.startsWith("pullfrog-gha:") && s !== currentRef.tag);
.filter((s) => s.startsWith("pullfrog-docker:") && s !== currentRef.tag);
if (images.length > 0) {
process.stderr.write(`» removing ${images.length} orphan image(s): ${images.join(", ")}\n`);
spawnSync("docker", ["image", "rm", "-f", ...images], { stdio: "inherit" });
@@ -236,7 +252,7 @@ function cleanOrphans(currentRef: ImageRef): void {
const volList = spawnSync("docker", ["volume", "ls", "-q"], { encoding: "utf8" });
const volumes = (volList.stdout ?? "")
.split("\n")
.filter((s) => s.startsWith("pullfrog-gha-node-modules-") && s !== currentRef.volumeName);
.filter((s) => s.startsWith("pullfrog-docker-node-modules-") && s !== currentRef.volumeName);
if (volumes.length > 0) {
process.stderr.write(`» removing ${volumes.length} orphan volume(s): ${volumes.join(", ")}\n`);
spawnSync("docker", ["volume", "rm", ...volumes], { stdio: "inherit" });
@@ -362,7 +378,7 @@ function initVolumeOwnership(ctx: { ref: ImageRef; uid: number; gid: number }):
type EnvParts = { envFile: string; multiLineFlags: string[] };
function buildEnvParts(env: NodeJS.ProcessEnv): EnvParts {
const dir = join(tmpdir(), "pullfrog-gha");
const dir = join(tmpdir(), "pullfrog-docker");
mkdirSync(dir, { recursive: true });
const envFile = join(dir, `env-${process.pid}-${Date.now()}.list`);
const lines: string[] = [];
+18 -7
View File
@@ -6,21 +6,28 @@
// auth.json during the run, the refreshed token must land back in Pullfrog
// even when the main step died unexpectedly.
//
// THIS IS WHY `CODEX_AUTH_JSON` HAS TO LIVE IN PULLFROG'S OWN SECRET STORE,
// NOT IN GITHUB ACTIONS SECRETS. The refresh chain rotates on every use; this
// hook PUTs the rotated chain back to Pullfrog Postgres so the next run starts
// from a fresh token. GH Actions secrets are read-only at runtime — there is
// no API to write them back from inside a job — so a token stashed there
// silently goes stale on the first refresh and the next run fails. See
// wiki/codex-auth.md.
//
// Today's only job: detect a Codex auth refresh by diffing the on-disk
// auth.json against the original refresh token (saved to GH Actions state
// by action/agents/opencode.ts), convert OpenCode's auth shape back to
// Codex CLI shape, and PUT it to /api/runtime/secret.
// by action/agents/opencode_v2.ts — see also the legacy v1 file kept as
// reference at action/agents/opencode.ts), convert OpenCode's auth shape
// back to Codex CLI shape, and PUT it to /api/runtime/secret.
//
// Silent no-op when the main step didn't materialize Codex auth (no state
// saved). Best-effort: failures are logged but never throw — the workflow
// is already done, and a missed refresh write-back means the user re-runs
// `pullfrog auth codex` next time the chain breaks.
//
// See wiki/codex-auth.md for the full flow.
import { existsSync, readFileSync } from "node:fs";
import * as core from "@actions/core";
import { getApiUrl } from "./utils/apiUrl.ts";
import { apiFetch } from "./utils/apiFetch.ts";
import { detectCodexRefresh } from "./utils/codexHome.ts";
async function main(): Promise<void> {
@@ -64,9 +71,13 @@ async function main(): Promise<void> {
return;
}
const url = `${getApiUrl()}/api/runtime/secret`;
try {
const response = await fetch(url, {
// route through apiFetch so the Vercel preview-deployment SSO gate gets
// the `x-vercel-protection-bypass` header/query (raw fetch silently 401s
// against preview envs — production is unaffected but every preview-run
// refresh would be lost). see action/utils/apiFetch.ts.
const response = await apiFetch({
path: "/api/runtime/secret",
method: "PUT",
headers: {
authorization: `Bearer ${state.apiToken}`,
+2 -1
View File
@@ -16,6 +16,7 @@
"typecheck": "tsc --noEmit",
"build": "node esbuild.config.js && tsc -p tsconfig.exports.json",
"check:entrypoints": "node scripts/check-entrypoint-imports.ts",
"docker": "node docker.ts",
"play": "node play.ts",
"runtest": "node test/run.ts",
"scratch": "node scratch.ts",
@@ -49,7 +50,7 @@
"fastmcp": "^3.34.0",
"file-type": "^21.3.0",
"husky": "^9.0.0",
"opencode-ai": "1.1.56",
"opencode-ai": "1.15.1",
"package-manager-detector": "^1.6.0",
"picocolors": "^1.1.1",
"semver": "^7.7.3",
+13 -7
View File
@@ -1,5 +1,11 @@
// thin CLI for ad-hoc fixture runs against the Pullfrog action.
// for the GHA-like containerized version, run `pnpm gha play.ts […]`.
//
// invoke from the repo root:
// pnpm play [args…] # host, in-process — fast iteration (default)
// pnpm play:docker [args…] # local docker container that mocks GHA
// pnpm docker play.ts [args…] # explicit container form (equivalent to `pnpm play:docker`)
//
// see wiki/docker.md for when host vs container matters.
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import arg from "arg";
@@ -38,19 +44,19 @@ if (isDirectExecution) {
if (args["--help"]) {
log.info(`
Usage: node play.ts [--raw <input>]
Usage: pnpm play [--raw <input>] (host, in-process; this entry)
pnpm play:docker [--raw <input>] (local docker container that mocks GHA)
Run the Pullfrog action against an inline fixture. Host-side, in-process.
For a GHA-like Linux container, use \`pnpm gha play.ts […]\` instead.
Run the Pullfrog action against an inline fixture.
Options:
--raw <input> raw string used as the prompt, or JSON object as full fixture
-h, --help show this message
Examples:
node play.ts
node play.ts --raw "Hello world"
node play.ts --raw '{"prompt":"Hi","timeout":"5s"}'
pnpm play
pnpm play --raw "Hello world"
pnpm play --raw '{"prompt":"Hi","timeout":"5s"}'
`);
process.exit(0);
}
+60 -49
View File
@@ -84,8 +84,8 @@ importers:
specifier: ^9.0.0
version: 9.1.7
opencode-ai:
specifier: 1.1.56
version: 1.1.56
specifier: 1.15.1
version: 1.15.1
package-manager-detector:
specifier: ^1.6.0
version: 1.6.0
@@ -1444,62 +1444,69 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
opencode-ai@1.1.56:
resolution: {integrity: sha512-OAF0G/1jVXpOrCh++M5gFEZ0bRLiXOnbxSYMFx5TOoD0OhCjHJS1JlARzaLDAx461qBnn+jocI9BBxD0wwFH3Q==}
opencode-ai@1.15.1:
resolution: {integrity: sha512-xLb1NuYZcMJ1p33hC/kgTMcJAueACVTfX6ps91a54GOFTM/wFp7br0t2cqHopEU9paqItbAnQwZB573qmPKH6w==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
opencode-darwin-arm64@1.1.56:
resolution: {integrity: sha512-0HqvLm7tcYZr4VJgEzi3Wicia5M9yCX66O7Cv470Qu4+GGbCC2sTTmzQu6pCehSleYgkZsiSBNRcFcW/6F7v0Q==}
opencode-darwin-arm64@1.15.1:
resolution: {integrity: sha512-eNgIfATsnHcud4Pr58OIR+TJGSsDvWmyNlfSDVVgP92qdnHFdZ5YsHKjcUGmeuuUN+oZwPb/z5nZSrkf+CCB2g==}
cpu: [arm64]
os: [darwin]
opencode-darwin-x64-baseline@1.1.56:
resolution: {integrity: sha512-Z9QO9cTC9TnlUxTfEtDbvZbRFxc5Je8rs2Ei0cLH46W9gHGhh1fW4oX64tcrlhF5NUDKJzr/qwdoMfmKkeu53A==}
opencode-darwin-x64-baseline@1.15.1:
resolution: {integrity: sha512-XDx90Hhj+SPUxu0rqewsNR10JTny7+VE4C5pjWB04I6eoiEuBWy2EMvPXPg2FUA5Suz1PXXJ6yThfRtOxXNHuw==}
cpu: [x64]
os: [darwin]
opencode-darwin-x64@1.1.56:
resolution: {integrity: sha512-8ZhNd4sFbDviT1OJs42C35Bx2/z6mkcb7uNhPHX3KKm27KOm7cYjFQa1UTF5or3ZpjkjPQpR/cpo7TELvnnmRA==}
opencode-darwin-x64@1.15.1:
resolution: {integrity: sha512-tNbzF6n+TczILEqo0adtup1ZXBgAcqftQd11+eQohGxtNAjmD7Z/gCTVpEzh9GlHUPzUEuREZ4gRAbJmPpafBQ==}
cpu: [x64]
os: [darwin]
opencode-linux-arm64-musl@1.1.56:
resolution: {integrity: sha512-f30SmYX4xE2fUsnNl66dDX++8iTMI9PLXz7BRHhgXL4XAdMpUUJbOKIsw3ZUB0KWHwjR8cjkEJQOZxR3HPbrZg==}
opencode-linux-arm64-musl@1.15.1:
resolution: {integrity: sha512-UuoizYN32eTWmQT494bw70Sq4AByS0pGk46Mo/z+KzV+KTQlsDXRQrnKATKYFDqE2T3c1VsPM1KqRV/DqvnXxw==}
cpu: [arm64]
os: [linux]
opencode-linux-arm64@1.1.56:
resolution: {integrity: sha512-wEmIEXiEKghurQYgKY5yFUFOmnT/QIlAbYnVeH73gSqOwlYFUUlowXLDJnF+3OGd6m000qalhCxjExL7qKINWw==}
opencode-linux-arm64@1.15.1:
resolution: {integrity: sha512-MG6tuLZqzDjHGeaotejhYuuv2USR0y3v8N+6g5gWPHScX/iJWkJDMFBeT6+KOV/CWawrGRqZfBDfdJSKirX2LQ==}
cpu: [arm64]
os: [linux]
opencode-linux-x64-baseline-musl@1.1.56:
resolution: {integrity: sha512-EC8EGTJCgTZgGIMZdQjOKGXw02+igo0am1Ry6wrPrB0li7XBlYbY6dz8tL3FlknRVMLamtGtb3tXQ6tqbwjX0g==}
opencode-linux-x64-baseline-musl@1.15.1:
resolution: {integrity: sha512-Is50zWUqa9fIJ+tiDOpxENcgn2XBk0QKNEocbu/x9aOdpfFsHhtxe33zi/+9CNdSr+O/6y9jRAMGn7AirJyZlg==}
cpu: [x64]
os: [linux]
opencode-linux-x64-baseline@1.1.56:
resolution: {integrity: sha512-jKOvhkNLcn0h6zmKX+hDdkODijSVDxjAGTZp8BPbJCCJVxnKll9dYfDMTqxi3YN6yXp6sBvyaJT1mcpf8knkeA==}
opencode-linux-x64-baseline@1.15.1:
resolution: {integrity: sha512-ExKWMk/6ULM9HBda2KKZJNE5Ejzaa51QWpr7+Ljv1AlazxQQZKwJfqcZcSNfk0YsgXDESw2w2dwBmOcMaxQZKA==}
cpu: [x64]
os: [linux]
opencode-linux-x64-musl@1.1.56:
resolution: {integrity: sha512-9BFYBPgpY2RrUd7/Ul0VNh+6B9l0FmCNRUV4yYmeFGee9ZDgPem5YGicsKAMQqS/5X0jW3ZWje+KBbBQz4RMwQ==}
opencode-linux-x64-musl@1.15.1:
resolution: {integrity: sha512-feNjVo7XGjqFHf5lejxuyZIkNi9Yi4B2H3w+p2SF9vcyUdPaJnta2/6Os7Pf8kwElRs6EnWRyUO2JVg4hjAjjg==}
cpu: [x64]
os: [linux]
opencode-linux-x64@1.1.56:
resolution: {integrity: sha512-0FP3BzLjn+a9naTtm9hpHibJ+eV1RkI+tItcDQXKwHtvzoBUvytDRP8v6TotEHNtNF0ZLAb3OGurwDIIdtLarg==}
opencode-linux-x64@1.15.1:
resolution: {integrity: sha512-mKRg+iHdwEYNDS+DYa9VQnN903zlw8FInCQRGpY155aR/AF1r3hIn+7IopOTDAwqkutL9vJWMXELxmNpPdaTQg==}
cpu: [x64]
os: [linux]
opencode-windows-x64-baseline@1.1.56:
resolution: {integrity: sha512-zEokdohfoDjWzwULvlSq0Y/tRoVmz2/6GofYO936buPQf09cBcPqpAgLUjZV+pQI0Atyd05YG3BCO4PQxmXzdw==}
opencode-windows-arm64@1.15.1:
resolution: {integrity: sha512-M3Wz4U+hF8paqrBpOWPqOM16MhDDZsnb0EZc1fFdKMfu1a8g2oR3gtq1heQgUOKd8FHaeDQHvBYOqaaJdoaCmA==}
cpu: [arm64]
os: [win32]
opencode-windows-x64-baseline@1.15.1:
resolution: {integrity: sha512-sFvI5sY4kijrkIt9qry34aqZASRo9jJKBLm6PH/zZbGdRtvFM32/n+A26Z/NDbowya8fOtj7MX2Ih5DvR9Md1A==}
cpu: [x64]
os: [win32]
opencode-windows-x64@1.1.56:
resolution: {integrity: sha512-zm/oaWT5uGrW3DumKRHiqv2L2pKwrTvhsT4XUSfIPLQn5EvrYNU6bh3WFO4v71ZBjSIKx5Q7rEvTEQvFiFEZFQ==}
opencode-windows-x64@1.15.1:
resolution: {integrity: sha512-MdCBncbhpcImw3zjYBuoI+ZqfMR1uI4mc8KCltwIgI2DrxuOZNe66A/3feOhWd9MQQ2c2PSdyyJfW9PE0FA/Ow==}
cpu: [x64]
os: [win32]
@@ -3154,51 +3161,55 @@ snapshots:
dependencies:
wrappy: 1.0.2
opencode-ai@1.1.56:
opencode-ai@1.15.1:
optionalDependencies:
opencode-darwin-arm64: 1.1.56
opencode-darwin-x64: 1.1.56
opencode-darwin-x64-baseline: 1.1.56
opencode-linux-arm64: 1.1.56
opencode-linux-arm64-musl: 1.1.56
opencode-linux-x64: 1.1.56
opencode-linux-x64-baseline: 1.1.56
opencode-linux-x64-baseline-musl: 1.1.56
opencode-linux-x64-musl: 1.1.56
opencode-windows-x64: 1.1.56
opencode-windows-x64-baseline: 1.1.56
opencode-darwin-arm64: 1.15.1
opencode-darwin-x64: 1.15.1
opencode-darwin-x64-baseline: 1.15.1
opencode-linux-arm64: 1.15.1
opencode-linux-arm64-musl: 1.15.1
opencode-linux-x64: 1.15.1
opencode-linux-x64-baseline: 1.15.1
opencode-linux-x64-baseline-musl: 1.15.1
opencode-linux-x64-musl: 1.15.1
opencode-windows-arm64: 1.15.1
opencode-windows-x64: 1.15.1
opencode-windows-x64-baseline: 1.15.1
opencode-darwin-arm64@1.1.56:
opencode-darwin-arm64@1.15.1:
optional: true
opencode-darwin-x64-baseline@1.1.56:
opencode-darwin-x64-baseline@1.15.1:
optional: true
opencode-darwin-x64@1.1.56:
opencode-darwin-x64@1.15.1:
optional: true
opencode-linux-arm64-musl@1.1.56:
opencode-linux-arm64-musl@1.15.1:
optional: true
opencode-linux-arm64@1.1.56:
opencode-linux-arm64@1.15.1:
optional: true
opencode-linux-x64-baseline-musl@1.1.56:
opencode-linux-x64-baseline-musl@1.15.1:
optional: true
opencode-linux-x64-baseline@1.1.56:
opencode-linux-x64-baseline@1.15.1:
optional: true
opencode-linux-x64-musl@1.1.56:
opencode-linux-x64-musl@1.15.1:
optional: true
opencode-linux-x64@1.1.56:
opencode-linux-x64@1.15.1:
optional: true
opencode-windows-x64-baseline@1.1.56:
opencode-windows-arm64@1.15.1:
optional: true
opencode-windows-x64@1.1.56:
opencode-windows-x64-baseline@1.15.1:
optional: true
opencode-windows-x64@1.15.1:
optional: true
package-manager-detector@1.6.0: {}
+5 -2
View File
@@ -57,10 +57,13 @@ const crossagentTests = getTestNamesFromDir("crossagent");
const agnosticTests = getTestNamesFromDir("agnostic");
const adhocTests = getTestNamesFromDir("adhoc");
// all provider API key names + GITHUB_TOKEN + model overrides
// all provider API key names + managed credentials (e.g. Codex auth blob)
// + GITHUB_TOKEN + model overrides
const expectedAgentEnvVars = [
"GITHUB_TOKEN",
...new Set(Object.values(providers).flatMap((p) => [...p.envVars])),
...new Set(
Object.values(providers).flatMap((p) => [...p.envVars, ...(p.managedCredentials ?? [])])
),
"PULLFROG_MODEL",
].sort();
+2 -2
View File
@@ -40,10 +40,10 @@ export const ALWAYS_RUN_ALL: string[] = [
"action/utils/install.ts",
"action/utils/runFixture.ts",
"action/utils/globals.ts",
// GHA-like container plumbing (changes invalidate every test's environment)
// local docker container plumbing (changes invalidate every test's environment)
"action/Dockerfile",
"action/docker-entrypoint.sh",
"action/gha.ts",
"action/docker.ts",
// MCP orchestrator (every test runs through it)
"action/mcp/server.ts",
"action/mcp/shared.ts",
+110
View File
@@ -0,0 +1,110 @@
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { detectCodexRefresh } from "../../utils/codexHome.ts";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture } from "../utils.ts";
/**
* codex-auth test — end-to-end Codex ChatGPT-subscription auth smoke.
*
* Pins openai/gpt-5.5 (in upstream opencode's Codex `ALLOWED_MODELS` allow
* list) and runs the full opencode harness against the developer's / CI's
* `CODEX_AUTH_JSON`. Exercises:
*
* - installCodexAuth() materializes auth.json at $HOME/.local/share/opencode/
* with `expires: 0` (forces refresh on first request).
* - opencode's CodexAuthPlugin routes openai requests through the ChatGPT
* subscription instead of needing OPENAI_API_KEY.
* - the refresh chain advances during the run (proving the refresh path
* works end-to-end against live Codex auth servers).
* - detectCodexRefresh() would surface the rotation to entryPost.ts for
* write-back to Pullfrog's secret store.
*
* the post-hook itself runs in a separate GHA `post:` step and is not
* invoked by `pnpm runtest`. instead, this test asserts the on-disk auth.json
* state that the post-hook would consume, which is the genuine integration
* boundary (everything past `detectCodexRefresh` is a single fetch + unit-
* tested in codexHome.test.ts).
*
* requires `CODEX_AUTH_JSON` in the environment. dev-local: put it in
* `.env`. CI: provisioned as `secrets.CODEX_AUTH_JSON` and forwarded by the
* `action-agents` job env block in `.github/workflows/test.yml`.
*/
const token = randomUUID();
const fixture = defineFixture(
{
prompt: `Call set_output with exactly this token and nothing else: ${token}`,
shell: "restricted",
push: "disabled",
timeout: "4m",
},
{ localOnly: true }
);
function parseOriginalRefresh(): string | null {
const raw = process.env.CODEX_AUTH_JSON;
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { tokens?: { refresh_token?: unknown } };
const rt = parsed?.tokens?.refresh_token;
return typeof rt === "string" && rt.length > 0 ? rt : null;
} catch {
return null;
}
}
function validator(result: AgentResult): ValidationCheck[] {
const setOutputCalled = result.structuredOutput !== null;
const tokenMatches = result.structuredOutput === token;
// installCodexAuth() emits this log line with the absolute path; we use it
// to find the per-test HOME (randomized inside runAgentStreaming).
const pathMatch = result.output.match(/installed Codex auth at (\S+)/);
const authPath = pathMatch?.[1];
let authMaterialized = false;
let refreshRotated = false;
if (authPath) {
try {
const content = readFileSync(authPath, "utf8");
authMaterialized = true;
const originalRefresh = parseOriginalRefresh();
if (originalRefresh) {
refreshRotated = detectCodexRefresh({ authFileContent: content, originalRefresh }) !== null;
}
} catch {
// authMaterialized stays false
}
}
return [
{ name: "set_output", passed: setOutputCalled },
{ name: "token_matches", passed: tokenMatches },
{ name: "auth_materialized", passed: authMaterialized },
{ name: "refresh_rotated", passed: refreshRotated },
];
}
export const test: TestRunnerOptions = {
name: "codex-auth",
fixture,
validator,
agents: ["opencode"],
env: {
PULLFROG_MODEL: "openai/gpt",
PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1",
},
coverage: [
"action/utils/codexHome.ts",
"action/entryPost.ts",
"action/agents/{opencode,opencode_v2}.ts",
],
// forks + contributors without the Codex secret skip cleanly rather than
// failing on `auth_materialized=✗` and (with fail-fast: true) cascading
// cancellation across the rest of the matrix. CI on `pullfrog/app` and
// dev-local with `.env` both have the secret and run the test as normal.
skipIf: () => (process.env.CODEX_AUTH_JSON ? null : "CODEX_AUTH_JSON unset"),
};
+1 -1
View File
@@ -44,5 +44,5 @@ export const test: TestRunnerOptions = {
repoSetup:
'mkdir -p /tmp/pullfrog-mcp-secret && printf "%s" "$PULLFROG_MCP_SECRET" > /tmp/pullfrog-mcp-secret/secret.txt',
// any MCP-layer change can affect repo-MCP merging; agents own MCP wiring.
coverage: ["action/mcp/**", "action/agents/{claude,opencode}.ts"],
coverage: ["action/mcp/**", "action/agents/{claude,opencode,opencode_v2}.ts"],
};
+1 -1
View File
@@ -43,5 +43,5 @@ export const test: TestRunnerOptions = {
validator,
agentEnv,
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
coverage: ["action/mcp/shell.ts", "action/agents/{claude,opencode}.ts"],
coverage: ["action/mcp/shell.ts", "action/agents/{claude,opencode,opencode_v2}.ts"],
};
+1 -1
View File
@@ -55,6 +55,6 @@ export const test: TestRunnerOptions = {
coverage: [
"action/utils/normalizeEnv.ts",
"action/mcp/shell.ts",
"action/agents/{claude,opencode}.ts",
"action/agents/{claude,opencode,opencode_v2}.ts",
],
};
+5 -1
View File
@@ -44,5 +44,9 @@ export const test: TestRunnerOptions = {
PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1",
PULLFROG_MODEL: "anthropic/claude-sonnet-4-6",
},
coverage: ["action/agents/opencode.ts", "action/agents/opencodePlugin.ts"],
coverage: [
"action/agents/opencode.ts",
"action/agents/opencode_v2.ts",
"action/agents/opencodePlugin.ts",
],
};
+8 -5
View File
@@ -2,13 +2,16 @@ import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.t
import { defineFixture } from "../utils.ts";
/**
* smoke test - validates agent can connect to API and call MCP tools.
* verifies set_output tool is called with correct value.
* smoke test validates agent can connect to the API and call MCP tools.
*
* two tool calls (not one) on purpose: this is the canary that exercises the
* 2nd model→agent round-trip across every providers-live flagship. bugs like
* the Gemini `thought_signature` echo only fire after the first tool result
* comes back. do not collapse to a single tool call.
*/
const fixture = defineFixture(
{
prompt: `Call set_output with "SMOKE TEST PASSED".`,
prompt: `First call the git tool with command "status" to confirm the repo is reachable. Then call set_output with exactly the literal string "SMOKE TEST PASSED".`,
},
{ localOnly: true }
);
@@ -31,5 +34,5 @@ export const test: TestRunnerOptions = {
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
// canary: any agent harness change runs the smoke. shared MCP set_output
// surface is also captured.
coverage: ["action/agents/{claude,opencode}.ts", "action/mcp/output.ts"],
coverage: ["action/agents/{claude,opencode,opencode_v2}.ts", "action/mcp/output.ts"],
};
+1 -1
View File
@@ -61,6 +61,6 @@ export const test: TestRunnerOptions = {
coverage: [
"action/utils/normalizeEnv.ts",
"action/mcp/shell.ts",
"action/agents/{claude,opencode}.ts",
"action/agents/{claude,opencode,opencode_v2}.ts",
],
};
+3 -1
View File
@@ -83,7 +83,9 @@ async function plan(slug: string): Promise<Plan> {
const cliPath = await installFromNpmTarball({
packageName: "opencode-ai",
version: getDevDependencyVersion("opencode-ai"),
executablePath: "bin/opencode",
// v1.14+: postinstall.mjs renames the platform-specific binary to
// `bin/opencode.exe` for every OS — see action/agents/opencode_v2.ts.
executablePath: "bin/opencode.exe",
installDependencies: true,
});
return {
+1
View File
@@ -33,6 +33,7 @@ export type ProviderEntry = {
const SHARED_OPENCODE_COVERAGE = [
"action/models.ts",
"action/agents/opencode.ts",
"action/agents/opencode_v2.ts",
"action/agents/opencodePlugin.ts",
];
+34 -10
View File
@@ -19,23 +19,25 @@ import {
/**
* unified test runner for all agent tests.
*
* usage: node test/run.ts [filters...]
* invoke from the repo root:
* pnpm runtest [filters…] # host, in-process — fast iteration (default)
* pnpm runtest:docker [filters…] # local docker container that mocks GHA
* pnpm docker test/run.ts [filters…] # explicit container form (equivalent to `pnpm runtest:docker`)
*
* filters can be test names, tags, or agent names:
* node test/run.ts # run all tests (excludes adhoc-tagged tests)
* node test/run.ts smoke # run tests named "smoke" or tagged "smoke"
* node test/run.ts opencode # run all tests for opencode only
* node test/run.ts security # run all tests tagged "security"
* node test/run.ts agnostic # run all agnostic-tagged tests (with opencode)
* node test/run.ts adhoc # run all adhoc-tagged tests
* node test/run.ts smoke opencode # run smoke tests for opencode only
* pnpm runtest # run all tests (excludes adhoc-tagged tests)
* pnpm runtest smoke # run tests named "smoke" or tagged "smoke"
* pnpm runtest opencode # run all tests for opencode only
* pnpm runtest security # run all tests tagged "security"
* pnpm runtest agnostic # run all agnostic-tagged tests (with opencode)
* pnpm runtest adhoc # run all adhoc-tagged tests
* pnpm runtest smoke opencode # run smoke tests for opencode only
*
* special tags:
* - "agnostic": runs with opencode only, excluded when filtering by agent
* - "adhoc": excluded from default runs, must be explicitly requested
*
* runs in-process. for the GHA-like Linux container, invoke via
* `pnpm gha test/run.ts […]` (the `runtest` package script does this).
* see wiki/docker.md for when host vs container matters.
*/
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -259,6 +261,28 @@ function shouldRetry(result: AgentResult, validation: ValidationResult): RetryDe
async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
const testConfig = ctx.testInfo.config;
// runtime-evaluated skip: gate on env (e.g. CODEX_AUTH_JSON for codex-auth).
// skipped runs short-circuit before any agent spawn AND count as passing so
// a missing optional secret doesn't fail-fast cancel the rest of the matrix.
const skipReason = testConfig.skipIf?.();
if (skipReason) {
const prefix = getPrefix({ test: ctx.testInfo.name, agent: ctx.agent });
console.log(`${prefix} ⏭ skipped: ${skipReason}`);
const skipped: ValidationResult = {
test: ctx.testInfo.name,
agent: ctx.agent,
passed: true,
canceled: false,
checks: [],
output: `skipped: ${skipReason}`,
skipped: true,
skipReason,
};
ctx.results.set(getRunKey(ctx.testInfo.name, ctx.agent), skipped);
return skipped;
}
const env: Record<string, string> = {};
if (testConfig.env) {
const entries = Object.entries(testConfig.env);
+23 -4
View File
@@ -152,6 +152,8 @@ export interface ValidationResult {
canceled: boolean;
checks: ValidationCheck[];
output: string;
skipped?: boolean;
skipReason?: string;
}
export type ValidatorFn = (result: AgentResult) => ValidationCheck[];
@@ -332,6 +334,12 @@ export interface TestRunnerOptions {
// trigger this test in CI. omit to opt out of filtering (test always runs
// — the defensive default). see action/test/coverage.ts.
coverage?: string[];
/** evaluated at test-runtime (after `pnpm install`, before agent spawn).
* return a non-empty reason string to skip the test entirely — the runner
* records a passing-with-skipped result so the matrix doesn't fail-fast
* cancel the rest of the jobs. used to gate tests on optional secrets
* (e.g. codex-auth needs `CODEX_AUTH_JSON`, which forks won't have). */
skipIf?: () => string | null;
}
export type TestTag = "adhoc" | "agnostic" | "security";
@@ -340,8 +348,9 @@ export function printSingleValidation(validation: ValidationResult): void {
const checksStr = validation.checks.map((c) => `${c.name}=${c.passed ? "✓" : "✗"}`).join(" ");
const color = AGENT_COLORS[validation.agent] ?? "";
const canceledNote = validation.canceled ? " (canceled)" : "";
const skippedNote = validation.skipped ? ` (skipped: ${validation.skipReason ?? ""})` : "";
console.log(
`\n${color}[${validation.test}][${validation.agent}]${RESET} ${checksStr}${canceledNote}`
`\n${color}[${validation.test}][${validation.agent}]${RESET} ${checksStr}${canceledNote}${skippedNote}`
);
}
@@ -353,8 +362,16 @@ export function printResults(validations: ValidationResult[]): void {
for (const v of validations) {
const color = AGENT_COLORS[v.agent] ?? "";
const status = v.canceled ? "❌ canceled" : v.passed ? "✅ pass" : "❌ fail";
const checkCols = v.checks.map((c) => `${c.name}=${c.passed ? "✓" : "✗"}`).join(" ");
const status = v.canceled
? "❌ canceled"
: v.skipped
? "⏭ skipped"
: v.passed
? "✅ pass"
: "❌ fail";
const checkCols = v.skipped
? `(skipped: ${v.skipReason ?? ""})`
: v.checks.map((c) => `${c.name}=${c.passed ? "✓" : "✗"}`).join(" ");
console.log(
`${status} ${v.test.padEnd(12)} ${color}${v.agent.padEnd(10)}${RESET} ${checkCols}`
);
@@ -362,5 +379,7 @@ export function printResults(validations: ValidationResult[]): void {
console.log("-".repeat(70));
const passed = validations.filter((v) => v.passed);
console.log(`\n${passed.length}/${validations.length} passed`);
const skipped = validations.filter((v) => v.skipped).length;
const skippedNote = skipped > 0 ? ` (${skipped} skipped)` : "";
console.log(`\n${passed.length}/${validations.length} passed${skippedNote}`);
}
+11 -2
View File
@@ -1,8 +1,17 @@
// Codex-to-OpenCode auth bridging for the action runtime.
//
// `pullfrog auth codex` stores a Codex CLI `auth.json` blob in the Pullfrog
// secret store. At runtime the harness injects it as `CODEX_AUTH_JSON` in
// process.env (via `dbSecrets` in main.ts). This utility:
// 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, ... } }`
+2 -2
View File
@@ -1,6 +1,6 @@
// in-process fixture runner used by `play.ts` (and any future host-side
// runner). does NOT know about Docker — that's `gha.ts`'s job. when run
// inside the GHA container, this is what executes after the entrypoint.
// runner). does NOT know about Docker — that's `docker.ts`'s job. when run
// inside the local docker container, this is what executes after the entrypoint.
import { execSync } from "node:child_process";
import { mkdtemp } from "node:fs/promises";
import { devNull, tmpdir } from "node:os";