agents/opencodePlugin.ts
Rama main · réplica de lectura del repositorio original.
/**
* Source for the opencode plugin we drop into the per-run tmpdir at
* `<XDG_CONFIG_HOME>/opencode/plugin/pullfrog-events.ts`. The harness already
* redirects `XDG_CONFIG_HOME` to `ctx.tmpdir/.config` (see `opencode.ts`
* `homeEnv`), so opencode's auto-discovery scans the tmpdir, never the user's
* working tree. opencode's `Global.Path.config` resolves to
* `path.join(xdgConfig, "opencode")` and the config layer auto-discovers
* plugins from every directory in its scan list — including
* `Global.Path.config` — by globbing `{plugin,plugins}/*.{ts,js}` via
* `ConfigPlugin.load(dir)`.
*
* We MUST NOT write into the user's repo working tree. The repo is a checkout
* the agent operates on; only the agent's own tools (gated by
* `OPENCODE_PERMISSION`) may modify it. The whole reason we redirect HOME and
* XDG_CONFIG_HOME is so harness-side files (config, plugins, scratch state)
* land in the tmpdir.
*
* Why the events plugin exists: opencode's `task` tool runs subagents
* in-process and the CLI's `cli/cmd/run.ts` event loop filters
* `part.sessionID !== sessionID`, so subagent-internal `message.part.updated`
* events are silently discarded before reaching our parent NDJSON stream.
* plugins, by contrast, receive EVERY bus event via `bus.subscribeAll()`
* regardless of session.
*
* The events plugin re-emits every relevant bus event onto opencode's stdout
* as a single JSON line wrapped in a sentinel envelope. our `runOpenCode`
* parser recognises the envelope, unpacks it, and routes the inner part
* through the existing handlers with a per-session label from `SessionLabeler`
* so each subagent's tool calls / text appear inline alongside the
* orchestrator's.
*
* The subagent gate (the `tool.execute.before` hook that hard-blocks
* state-mutating MCP tool calls from a subagent session) lives in a SEPARATE
* plugin — `buildOpencodeSubagentGateSource` below — because it's the
* load-bearing security fence and must ship into both opencode harnesses,
* whereas this events re-emitter is only needed by the legacy `opencode.ts`
* CLI-parsing path (the active `opencode.ts` reads subagent events directly
* off the SDK event stream, so it installs ONLY the gate plugin). Deny-list
* source of truth: `action/agents/subagentToolGates.ts`.
*
* Dumb plugin / smart parent split: the events plugin emits every part for
* every session. the parent dedupes against the orchestrator's own session id
* (which it already knows from the `init` event). this keeps the plugin trivial
* and keeps the per-session attribution logic on the parent side where the
* SessionLabeler already lives.
*
* Event-name prefixing: the wrapped event-type sentinel is
* `pullfrog_bus_event` — picked to be unmistakably ours so a future opencode
* release that introduces a coincidentally-named event type won't collide.
*/
import { join } from "node:path";
import { formatMcpToolRef } from "../external.ts";
import { OPENAI_COMPATIBLE_PROVIDER } from "../models.ts";
import { openCodeHomeEnv } from "../utils/openCodeModels.ts";
import { resolveRepoRoot } from "../utils/sandbox.ts";
import { HOOK_ASSET_TMPDIR_RELPATHS } from "./nativeFsDenies.ts";
import { type AgentRunContext, DEADLINE_RESERVE_MS } from "./shared.ts";
export const PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event" as const;
export const PULLFROG_OPENCODE_PLUGIN_FILENAME = "pullfrog-events.ts" as const;
export const PULLFROG_OPENCODE_GATE_PLUGIN_FILENAME = "pullfrog-subagent-gate.ts" as const;
/**
* Source written verbatim to `<XDG_CONFIG_HOME>/opencode/plugin/pullfrog-events.ts`.
*
* - Structural typing only (no runtime import of `@opencode-ai/plugin`):
* opencode installs that dep into the directory containing the plugin
* alongside discovery, but a) the dep isn't required for the structural
* shape we use, and b) keeping zero imports avoids any module-resolution
* coupling to opencode's plugin-loader internals across versions.
* - default export is the plugin factory (opencode's plugin loader accepts
* default exports as the server entrypoint).
* - we only forward `message.part.updated`. that's where the user-visible
* subagent activity (tool calls, text, step transitions) lives. add more
* event types here if the parent needs them.
* - JSON.stringify+single write keeps the line atomic up to PIPE_BUF (4KB on
* Linux). longer parts may interleave with concurrent stdout writers; the
* parser tolerates non-JSON lines (logs them at debug) so a torn line is a
* missed event, not a crash.
*/
export const PULLFROG_OPENCODE_PLUGIN_SOURCE = `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
// surfaces opencode subagent activity that the CLI's run-loop discards. see
// action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
// inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
// the user's working tree. the subagent gate ships as a separate plugin
// (pullfrog-subagent-gate.ts).
const PULLFROG_BUS_EVENT_TYPE = ${JSON.stringify(PULLFROG_BUS_EVENT_TYPE)};
// orchestratorSessionID locks to the first sessionID we observe on the bus
// (\`message.part.updated\`). opencode's run command creates exactly one
// top-level session before any subagent is dispatched, and the orchestrator
// emits at least one event before any subagent does — so first-seen-wins
// captures the orchestrator; every subagent part has a non-matching
// sessionID.
let orchestratorSessionID: string | undefined;
function isOrchestratorTaskDispatch(part: {
type?: string;
tool?: string;
state?: { status?: string };
} | undefined): boolean {
if (!part || part.type !== "tool") return false;
if (part.tool !== "task") return false;
// only forward at status="running" (not "pending"). at pending the
// state.input is still {} — the orchestrator has emitted the part shell
// but the LLM hasn't filled in description/subagent_type/prompt yet. by
// running, input is populated and recordTaskDispatch can derive the lens
// label correctly.
return part.state?.status === "running";
}
export default async function pullfrogEventsPlugin() {
return {
event: async (input: {
event: {
type: string;
properties?: {
part?: {
sessionID?: string;
type?: string;
tool?: string;
state?: { status?: string };
};
};
};
}) => {
const event = input?.event;
if (!event || typeof event !== "object") return;
if (event.type !== "message.part.updated") return;
const part = event.properties?.part;
const sessionID = part?.sessionID;
if (typeof sessionID !== "string" || sessionID.length === 0) return;
if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
if (sessionID === orchestratorSessionID) {
// skip orchestrator events EXCEPT early task dispatches.
if (!isOrchestratorTaskDispatch(part)) return;
}
try {
const line = JSON.stringify({
type: PULLFROG_BUS_EVENT_TYPE,
bus_event: event,
});
process.stdout.write(line + "\\n");
} catch {
// a circular reference or BigInt etc. would throw; swallow rather
// than letting a single bad event take down the plugin.
}
},
};
}
`;
/**
* Standalone subagent gate plugin written to
* `<XDG_CONFIG_HOME>/opencode/plugin/pullfrog-subagent-gate.ts`. Installed by
* BOTH opencode harnesses (the legacy `opencode.ts` and the active in-process
* `opencode.ts`) — the gate is the load-bearing security fence, so it ships
* independently of the events re-emitter above (which v2 doesn't need).
*
* Hard-blocks state-mutating MCP tool calls originating from a subagent
* session via `tool.execute.before`, complementing the runtime backstops from
* PR #796 (action/mcp/checkout.ts, action/mcp/git.ts). Deny-list source of
* truth: `action/agents/subagentToolGates.ts`.
*
* Also hard-blocks the native `bash` tool for EVERY session. bash stays in the
* tool list because Zen's free tier rejects a request without it (2026-09-17);
* `permission.bash: "ask"` is the fail-closed backstop should this plugin ever
* not load. the refusal names the replacement, because a bare one left models
* retrying bash until the 1h cap (#1476). see wiki/security.md.
*/
export function buildOpencodeSubagentGateSource(ctx: AgentRunContext): string {
const bashRefusal = nativeShellRefusal(ctx, "bash");
return `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
// hard-blocks state-mutating MCP tool calls from opencode subagents (PR
// following pullfrog/app#796 — see action/agents/subagentToolGates.ts). see
// action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
// inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
// the user's working tree.
${realPathGateSource(ctx)}
${reserveGateSource(ctx)}
// embedded at template-render time so the plugin source has no project imports.
// the denied set is derived from each tool's \`mutates\` flag — see
// action/agents/subagentToolGates.ts.
const SUBAGENT_DENIED_TOOLS = new Set(${JSON.stringify(ctx.subagentDeniedTools)});
// opencode presents MCP tools as \`pullfrog_<bare>\`; strip that to compare
// against the canonical bare names in SUBAGENT_DENIED_TOOLS. native tools
// (read, write, bash, task, etc.) lack the prefix and never match the deny
// list anyway.
function stripPullfrogPrefix(toolName: string): string {
if (toolName.startsWith("pullfrog_")) return toolName.slice("pullfrog_".length);
return toolName;
}
// orchestratorSessionID locks to the first sessionID we observe on
// \`tool.execute.before\`. the orchestrator must invoke a tool (e.g. its
// \`task\` dispatch) before any subagent session exists, so first-seen-wins
// always captures the orchestrator; every subagent call therefore has a
// non-matching sessionID and is subject to the deny check.
let orchestratorSessionID: string | undefined;
export default async function pullfrogSubagentGatePlugin() {
return {
"tool.execute.before": async (
input: { tool?: string; sessionID?: string; callID?: string },
output: { args?: Record<string, unknown> }
) => {
// input: { tool, sessionID, callID } — confirmed against
// anomalyco/opencode packages/opencode/src/session/tools.ts which
// calls plugin.trigger("tool.execute.before", { tool, sessionID,
// callID }, { args }). throwing here surfaces as a tool error to the
// model (Effect's run.promise propagates the rejection through the
// AI SDK's tool-runtime), so the subagent sees a denial and can
// pick a different action.
const sessionID = typeof input?.sessionID === "string" ? input.sessionID : "";
const tool = typeof input?.tool === "string" ? input.tool : "";
// above the sessionID check: the bash block must never depend on a
// session id being present.
if (tool === "bash") {
throw new Error(${JSON.stringify(bashRefusal)});
}
refuseEscape(tool, output?.args);
if (tool === "task" && Date.now() >= RESERVE_STARTS_AT) throw new Error(RESERVE_REFUSAL);
if (!sessionID || !tool) return;
if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
if (sessionID === orchestratorSessionID) return;
const bare = stripPullfrogPrefix(tool);
if (!SUBAGENT_DENIED_TOOLS.has(bare)) return;
throw new Error("subagent attempted to call denied tool '" + bare + "'. " + ${JSON.stringify(SUBAGENT_DENIAL)});
},
};
}
`;
}
/**
* `external_directory` and the `edit` denies judge the path a tool names, and V1 and V2 both check it
* lexically. the tool then follows any symlink in it, out here where the sandbox's read-only binds do
* not exist, so a link made from the shell would carry a read or a write past all of them. this judges
* where the path really leads, against the same roots. input it cannot read is refused, since V2 may
* still repair it into a call.
*/
function realPathGateSource(ctx: AgentRunContext): string {
const repoRoot = resolveRepoRoot();
const roots = [repoRoot, process.cwd(), "/tmp", ctx.tmpdir];
const sealed = HOOK_ASSET_TMPDIR_RELPATHS.map((rel) => join(ctx.tmpdir, rel));
const toolOutput = join(openCodeHomeEnv(ctx.tmpdir).XDG_DATA_HOME, "opencode", "tool-output");
return String.raw`
import { readlinkSync } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
function realPath(path: string): string {
const parent = dirname(path);
if (parent === path) return path;
const real = join(realPath(parent), basename(path));
let target: string;
try {
target = readlinkSync(real);
} catch {
return real;
}
return realPath(resolve(dirname(real), target));
}
const ROOTS = ${JSON.stringify(roots)}.map(realPath);
const SEALED = ${JSON.stringify(sealed)}.map(realPath);
const GIT_DIR = realPath(${JSON.stringify(join(repoRoot, ".git"))});
const TOOL_OUTPUT = realPath(${JSON.stringify(toolOutput)});
const isInside = (root: string, path: string) => path === root || path.startsWith(root + "/");
function refuseEscape(tool: string, args: any) {
const patch = tool === "apply_patch" || tool === "patch";
const writes = patch || tool === "write" || tool === "edit";
const search = tool === "grep" || tool === "glob";
if (!writes && !search && tool !== "read") return;
// a search with no path runs in the working directory
if (search && typeof args === "object" && args?.path === undefined) return;
const named = patch ? args?.patchText : (args?.filePath ?? args?.path);
if (typeof named !== "string") throw new Error(tool + " needs its arguments as an object.");
const paths = patch
? [...named.matchAll(/^\s*\*\*\*\s*(?:Add File|Update File|Delete File|Move to):\s*(.+?)\s*$/gm)].map((match) => match[1])
: [named];
for (const path of paths) {
const absolute = resolve(${JSON.stringify(process.cwd())}, path.replace(/^~(?=\/|$)/, process.env.HOME ?? "~"));
const real = realPath(absolute);
const allowed =
(ROOTS.some((root) => isInside(root, real)) || (!writes && isInside(TOOL_OUTPUT, real))) &&
!SEALED.some((root) => isInside(root, real)) &&
!(writes && isInside(GIT_DIR, real));
if (!allowed) throw new Error(path + " is outside what " + tool + " may reach, once symbolic links are followed.");
}
}
`;
}
// with `shell: disabled` the MCP shell tool is not registered either (mcp/server.ts)
function nativeShellRefusal(ctx: AgentRunContext, tool: "bash" | "shell"): string {
return ctx.payload.shell === "disabled"
? "shell execution is disabled for this run and no shell tool exists — use read, grep and glob instead."
: `native ${tool} is disabled in Pullfrog runs — call ${formatMcpToolRef("opencode", "shell")} with the same command instead.`;
}
const SUBAGENT_DENIAL =
"subagents share the orchestrator's in-process working tree and toolState; " +
"state-changing MCP tools (checkout_pr, push_branch, create_pull_request_review, " +
"report_progress, etc.) are reserved for the orchestrator. " +
"report findings back to the orchestrator and let it perform the mutation.";
/** inside the deadline reserve only submitting is left, so a new subagent is refused (#1666). */
function reserveGateSource(ctx: AgentRunContext): string {
const startsAt =
ctx.deadlineAt === undefined ? Number.POSITIVE_INFINITY : ctx.deadlineAt - DEADLINE_RESERVE_MS;
const refusal =
"the run is within 5 minutes of its time limit, so no subagent may start. Submit your work now, with what you have.";
return `const RESERVE_STARTS_AT = ${startsAt};
const RESERVE_REFUSAL = ${JSON.stringify(refusal)};`;
}
/** the id the V2 gate registers under, which the harness requires `plugin.list` to report active. */
export const OPENCODE_V2_GATE_PLUGIN_ID = "pullfrog.gate";
/**
* The same gate in OpenCode V2's plugin API: a structural `{ id, setup }` default
* export whose `execute.before` hook runs ahead of permission evaluation. V2 skips
* a V1-shaped plugin with only a WARN, which would drop both fences silently, so
* the V2 driver refuses to run unless `plugin.list` reports this one active.
* Written to the same sealed path as the V1 gate.
*/
export function buildOpencode2GateSource(ctx: AgentRunContext): string {
return `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
// blocks native shell, and state-mutating MCP tool calls from subagents. see
// action/agents/opencodePlugin.ts in pullfrog/app. lives inside the per-run
// tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside the user's working tree.
${realPathGateSource(ctx)}
${reserveGateSource(ctx)}
const SUBAGENT_DENIED_TOOLS = new Set(${JSON.stringify(ctx.subagentDeniedTools)});
// the orchestrator calls a tool (its \`subagent\` dispatch) before any subagent
// session exists, so the first session seen here is always the orchestrator's.
let orchestratorSessionID: string | undefined;
export default {
id: ${JSON.stringify(OPENCODE_V2_GATE_PLUGIN_ID)},
async setup(ctx: {
tool: { hook: (name: string, fn: (event: { tool?: string; sessionID?: string; input?: unknown }) => void) => Promise<unknown> };
session: { hook: (name: string, fn: (event: { options: { maxTokens?: number } }) => void, options: { providerID: string }) => Promise<unknown> };
}) {
// V2 fails the run on a summary cut off at the output limit, and a custom endpoint's limit can be
// smaller than a summary. the limit is optional on that API, so a compaction just doesn't send it.
await ctx.session.hook("compaction", (event) => { delete event.options.maxTokens; }, { providerID: ${JSON.stringify(OPENAI_COMPATIBLE_PROVIDER)} });
await ctx.tool.hook("execute.before", (event) => {
const tool = typeof event?.tool === "string" ? event.tool : "";
const sessionID = typeof event?.sessionID === "string" ? event.sessionID : "";
if (tool === "shell") throw new Error(${JSON.stringify(nativeShellRefusal(ctx, "shell"))});
refuseEscape(tool, event.input);
if (tool === "subagent" && Date.now() >= RESERVE_STARTS_AT) throw new Error(RESERVE_REFUSAL);
if (!sessionID || !tool) return;
if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
if (sessionID === orchestratorSessionID) return;
const bare = tool.startsWith("pullfrog_") ? tool.slice("pullfrog_".length) : tool;
if (!SUBAGENT_DENIED_TOOLS.has(bare)) return;
throw new Error("subagent attempted to call denied tool '" + bare + "'. " + ${JSON.stringify(SUBAGENT_DENIAL)});
});
},
};
`;
}