agents/opencode.ts
Rama main · réplica de lectura del repositorio original.
/**
* OpenCode agent — in-process harness, driving `opencode serve` over loopback
* HTTP. Two OpenCode generations sit behind it: V2 (`@opencode/cli`) by default,
* and V1 (`opencode-ai` >=1.14.x, the Effect-ts CLI rewrite) when
* `opencodeGeneration()` picks it. This file is everything the two share — the
* run's setup, the server process, the watchdog, the salvage and the post-run
* loop; `opencode1.ts` and `opencode2.ts` are each generation's driver (session
* setup, event stream, one prompt turn), and `opencodeSession.ts` is the
* bookkeeping both drivers feed. See wiki/opencode-v2.md.
*
* Naming note: this was `opencode_v2.ts`, where "v2" meant the second
* generation of PULLFROG's harness — never a version of OpenCode itself. The
* original CLI-subprocess harness sat beside it as `opencode.ts` for a while,
* imported by nothing, which is exactly how a fix lands in the file that
* doesn't run. It is deleted; this is the only OpenCode harness.
*
* Architecture:
*
* 1. Spawn ONE `opencode serve --port <p>` subprocess per Pullfrog run via
* `node:child_process.spawn` directly (NOT our `spawn()` wrapper — see
* `bootOpencodeServer` for why: long-lived stdio streaming, manual
* activity gating against the SDK event loop, killGroup teardown).
* 2. Talk to it over loopback HTTP via the generation's typed client — no
* `Server.Default()` embed, no `createOpencode()` SDK lifecycle (would
* re-wrap our subprocess).
* 3. Create ONE session up front (the driver's `open`).
* 4. Subscribe to events once and pump them through a single per-run
* handler set for live logging + activity tracking + subagent labeling.
* 5. Run the initial prompt via the driver's `prompt`. Every post-run gate
* retry AND the reflection turn re-enter the same session via another
* prompt. Warm MCP, warm plugins, warm provider connections, same context
* window — no `--continue` subprocess respawn.
* 6. Close the server in a finally.
*
* What that replaces (vs the pre-migration v2 harness):
* - The per-run `opencode run --format json --print-logs --thinking` CLI
* subprocess that emitted NDJSON envelopes.
* - The `runOpenCode(... args: [...baseArgs, "--continue", c.prompt] ...)`
* resume callback that booted a SECOND opencode process (fresh MCP,
* fresh plugins, cold cache) for each gate retry / reflection turn.
* - The `opencodePlugin.ts` bus-event re-emitter — we subscribe to the
* global event stream now, so subagent events arrive naturally without
* a stdout sentinel envelope.
*
* What stays identical:
* - native bash blocked (listed as "ask", thrown on by the gate plugin)
* - OPENCODE_PERMISSION filesystem sandbox — deny-all + allow /tmp
* - MCP Pullfrog server injected via `mcp.<name> = { type: "remote", url }`
* - ASKPASS for git auth
* - codex auth materialization + post-hook writeback
* - reviewfrog subagent config / model derivation
* - bedrock model prefix routing
* - skills install
* - todo tracker / onToolUse forwarding
*/
import { spawn as nodeSpawn } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { totalmem } from "node:os";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { setTimeout as sleep } from "node:timers/promises";
import { Agent, fetch as undiciFetch } from "undici";
import { pullfrogMcpName } from "../external.ts";
import { BEDROCK_MODEL_ID_ENV } from "../models.ts";
import {
AGENT_ACTIVITY_TIMEOUT_MS,
AGENT_FIRST_EVENT_TIMEOUT_MS,
isDebugEnabled,
watchdogBudgetMs,
} from "../utils/activity.ts";
import type { AgentDiagnostic } from "../utils/agentHangReport.ts";
import { isApiKeyAuthError } from "../utils/apiKeys.ts";
import { log } from "../utils/cli.ts";
import { installCodexAuth, installXaiAuth } from "../utils/codexHome.ts";
import type { OAuthWriteback } from "../utils/codexRefreshDetect.ts";
import { OAUTH_WRITEBACK_STATE } from "../utils/oauthWriteback.ts";
import { openCodeHomeEnv, syncOpenCodeCredentials } from "../utils/openCodeModels.ts";
import { findProviderErrorMatch } from "../utils/providerErrors.ts";
import { resolveRunEffort } from "../utils/runEffort.ts";
import { saveSecretState } from "../utils/secretCommands.ts";
import { addSkill, installBundledSkills } from "../utils/skills.ts";
import { isTrackedChild, trackChild, untrackChild } from "../utils/subprocess.ts";
import { getDevDependencyVersion } from "../utils/version.ts";
import { resolveVertexOpenCodeModel } from "../utils/vertex.ts";
import { dirtyTrackedPaths, restoreDirtiedSince } from "../utils/worktree.ts";
import {
AGENT_BIN_DIR_RELPATH,
GIT_NATIVE_READ_DENY_OPENCODE,
GIT_NATIVE_WRITE_DENY_OPENCODE,
OPENCODE_BIN_DIR_RELPATH,
} from "./nativeFsDenies.ts";
import { opencode1 } from "./opencode1.ts";
import { opencode2 } from "./opencode2.ts";
import { opencodeGeneration } from "./opencodeGeneration.ts";
import { PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME } from "./opencodePlugin.ts";
import {
type OpencodeSession,
type RunState,
type Sandbox,
type ServerHandle,
withServerStderr,
} from "./opencodeSession.ts";
import {
autoSelectModel,
azureProvider,
buildReviewerAgentConfig,
installOpencodeCli,
type OpenCodeConfig,
openAICompatibleProvider,
openRouterDataCollection,
openRouterProvider,
providerGatewayOverride,
} from "./opencodeShared.ts";
import { runPostRunRetryLoop } from "./postRun.ts";
import { REVIEWER_AGENT_NAME } from "./reviewer.ts";
import {
type AgentResult,
type AgentRunContext,
agent,
DEADLINE_RESERVE_MS,
MAX_STDERR_LINES,
mergeAgentUsage,
} from "./shared.ts";
// ── config ─────────────────────────────────────────────────────────────────────
/** the run's config, in V1's shape: V2 normalizes it in memory, so one builder serves both. */
function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): OpenCodeConfig {
const config: OpenCodeConfig = {
permission: {
// listed but never run: Zen's free tier 403s a tool list missing `bash`,
// `glob`, `grep` or `read` (2026-09-17), so the gate plugin throws on
// every bash call and "ask" (no responder in serve mode) fails closed.
// never switch one of those four to "deny". see wiki/security.md.
bash: "ask",
edit: "allow",
read: "allow",
webfetch: "allow",
external_directory: "allow",
skill: "allow",
},
// per-step snapshots run an unbounded synchronous diff over everything `checkout_pr`
// changed, pinning the server for minutes (#1486). nothing reads them. V2 maps it to `snapshots`.
snapshot: false,
mcp: {
// tool timeout (vs the MCP SDK's 60s default). `checkout_pr` runs a
// multi-minute `git fetch` on large repos (remotion); a 60s client abort
// surfaces as `MCP error -32001` and used to push the agent toward
// deleting live git locks (the corruption in #860/#864 — the dangerous
// `rm` guidance is gone, but the spurious aborts shouldn't happen either).
// MUST exceed `checkout_pr`'s own 600s `timeoutMs`: at 300s a checkout
// that legitimately took 300-600s was GUARANTEED to abort client-side
// while the server kept working, which is half of #1171. the tool now
// enforces its own deadline and always answers, so waiting for that
// answer beats aborting into a retry.
[pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl, timeout: 660_000 },
},
agent: (() => {
const cfg = buildReviewerAgentConfig(model);
const reviewerModel = (cfg[REVIEWER_AGENT_NAME] as { model?: string })?.model ?? "(inherit)";
log.info(`» subagent models: reviewfrog=${reviewerModel}`);
return cfg;
})(),
// openrouter routing, not reasoning (every model's effort rides the
// per-prompt `variant`): the run's data-collection policy, kimi pinned
// away from Enforcer-less providers, the Muse Spark contributor tier
// opened to training hosts. see opencodeShared.ts.
provider: {
openrouter: openRouterProvider(openRouterDataCollection(ctx)),
...openAICompatibleProvider(model),
...azureProvider(model),
...providerGatewayOverride(model),
},
};
if (model) {
config.model = model;
const slashIndex = model.indexOf("/");
if (slashIndex > 0) {
config.enabled_providers = [model.slice(0, slashIndex).toLowerCase()];
}
}
return config;
}
// ── server boot ────────────────────────────────────────────────────────────────
/**
* Spawn `<cliPath> serve --port 0 --hostname 127.0.0.1` and wait for the
* "server listening on http://..." stdout line (V1 prefixes it "opencode ").
*
* Direct node:child_process.spawn instead of our `spawn()` wrapper because
* the wrapper's contract is "Promise<SpawnResult> that resolves on exit" —
* we need a handle that stays alive across many session.prompt() calls.
* We still register with `trackChild()` so Ctrl-C kills the server alongside
* everything else.
*/
function bootOpencodeServer(params: {
cliPath: string;
env: NodeJS.ProcessEnv;
cwd: string;
}): Promise<ServerHandle> {
// --print-logs routes opencode's own logger to stderr (it otherwise writes
// only to a log file inside the throwaway per-run HOME, which nothing reads).
// that logger is the ONLY place the server records the cause behind a 500
// `UnknownError` + `ref` — without it `recentStderr` is empty and an invalid
// repo config is undiagnosable. ERROR level keeps the ring buffer signal-dense.
//
// a debug run raises that to INFO, the only way to see a stall that produces
// no error at all: opencode logs the provider request as `stream` at INFO
// (`session/llm.ts`), so at ERROR a silent provider leaves the buffer empty
// with no evidence the request was even issued. INFO and not DEBUG
// deliberately — the `stream` line carries only provider/model/session
// metadata, while DEBUG can carry payloads, and these logs are readable by
// anyone with repo read. see wiki/opencode-silent-stall.md.
const logLevel = isDebugEnabled() ? "INFO" : "ERROR";
if (logLevel !== "ERROR") log.info(`» opencode log level: ${logLevel} (debug run)`);
// V1's CLI takes the level upper-case and V2's lower-case; each refuses the other's spelling.
const level = opencodeGeneration() === 2 ? logLevel.toLowerCase() : logLevel;
const proc = nodeSpawn(
params.cliPath,
["serve", "--port", "0", "--hostname", "127.0.0.1", "--print-logs", "--log-level", level],
{
cwd: params.cwd,
env: params.env,
stdio: ["ignore", "pipe", "pipe"],
// detached + killGroup so SIGKILL nukes the whole tree: node_modules/
// opencode-ai/bin/opencode is a Node shim that spawnSync's the native
// binary; without process-group kill the native binary is reparented
// to PID 1 and never dies. mirrors the same fix in runOpenCode's
// original spawn().
detached: true,
}
);
trackChild({ child: proc, killGroup: true });
const recentStderr: string[] = [];
proc.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString().trim();
if (!text) return;
recentStderr.push(text);
if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
log.debug(`[opencode serve] ${text}`);
});
let closed = false;
let exitCause: string | undefined;
const close = async (): Promise<void> => {
if (closed) return;
closed = true;
untrackChild(proc);
// `killed` only records a signal WE sent. a server that already died has
// fired its `close` event, and waiting for it again hangs the run until
// the outer watchdog kills it.
if (proc.pid && proc.exitCode === null && proc.signalCode === null) {
try {
process.kill(-proc.pid, "SIGTERM");
} catch {
proc.kill("SIGTERM");
}
// give the server 2s to exit cleanly, then SIGKILL the group.
await new Promise<void>((resolve) => {
const escalator = setTimeout(() => {
if (!proc.killed) {
try {
process.kill(-proc.pid!, "SIGKILL");
} catch {
proc.kill("SIGKILL");
}
}
}, 2000);
proc.once("close", () => {
clearTimeout(escalator);
resolve();
});
});
}
};
return new Promise<ServerHandle>((resolve, reject) => {
// serve logs `[opencode ]server listening on http://<host>:<port>` once
// bound. parse it out, then resolve. drain remaining stdout to debug.
let buffer = "";
let resolved = false;
const onStdout = (chunk: Buffer) => {
const text = chunk.toString();
buffer += text;
if (!resolved) {
const match = buffer.match(/server listening on (https?:\/\/[^\s]+)/);
if (match?.[1]) {
resolved = true;
log.info(`» opencode server up: ${match[1]}`);
resolve({ baseUrl: match[1], proc, close, recentStderr, exitCause: () => exitCause });
// keep draining for debug visibility after handover.
}
}
// log any stdout line that's not the listening sentinel at debug level
// so a noisy serve startup is visible without polluting info logs.
const lines = text.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.includes("server listening")) {
log.debug(`[opencode serve] ${trimmed}`);
}
}
};
proc.stdout?.on("data", onStdout);
proc.once("error", (err) => {
if (!resolved) {
reject(new Error(`failed to spawn opencode serve: ${err.message}`));
}
});
proc.once("exit", (code, signal) => {
// untracked means we killed it: `close()`, or `killTrackedChildren()` on a timeout or cancel.
if (!resolved || !isTrackedChild(proc)) return;
exitCause = describeServerExit(code, signal);
log.warning(withServerStderr(`» ${exitCause}`, recentStderr));
});
proc.once("close", (code, signal) => {
if (!resolved) {
const tail = recentStderr.slice(-5).join("\n");
reject(
new Error(
`opencode serve exited before ready (code=${code} signal=${signal})${tail ? `\n${tail}` : ""}`
)
);
}
});
// safety: if the listening line never arrives, bail after 30s.
const bootTimeout = setTimeout(() => {
if (!resolved) {
resolved = true;
const tail = recentStderr.slice(-5).join("\n");
void close();
reject(
new Error(
`timed out after 30s waiting for opencode serve to bind${tail ? `\n${tail}` : ""}`
)
);
}
}, 30_000);
bootTimeout.unref?.();
});
}
/**
* Name why the server died under a live run. Our own kills untrack it first, so
* a SIGKILL arriving here is almost always the kernel's OOM killer, and the
* memory the job may use is what the customer needs to act on it.
*/
function describeServerExit(code: number | null, signal: NodeJS.Signals | null): string {
if (signal !== "SIGKILL") return `opencode server exited mid-run (${signal ?? `code ${code}`})`;
// a cgroup limit (k8s, ARC) is what the OOM killer enforces; with none it reads 0 or UINT64_MAX.
const limit = process.constrainedMemory();
const memoryGb = ((limit > 0 ? Math.min(limit, totalmem()) : totalmem()) / 1024 ** 3).toFixed(1);
return `opencode server was killed (SIGKILL) mid-run, usually by the kernel OOM killer — this runner has ${memoryGb} GB of memory`;
}
/** server stderr → provider-error attribution (same pattern as the old CLI subprocess harness's onStderr handler). */
function watchProviderErrors(server: ServerHandle, diagnostic: AgentDiagnostic): void {
server.proc.stderr?.on("data", (chunk: Buffer) => {
for (const line of chunk.toString().split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = findProviderErrorMatch(trimmed);
if (match) {
diagnostic.lastProviderError = match.label;
log.info(`» provider error detected (${match.label}): ${match.excerpt}`);
}
}
});
}
// ── inner activity timer ───────────────────────────────────────────────────────
/**
* Sent when the watchdog cut a turn off mid-flight. Deliberately terse and only
* ever billed on the salvage path, never on a healthy run.
*/
const WATCHDOG_SALVAGE_PROMPT =
"Your previous turn was cut off mid-response by a provider stall. Nothing you had not already submitted through a tool was saved. Continue from where you stopped and submit your work now — do not restart your analysis.";
/** OpenRouter intermittently refuses a key it minted seconds earlier (#1665), so wait that out. */
const PROXY_KEY_RETRY_DELAYS_MS = [15_000, 30_000];
const PROXY_KEY_RETRY_PROMPT =
"Your previous attempt failed before the model could respond. Start on the task above now.";
/**
* Start an event-silence watchdog. The outer process-level activity timer
* (main.ts `createProcessOutputActivityTimeout`) watches `process.stdout.write`
* which our harness log lines drive — but it doesn't see SSE event silence
* when the harness is itself quiet. This inner timer specifically watches
* `state.lastEventAt` and fires `onActivityTimeout` so main.ts can tear down
* the MCP server early, mirroring the per-spawn watchdog in `subprocess.ts`.
*
* `state.lastEventAt` is refreshed only on meaningful progress (the model's
* own token/tool events), so any prolonged gap with no progress advances the
* clock — including a long in-flight tool call. the budget is the same flat
* idle timeout as the outer watchdog, sized to exceed the worst-case
* legitimate silent tool window (#760), so a real tool can't trip it; a
* genuinely stalled provider or a hung tool does, at the flat budget.
*
* Before the first model event the tighter {@link AGENT_FIRST_EVENT_TIMEOUT_MS}
* applies instead: the flat budget exists to protect an in-flight tool call, and
* no tool has been called yet.
*
* The interval spans the whole run, so it also watches the gap BETWEEN turns.
* A fire there arms the safety net but never stands it down — the next turn's
* `armForTurn()` clears the latch, so `firedThisTurn()` reads false at that
* turn's end. Unreachable today: the only between-turn work is `getGitStatus`
* (10s cap), `isSummaryUnchanged`, `getUnsubmittedReview` and `adoptPostedReview`
* (10s cap), and the stop hook
* is commented out (`postRun.ts`, #714), so the budget cannot elapse there.
* Re-enabling the stop hook would make it reachable.
*/
function startInnerActivityWatchdog(params: {
state: RunState;
timeoutMs: number;
abortTurn: () => void;
onActivityTimeout: AgentRunContext["onActivityTimeout"];
/** runs on every tick, whatever the turn's own state. */
onTick: () => void;
}): { stop: () => void; armForTurn: () => void; firedThisTurn: () => boolean } {
let fired = false;
let everFired = false;
const id = setInterval(() => {
params.onTick();
if (fired) return;
const idleMs = performance.now() - params.state.lastEventAt;
const compiledMs = params.state.diagnostic.sawModelOutput
? params.timeoutMs
: AGENT_FIRST_EVENT_TIMEOUT_MS;
// the e2e override is SPENT ON THE FIRST FIRE. its whole point is to abort
// one turn on demand and then watch the salvage; clamping the salvage just
// as hard aborts that too, and both turns draw first-token latency from the
// same distribution — so a value low enough to trip turn 1 trips the salvage
// at the same rate, and one high enough for the salvage never fires at all.
// that is the one setting which cannot produce a LANDED salvage.
const budgetMs = everFired
? compiledMs
: watchdogBudgetMs(
compiledMs,
params.state.diagnostic.sawModelOutput
? "PULLFROG_E2E_ACTIVITY_TIMEOUT_MS"
: "PULLFROG_E2E_FIRST_EVENT_TIMEOUT_MS"
);
if (idleMs <= budgetMs) return;
fired = true;
everFired = true;
const idleSec = Math.round(idleMs / 1000);
params.state.diagnostic.idleSec = idleSec;
log.info(
params.state.diagnostic.sawModelOutput
? `» no opencode events for ${idleSec}s — aborting in-flight prompt and notifying harness`
: `» no opencode events for ${idleSec}s — the provider never returned a first token; aborting in-flight prompt and notifying harness`
);
params.abortTurn();
// the safety net is armed here and NOT after the turn returns, because an
// abort opencode ignores would otherwise hang with nothing watching. the
// harness stands it down via `onTurnRecovered` the moment the turn does
// come back, so it only ever guards the abort→return window. see #1085.
try {
params.onActivityTimeout?.();
} catch (err) {
log.debug(
`inner activity callback threw: ${err instanceof Error ? err.message : String(err)}`
);
}
}, 5_000);
id.unref?.();
return {
stop: () => clearInterval(id),
/**
* Start a turn's idle budget from now. The clock must be reset with the
* latch: `lastEventAt` only advances on model output, so a turn following a
* stall would inherit the full stalled interval and be aborted on the very
* next 5s tick — killing the salvage before the provider could answer. It
* is the right semantics for an ordinary resume too, whose budget should
* not be pre-spent by the post-run gate checks that ran between turns.
*/
armForTurn: () => {
fired = false;
params.state.lastEventAt = performance.now();
},
firedThisTurn: () => fired,
};
}
/** a `reviewfrog` lens's budget: leaves 4,608 of 4,615 finished lenses untouched (#1524). */
const LENS_BUDGET_MS = 20 * 60_000;
function noteOrchestrator(session: OpencodeSession, text: string): void {
session.note(text).catch((err) => log.debug(`» could not note the orchestrator: ${String(err)}`));
}
/**
* Interrupt a lens past its budget, or any subagent still running inside the run's last minutes. The
* `task` call waiting on it then fails, so the orchestrator submits with what it has instead of idling
* until the run timer fails everything. The clock is the child session's, not the call's, because a
* resume by session id would otherwise start a fresh budget. A bare cancellation reads as a transient
* failure, and got lenses re-dispatched until the run timed out (#1666), so the orchestrator is told why.
*/
function enforceLensBudgets(ctx: AgentRunContext, session: OpencodeSession): void {
// read per tick, not at import: `unsafe_overrides` lands in the env after modules load
const budgetMs = watchdogBudgetMs(LENS_BUDGET_MS, "PULLFROG_E2E_LENS_BUDGET_MS");
const nearDeadline =
ctx.deadlineAt !== undefined && ctx.deadlineAt - Date.now() < DEADLINE_RESERVE_MS;
if (!nearDeadline) {
session.state.reserveNoteArmed = true;
} else if (session.state.reserveNoteArmed) {
session.state.reserveNoteArmed = false;
noteOrchestrator(
session,
"This run is within 5 minutes of its time limit. Stop exploring and submit your review or push your work now, with what you have."
);
}
for (const dispatch of session.state.taskDispatchByCallID.values()) {
if (!dispatch.child) continue;
const elapsedMs = performance.now() - dispatch.child.startedAt;
const overBudget = dispatch.subagentType === REVIEWER_AGENT_NAME && elapsedMs > budgetMs;
if (!overBudget && !nearDeadline) continue;
const why = overBudget ? "over budget" : "the run is near its time limit";
// re-sent every tick until the `task` call ends: V1's abort reports success even before the child starts
session.interrupt(dispatch.child.sessionID).then(
() => {
if (dispatch.interrupted) return;
dispatch.interrupted = true;
log.info(
`» interrupted subagent ${dispatch.label} after ${Math.round(elapsedMs / 1000)}s: ${why}`
);
if (!overBudget) return;
noteOrchestrator(
session,
`Pullfrog stopped subagent ${dispatch.label} for exceeding its time budget. This is not a transient failure, so do not dispatch it again: continue with the findings you have.`
);
},
(err) => log.debug(`» could not interrupt subagent ${dispatch.label}: ${String(err)}`)
);
}
}
// ── agent entrypoint ───────────────────────────────────────────────────────────
export const opencode = agent({
name: "opencode",
install: installOpencodeCli,
run: async (ctx) => {
const driver = opencodeGeneration() === 2 ? opencode2 : opencode1;
const cliPath = await installOpencodeCli();
const rawModel = ctx.payload.proxyModel ?? ctx.resolvedModel ?? autoSelectModel();
// rawModel is the authoritative "what actually ran" — including the
// auto-select pick that main.ts cannot know (it's opencode-specific:
// folding it into `resolvedModel` earlier would mis-route `resolveAgent`).
// overwrite the pre-agent best-effort so `toolState.model` (footers + the
// end-of-run PATCH that persists `WorkflowRun.model`) reflects the real model.
if (rawModel) ctx.toolState.model = rawModel;
// resolve effort against the model that actually runs, not `ctx.resolvedModel`
// — an auto-selected run has none, and would otherwise lose its setting.
const effort = resolveRunEffort({ ...ctx, resolvedModel: rawModel });
// the startup block prints before auto-select happens, so it can only say
// "pending" for those runs. this is the first point the real level is known.
if (!ctx.resolvedModel && !ctx.payload.proxyModel) {
log.info(`» effort: ${effort.rung ?? "n/a (model has no effort control)"}`);
}
// bedrock route: opencode's `amazon-bedrock` provider expects the model
// in `amazon-bedrock/<bedrock-id>` form. detect via env-var sentinel
// (same pattern as claude.ts). do not gate on Anthropic-vs-other — that
// discriminant lives in resolveAgent.
const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
const isBedrockRoute =
rawModel !== undefined && bedrockModelId !== undefined && bedrockModelId === rawModel;
const vertexModel = resolveVertexOpenCodeModel(rawModel);
const model = vertexModel ?? (isBedrockRoute ? `amazon-bedrock/${rawModel}` : rawModel);
// materialize CODEX_AUTH_JSON into the run's subscription data dir so
// OpenCode's CodexAuthPlugin picks it up. see action/utils/codexHome.ts and
// wiki/codex-auth.md.
const codexAuth = installCodexAuth();
// same for GROK_AUTH_JSON -> opencode's native XaiAuthPlugin. an account
// can hold both chains; the writer merges rather than overwrites.
const xaiAuth = installXaiAuth();
// after both installs: XDG_DATA_HOME names the dir they wrote into.
const homeEnv = openCodeHomeEnv(ctx.tmpdir);
// install the subagent gate into opencode's auto-discovered plugin dir
// (under the tmpdir-redirected XDG_CONFIG_HOME). v2 installs ONLY the gate,
// not the events re-emitter — it reads subagent events off the SDK stream,
// so the re-emitter would be dead weight. see action/agents/opencodePlugin.ts.
const opencodePluginDir = join(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
mkdirSync(opencodePluginDir, { recursive: true });
writeFileSync(
join(opencodePluginDir, PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME),
driver.gatePlugin(ctx)
);
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
addSkill({
ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`,
skill: "agent-browser",
env: homeEnv,
agent: "opencode",
});
installBundledSkills({ home: homeEnv.HOME });
// external_directory gates ALL native filesystem tools (Read, Write, Edit, Glob, Grep, etc.)
// for paths outside the project root. last-match-wins: deny everything, then allow /tmp.
// codex auth lives at /var/lib/pullfrog/run-*/opencode/auth.json in CI (see codexHome.ts),
// which is outside /tmp/* — deny-default protects it from native FS tools.
//
// read + edit rules deny git surfaces INSIDE the project root, where
// external_directory short-circuits (Instance.containsPath). edit denies
// ALL of .git (blanket write — nothing legit writes .git via native tools;
// MCP git tools run in the action process, outside this gate); read denies
// only .git/config (narrow — broad .git read-blocks break orientation reads
// like .git/HEAD, and ASKPASS keeps live tokens out of .git/config). `*` is
// recursive in opencode's Wildcard dialect. grep/glob match the search
// pattern not a filepath, so they can't be path-denied (documented in
// wiki/security.md). canonical surfaces: action/agents/nativeFsDenies.ts.
// the plugin dir sits under /tmp (the per-run HOME), which the /tmp allow
// would otherwise expose to native FS tools. deny it back AFTER that allow
// (last-match-wins) so the agent cannot rewrite a pullfrog plugin to capture
// the env it loads with — the OpenCode side of the hook seal. opencode's own
// plugin loader is unaffected (it does not go through this tool gate). the
// MCP shell is sealed separately by the read-only bind in action/mcp/shell.ts.
const sandbox: Sandbox = {
external_directory: {
"*": "deny",
"/tmp/*": "allow",
// the run's own files follow $TMPDIR, often $RUNNER_TEMP on self-hosted runners (#1515).
// never allow $TMPDIR itself: $RUNNER_TEMP holds the runner's GITHUB_ENV/GITHUB_PATH files.
[`${ctx.tmpdir}/*`]: "allow",
// where a truncated tool result's full text is saved. V1 appends this allow itself; V2 does not.
[join(homeEnv.XDG_DATA_HOME, "opencode", "tool-output", "*")]: "allow",
[opencodePluginDir]: "deny",
[`${opencodePluginDir}/*`]: "deny",
// on the agent's PATH, so its own edit tool must not plant there either
...Object.fromEntries(
[AGENT_BIN_DIR_RELPATH, OPENCODE_BIN_DIR_RELPATH].flatMap((rel) => {
const dir = join(ctx.tmpdir, rel);
return [
[dir, "deny"],
[`${dir}/*`, "deny"],
];
})
),
},
read: { "*": "allow", ...GIT_NATIVE_READ_DENY_OPENCODE },
edit: { "*": "allow", ...GIT_NATIVE_WRITE_DENY_OPENCODE },
};
const repoDir = process.cwd();
// opencode-ai >=1.14 resolves the session's `directory` from process.env.PWD
// first (cli/cmd/run.ts:282 → Filesystem.resolve(PWD ?? cwd)). The server
// does the same per-request via the x-opencode-directory header, but we
// also pass PWD on the spawn env so any in-server tool that re-resolves
// cwd locally lands in repoDir.
const env: NodeJS.ProcessEnv & { OPENCODE_DB: string } = {
...process.env,
...homeEnv,
PWD: repoDir,
...driver.env({
config: buildSecurityConfig(ctx, model),
sandbox,
home: homeEnv.HOME,
configHome: homeEnv.XDG_CONFIG_HOME,
repoDir,
}),
GOOGLE_GENERATIVE_AI_API_KEY:
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
};
// opencode 1.18 gates a code-mode `execute` tool — a script interpreter —
// behind either of these. the spawn env inherits the workflow's `env:` block,
// so leaving them set would let a repo-level variable hand the agent shell
// execution that sidesteps the bash block. shell goes through pullfrog_shell,
// always. see wiki/sandbox-v2.md.
delete env.OPENCODE_EXPERIMENTAL;
delete env.OPENCODE_EXPERIMENTAL_CODE_MODE;
// both chains rotate mid-run inside opencode, so each needs a post-hook
// writeback entry. the API key is dropped only for the provider whose
// subscription is driving the run — an OPENAI_API_KEY outranks the Codex
// OAuth chain, and XAI_API_KEY likewise outranks the Grok one.
const writebacks: OAuthWriteback[] = [];
if (codexAuth) {
delete env.OPENAI_API_KEY;
writebacks.push({
secretName: "CODEX_AUTH_JSON",
provider: "openai",
authPath: codexAuth.authPath,
originalRefresh: codexAuth.originalRefresh,
originalIdToken: codexAuth.originalIdToken,
});
}
if (xaiAuth) {
delete env.XAI_API_KEY;
writebacks.push({
secretName: "GROK_AUTH_JSON",
provider: "xai",
authPath: xaiAuth.authPath,
originalRefresh: xaiAuth.originalRefresh,
});
}
if (writebacks.length > 0) {
saveSecretState(
OAUTH_WRITEBACK_STATE,
JSON.stringify({ apiToken: ctx.apiToken, entries: writebacks })
);
}
// the credential pool may have swapped a chain since main.ts's capture imported it.
syncOpenCodeCredentials({ cliPath, env });
log.debug(
`» starting Pullfrog (OpenCode V${opencodeGeneration()}, in-process SDK): ${cliPath}`
);
log.debug(`» working directory: ${repoDir}`);
// ── boot server + create session ─────────────────────────────────────────
// opencode's instance bootstrap writes into the project directory: it
// injects `$schema` into any `opencode.json` it loads, and installs
// `@opencode-ai/plugin` into `.opencode/` when the repo ships project
// plugins. a repo that TRACKS either file goes dirty, and since the writes
// land after prep's restore window nothing owned them — `checkout_pr` then
// refused for the rest of the run (#1133). the binary exposes no
// project-plugin-root override, so snapshot here and restore below.
const preBootDirty = dirtyTrackedPaths();
const server = await bootOpencodeServer({ cliPath, env, cwd: repoDir });
// the SDK's bundled fetch tries to disable per-request timeouts via the
// bun-only `req.timeout = false` no-op, which does nothing under node/undici
// — so undici's default 300s headers/body timeout aborts any turn that
// streams for >5min as `TypeError: fetch failed`. wire an unbounded undici
// dispatcher through a custom fetch (createOpencodeClient's `fetch` override
// bypasses the SDK's own fetch) so a long turn isn't capped client-side.
// the inner activity watchdog below — not undici — is what bounds true stalls.
const dispatcher = new Agent({ headersTimeout: 0, bodyTimeout: 0, connectTimeout: 0 });
// forward the request through undici's own dispatcher-aware fetch (Agent and
// fetch from the same package, so `dispatcher` typechecks with no cast). the
// SDK hands our override a global `Request`, which the esbuild-bundled undici
// realm can't consume directly ("Failed to parse URL from [object Request]"),
// so we re-state its fields explicitly. `duplex: "half"` is required by the
// fetch spec whenever a stream body is sent. the caller's own signal goes
// through: a throwaway Request's follows it only until the Request is
// garbage-collected, which silently drops a watchdog abort of a long V2 wait.
const fetchWithoutTimeout: typeof fetch = (input, init) => {
const request = input instanceof Request ? input : new Request(input, init);
return undiciFetch(request.url, {
method: request.method,
headers: [...request.headers],
body: request.body,
duplex: "half",
signal: init?.signal ?? request.signal,
dispatcher,
});
};
try {
const diagnostic: AgentDiagnostic = {
label: "Pullfrog",
recentStderr: server.recentStderr,
lastProviderError: undefined,
idleSec: undefined,
sawModelOutput: false,
eventCount: 0,
};
watchProviderErrors(server, diagnostic);
const opened = await driver
.open({
ctx,
server,
sandbox,
env,
repoDir,
model,
rawModel,
effort,
fetch: fetchWithoutTimeout,
diagnostic,
})
.catch((err: unknown) => (err instanceof Error ? err : new Error(String(err))));
// config and plugins load lazily during instance bootstrap — on the first
// request, not at spawn — so this is the earliest point the startup writes
// above have all landed, and it is still ahead of the first agent turn.
restoreDirtiedSince({ before: preBootDirty, actor: "agent startup" });
if (opened instanceof Error) {
return {
success: false,
output: "",
error: withServerStderr(opened.message, server.recentStderr),
};
}
ctx.toolState.agentDiagnostic = diagnostic;
return await runTurns(ctx, opened);
} finally {
await server.close().catch((err) => {
log.debug(
`opencode server close failed: ${err instanceof Error ? err.message : String(err)}`
);
});
await dispatcher.close().catch(() => {});
}
},
});
/** the initial turn, the post-run gate retries and the reflection, all on one session. */
async function runTurns(ctx: AgentRunContext, session: OpencodeSession): Promise<AgentResult> {
const state = session.state;
// run-scoped: the outer `finally` binds to this, so it must OUTLIVE a
// stalled turn. before #1085 the watchdog aborted this one controller,
// which killed the event loop and left every subsequent `session.prompt`
// rejecting as already-aborted — so the resume loop below was structurally
// dead and 15-58min of work was discarded with no review.
const runController = new AbortController();
// per-turn: replaced for each prompt, and the only thing the watchdog
// aborts. `AbortSignal.any` keeps run-scoped cancellation reaching the
// in-flight turn.
let turnController = new AbortController();
const nextTurnSignal = () => {
turnController = new AbortController();
return AbortSignal.any([runController.signal, turnController.signal]);
};
const watchdog = startInnerActivityWatchdog({
state,
// model-stall budget: how long the orchestrator may stream NO progress
// (no token/tool event) before we tear the turn down. opencode's
// keepalive/lifecycle events keep the outer process-output monitor
// alive even while the model is silent, so this inner timer is the only
// stall detector for the SSE path. it shares the flat idle budget so
// a long synchronous tool call (no model event while it runs) can't
// false-positive it.
timeoutMs: AGENT_ACTIVITY_TIMEOUT_MS,
abortTurn: () => turnController.abort(),
onActivityTimeout: ctx.onActivityTimeout,
onTick: () => enforceLensBudgets(ctx, session),
});
// one salvage per run. the stall is provider-side, so a second attempt in
// the same run is unlikely to differ, and each one costs up to a full idle
// budget — the 1h cap still bounds the worst case either way.
let salvagesLeft = 1;
/**
* Run one prompt turn, and re-prompt once if the activity watchdog cut it
* off mid-flight. The turn's own controller is dead by then, so the retry
* gets a fresh one; the session, MCP dispatcher and provider sockets are
* all still live because only the turn was aborted.
*/
const runTurn = async (text: string): Promise<AgentResult> => {
const attempt = (prompt: string) => {
watchdog.armForTurn();
return runTurnGuarded(state, () => session.prompt(prompt, nextTurnSignal()));
};
/**
* Stand the safety net down iff this turn both tripped the watchdog and
* came back with work the run will act on. Applied to EVERY turn, the
* salvage included: a second fire arms a fresh net, and leaving that one
* up force-exits a run that has just recovered.
*
* A failure is left alone rather than actively re-armed. On the give-up
* returns that means the net stays up, which is intended — it is the
* only tight bound left on the shutdown path (`await eventLoopPromise`
* has hung before, #876). On the no-tools return the net is down unless
* the salvage tripped its own fire and re-armed it, and either state is
* right: the salvage returned, so the abort was provably honored, and an
* armed net is only ever the shutdown bound a failing return wants.
*/
const standDownIfRecovered = (turn: AgentResult): AgentResult => {
if (!watchdog.firedThisTurn() || !turn.success) return turn;
// this fire ended nothing, and no later turn need follow to clear it —
// the outer process-output timer can still terminalize the run, and
// would otherwise report this span instead of its own.
state.diagnostic.idleSec = undefined;
ctx.onTurnRecovered?.();
return turn;
};
const result = await attempt(text);
if (!watchdog.firedThisTurn()) return result;
// the watchdog fired but the turn landed anyway — it can beat the abort
// through the driver's post-turn usage round trip.
if (result.success) return standDownIfRecovered(result);
if (salvagesLeft <= 0) return result;
salvagesLeft--;
// about to run another turn, so this net has done its job.
ctx.onTurnRecovered?.();
log.info("» activity watchdog cut the turn off — re-prompting once on the same session");
const mcpCallsBefore = state.mcpToolCalls;
const salvaged = await attempt(WATCHDOG_SALVAGE_PROMPT);
// the aborted turn's tokens were really spent — returning only the
// salvage's usage would bill the customer for less than the run cost and
// under-report `WorkflowRun.inputTokens`. the post-run loop merges across
// its own resumes but sits above this, so the pair is merged here.
const usage = mergeAgentUsage(result.usage, salvaged.usage);
// a salvage that reached no Pullfrog tool left nothing behind. Review
// gates itself on `create_pull_request_review`, but Build/Fix/Plan have
// no terminal gate — so letting prose alone stand as success would post
// "sorry, I was cut off" as the run's answer and report a green run
// where the pre-salvage code reported the timeout. counting ANY tool is
// too weak for that: `read`/`grep` are orchestrator calls too, so a
// salvage that re-read one file and then apologised would have passed.
if (state.mcpToolCalls === mcpCallsBefore) {
log.info("» salvage turn reached no Pullfrog tool — keeping the activity-timeout failure");
return { ...result, usage };
}
return standDownIfRecovered({ ...salvaged, usage });
};
try {
let initial = await runTurn(ctx.instructions.full);
for (const delayMs of PROXY_KEY_RETRY_DELAYS_MS) {
const refused =
!initial.success &&
!!ctx.payload.proxyModel &&
!state.diagnostic.sawModelOutput &&
isApiKeyAuthError(initial.error ?? "");
if (!refused) break;
log.info(`» the provider refused this run's minted key — retrying in ${delayMs / 1000}s`);
await sleep(delayMs);
initial = await runTurn(PROXY_KEY_RETRY_PROMPT);
}
// post-run gate retry loop — every resume is another prompt against the
// same session, so MCP, plugins, provider sockets stay warm and the
// session's prompt cache survives.
const result = await runPostRunRetryLoop({
ctx,
initialResult: initial,
initialUsage: initial.usage,
resume: async (c) => runTurn(c.prompt),
});
// gate the todo-tracker flush on the post-run loop's final verdict
// (`result.success`), not the initial turn — otherwise a Review that
// exhausts the `unsubmittedReview` retry budget flips success to
// false but the tracker still flushes "completed" tasks to GitHub.
// mirrors the old `if (result.exitCode === 0)` discriminant.
if (result.success) {
await ctx.todoTracker?.flush();
} else {
ctx.todoTracker?.cancel();
}
return result;
} finally {
watchdog.stop();
runController.abort();
await session.close().catch(() => {});
}
}
/**
* Safety net around a single turn: convert any unexpected throw that escapes
* the driver's prompt into a `success: false` result so the post-run gate loop
* (which expects a result, not a rejection) can surface it through the generic
* renderer.
*
* Watchdog-fired aborts do NOT reach here — each driver owns the abort signal,
* catches the aborted request internally, and classifies it as an `activity
* timeout` error itself. This wrapper must not re-classify, since a stray
* post-prompt throw is not a hang.
*/
async function runTurnGuarded(
state: RunState,
fn: () => Promise<AgentResult>
): Promise<AgentResult> {
try {
return await fn();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.info(`» ${state.label} turn failed: ${errorMessage}`);
return {
success: false,
output: state.currentTurn?.finalText ?? "",
error: errorMessage,
};
}
}