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:
committed by
pullfrog[bot]
parent
4d1fd5ea1a
commit
a0576a702a
@@ -30,6 +30,7 @@ jobs:
|
|||||||
agent: [claude, opencode]
|
agent: [claude, opencode]
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
|
codex-auth,
|
||||||
mcpmerge,
|
mcpmerge,
|
||||||
nobash,
|
nobash,
|
||||||
restricted,
|
restricted,
|
||||||
@@ -41,6 +42,8 @@ jobs:
|
|||||||
exclude:
|
exclude:
|
||||||
- agent: claude
|
- agent: claude
|
||||||
test: skill-invoke-opencode
|
test: skill-invoke-opencode
|
||||||
|
- agent: claude
|
||||||
|
test: codex-auth
|
||||||
- agent: opencode
|
- agent: opencode
|
||||||
test: skill-invoke-claude
|
test: skill-invoke-claude
|
||||||
env:
|
env:
|
||||||
@@ -59,6 +62,13 @@ jobs:
|
|||||||
AWS_REGION: us-east-1
|
AWS_REGION: us-east-1
|
||||||
BEDROCK_MODEL_ID: us.anthropic.claude-opus-4-6-v1
|
BEDROCK_MODEL_ID: us.anthropic.claude-opus-4-6-v1
|
||||||
PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }}
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|||||||
+5
-5
@@ -1,11 +1,11 @@
|
|||||||
# pullfrog GHA-like test container.
|
# pullfrog GHA-like test container.
|
||||||
#
|
#
|
||||||
# baked once at image build time, used by `pnpm gha`. all runtime cost
|
# baked once at image build time, used by `pnpm docker`. all runtime cost
|
||||||
# (apt-get, useradd, sudoers wiring) is paid here so each `gha` invocation
|
# (apt-get, useradd, sudoers wiring) is paid here so each `docker` invocation
|
||||||
# is a single `docker run` with no in-container setup.
|
# is a single `docker run` with no in-container setup.
|
||||||
#
|
#
|
||||||
# rebuild is content-hash gated by gha.ts (Dockerfile + docker-entrypoint.sh).
|
# rebuild is content-hash gated by docker.ts (Dockerfile + docker-entrypoint.sh).
|
||||||
# bump anything in this file or the entrypoint and the next `pnpm gha` rebuilds.
|
# bump anything in this file or the entrypoint and the next `pnpm docker` rebuilds.
|
||||||
|
|
||||||
FROM ubuntu:24.04
|
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 \
|
&& echo "testuser ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/testuser \
|
||||||
&& chmod 0440 /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 \
|
RUN mkdir -p /app/action /app/action/node_modules /tmp/home/.config /tmp/home/.cache \
|
||||||
&& chown -R testuser:testuser /app /tmp/home
|
&& chown -R testuser:testuser /app /tmp/home
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -949,9 +949,20 @@ export const claude = agent({
|
|||||||
// bedrock run; if the user has set the env var manually for some other
|
// bedrock run; if the user has set the env var manually for some other
|
||||||
// reason (e.g. always-Bedrock org policy), `...process.env` already
|
// reason (e.g. always-Bedrock org policy), `...process.env` already
|
||||||
// carries it through and we don't disturb it.
|
// 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> = {
|
const env: Record<string, string | undefined> = {
|
||||||
...process.env,
|
...process.env,
|
||||||
...homeEnv,
|
...homeEnv,
|
||||||
|
PWD: repoDir,
|
||||||
};
|
};
|
||||||
if (isBedrockRoute) {
|
if (isBedrockRoute) {
|
||||||
env.CLAUDE_CODE_USE_BEDROCK = "1";
|
env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
@@ -967,8 +978,6 @@ export const claude = agent({
|
|||||||
delete env.ANTHROPIC_API_KEY;
|
delete env.ANTHROPIC_API_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
const repoDir = process.cwd();
|
|
||||||
|
|
||||||
log.info(`» effort: ${effort}`);
|
log.info(`» effort: ${effort}`);
|
||||||
log.debug(`» starting Pullfrog (Claude Code): node ${baseArgs.join(" ")}`);
|
log.debug(`» starting Pullfrog (Claude Code): node ${baseArgs.join(" ")}`);
|
||||||
log.debug(`» working directory: ${repoDir}`);
|
log.debug(`» working directory: ${repoDir}`);
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,8 @@
|
|||||||
import { claude } from "./claude.ts";
|
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";
|
import type { Agent } from "./shared.ts";
|
||||||
|
|
||||||
export type { Agent, AgentUsage } from "./shared.ts";
|
export type { Agent, AgentUsage } from "./shared.ts";
|
||||||
|
|||||||
+16
-127
@@ -11,13 +11,12 @@
|
|||||||
* the agent process itself gets full env (needs LLM API keys, PATH, etc.).
|
* the agent process itself gets full env (needs LLM API keys, PATH, etc.).
|
||||||
* security is enforced at the tool layer, not the process layer.
|
* security is enforced at the tool layer, not the process layer.
|
||||||
*/
|
*/
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import { mkdirSync, writeFileSync } from "node:fs";
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { pullfrogMcpName } from "../external.ts";
|
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 type { ToolState } from "../toolState.ts";
|
||||||
import {
|
import {
|
||||||
getIdleMs,
|
getIdleMs,
|
||||||
@@ -29,7 +28,6 @@ import {
|
|||||||
import { type AgentDiagnostic, formatAgentHangBody } from "../utils/agentHangReport.ts";
|
import { type AgentDiagnostic, formatAgentHangBody } from "../utils/agentHangReport.ts";
|
||||||
import { formatJsonValue, log } from "../utils/cli.ts";
|
import { formatJsonValue, log } from "../utils/cli.ts";
|
||||||
import { installCodexAuth } from "../utils/codexHome.ts";
|
import { installCodexAuth } from "../utils/codexHome.ts";
|
||||||
import { installFromNpmTarball } from "../utils/install.ts";
|
|
||||||
import { findProviderErrorMatch } from "../utils/providerErrors.ts";
|
import { findProviderErrorMatch } from "../utils/providerErrors.ts";
|
||||||
import { addSkill, installBundledSkills } from "../utils/skills.ts";
|
import { addSkill, installBundledSkills } from "../utils/skills.ts";
|
||||||
import {
|
import {
|
||||||
@@ -47,12 +45,19 @@ import {
|
|||||||
PULLFROG_OPENCODE_PLUGIN_FILENAME,
|
PULLFROG_OPENCODE_PLUGIN_FILENAME,
|
||||||
PULLFROG_OPENCODE_PLUGIN_SOURCE,
|
PULLFROG_OPENCODE_PLUGIN_SOURCE,
|
||||||
} from "./opencodePlugin.ts";
|
} from "./opencodePlugin.ts";
|
||||||
|
import {
|
||||||
|
autoSelectModel,
|
||||||
|
buildReviewerAgentConfig,
|
||||||
|
geminiHighThinkingOverrides,
|
||||||
|
installOpencodeCli,
|
||||||
|
type OpenCodeConfig,
|
||||||
|
} from "./opencodeShared.ts";
|
||||||
import {
|
import {
|
||||||
buildLearningsReflectionPrompt,
|
buildLearningsReflectionPrompt,
|
||||||
runPostRunRetryLoop,
|
runPostRunRetryLoop,
|
||||||
shouldRunReflection,
|
shouldRunReflection,
|
||||||
} from "./postRun.ts";
|
} 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 { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "./sessionLabeler.ts";
|
||||||
import {
|
import {
|
||||||
type AgentResult,
|
type AgentResult,
|
||||||
@@ -62,29 +67,13 @@ import {
|
|||||||
logTokenTable,
|
logTokenTable,
|
||||||
MAX_STDERR_LINES,
|
MAX_STDERR_LINES,
|
||||||
} from "./shared.ts";
|
} from "./shared.ts";
|
||||||
import { deriveSubagentModels } from "./subagentModels.ts";
|
|
||||||
|
|
||||||
async function installOpencodeCli(): Promise<string> {
|
// re-export for the existing test (`./opencode.test.ts`) — once v1 is
|
||||||
return await installFromNpmTarball({
|
// retired this module collapses and the test imports from opencodeShared.
|
||||||
packageName: "opencode-ai",
|
export { geminiHighThinkingOverrides } from "./opencodeShared.ts";
|
||||||
version: getDevDependencyVersion("opencode-ai"),
|
|
||||||
executablePath: "bin/opencode",
|
|
||||||
installDependencies: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── config ─────────────────────────────────────────────────────────────────────
|
// v1.4-era npm package shipped a per-platform binary directly at this path.
|
||||||
|
const installCli = () => installOpencodeCli({ binPath: "bin/opencode" });
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
// NOTE: OpenCode's per-call `max_tokens` defaults to 32_000. We previously
|
// NOTE: OpenCode's per-call `max_tokens` defaults to 32_000. We previously
|
||||||
// overrode this via `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX = 5000` in #616
|
// 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
|
// 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.
|
// 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 {
|
function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
|
||||||
const config: OpenCodeConfig = {
|
const config: OpenCodeConfig = {
|
||||||
permission: {
|
permission: {
|
||||||
@@ -171,90 +144,6 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
|||||||
return JSON.stringify(config);
|
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 1–2 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 ─────────────────────────────────────────────────────────
|
// ── NDJSON event types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface OpenCodeInitEvent {
|
interface OpenCodeInitEvent {
|
||||||
@@ -1220,9 +1109,9 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
|||||||
|
|
||||||
export const opencode = agent({
|
export const opencode = agent({
|
||||||
name: "opencode",
|
name: "opencode",
|
||||||
install: installOpencodeCli,
|
install: installCli,
|
||||||
run: async (ctx) => {
|
run: async (ctx) => {
|
||||||
const cliPath = await installOpencodeCli();
|
const cliPath = await installCli();
|
||||||
|
|
||||||
const rawModel = ctx.payload.proxyModel ?? ctx.resolvedModel ?? autoSelectModel(cliPath);
|
const rawModel = ctx.payload.proxyModel ?? ctx.resolvedModel ?? autoSelectModel(cliPath);
|
||||||
|
|
||||||
|
|||||||
@@ -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 1–2 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
@@ -3,7 +3,8 @@ import { join } from "node:path";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const claudeSource = readFileSync(join(__dirname, "claude.ts"), "utf-8");
|
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
|
* 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", () => {
|
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", () => {
|
it("uses deriveSubagentModels for the reviewer model override", () => {
|
||||||
expect(opencodeSource).toMatch(/deriveSubagentModels\(/);
|
expect(opencodeSharedSource).toMatch(/deriveSubagentModels\(/);
|
||||||
expect(opencodeSource).toMatch(/overrides\.reviewer/);
|
expect(opencodeSharedSource).toMatch(/overrides\.reviewer/);
|
||||||
});
|
});
|
||||||
it("passes orchestrator model to buildReviewerAgentConfig", () => {
|
it("v2 runner passes orchestrator model to buildReviewerAgentConfig", () => {
|
||||||
expect(opencodeSource).toMatch(/buildReviewerAgentConfig\(model\)/);
|
expect(opencodeV2Source).toMatch(/buildReviewerAgentConfig\(model\)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+58
-35
@@ -10,6 +10,7 @@
|
|||||||
// secrets API. used both for first-time setup of a Codex subscription on a
|
// secrets API. used both for first-time setup of a Codex subscription on a
|
||||||
// repo and for rotating a stale credential.
|
// repo and for rotating a stale credential.
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import arg from "arg";
|
import arg from "arg";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
@@ -22,7 +23,6 @@ import {
|
|||||||
PULLFROG_API_URL,
|
PULLFROG_API_URL,
|
||||||
parseGitRemote,
|
parseGitRemote,
|
||||||
promptScope,
|
promptScope,
|
||||||
type SecretScope,
|
|
||||||
setActiveSpin,
|
setActiveSpin,
|
||||||
setPullfrogSecret,
|
setPullfrogSecret,
|
||||||
} from "./_shared.ts";
|
} from "./_shared.ts";
|
||||||
@@ -38,6 +38,43 @@ function stripAnsi(s: string): string {
|
|||||||
return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
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 {
|
interface AuthCliParams {
|
||||||
args: string[];
|
args: string[];
|
||||||
prog: 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("mint a Codex subscription credential and save it as CODEX_AUTH_JSON.");
|
||||||
params.stream("");
|
params.stream("");
|
||||||
params.stream("options:");
|
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");
|
params.stream(" -h, --help show help");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +136,6 @@ function parseCodexArgs(args: string[]) {
|
|||||||
return arg(
|
return arg(
|
||||||
{
|
{
|
||||||
"--help": Boolean,
|
"--help": Boolean,
|
||||||
"--scope": String,
|
|
||||||
"-h": "--help",
|
"-h": "--help",
|
||||||
},
|
},
|
||||||
{ argv: args }
|
{ argv: args }
|
||||||
@@ -126,26 +158,10 @@ async function runCodex(params: CodexCliParams): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawScope = parsed["--scope"];
|
await runCodexAuth();
|
||||||
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({ explicitScope });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RunCodexAuthCtx {
|
async function runCodexAuth(): Promise<void> {
|
||||||
explicitScope: SecretScope | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCodexAuth(ctx: RunCodexAuthCtx): Promise<void> {
|
|
||||||
p.intro(pc.bgGreen(pc.black(" pullfrog auth codex ")));
|
p.intro(pc.bgGreen(pc.black(" pullfrog auth codex ")));
|
||||||
|
|
||||||
const spin = p.spinner();
|
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
|
// 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
|
// store for user accounts), so we never bother prompting. on org-owned
|
||||||
// repos, default to interactive prompt — matches `init`'s behavior —
|
// repos, prompt interactively — matches `init`'s behavior.
|
||||||
// unless the caller passed `--scope` to skip it.
|
const scope = status.isOrg
|
||||||
let scope: SecretScope;
|
? await promptScope({ owner: remote.owner, repo: remote.repo })
|
||||||
if (ctx.explicitScope) {
|
: "account";
|
||||||
scope = ctx.explicitScope;
|
|
||||||
} else if (status.isOrg) {
|
|
||||||
scope = await promptScope({ owner: remote.owner, repo: remote.repo });
|
|
||||||
} else {
|
|
||||||
scope = "account";
|
|
||||||
}
|
|
||||||
|
|
||||||
p.log.info(
|
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
|
// tracks the most recent exit so the retry prompt can tell the user
|
||||||
// *why* no auth.json was written (timeout vs. early-exit).
|
// *why* no auth.json was written (timeout vs. early-exit).
|
||||||
let lastTimedOut = false;
|
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({
|
const auth = await mintCodexAuth({
|
||||||
childStdio: "pipe",
|
childStdio: "pipe",
|
||||||
onChildLine: (line) => {
|
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
|
// gray) so the user reads it as sub-process noise, not Pullfrog's
|
||||||
// own prompts. the rail char matches @clack/prompts so the column
|
// own prompts. the rail char matches @clack/prompts so the column
|
||||||
// reads as one continuous flow.
|
// 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) => {
|
onProgress: (event) => {
|
||||||
if (event.kind === "start") {
|
if (event.kind === "start") {
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ fi
|
|||||||
# this idempotent and fast (~1.5s when nothing changed).
|
# this idempotent and fast (~1.5s when nothing changed).
|
||||||
#
|
#
|
||||||
# the lockfile lives IN the shared node_modules volume so concurrent
|
# the lockfile lives IN the shared node_modules volume so concurrent
|
||||||
# `pnpm gha` invocations (e.g. `pnpm play` in one terminal and
|
# `pnpm docker` invocations (e.g. `pnpm play:docker` in one terminal and
|
||||||
# `pnpm runtest` in another) serialize their install instead of racing.
|
# `pnpm runtest:docker` in another) serialize their install instead of racing.
|
||||||
# `flock -w 120` waits up to 2min before giving up — well under any
|
# `flock -w 120` waits up to 2min before giving up — well under any
|
||||||
# real-world install time but short enough to surface true deadlocks.
|
# real-world install time but short enough to surface true deadlocks.
|
||||||
mkdir -p /app/action/node_modules
|
mkdir -p /app/action/node_modules
|
||||||
|
|||||||
+51
-35
@@ -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:
|
// usage:
|
||||||
// pnpm gha <script> [args…] # run script in container
|
// pnpm docker <script> [args…] # run script in container
|
||||||
// pnpm gha --shell # interactive bash (requires TTY)
|
// pnpm docker --shell # interactive bash (requires TTY)
|
||||||
// pnpm gha --build [--no-cache] # force-rebuild image
|
// pnpm docker --build [--no-cache] # force-rebuild image
|
||||||
// pnpm gha --clean # prune orphan images/volumes
|
// pnpm docker --clean # prune orphan images/volumes
|
||||||
// pnpm gha --doctor # versions of every baked tool
|
// 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
|
// 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
|
// same toolset as GHA `ubuntu-24.04` runners. host env passes through
|
||||||
@@ -66,7 +76,7 @@ const HOST_ONLY_VARS = new Set([
|
|||||||
"COLORTERM",
|
"COLORTERM",
|
||||||
"ITERM_PROFILE",
|
"ITERM_PROFILE",
|
||||||
"ITERM_SESSION_ID",
|
"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
|
// 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 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
|
// 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 `--`,
|
* argument. anything after the first positional, or after a literal `--`,
|
||||||
* passes through verbatim to the inner script. this prevents
|
* passes through verbatim to the inner script. this prevents
|
||||||
* `pnpm gha test/run.ts --build` from intercepting `--build` as a
|
* `pnpm docker test/run.ts --build` from intercepting `--build` as a
|
||||||
* gha flag.
|
* docker flag.
|
||||||
*/
|
*/
|
||||||
function parseArgs(argv: string[]): Args {
|
function parseArgs(argv: string[]): Args {
|
||||||
const out: Args = {
|
const out: Args = {
|
||||||
@@ -140,18 +150,24 @@ function parseArgs(argv: string[]): Args {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showHelp(): void {
|
function showHelp(): void {
|
||||||
process.stdout.write(`Usage: pnpm gha <script> [args…]
|
process.stdout.write(`Usage: pnpm docker <script> [args…]
|
||||||
pnpm gha --shell
|
pnpm docker --shell
|
||||||
pnpm gha --build [--no-cache]
|
pnpm docker --build [--no-cache]
|
||||||
pnpm gha --clean
|
pnpm docker --clean
|
||||||
pnpm gha --doctor
|
pnpm docker --doctor
|
||||||
|
|
||||||
Run a node script inside the pullfrog GHA-like container. Mirrors the
|
Run a node script inside the pullfrog local docker container that mocks
|
||||||
GitHub Actions ubuntu-24.04 runner toolset (gh, jq, python3, sudo, +
|
the GHA ubuntu-24.04 runner toolset (gh, jq, python3, sudo, +
|
||||||
build-essential / wget / xz / file). Host env passes through verbatim.
|
build-essential / wget / xz / file). Host env passes through verbatim.
|
||||||
The host is reachable from inside the container at host.docker.internal
|
The host is reachable from inside the container at host.docker.internal
|
||||||
(useful for scripts that hit your local dev server).
|
(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:
|
Options:
|
||||||
--build rebuild the current image (otherwise rebuilt automatically
|
--build rebuild the current image (otherwise rebuilt automatically
|
||||||
when Dockerfile or docker-entrypoint.sh content changes).
|
when Dockerfile or docker-entrypoint.sh content changes).
|
||||||
@@ -160,7 +176,7 @@ Options:
|
|||||||
useful when an apt mirror or base image changed.
|
useful when an apt mirror or base image changed.
|
||||||
--shell drop into an interactive bash inside the container.
|
--shell drop into an interactive bash inside the container.
|
||||||
requires a TTY.
|
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.
|
volumes whose hash doesn't match the current Dockerfile.
|
||||||
--doctor print version info for tools inside the container (node,
|
--doctor print version info for tools inside the container (node,
|
||||||
pnpm, gh, jq, git, python3, ssh, …). useful for diagnosing
|
pnpm, gh, jq, git, python3, ssh, …). useful for diagnosing
|
||||||
@@ -169,24 +185,24 @@ Options:
|
|||||||
|
|
||||||
Pass-through:
|
Pass-through:
|
||||||
Anything after the first positional argument (or after a literal \`--\`)
|
Anything after the first positional argument (or after a literal \`--\`)
|
||||||
goes to the inner script verbatim. so \`pnpm gha test/run.ts --build\`
|
goes to the inner script verbatim. so \`pnpm docker test/run.ts --build\`
|
||||||
passes \`--build\` to test/run.ts, not to gha.
|
passes \`--build\` to test/run.ts, not to docker.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
pnpm gha play.ts
|
pnpm docker play.ts
|
||||||
pnpm gha play.ts --raw '{"prompt":"hi"}'
|
pnpm docker play.ts --raw '{"prompt":"hi"}'
|
||||||
pnpm gha test/run.ts smoke
|
pnpm docker test/run.ts smoke
|
||||||
pnpm gha --shell
|
pnpm docker --shell
|
||||||
pnpm gha --build # build image, then exit
|
pnpm docker --build # build image, then exit
|
||||||
pnpm gha --build --no-cache # rebuild from scratch
|
pnpm docker --build --no-cache # rebuild from scratch
|
||||||
pnpm gha --clean # reclaim disk from old image hashes
|
pnpm docker --clean # reclaim disk from old image hashes
|
||||||
pnpm gha --doctor # fidelity audit
|
pnpm docker --doctor # fidelity audit
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDocker(): void {
|
function ensureDocker(): void {
|
||||||
if (platform() === "win32") {
|
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" });
|
const probe = spawnSync("docker", ["info"], { stdio: "ignore" });
|
||||||
if (probe.status !== 0) {
|
if (probe.status !== 0) {
|
||||||
@@ -208,15 +224,15 @@ function imageRefFor(ctx: { dockerfile: string; entrypoint: string }): ImageRef
|
|||||||
.digest("hex")
|
.digest("hex")
|
||||||
.slice(0, 12);
|
.slice(0, 12);
|
||||||
return {
|
return {
|
||||||
tag: `pullfrog-gha:${hash}`,
|
tag: `pullfrog-docker:${hash}`,
|
||||||
// version the volume by image hash so a stale node_modules cache from
|
// 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.
|
// 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
|
* whose hash doesn't match the current Dockerfile + entrypoint. each
|
||||||
* Dockerfile/entrypoint edit creates a fresh hash and orphans the prior
|
* Dockerfile/entrypoint edit creates a fresh hash and orphans the prior
|
||||||
* pair; without periodic cleanup these accumulate (~600MB image + ~200MB
|
* pair; without periodic cleanup these accumulate (~600MB image + ~200MB
|
||||||
@@ -228,7 +244,7 @@ function cleanOrphans(currentRef: ImageRef): void {
|
|||||||
});
|
});
|
||||||
const images = (imgList.stdout ?? "")
|
const images = (imgList.stdout ?? "")
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((s) => s.startsWith("pullfrog-gha:") && s !== currentRef.tag);
|
.filter((s) => s.startsWith("pullfrog-docker:") && s !== currentRef.tag);
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
process.stderr.write(`» removing ${images.length} orphan image(s): ${images.join(", ")}\n`);
|
process.stderr.write(`» removing ${images.length} orphan image(s): ${images.join(", ")}\n`);
|
||||||
spawnSync("docker", ["image", "rm", "-f", ...images], { stdio: "inherit" });
|
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 volList = spawnSync("docker", ["volume", "ls", "-q"], { encoding: "utf8" });
|
||||||
const volumes = (volList.stdout ?? "")
|
const volumes = (volList.stdout ?? "")
|
||||||
.split("\n")
|
.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) {
|
if (volumes.length > 0) {
|
||||||
process.stderr.write(`» removing ${volumes.length} orphan volume(s): ${volumes.join(", ")}\n`);
|
process.stderr.write(`» removing ${volumes.length} orphan volume(s): ${volumes.join(", ")}\n`);
|
||||||
spawnSync("docker", ["volume", "rm", ...volumes], { stdio: "inherit" });
|
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[] };
|
type EnvParts = { envFile: string; multiLineFlags: string[] };
|
||||||
|
|
||||||
function buildEnvParts(env: NodeJS.ProcessEnv): EnvParts {
|
function buildEnvParts(env: NodeJS.ProcessEnv): EnvParts {
|
||||||
const dir = join(tmpdir(), "pullfrog-gha");
|
const dir = join(tmpdir(), "pullfrog-docker");
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
const envFile = join(dir, `env-${process.pid}-${Date.now()}.list`);
|
const envFile = join(dir, `env-${process.pid}-${Date.now()}.list`);
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
+18
-7
@@ -6,21 +6,28 @@
|
|||||||
// auth.json during the run, the refreshed token must land back in Pullfrog
|
// auth.json during the run, the refreshed token must land back in Pullfrog
|
||||||
// even when the main step died unexpectedly.
|
// 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
|
// 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
|
// auth.json against the original refresh token (saved to GH Actions state
|
||||||
// by action/agents/opencode.ts), convert OpenCode's auth shape back to
|
// by action/agents/opencode_v2.ts — see also the legacy v1 file kept as
|
||||||
// Codex CLI shape, and PUT it to /api/runtime/secret.
|
// 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
|
// 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
|
// 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
|
// is already done, and a missed refresh write-back means the user re-runs
|
||||||
// `pullfrog auth codex` next time the chain breaks.
|
// `pullfrog auth codex` next time the chain breaks.
|
||||||
//
|
|
||||||
// See wiki/codex-auth.md for the full flow.
|
|
||||||
|
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { getApiUrl } from "./utils/apiUrl.ts";
|
import { apiFetch } from "./utils/apiFetch.ts";
|
||||||
import { detectCodexRefresh } from "./utils/codexHome.ts";
|
import { detectCodexRefresh } from "./utils/codexHome.ts";
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -64,9 +71,13 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${getApiUrl()}/api/runtime/secret`;
|
|
||||||
try {
|
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",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${state.apiToken}`,
|
authorization: `Bearer ${state.apiToken}`,
|
||||||
|
|||||||
+2
-1
@@ -16,6 +16,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "node esbuild.config.js && tsc -p tsconfig.exports.json",
|
"build": "node esbuild.config.js && tsc -p tsconfig.exports.json",
|
||||||
"check:entrypoints": "node scripts/check-entrypoint-imports.ts",
|
"check:entrypoints": "node scripts/check-entrypoint-imports.ts",
|
||||||
|
"docker": "node docker.ts",
|
||||||
"play": "node play.ts",
|
"play": "node play.ts",
|
||||||
"runtest": "node test/run.ts",
|
"runtest": "node test/run.ts",
|
||||||
"scratch": "node scratch.ts",
|
"scratch": "node scratch.ts",
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
"fastmcp": "^3.34.0",
|
"fastmcp": "^3.34.0",
|
||||||
"file-type": "^21.3.0",
|
"file-type": "^21.3.0",
|
||||||
"husky": "^9.0.0",
|
"husky": "^9.0.0",
|
||||||
"opencode-ai": "1.1.56",
|
"opencode-ai": "1.15.1",
|
||||||
"package-manager-detector": "^1.6.0",
|
"package-manager-detector": "^1.6.0",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
// thin CLI for ad-hoc fixture runs against the Pullfrog action.
|
// 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 { dirname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import arg from "arg";
|
import arg from "arg";
|
||||||
@@ -38,19 +44,19 @@ if (isDirectExecution) {
|
|||||||
|
|
||||||
if (args["--help"]) {
|
if (args["--help"]) {
|
||||||
log.info(`
|
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.
|
Run the Pullfrog action against an inline fixture.
|
||||||
For a GHA-like Linux container, use \`pnpm gha play.ts […]\` instead.
|
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--raw <input> raw string used as the prompt, or JSON object as full fixture
|
--raw <input> raw string used as the prompt, or JSON object as full fixture
|
||||||
-h, --help show this message
|
-h, --help show this message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
node play.ts
|
pnpm play
|
||||||
node play.ts --raw "Hello world"
|
pnpm play --raw "Hello world"
|
||||||
node play.ts --raw '{"prompt":"Hi","timeout":"5s"}'
|
pnpm play --raw '{"prompt":"Hi","timeout":"5s"}'
|
||||||
`);
|
`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+60
-49
@@ -84,8 +84,8 @@ importers:
|
|||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.1.7
|
version: 9.1.7
|
||||||
opencode-ai:
|
opencode-ai:
|
||||||
specifier: 1.1.56
|
specifier: 1.15.1
|
||||||
version: 1.1.56
|
version: 1.15.1
|
||||||
package-manager-detector:
|
package-manager-detector:
|
||||||
specifier: ^1.6.0
|
specifier: ^1.6.0
|
||||||
version: 1.6.0
|
version: 1.6.0
|
||||||
@@ -1444,62 +1444,69 @@ packages:
|
|||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||||
|
|
||||||
opencode-ai@1.1.56:
|
opencode-ai@1.15.1:
|
||||||
resolution: {integrity: sha512-OAF0G/1jVXpOrCh++M5gFEZ0bRLiXOnbxSYMFx5TOoD0OhCjHJS1JlARzaLDAx461qBnn+jocI9BBxD0wwFH3Q==}
|
resolution: {integrity: sha512-xLb1NuYZcMJ1p33hC/kgTMcJAueACVTfX6ps91a54GOFTM/wFp7br0t2cqHopEU9paqItbAnQwZB573qmPKH6w==}
|
||||||
|
cpu: [arm64, x64]
|
||||||
|
os: [darwin, linux, win32]
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
opencode-darwin-arm64@1.1.56:
|
opencode-darwin-arm64@1.15.1:
|
||||||
resolution: {integrity: sha512-0HqvLm7tcYZr4VJgEzi3Wicia5M9yCX66O7Cv470Qu4+GGbCC2sTTmzQu6pCehSleYgkZsiSBNRcFcW/6F7v0Q==}
|
resolution: {integrity: sha512-eNgIfATsnHcud4Pr58OIR+TJGSsDvWmyNlfSDVVgP92qdnHFdZ5YsHKjcUGmeuuUN+oZwPb/z5nZSrkf+CCB2g==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
opencode-darwin-x64-baseline@1.1.56:
|
opencode-darwin-x64-baseline@1.15.1:
|
||||||
resolution: {integrity: sha512-Z9QO9cTC9TnlUxTfEtDbvZbRFxc5Je8rs2Ei0cLH46W9gHGhh1fW4oX64tcrlhF5NUDKJzr/qwdoMfmKkeu53A==}
|
resolution: {integrity: sha512-XDx90Hhj+SPUxu0rqewsNR10JTny7+VE4C5pjWB04I6eoiEuBWy2EMvPXPg2FUA5Suz1PXXJ6yThfRtOxXNHuw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
opencode-darwin-x64@1.1.56:
|
opencode-darwin-x64@1.15.1:
|
||||||
resolution: {integrity: sha512-8ZhNd4sFbDviT1OJs42C35Bx2/z6mkcb7uNhPHX3KKm27KOm7cYjFQa1UTF5or3ZpjkjPQpR/cpo7TELvnnmRA==}
|
resolution: {integrity: sha512-tNbzF6n+TczILEqo0adtup1ZXBgAcqftQd11+eQohGxtNAjmD7Z/gCTVpEzh9GlHUPzUEuREZ4gRAbJmPpafBQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
opencode-linux-arm64-musl@1.1.56:
|
opencode-linux-arm64-musl@1.15.1:
|
||||||
resolution: {integrity: sha512-f30SmYX4xE2fUsnNl66dDX++8iTMI9PLXz7BRHhgXL4XAdMpUUJbOKIsw3ZUB0KWHwjR8cjkEJQOZxR3HPbrZg==}
|
resolution: {integrity: sha512-UuoizYN32eTWmQT494bw70Sq4AByS0pGk46Mo/z+KzV+KTQlsDXRQrnKATKYFDqE2T3c1VsPM1KqRV/DqvnXxw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
opencode-linux-arm64@1.1.56:
|
opencode-linux-arm64@1.15.1:
|
||||||
resolution: {integrity: sha512-wEmIEXiEKghurQYgKY5yFUFOmnT/QIlAbYnVeH73gSqOwlYFUUlowXLDJnF+3OGd6m000qalhCxjExL7qKINWw==}
|
resolution: {integrity: sha512-MG6tuLZqzDjHGeaotejhYuuv2USR0y3v8N+6g5gWPHScX/iJWkJDMFBeT6+KOV/CWawrGRqZfBDfdJSKirX2LQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
opencode-linux-x64-baseline-musl@1.1.56:
|
opencode-linux-x64-baseline-musl@1.15.1:
|
||||||
resolution: {integrity: sha512-EC8EGTJCgTZgGIMZdQjOKGXw02+igo0am1Ry6wrPrB0li7XBlYbY6dz8tL3FlknRVMLamtGtb3tXQ6tqbwjX0g==}
|
resolution: {integrity: sha512-Is50zWUqa9fIJ+tiDOpxENcgn2XBk0QKNEocbu/x9aOdpfFsHhtxe33zi/+9CNdSr+O/6y9jRAMGn7AirJyZlg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
opencode-linux-x64-baseline@1.1.56:
|
opencode-linux-x64-baseline@1.15.1:
|
||||||
resolution: {integrity: sha512-jKOvhkNLcn0h6zmKX+hDdkODijSVDxjAGTZp8BPbJCCJVxnKll9dYfDMTqxi3YN6yXp6sBvyaJT1mcpf8knkeA==}
|
resolution: {integrity: sha512-ExKWMk/6ULM9HBda2KKZJNE5Ejzaa51QWpr7+Ljv1AlazxQQZKwJfqcZcSNfk0YsgXDESw2w2dwBmOcMaxQZKA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
opencode-linux-x64-musl@1.1.56:
|
opencode-linux-x64-musl@1.15.1:
|
||||||
resolution: {integrity: sha512-9BFYBPgpY2RrUd7/Ul0VNh+6B9l0FmCNRUV4yYmeFGee9ZDgPem5YGicsKAMQqS/5X0jW3ZWje+KBbBQz4RMwQ==}
|
resolution: {integrity: sha512-feNjVo7XGjqFHf5lejxuyZIkNi9Yi4B2H3w+p2SF9vcyUdPaJnta2/6Os7Pf8kwElRs6EnWRyUO2JVg4hjAjjg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
opencode-linux-x64@1.1.56:
|
opencode-linux-x64@1.15.1:
|
||||||
resolution: {integrity: sha512-0FP3BzLjn+a9naTtm9hpHibJ+eV1RkI+tItcDQXKwHtvzoBUvytDRP8v6TotEHNtNF0ZLAb3OGurwDIIdtLarg==}
|
resolution: {integrity: sha512-mKRg+iHdwEYNDS+DYa9VQnN903zlw8FInCQRGpY155aR/AF1r3hIn+7IopOTDAwqkutL9vJWMXELxmNpPdaTQg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
opencode-windows-x64-baseline@1.1.56:
|
opencode-windows-arm64@1.15.1:
|
||||||
resolution: {integrity: sha512-zEokdohfoDjWzwULvlSq0Y/tRoVmz2/6GofYO936buPQf09cBcPqpAgLUjZV+pQI0Atyd05YG3BCO4PQxmXzdw==}
|
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]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
opencode-windows-x64@1.1.56:
|
opencode-windows-x64@1.15.1:
|
||||||
resolution: {integrity: sha512-zm/oaWT5uGrW3DumKRHiqv2L2pKwrTvhsT4XUSfIPLQn5EvrYNU6bh3WFO4v71ZBjSIKx5Q7rEvTEQvFiFEZFQ==}
|
resolution: {integrity: sha512-MdCBncbhpcImw3zjYBuoI+ZqfMR1uI4mc8KCltwIgI2DrxuOZNe66A/3feOhWd9MQQ2c2PSdyyJfW9PE0FA/Ow==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
@@ -3154,51 +3161,55 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
wrappy: 1.0.2
|
wrappy: 1.0.2
|
||||||
|
|
||||||
opencode-ai@1.1.56:
|
opencode-ai@1.15.1:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
opencode-darwin-arm64: 1.1.56
|
opencode-darwin-arm64: 1.15.1
|
||||||
opencode-darwin-x64: 1.1.56
|
opencode-darwin-x64: 1.15.1
|
||||||
opencode-darwin-x64-baseline: 1.1.56
|
opencode-darwin-x64-baseline: 1.15.1
|
||||||
opencode-linux-arm64: 1.1.56
|
opencode-linux-arm64: 1.15.1
|
||||||
opencode-linux-arm64-musl: 1.1.56
|
opencode-linux-arm64-musl: 1.15.1
|
||||||
opencode-linux-x64: 1.1.56
|
opencode-linux-x64: 1.15.1
|
||||||
opencode-linux-x64-baseline: 1.1.56
|
opencode-linux-x64-baseline: 1.15.1
|
||||||
opencode-linux-x64-baseline-musl: 1.1.56
|
opencode-linux-x64-baseline-musl: 1.15.1
|
||||||
opencode-linux-x64-musl: 1.1.56
|
opencode-linux-x64-musl: 1.15.1
|
||||||
opencode-windows-x64: 1.1.56
|
opencode-windows-arm64: 1.15.1
|
||||||
opencode-windows-x64-baseline: 1.1.56
|
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
|
optional: true
|
||||||
|
|
||||||
opencode-darwin-x64-baseline@1.1.56:
|
opencode-darwin-x64-baseline@1.15.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-darwin-x64@1.1.56:
|
opencode-darwin-x64@1.15.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-linux-arm64-musl@1.1.56:
|
opencode-linux-arm64-musl@1.15.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-linux-arm64@1.1.56:
|
opencode-linux-arm64@1.15.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-linux-x64-baseline-musl@1.1.56:
|
opencode-linux-x64-baseline-musl@1.15.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-linux-x64-baseline@1.1.56:
|
opencode-linux-x64-baseline@1.15.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-linux-x64-musl@1.1.56:
|
opencode-linux-x64-musl@1.15.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-linux-x64@1.1.56:
|
opencode-linux-x64@1.15.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-windows-x64-baseline@1.1.56:
|
opencode-windows-arm64@1.15.1:
|
||||||
optional: true
|
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
|
optional: true
|
||||||
|
|
||||||
package-manager-detector@1.6.0: {}
|
package-manager-detector@1.6.0: {}
|
||||||
|
|||||||
+5
-2
@@ -57,10 +57,13 @@ const crossagentTests = getTestNamesFromDir("crossagent");
|
|||||||
const agnosticTests = getTestNamesFromDir("agnostic");
|
const agnosticTests = getTestNamesFromDir("agnostic");
|
||||||
const adhocTests = getTestNamesFromDir("adhoc");
|
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 = [
|
const expectedAgentEnvVars = [
|
||||||
"GITHUB_TOKEN",
|
"GITHUB_TOKEN",
|
||||||
...new Set(Object.values(providers).flatMap((p) => [...p.envVars])),
|
...new Set(
|
||||||
|
Object.values(providers).flatMap((p) => [...p.envVars, ...(p.managedCredentials ?? [])])
|
||||||
|
),
|
||||||
"PULLFROG_MODEL",
|
"PULLFROG_MODEL",
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -40,10 +40,10 @@ export const ALWAYS_RUN_ALL: string[] = [
|
|||||||
"action/utils/install.ts",
|
"action/utils/install.ts",
|
||||||
"action/utils/runFixture.ts",
|
"action/utils/runFixture.ts",
|
||||||
"action/utils/globals.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/Dockerfile",
|
||||||
"action/docker-entrypoint.sh",
|
"action/docker-entrypoint.sh",
|
||||||
"action/gha.ts",
|
"action/docker.ts",
|
||||||
// MCP orchestrator (every test runs through it)
|
// MCP orchestrator (every test runs through it)
|
||||||
"action/mcp/server.ts",
|
"action/mcp/server.ts",
|
||||||
"action/mcp/shared.ts",
|
"action/mcp/shared.ts",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
};
|
||||||
@@ -44,5 +44,5 @@ export const test: TestRunnerOptions = {
|
|||||||
repoSetup:
|
repoSetup:
|
||||||
'mkdir -p /tmp/pullfrog-mcp-secret && printf "%s" "$PULLFROG_MCP_SECRET" > /tmp/pullfrog-mcp-secret/secret.txt',
|
'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.
|
// 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"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,5 +43,5 @@ export const test: TestRunnerOptions = {
|
|||||||
validator,
|
validator,
|
||||||
agentEnv,
|
agentEnv,
|
||||||
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
|
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"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,6 +55,6 @@ export const test: TestRunnerOptions = {
|
|||||||
coverage: [
|
coverage: [
|
||||||
"action/utils/normalizeEnv.ts",
|
"action/utils/normalizeEnv.ts",
|
||||||
"action/mcp/shell.ts",
|
"action/mcp/shell.ts",
|
||||||
"action/agents/{claude,opencode}.ts",
|
"action/agents/{claude,opencode,opencode_v2}.ts",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,5 +44,9 @@ export const test: TestRunnerOptions = {
|
|||||||
PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1",
|
PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1",
|
||||||
PULLFROG_MODEL: "anthropic/claude-sonnet-4-6",
|
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",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.t
|
|||||||
import { defineFixture } from "../utils.ts";
|
import { defineFixture } from "../utils.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* smoke test - validates agent can connect to API and call MCP tools.
|
* smoke test — validates agent can connect to the API and call MCP tools.
|
||||||
* verifies set_output tool is called with correct value.
|
*
|
||||||
|
* 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(
|
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 }
|
{ localOnly: true }
|
||||||
);
|
);
|
||||||
@@ -31,5 +34,5 @@ export const test: TestRunnerOptions = {
|
|||||||
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
|
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
|
||||||
// canary: any agent harness change runs the smoke. shared MCP set_output
|
// canary: any agent harness change runs the smoke. shared MCP set_output
|
||||||
// surface is also captured.
|
// 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"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,6 +61,6 @@ export const test: TestRunnerOptions = {
|
|||||||
coverage: [
|
coverage: [
|
||||||
"action/utils/normalizeEnv.ts",
|
"action/utils/normalizeEnv.ts",
|
||||||
"action/mcp/shell.ts",
|
"action/mcp/shell.ts",
|
||||||
"action/agents/{claude,opencode}.ts",
|
"action/agents/{claude,opencode,opencode_v2}.ts",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-1
@@ -83,7 +83,9 @@ async function plan(slug: string): Promise<Plan> {
|
|||||||
const cliPath = await installFromNpmTarball({
|
const cliPath = await installFromNpmTarball({
|
||||||
packageName: "opencode-ai",
|
packageName: "opencode-ai",
|
||||||
version: getDevDependencyVersion("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,
|
installDependencies: true,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type ProviderEntry = {
|
|||||||
const SHARED_OPENCODE_COVERAGE = [
|
const SHARED_OPENCODE_COVERAGE = [
|
||||||
"action/models.ts",
|
"action/models.ts",
|
||||||
"action/agents/opencode.ts",
|
"action/agents/opencode.ts",
|
||||||
|
"action/agents/opencode_v2.ts",
|
||||||
"action/agents/opencodePlugin.ts",
|
"action/agents/opencodePlugin.ts",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+34
-10
@@ -19,23 +19,25 @@ import {
|
|||||||
/**
|
/**
|
||||||
* unified test runner for all agent tests.
|
* 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:
|
* filters can be test names, tags, or agent names:
|
||||||
* node test/run.ts # run all tests (excludes adhoc-tagged tests)
|
* pnpm runtest # run all tests (excludes adhoc-tagged tests)
|
||||||
* node test/run.ts smoke # run tests named "smoke" or tagged "smoke"
|
* pnpm runtest smoke # run tests named "smoke" or tagged "smoke"
|
||||||
* node test/run.ts opencode # run all tests for opencode only
|
* pnpm runtest opencode # run all tests for opencode only
|
||||||
* node test/run.ts security # run all tests tagged "security"
|
* pnpm runtest security # run all tests tagged "security"
|
||||||
* node test/run.ts agnostic # run all agnostic-tagged tests (with opencode)
|
* pnpm runtest agnostic # run all agnostic-tagged tests (with opencode)
|
||||||
* node test/run.ts adhoc # run all adhoc-tagged tests
|
* pnpm runtest adhoc # run all adhoc-tagged tests
|
||||||
* node test/run.ts smoke opencode # run smoke tests for opencode only
|
* pnpm runtest smoke opencode # run smoke tests for opencode only
|
||||||
*
|
*
|
||||||
* special tags:
|
* special tags:
|
||||||
* - "agnostic": runs with opencode only, excluded when filtering by agent
|
* - "agnostic": runs with opencode only, excluded when filtering by agent
|
||||||
* - "adhoc": excluded from default runs, must be explicitly requested
|
* - "adhoc": excluded from default runs, must be explicitly requested
|
||||||
*
|
*
|
||||||
* runs in-process. for the GHA-like Linux container, invoke via
|
* see wiki/docker.md for when host vs container matters.
|
||||||
* `pnpm gha test/run.ts […]` (the `runtest` package script does this).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
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> {
|
async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
|
||||||
const testConfig = ctx.testInfo.config;
|
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> = {};
|
const env: Record<string, string> = {};
|
||||||
if (testConfig.env) {
|
if (testConfig.env) {
|
||||||
const entries = Object.entries(testConfig.env);
|
const entries = Object.entries(testConfig.env);
|
||||||
|
|||||||
+23
-4
@@ -152,6 +152,8 @@ export interface ValidationResult {
|
|||||||
canceled: boolean;
|
canceled: boolean;
|
||||||
checks: ValidationCheck[];
|
checks: ValidationCheck[];
|
||||||
output: string;
|
output: string;
|
||||||
|
skipped?: boolean;
|
||||||
|
skipReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ValidatorFn = (result: AgentResult) => ValidationCheck[];
|
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
|
// trigger this test in CI. omit to opt out of filtering (test always runs
|
||||||
// — the defensive default). see action/test/coverage.ts.
|
// — the defensive default). see action/test/coverage.ts.
|
||||||
coverage?: string[];
|
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";
|
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 checksStr = validation.checks.map((c) => `${c.name}=${c.passed ? "✓" : "✗"}`).join(" ");
|
||||||
const color = AGENT_COLORS[validation.agent] ?? "";
|
const color = AGENT_COLORS[validation.agent] ?? "";
|
||||||
const canceledNote = validation.canceled ? " (canceled)" : "";
|
const canceledNote = validation.canceled ? " (canceled)" : "";
|
||||||
|
const skippedNote = validation.skipped ? ` (skipped: ${validation.skipReason ?? ""})` : "";
|
||||||
console.log(
|
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) {
|
for (const v of validations) {
|
||||||
const color = AGENT_COLORS[v.agent] ?? "";
|
const color = AGENT_COLORS[v.agent] ?? "";
|
||||||
const status = v.canceled ? "❌ canceled" : v.passed ? "✅ pass" : "❌ fail";
|
const status = v.canceled
|
||||||
const checkCols = v.checks.map((c) => `${c.name}=${c.passed ? "✓" : "✗"}`).join(" ");
|
? "❌ 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(
|
console.log(
|
||||||
`${status} ${v.test.padEnd(12)} ${color}${v.agent.padEnd(10)}${RESET} ${checkCols}`
|
`${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));
|
console.log("-".repeat(70));
|
||||||
|
|
||||||
const passed = validations.filter((v) => v.passed);
|
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
@@ -1,8 +1,17 @@
|
|||||||
// Codex-to-OpenCode auth bridging for the action runtime.
|
// Codex-to-OpenCode auth bridging for the action runtime.
|
||||||
//
|
//
|
||||||
// `pullfrog auth codex` stores a Codex CLI `auth.json` blob in the Pullfrog
|
// `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
|
// per-org secret store (production Postgres) — NOT a GitHub Actions secret.
|
||||||
// process.env (via `dbSecrets` in main.ts). This utility:
|
// 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
|
// 1. parses + validates that env value
|
||||||
// 2. converts Codex's shape `{ auth_mode, tokens: { access_token, refresh_token, ... } }`
|
// 2. converts Codex's shape `{ auth_mode, tokens: { access_token, refresh_token, ... } }`
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
// in-process fixture runner used by `play.ts` (and any future host-side
|
// 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
|
// runner). does NOT know about Docker — that's `docker.ts`'s job. when run
|
||||||
// inside the GHA container, this is what executes after the entrypoint.
|
// inside the local docker container, this is what executes after the entrypoint.
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { mkdtemp } from "node:fs/promises";
|
import { mkdtemp } from "node:fs/promises";
|
||||||
import { devNull, tmpdir } from "node:os";
|
import { devNull, tmpdir } from "node:os";
|
||||||
|
|||||||
Reference in New Issue
Block a user