Ctrl K
ComunidadGuías…

agents/opencodePlugin.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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)});
    });
  },
};
`;
}