a0576a702a
* 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>
533 lines
18 KiB
TypeScript
533 lines
18 KiB
TypeScript
// run any node script inside the pullfrog local docker container that
|
|
// mocks the GHA `ubuntu-24.04` runner environment. NOT a real GitHub
|
|
// Actions runner — for the real thing, see `.github/workflows/*.yml`
|
|
// and `action/commands/gha.ts` (the action's GHA entry point).
|
|
//
|
|
// usage:
|
|
// pnpm docker <script> [args…] # run script in container
|
|
// pnpm docker --shell # interactive bash (requires TTY)
|
|
// pnpm docker --build [--no-cache] # force-rebuild image
|
|
// pnpm docker --clean # prune orphan images/volumes
|
|
// pnpm docker --doctor # versions of every baked tool
|
|
//
|
|
// the action's two main entrypoints default to the host (fast iteration).
|
|
// `:docker` suffix wraps this script:
|
|
// pnpm play [args…] # host (this is the fast default)
|
|
// pnpm play:docker [args…] # === pnpm docker play.ts [args…]
|
|
// pnpm runtest [filters…] # host
|
|
// pnpm runtest:docker [filters…] # === pnpm docker test/run.ts [filters…]
|
|
//
|
|
// the container is a baked ubuntu:24.04 image (see Dockerfile) with the
|
|
// same toolset as GHA `ubuntu-24.04` runners. host env passes through
|
|
// verbatim — no allowlist. multi-line values (RSA keys) handled via -e
|
|
// fallback; everything else flows through `--env-file` for cleanliness.
|
|
//
|
|
// host services are reachable at `host.docker.internal:<port>` (works on
|
|
// both linux and macOS — see --add-host below).
|
|
//
|
|
// rebuild is content-hash gated on Dockerfile + docker-entrypoint.sh.
|
|
//
|
|
// design rationale + gaps: wiki/docker.md.
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
import { platform, tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { config } from "dotenv";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const actionDir = __dirname;
|
|
const repoRoot = join(actionDir, "..");
|
|
|
|
config({ path: join(actionDir, ".env") });
|
|
config({ path: join(repoRoot, ".env") });
|
|
|
|
// host env vars that would actively conflict with the container's own
|
|
// configuration (paths, identity, shell, and outer-CI workflow-run identifiers
|
|
// that don't apply to whatever repo the harness is acting against). everything
|
|
// else passes through.
|
|
const HOST_ONLY_VARS = new Set([
|
|
// paths / identity / shell — would clobber the container's testuser setup
|
|
"PATH",
|
|
"HOME",
|
|
"USER",
|
|
"LOGNAME",
|
|
"SHELL",
|
|
"PWD",
|
|
"OLDPWD",
|
|
"TMPDIR",
|
|
"TMP",
|
|
"TEMP",
|
|
"DOCKER_HOST",
|
|
"DOCKER_CONFIG",
|
|
"_",
|
|
"SHLVL",
|
|
"PS1",
|
|
"PS2",
|
|
"TERM_PROGRAM",
|
|
"TERM_PROGRAM_VERSION",
|
|
"TERM_SESSION_ID",
|
|
"__CF_USER_TEXT_ENCODING",
|
|
"XPC_SERVICE_NAME",
|
|
"XPC_FLAGS",
|
|
"Apple_PubSub_Socket_Render",
|
|
"COMMAND_MODE",
|
|
"COLORTERM",
|
|
"ITERM_PROFILE",
|
|
"ITERM_SESSION_ID",
|
|
// outer-CI workflow-run identifiers — when the test suite runs inside
|
|
// pullfrog/app's CI, these refer to pullfrog/app's run, NOT the test repo
|
|
// the harness is acting against (e.g. pullfrog/test-repo). Anything inside
|
|
// the action that uses them as keys to look up state on the test repo (most
|
|
// notably `resolveRun()`'s `actions.listJobsForWorkflowRun(...)` call) will
|
|
// 404. Filtering them here means the action sees them as undefined and
|
|
// skips the lookup, instead of misdirecting it. `GITHUB_REPOSITORY` and
|
|
// `GITHUB_TOKEN` are NOT filtered — those are genuinely needed inside.
|
|
"GITHUB_RUN_ID",
|
|
"GITHUB_RUN_NUMBER",
|
|
"GITHUB_RUN_ATTEMPT",
|
|
"GITHUB_JOB",
|
|
"GITHUB_WORKFLOW",
|
|
"GITHUB_ACTION",
|
|
"GITHUB_REF",
|
|
"GITHUB_SHA",
|
|
"GITHUB_HEAD_REF",
|
|
"GITHUB_BASE_REF",
|
|
"GITHUB_TRIGGERING_ACTOR",
|
|
]);
|
|
|
|
type Args = {
|
|
forceBuild: boolean;
|
|
noCache: boolean;
|
|
shell: boolean;
|
|
clean: boolean;
|
|
doctor: boolean;
|
|
passthrough: string[];
|
|
};
|
|
|
|
/**
|
|
* parses docker-level flags up to (but not including) the first positional
|
|
* argument. anything after the first positional, or after a literal `--`,
|
|
* passes through verbatim to the inner script. this prevents
|
|
* `pnpm docker test/run.ts --build` from intercepting `--build` as a
|
|
* docker flag.
|
|
*/
|
|
function parseArgs(argv: string[]): Args {
|
|
const out: Args = {
|
|
forceBuild: false,
|
|
noCache: false,
|
|
shell: false,
|
|
clean: false,
|
|
doctor: false,
|
|
passthrough: [],
|
|
};
|
|
let i = 0;
|
|
while (i < argv.length) {
|
|
const a = argv[i];
|
|
if (a === "--") {
|
|
out.passthrough.push(...argv.slice(i + 1));
|
|
return out;
|
|
}
|
|
if (a === "--build") out.forceBuild = true;
|
|
else if (a === "--no-cache") {
|
|
out.forceBuild = true;
|
|
out.noCache = true;
|
|
} else if (a === "--shell") out.shell = true;
|
|
else if (a === "--clean") out.clean = true;
|
|
else if (a === "--doctor") out.doctor = true;
|
|
else if (a === "--help" || a === "-h") {
|
|
showHelp();
|
|
process.exit(0);
|
|
} else {
|
|
// first positional — script name and everything after passes through.
|
|
out.passthrough.push(...argv.slice(i));
|
|
return out;
|
|
}
|
|
i++;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function showHelp(): void {
|
|
process.stdout.write(`Usage: pnpm docker <script> [args…]
|
|
pnpm docker --shell
|
|
pnpm docker --build [--no-cache]
|
|
pnpm docker --clean
|
|
pnpm docker --doctor
|
|
|
|
Run a node script inside the pullfrog local docker container that mocks
|
|
the GHA ubuntu-24.04 runner toolset (gh, jq, python3, sudo, +
|
|
build-essential / wget / xz / file). Host env passes through verbatim.
|
|
The host is reachable from inside the container at host.docker.internal
|
|
(useful for scripts that hit your local dev server).
|
|
|
|
The action's two main entrypoints have host (fast) and docker variants:
|
|
pnpm play [args…] # host — the fast default
|
|
pnpm play:docker [args…] # === pnpm docker play.ts [args…]
|
|
pnpm runtest [filters…] # host
|
|
pnpm runtest:docker [filters…] # === pnpm docker test/run.ts [filters…]
|
|
|
|
Options:
|
|
--build rebuild the current image (otherwise rebuilt automatically
|
|
when Dockerfile or docker-entrypoint.sh content changes).
|
|
on its own, builds and exits.
|
|
--no-cache pair with --build to also bust docker's layer cache;
|
|
useful when an apt mirror or base image changed.
|
|
--shell drop into an interactive bash inside the container.
|
|
requires a TTY.
|
|
--clean prune orphaned pullfrog-docker:* images and node_modules
|
|
volumes whose hash doesn't match the current Dockerfile.
|
|
--doctor print version info for tools inside the container (node,
|
|
pnpm, gh, jq, git, python3, ssh, …). useful for diagnosing
|
|
"works in CI fails locally" or vice versa.
|
|
-h, --help show this message.
|
|
|
|
Pass-through:
|
|
Anything after the first positional argument (or after a literal \`--\`)
|
|
goes to the inner script verbatim. so \`pnpm docker test/run.ts --build\`
|
|
passes \`--build\` to test/run.ts, not to docker.
|
|
|
|
Examples:
|
|
pnpm docker play.ts
|
|
pnpm docker play.ts --raw '{"prompt":"hi"}'
|
|
pnpm docker test/run.ts smoke
|
|
pnpm docker --shell
|
|
pnpm docker --build # build image, then exit
|
|
pnpm docker --build --no-cache # rebuild from scratch
|
|
pnpm docker --clean # reclaim disk from old image hashes
|
|
pnpm docker --doctor # fidelity audit
|
|
`);
|
|
}
|
|
|
|
function ensureDocker(): void {
|
|
if (platform() === "win32") {
|
|
fail("pnpm docker is not supported on native windows. use wsl2.");
|
|
}
|
|
const probe = spawnSync("docker", ["info"], { stdio: "ignore" });
|
|
if (probe.status !== 0) {
|
|
fail("docker is not running. start docker desktop and retry.");
|
|
}
|
|
}
|
|
|
|
function fail(msg: string): never {
|
|
process.stderr.write(`error: ${msg}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
type ImageRef = { tag: string; volumeName: string };
|
|
|
|
function imageRefFor(ctx: { dockerfile: string; entrypoint: string }): ImageRef {
|
|
const hash = createHash("sha256")
|
|
.update(readFileSync(ctx.dockerfile))
|
|
.update(readFileSync(ctx.entrypoint))
|
|
.digest("hex")
|
|
.slice(0, 12);
|
|
return {
|
|
tag: `pullfrog-docker:${hash}`,
|
|
// version the volume by image hash so a stale node_modules cache from
|
|
// an old image (e.g. different node major) can't poison a new image.
|
|
volumeName: `pullfrog-docker-node-modules-${hash}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* remove pullfrog-docker:* images and pullfrog-docker-node-modules-* volumes
|
|
* whose hash doesn't match the current Dockerfile + entrypoint. each
|
|
* Dockerfile/entrypoint edit creates a fresh hash and orphans the prior
|
|
* pair; without periodic cleanup these accumulate (~600MB image + ~200MB
|
|
* node_modules each).
|
|
*/
|
|
function cleanOrphans(currentRef: ImageRef): void {
|
|
const imgList = spawnSync("docker", ["image", "ls", "--format", "{{.Repository}}:{{.Tag}}"], {
|
|
encoding: "utf8",
|
|
});
|
|
const images = (imgList.stdout ?? "")
|
|
.split("\n")
|
|
.filter((s) => s.startsWith("pullfrog-docker:") && s !== currentRef.tag);
|
|
if (images.length > 0) {
|
|
process.stderr.write(`» removing ${images.length} orphan image(s): ${images.join(", ")}\n`);
|
|
spawnSync("docker", ["image", "rm", "-f", ...images], { stdio: "inherit" });
|
|
}
|
|
const volList = spawnSync("docker", ["volume", "ls", "-q"], { encoding: "utf8" });
|
|
const volumes = (volList.stdout ?? "")
|
|
.split("\n")
|
|
.filter((s) => s.startsWith("pullfrog-docker-node-modules-") && s !== currentRef.volumeName);
|
|
if (volumes.length > 0) {
|
|
process.stderr.write(`» removing ${volumes.length} orphan volume(s): ${volumes.join(", ")}\n`);
|
|
spawnSync("docker", ["volume", "rm", ...volumes], { stdio: "inherit" });
|
|
}
|
|
if (images.length === 0 && volumes.length === 0) {
|
|
process.stderr.write("» no orphans to clean (all matching current image hash)\n");
|
|
}
|
|
}
|
|
|
|
function buildImageIfNeeded(ctx: {
|
|
ref: ImageRef;
|
|
force: boolean;
|
|
noCache: boolean;
|
|
dockerfile: string;
|
|
}): void {
|
|
if (!ctx.force) {
|
|
const inspect = spawnSync("docker", ["image", "inspect", ctx.ref.tag], { stdio: "ignore" });
|
|
if (inspect.status === 0) return;
|
|
}
|
|
process.stderr.write(
|
|
`» building ${ctx.ref.tag}${ctx.noCache ? " (--no-cache)" : ""} (one-time, ~30-60s)…\n`
|
|
);
|
|
const buildArgs = ["build", "-t", ctx.ref.tag, "-f", ctx.dockerfile];
|
|
if (ctx.noCache) buildArgs.push("--no-cache");
|
|
buildArgs.push(actionDir);
|
|
const build = spawnSync("docker", buildArgs, { stdio: "inherit" });
|
|
if (build.status !== 0) {
|
|
fail("image build failed");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* print versions of every tool we expect to be available, so contributors
|
|
* can sanity-check fidelity with the GHA `ubuntu-24.04` runner when a test
|
|
* passes locally but fails in CI (or vice versa).
|
|
*/
|
|
function runDoctor(ref: ImageRef): void {
|
|
// multi-line bash script; spawnSync passes the whole thing as one argv
|
|
// entry so there's no nested-shell quoting to worry about, and `do` is
|
|
// not followed by a stray semicolon.
|
|
const script = `set +e
|
|
echo '--- container ---'
|
|
grep -E '^(NAME|VERSION)=' /etc/os-release
|
|
echo "arch=$(uname -m)"
|
|
|
|
echo
|
|
echo '--- runtimes ---'
|
|
echo "node $(node --version)"
|
|
if cd /app/action 2>/dev/null; then
|
|
echo "pnpm $(corepack pnpm --version) (corepack-resolved from packageManager)"
|
|
else
|
|
echo "pnpm $(pnpm --version) (system fallback — /app/action not mounted?)"
|
|
fi
|
|
python3 --version
|
|
|
|
echo
|
|
echo '--- tools ---'
|
|
for t in gh jq git ssh curl wget tar gzip xz unzip file make gcc g++ sudo unshare awk sed grep find xargs; do
|
|
if ! command -v "$t" >/dev/null 2>&1; then
|
|
printf ' %-10s MISSING\\n' "$t"
|
|
continue
|
|
fi
|
|
case "$t" in
|
|
ssh|unzip) v=$("$t" -V 2>&1 | head -1) ;;
|
|
*) v=$("$t" --version 2>&1 | head -1) ;;
|
|
esac
|
|
printf ' %-10s %s\\n' "$t" "$v"
|
|
done
|
|
|
|
echo
|
|
echo '--- env ---'
|
|
echo "CI=$CI HOME=$HOME TMPDIR=$TMPDIR"
|
|
echo "doctor runs as: $(whoami) (uid=$(id -u) gid=$(id -g))"
|
|
echo "tests run as: testuser (uid remapped to host uid at entrypoint)"
|
|
echo "host.docker.internal -> $(getent hosts host.docker.internal | awk '{print $1}' || echo UNRESOLVED)"
|
|
`;
|
|
const result = spawnSync(
|
|
"docker",
|
|
[
|
|
"run",
|
|
"--rm",
|
|
"-v",
|
|
`${actionDir}:/app/action:cached`,
|
|
"--add-host=host.docker.internal:host-gateway",
|
|
"--entrypoint",
|
|
"/bin/bash",
|
|
ref.tag,
|
|
"-c",
|
|
script,
|
|
],
|
|
{ stdio: "inherit" }
|
|
);
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
|
|
function volumeExists(name: string): boolean {
|
|
return spawnSync("docker", ["volume", "inspect", name], { stdio: "ignore" }).status === 0;
|
|
}
|
|
|
|
function initVolumeOwnership(ctx: { ref: ImageRef; uid: number; gid: number }): void {
|
|
// a fresh named volume is owned by root; chown once on creation. on warm
|
|
// runs the volume already has the right ownership and `docker run … chown`
|
|
// is sub-second pure overhead — skip it.
|
|
if (volumeExists(ctx.ref.volumeName)) return;
|
|
spawnSync(
|
|
"docker",
|
|
[
|
|
"run",
|
|
"--rm",
|
|
"--entrypoint",
|
|
"chown",
|
|
"-v",
|
|
`${ctx.ref.volumeName}:/app/action/node_modules`,
|
|
ctx.ref.tag,
|
|
"-R",
|
|
`${ctx.uid}:${ctx.gid}`,
|
|
"/app/action/node_modules",
|
|
],
|
|
{ stdio: "ignore" }
|
|
);
|
|
}
|
|
|
|
type EnvParts = { envFile: string; multiLineFlags: string[] };
|
|
|
|
function buildEnvParts(env: NodeJS.ProcessEnv): EnvParts {
|
|
const dir = join(tmpdir(), "pullfrog-docker");
|
|
mkdirSync(dir, { recursive: true });
|
|
const envFile = join(dir, `env-${process.pid}-${Date.now()}.list`);
|
|
const lines: string[] = [];
|
|
const multiLineFlags: string[] = [];
|
|
for (const key of Object.keys(env)) {
|
|
if (HOST_ONLY_VARS.has(key)) continue;
|
|
const value = env[key];
|
|
if (value === undefined) continue;
|
|
// docker --env-file is line-oriented and does not support multi-line
|
|
// values. fall back to -e for those (RSA keys, multi-line PEMs, etc.).
|
|
if (value.includes("\n") || value.includes("\r")) {
|
|
multiLineFlags.push("-e", `${key}=${value}`);
|
|
} else {
|
|
lines.push(`${key}=${value}`);
|
|
}
|
|
}
|
|
writeFileSync(envFile, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
return { envFile, multiLineFlags };
|
|
}
|
|
|
|
function buildSshFlags(home: string | undefined): string[] {
|
|
const flags: string[] = [];
|
|
if (!home) return flags;
|
|
if (platform() === "darwin") {
|
|
const knownHosts = join(home, ".ssh", "known_hosts");
|
|
if (existsSync(knownHosts)) {
|
|
flags.push("-v", `${knownHosts}:/tmp/home/.ssh/known_hosts:ro`);
|
|
}
|
|
flags.push(
|
|
"-v",
|
|
"/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock",
|
|
"-e",
|
|
"SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock"
|
|
);
|
|
} else {
|
|
const sshDir = join(home, ".ssh");
|
|
if (existsSync(sshDir)) {
|
|
flags.push("-v", `${sshDir}:/tmp/.ssh-host:ro`);
|
|
}
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
function main(): void {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
ensureDocker();
|
|
|
|
const dockerfile = join(actionDir, "Dockerfile");
|
|
const entrypoint = join(actionDir, "docker-entrypoint.sh");
|
|
const ref = imageRefFor({ dockerfile, entrypoint });
|
|
|
|
if (args.clean) {
|
|
cleanOrphans(ref);
|
|
if (!args.shell && !args.doctor && args.passthrough.length === 0 && !args.forceBuild) {
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
buildImageIfNeeded({ ref, force: args.forceBuild, noCache: args.noCache, dockerfile });
|
|
|
|
if (args.doctor) {
|
|
runDoctor(ref);
|
|
// runDoctor exits; unreachable.
|
|
}
|
|
|
|
// standalone `--build`: image's done, nothing to run.
|
|
if (!args.shell && args.passthrough.length === 0) {
|
|
if (!args.forceBuild) {
|
|
showHelp();
|
|
process.exit(1);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
// node sets isTTY to `true` for a terminal stdin, `undefined` otherwise
|
|
// (never `false`). check truthiness, not equality.
|
|
if (args.shell && !process.stdin.isTTY) {
|
|
fail("--shell needs a TTY (stdin is not a terminal). run from an interactive shell.");
|
|
}
|
|
|
|
const uid = process.getuid?.() ?? 1000;
|
|
const gid = process.getgid?.() ?? 1000;
|
|
initVolumeOwnership({ ref, uid, gid });
|
|
|
|
const envParts = buildEnvParts(process.env);
|
|
const sshFlags = buildSshFlags(process.env.HOME);
|
|
|
|
const runArgs: string[] = [
|
|
"run",
|
|
"--rm",
|
|
// `--init` uses tini as PID 1, which forwards signals (SIGINT/SIGTERM)
|
|
// to our entrypoint and reaps zombies. Without it, bash-as-PID-1
|
|
// swallows Ctrl-C during the pre-exec warmup phase.
|
|
"--init",
|
|
args.shell ? "-it" : "-t",
|
|
"--privileged",
|
|
// make the host reachable from inside the container at a stable name
|
|
// (macOS Docker Desktop bakes this in; the flag makes Linux match,
|
|
// matters when scripts hit local dev servers like API_URL=
|
|
// http://host.docker.internal:3100).
|
|
"--add-host=host.docker.internal:host-gateway",
|
|
"-v",
|
|
`${actionDir}:/app/action:cached`,
|
|
"-v",
|
|
`${ref.volumeName}:/app/action/node_modules`,
|
|
"-w",
|
|
"/app/action",
|
|
"--env-file",
|
|
envParts.envFile,
|
|
"-e",
|
|
`HOST_UID=${uid}`,
|
|
"-e",
|
|
`HOST_GID=${gid}`,
|
|
...envParts.multiLineFlags,
|
|
...sshFlags,
|
|
ref.tag,
|
|
];
|
|
|
|
if (args.shell) {
|
|
runArgs.push("--shell");
|
|
} else {
|
|
// resolve script paths relative to actionDir (matches `pnpm -C action`
|
|
// mental model). absolute paths and bare flags pass through unchanged.
|
|
const [script, ...rest] = args.passthrough;
|
|
if (script === undefined) {
|
|
fail("internal: passthrough empty");
|
|
}
|
|
runArgs.push("node", script, ...rest);
|
|
}
|
|
|
|
let exitCode = 1;
|
|
try {
|
|
const result = spawnSync("docker", runArgs, { stdio: "inherit" });
|
|
exitCode = result.status ?? 1;
|
|
} finally {
|
|
try {
|
|
unlinkSync(envParts.envFile);
|
|
} catch {
|
|
// best-effort; tmpdir is GC'd by the OS regardless.
|
|
}
|
|
}
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
const isDirectExecution = process.argv[1]
|
|
? import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
: false;
|
|
|
|
if (isDirectExecution) {
|
|
main();
|
|
}
|