Agent & model refactor (#478)

* agent & model refactor with ASKPASS git auth, UI restructure, clerk v7

Made-with: Cursor

* fix stale agent/effort refs, add tests for askpass + model resolution

- reviewCleanup.ts: payload.agent -> payload.model, remove effort
- selectMode.ts PlanEdit: remove delegation/subagent/effort references
- pullfrog.yml.ts: update env vars (drop GOOGLE_API_KEY/CURSOR_API_KEY,
  add GOOGLE_GENERATIVE_AI_API_KEY/XAI_API_KEY/MOONSHOT_API_KEY/OPENCODE_API_KEY)
- FlagsSettings/RepoInstructionsSection: remove stale effort/timeout copy
- new: gitAuthServer.test.ts (10 tests — lifecycle, token delivery, tamper detection, script gen)
- new: agent.test.ts (4 tests — default opentoad, AGENT_OVERRIDE, invalid override)
- new: models.test.ts (19 tests — parseModel, resolution, registry invariants)
- update models.dev snapshot

Made-with: Cursor

* fix changed-agents.sh to filter legacy agent files from CI matrix

legacy agent files (claude.ts, codex.ts, etc.) are @ts-nocheck and not
exported from index.ts. changed-agents.sh now reads index.ts imports to
build the active agent set and treats changes to inactive files as
non-agent changes (opentoad canary only).

Made-with: Cursor

* remove MCP file tools, old agent harnesses, and obsolete security tests

ASKPASS-based git auth makes the old MCP file tool security layer unnecessary:
- token never in subprocess env, so symlink/gitattributes/hook attacks can't exfiltrate it
- agents now use native file tools (OpenCode builtin read/edit)

deleted:
- action/mcp/file.ts (file_read, file_write, file_edit, file_delete, list_directory)
- action/mcp/index.ts (dead re-export)
- agent harnesses: claude.ts, codex.ts, cursor.ts, gemini.ts, opencode.ts
- opencode-runner.ts (inlined into opentoad.ts)
- security tests that validated MCP file tool restrictions
- commented-out three-step review flow (~300 lines)
- sanitizeSchema/wrapSchema dead code from mcp/shared.ts
- OPENCODE_MODEL_MINI/MAX env vars (effort-level model overrides removed)

updated test prompts to use generic file ops instead of MCP tool names.
restored pkg-json-scripts + requirements-txt-attack (test --ignore-scripts defense).

Made-with: Cursor

* bump actions/checkout v4 → v6 (node 24)

node 20 actions deprecated june 2, 2026.

Made-with: Cursor

* temporarily disable fail-fast on agnostic tests to debug checkout@v6

Made-with: Cursor

* re-enable fail-fast on agnostic tests

Made-with: Cursor

* fix test token mismatch: mint OIDC tokens scoped to target repo

CI tests override GITHUB_REPOSITORY to pullfrog/test-repo but inherit
the runner's GITHUB_TOKEN (scoped to pullfrog/app), causing 401s on
every run-context fetch. Clear GITHUB_TOKEN in the test subprocess so
ensureGitHubToken() mints a properly scoped token via OIDC.

Also centralizes the default GITHUB_REPOSITORY in runAgentStreaming
instead of repeating it in every test file, and fixes preview-cleanup
to remove workers from all queues (not just name-matching ones).

Made-with: Cursor

* fix ensureGitHubToken to try OIDC when app credentials are absent

ensureGitHubToken only attempted token minting when GITHUB_APP_ID and
GITHUB_PRIVATE_KEY were set. In CI, OIDC is available but app creds
aren't exposed — so the guard prevented minting entirely.

Made-with: Cursor

* dead code cleanup: remove remnants of deleted agents, file tools, effort system

remove unused @anthropic-ai/claude-agent-sdk and @openai/codex-sdk deps,
orphaned file-tool security tests, dead GEMINI_MODEL passthrough, stale
opencode-runner wiki refs, deleted test file references, and MCP file tool
docs. rename docs/effort → docs/models. fix vitest setup: move dotenv to
globalSetup (runs once before forks instead of per-file, 19s → 200ms).

Made-with: Cursor

* address review feedback: remove dead code, update stale references

- remove AGENT_OVERRIDE (only opentoad exists)
- remove shellToolName plumbing (always restricted shell)
- bump action version to 0.0.179
- remove CURSOR_API_KEY from all workflows/configs
- remove OPENCODE_MODEL_MINI/MAX from workflows/docs
- delete wiki/effort.md, rewrite docs/effort.mdx as "Models"
- rewrite wiki/modes.md: orchestrator/subagent → single agent
- simplify flag system: drop builtin flag extraction (debug, effort,
  timeout, agent), keep custom flag replacement only
- reserve all legacy flag names to prevent custom flag conflicts

Made-with: Cursor

* regenerate lockfile after removing claude-agent-sdk and codex-sdk

Made-with: Cursor

* fix import ordering, add lockfile check to pre-push hook

Made-with: Cursor

* remove dead debug payload field, stale packageExtensions

Made-with: Cursor

* merge proc-sandbox and token-exfil into a single test

proc-sandbox and token-exfil were duplicative — both tested that
SANDBOX_TEST_TOKEN couldn't be exfiltrated. consolidated into
token-exfil with shell:restricted (which actually exercises filterEnv)
and the /proc attack vector hints from proc-sandbox.

Made-with: Cursor

* fix wiki adversarial.md to match actual tokenExfil validator

Made-with: Cursor
This commit is contained in:
Colin McDonnell
2026-03-12 05:22:51 +00:00
committed by pullfrog[bot]
parent 5bcfae990a
commit 6d25adfd1a
111 changed files with 4196 additions and 10202 deletions
-60
View File
@@ -1,60 +0,0 @@
import { type } from "arktype";
import { ghPullfrogMcpName } from "../external.ts";
import { log } from "../utils/cli.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
import { createSubagentState, hasRunningSubagents, runSubagent } from "./subagent.ts";
export const AskQuestionParams = type({
question: type.string.describe(
"the question to answer about the codebase, architecture, or implementation details"
),
});
function buildQuestionPrompt(question: string): string {
return `Answer the following question by exploring the codebase using the available MCP tools (${ghPullfrogMcpName}/file_read, ${ghPullfrogMcpName}/list_directory, etc.).
Be thorough in your investigation but concise in your answer. Key facts only, no filler, no preamble.
Question: ${question}`;
}
export function AskQuestionTool(ctx: ToolContext) {
return tool({
name: "ask_question",
description:
"Ask a question about the codebase and get a concise answer from a lightweight research subagent. The intermediate exploration context stays in the subagent — only the concise answer returns to you.",
parameters: AskQuestionParams,
execute: execute(async (params) => {
if (hasRunningSubagents(ctx)) {
return { error: "cannot ask questions while subagents are running" };
}
const label = `ask-${params.question
.slice(0, 40)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")}`;
const subagent = createSubagentState({ ctx, mode: "ask_question", label });
// matched by delegateAskQuestion test validator — update tests if changed
log.info(`» ask_question "${label}": ${params.question.slice(0, 100)}`);
const result = await runSubagent({
ctx,
subagent,
effort: "mini",
instructions: buildQuestionPrompt(params.question),
});
log.info(`» ask_question completed (success=${result.success})`);
return {
success: result.success,
answer:
subagent.output ??
result.error ??
"no answer produced — the subagent may not have called set_output. check stdoutFile for details.",
stdoutFile: subagent.stdoutFilePath,
};
}),
});
}
+11 -14
View File
@@ -183,7 +183,7 @@ export async function checkoutPrBranch(
pullNumber: number,
params: CheckoutPrBranchParams
): Promise<CheckoutPrBranchResult> {
const { octokit, owner, name, gitToken, toolState, shell } = params;
const { octokit, owner, name, gitToken, toolState } = params;
log.info(`» checking out PR #${pullNumber}...`);
// fetch PR metadata
@@ -241,24 +241,22 @@ export async function checkoutPrBranch(
} else {
// fetch base branch so origin/<base> exists for diff operations
log.debug(`» fetching base branch (${baseBranch})...`);
$git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], {
await $git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], {
token: gitToken,
restricted: shell !== "enabled",
});
// checkout base branch first to avoid "refusing to fetch into current branch" error
// -B creates or resets the branch to match origin/baseBranch
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`], { log: false });
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
log.debug(`» fetching PR #${pullNumber} (${localBranch})...`);
$git("fetch", ["--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`], {
await $git("fetch", ["--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`], {
token: gitToken,
restricted: shell !== "enabled",
});
// checkout the branch
$("git", ["checkout", localBranch]);
$("git", ["checkout", localBranch], { log: false });
log.debug(`» checked out PR #${pullNumber}`);
}
@@ -266,9 +264,8 @@ export async function checkoutPrBranch(
// fetch if we skipped checkout (already on branch) - otherwise already fetched above
if (alreadyOnBranch) {
log.debug(`» fetching base branch (${baseBranch})...`);
$git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], {
await $git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], {
token: gitToken,
restricted: shell !== "enabled",
});
}
@@ -277,7 +274,7 @@ export async function checkoutPrBranch(
// fork remotes. This ensures fork PRs can push even when checkout_pr is called after setupGit.
if (isFork) {
const remoteName = `pr-${pullNumber}`;
// SECURITY: fork URL without token - auth is injected via GIT_CONFIG_PARAMETERS in $git()
// SECURITY: fork URL without token - auth is injected via GIT_ASKPASS in $git()
const forkUrl = `https://github.com/${headRepo.full_name}.git`;
// add fork as a named remote (suppress logging to avoid "error: remote already exists" spam)
@@ -291,9 +288,9 @@ export async function checkoutPrBranch(
}
// set branch push config so `git push` knows where to push
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName]);
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName], { log: false });
// set merge ref so git knows the remote branch name (may differ from local)
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`]);
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`], { log: false });
log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${headBranch}'`);
// warn if maintainer can't modify (push will likely fail)
@@ -305,8 +302,8 @@ export async function checkoutPrBranch(
}
} else {
// for same-repo PRs, push to origin
$("git", ["config", `branch.${localBranch}.pushRemote`, "origin"]);
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`]);
$("git", ["config", `branch.${localBranch}.pushRemote`, "origin"], { log: false });
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`], { log: false });
}
// update toolState
+8 -23
View File
@@ -1,9 +1,9 @@
import { type } from "arktype";
import type { Agent } from "../agents/index.ts";
import { apiFetch } from "../utils/apiFetch.ts";
import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts";
import { retry } from "../utils/retry.ts";
import type { ToolContext } from "./server.ts";
@@ -46,31 +46,24 @@ async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string):
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
interface BuildCommentFooterParams {
agent: Agent | undefined;
octokit?: OctokitWithPlugins | undefined;
customParts?: string[] | undefined;
}
async function buildCommentFooter({
agent,
octokit,
customParts,
}: BuildCommentFooterParams): Promise<string> {
async function buildCommentFooter(params: BuildCommentFooterParams): Promise<string> {
const repoContext = parseRepoContext();
const runId = process.env.GITHUB_RUN_ID
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
: undefined;
let jobId: string | undefined;
if (runId && octokit) {
if (runId && params.octokit) {
try {
// fetch jobs to get the job URL for deep linking
const { data: jobs } = await octokit.rest.actions.listJobsForWorkflowRun({
const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({
owner: repoContext.owner,
repo: repoContext.name,
run_id: runId,
});
// use the first job's ID available
jobId = jobs.jobs[0]?.id.toString();
} catch {
// fall back to computed URL from runId alone
@@ -79,17 +72,13 @@ async function buildCommentFooter({
const footerParams = {
triggeredBy: true,
agent: {
displayName: agent?.displayName || "Unknown agent",
url: agent?.url || "https://pullfrog.com",
},
workflowRun: runId
? { owner: repoContext.owner, repo: repoContext.name, runId, jobId }
: undefined,
};
if (customParts && customParts.length > 0) {
return buildPullfrogFooter({ ...footerParams, customParts });
if (params.customParts && params.customParts.length > 0) {
return buildPullfrogFooter({ ...footerParams, customParts: params.customParts });
}
return buildPullfrogFooter(footerParams);
}
@@ -105,13 +94,12 @@ function buildImplementPlanLink(
}
export interface AddFooterCtx {
agent?: Agent | undefined;
octokit?: OctokitWithPlugins | undefined;
}
export async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({ agent: ctx.agent, octokit: ctx.octokit });
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
const footer = await buildCommentFooter({ octokit: ctx.octokit });
return `${bodyWithoutFooter}${footer}`;
}
@@ -238,7 +226,6 @@ export async function reportProgress(
: undefined;
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({
agent: ctx.agent,
octokit: ctx.octokit,
customParts,
});
@@ -276,7 +263,6 @@ export async function reportProgress(
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({
agent: ctx.agent,
octokit: ctx.octokit,
customParts,
});
@@ -337,7 +323,6 @@ export async function reportProgress(
];
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({
agent: ctx.agent,
octokit: ctx.octokit,
customParts,
});
-118
View File
@@ -1,118 +0,0 @@
import { type } from "arktype";
import { Effort } from "../external.ts";
import { log } from "../utils/cli.ts";
import type { SubagentState, ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
import { createSubagentState, hasRunningSubagents, runSubagent } from "./subagent.ts";
const DelegateTask = type({
label: type.string.describe(
"short label identifying this task (e.g. 'frontend-review', 'schema-check'). returned in results for easy matching."
),
instructions: type.string.describe(
"the complete prompt for the subagent. the subagent receives ONLY this text (plus a system preamble) — include all context it needs (file paths, constraints, conventions, tool usage instructions). specify exactly what information to return. craft a focused, self-contained task description."
),
"effort?": Effort.describe(
'effort level for the subagent: "mini" (low-effort and fast, only for simple tasks), "auto" (medium-effort, good for typical tasks that don\'t require significant reasoning), or "max" (high-effort, good for PR reviews and complex coding tasks). defaults to "auto".'
),
});
export const DelegateParams = type({
tasks: DelegateTask.array()
.atLeastLength(1)
.describe(
"array of tasks to delegate. all tasks run as parallel subagents and results are returned together."
),
});
type DelegateTaskResult = {
label: string;
success: boolean;
effort: string;
summary: string;
stdoutFile: string;
error: string | undefined;
};
function buildTaskResult(
label: string,
effort: string,
subagent: SubagentState,
error: string | undefined
): DelegateTaskResult {
return {
label,
success: subagent.status === "completed",
effort,
summary:
subagent.output ??
error ??
"no output produced — the subagent may not have called set_output. check stdoutFile for full logs.",
stdoutFile: subagent.stdoutFilePath,
error,
};
}
export function DelegateTool(ctx: ToolContext) {
return tool({
name: "delegate",
description:
"Delegate research, local coding tasks, and codebase investigations to subagents. Accepts an array of tasks that run in parallel — use this to fan out work (e.g. reviewing different areas of a PR simultaneously). Each subagent receives ONLY the instructions you provide (plus a system preamble enforcing set_output). Use select_mode first to get guidance on how to craft instructions. Subagents have file operations, shell, read-only GitHub tools (PR/issue info, review comments, check suite logs), and upload_file. They have NO git/checkout tools (would conflict between parallel subagents), NO dependency tools, and NO GitHub-write tools (commenting, reviews, labels, issues). All state-mutating and user-facing operations are your responsibility as orchestrator.",
parameters: DelegateParams,
execute: execute(async (params) => {
if (ctx.toolState.selfSubagentId) {
return {
error:
"delegation is not available inside a subagent. you are already running as a delegated subagent. complete the task directly using the available tools.",
};
}
if (hasRunningSubagents(ctx)) {
return { error: "delegation is already in progress" };
}
const mode = ctx.toolState.selectedMode ?? "unknown";
if (!ctx.toolState.selectedMode) {
log.info(`» warning: delegating without calling select_mode first (mode=${mode})`);
}
// matched by delegate test validators — update tests if changed
const n = params.tasks.length;
log.info(
`» delegating ${n} task${n === 1 ? "" : "s"}${n > 1 ? " in parallel" : ""} (mode=${mode})`
);
const taskEntries = params.tasks.map((task) => {
const effort = task.effort ?? "auto";
const subagent = createSubagentState({ ctx, mode, label: task.label });
log.info(`» task "${task.label}" (effort=${effort})`);
return { task, effort, subagent };
});
const settled = await Promise.allSettled(
taskEntries.map((entry) =>
runSubagent({
ctx,
subagent: entry.subagent,
effort: entry.effort,
instructions: entry.task.instructions,
})
)
);
const results: DelegateTaskResult[] = taskEntries.map((entry, i) => {
const outcome = settled[i];
const error = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error;
const result = buildTaskResult(entry.task.label, entry.effort, entry.subagent, error);
const status = result.success ? "succeeded" : "failed";
log.box(result.summary, { title: `task "${entry.task.label}" ${status}` });
return result;
});
const succeeded = results.filter((r) => r.success).length;
log.info(`» delegation completed: ${succeeded}/${results.length} succeeded (mode=${mode})`);
return { mode, results };
}),
});
}
-270
View File
@@ -1,270 +0,0 @@
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { type } from "arktype";
import type { ShellPermission } from "../external.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const FileReadParams = type({
path: "string",
"offset?": "number",
"limit?": "number",
});
export const FileWriteParams = type({
path: "string",
content: "string",
});
export const FileEditParams = type({
path: "string",
old_string: "string",
new_string: "string",
"replace_all?": "boolean",
});
export const FileDeleteParams = type({
path: "string",
});
export const ListDirectoryParams = type({
path: "string",
});
// SECURITY: files that git interprets and can trigger code execution.
// .gitattributes can define filter drivers (clean/smudge) that execute arbitrary commands.
// .gitmodules can reference malicious submodule URLs that execute code on update.
// only blocked when shell is disabled — in restricted mode the agent already has shell
// and could write these files via shell, so blocking via MCP is redundant.
const GIT_INTERPRETED_FILES = [".gitattributes", ".gitmodules"];
// resolve and validate a read path. allows:
// 1. paths within the repo (with symlink protection to prevent malicious PR symlinks)
// 2. paths within PULLFROG_TEMP_DIR (tool result files: diffs, CI logs, review threads, etc.)
function resolveReadPath(filePath: string): string {
const cwd = realpathSync(process.cwd());
const resolved = resolve(cwd, filePath);
// allow reads from PULLFROG_TEMP_DIR (tool result files)
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (tempDir && (resolved === tempDir || resolved.startsWith(tempDir + "/"))) {
return resolved;
}
// allow reads from Cursor's project directory (internal agent coordination files)
const home = process.env.HOME;
if (home) {
const cursorProjectsDir = join(home, ".cursor", "projects");
if (resolved.startsWith(cursorProjectsDir + "/")) {
return resolved;
}
}
// allow reads from the repo with symlink protection.
// threat model: a malicious PR plants symlinks (e.g. `secrets -> /etc/shadow`).
// git materializes symlinks on linux, so after checkout the working tree contains
// live symlinks. realpathSync catches these and blocks the read.
if (existsSync(resolved)) {
const real = realpathSync(resolved);
if (real === cwd || real.startsWith(cwd + "/")) {
return real;
}
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
}
// path doesn't exist — check if it's within the repo
if (resolved === cwd || resolved.startsWith(cwd + "/")) {
return resolved;
}
throw new Error(`path must be within the repository or temp directory: ${filePath}`);
}
// resolve and validate a write path. enforces:
// - repo-scoping with symlink protection (when shell !== "enabled")
// - .git/ always blocked (defense-in-depth)
// - .gitattributes/.gitmodules blocked when shell === "disabled"
//
// when shell=enabled, repo-scoping is dropped — the agent can write anywhere via native
// shell, so restricting file_write to the repo would be security theater.
function resolveWritePath(filePath: string, shellPermission: ShellPermission): string {
const cwd = realpathSync(process.cwd());
const resolved = resolve(cwd, filePath);
// repo-scoping: enforced when agent doesn't have full shell
if (shellPermission !== "enabled") {
if (existsSync(resolved)) {
const real = realpathSync(resolved);
if (real !== cwd && !real.startsWith(cwd + "/")) {
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
}
} else {
// target doesn't exist yet — walk up to find the first existing ancestor
// and verify it resolves within the repo. prevents creating files through
// symlinked parent directories.
let ancestor = dirname(resolved);
while (!existsSync(ancestor)) {
const parent = dirname(ancestor);
if (parent === ancestor) break;
ancestor = parent;
}
if (existsSync(ancestor)) {
const realAncestor = realpathSync(ancestor);
if (realAncestor !== cwd && !realAncestor.startsWith(cwd + "/")) {
throw new Error(
`path must be within the repository (symlink escape blocked): ${filePath}`
);
}
}
if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
throw new Error(`path must be within the repository: ${filePath}`);
}
}
}
// .git always blocked anywhere in the path (defense-in-depth even with shell=enabled)
if (resolved.includes("/.git/") || resolved.endsWith("/.git")) {
throw new Error(`writing to .git is not allowed: ${filePath}`);
}
// git-interpreted files blocked anywhere in the path when shell is disabled
if (shellPermission === "disabled") {
const basename = resolved.split("/").pop() || "";
if (GIT_INTERPRETED_FILES.includes(basename)) {
throw new Error(
`writing to ${basename} is not allowed when shell is ${shellPermission} (can trigger code execution via git filter drivers): ${filePath}`
);
}
}
return resolved;
}
export function FileReadTool(_ctx: ToolContext) {
return tool({
name: "file_read",
description:
"Read a file. Path is relative to the repository root, or an absolute path " +
"to read tool result files (diffs, CI logs, etc.) from the temp directory.",
parameters: FileReadParams,
execute: execute(async (params) => {
const resolved = resolveReadPath(params.path);
const raw = readFileSync(resolved, "utf-8");
const lines = raw.split("\n");
const offset = params.offset;
const limit = params.limit;
if (offset === undefined && limit === undefined) {
return { content: raw };
}
// 1-indexed line numbers, clamp to valid range
const oneBasedOffset = offset ?? 1;
const start = Math.max(0, oneBasedOffset - 1);
const end = limit !== undefined ? Math.min(lines.length, start + limit) : lines.length;
const slice = lines.slice(start, end).join("\n");
return { content: slice };
}),
});
}
export function FileWriteTool(ctx: ToolContext) {
return tool({
name: "file_write",
description:
"Write content to a file. Path is relative to the repository root. " +
"Writes to .git/ are blocked. Creates parent directories if needed.",
parameters: FileWriteParams,
execute: execute(async (params) => {
const resolved = resolveWritePath(params.path, ctx.payload.shell);
const dir = dirname(resolved);
mkdirSync(dir, { recursive: true });
writeFileSync(resolved, params.content, "utf-8");
return { path: params.path, written: true };
}),
});
}
export function FileEditTool(ctx: ToolContext) {
return tool({
name: "file_edit",
description:
"Replace text in a file. old_string must match exactly (including whitespace and indentation). " +
"By default replaces a single unique occurrence — set replace_all to replace every occurrence. " +
"Path is relative to the repository root. Writes to .git/ are blocked.",
parameters: FileEditParams,
execute: execute(async (params) => {
if (params.old_string.length === 0) {
throw new Error("old_string must not be empty");
}
if (params.old_string === params.new_string) {
throw new Error("old_string and new_string are identical");
}
const resolved = resolveWritePath(params.path, ctx.payload.shell);
const content = readFileSync(resolved, "utf-8");
const count = content.split(params.old_string).length - 1;
if (count === 0) {
throw new Error(`old_string not found in ${params.path}`);
}
if (count > 1 && !params.replace_all) {
throw new Error(
`old_string found ${count} times in ${params.path}. Set replace_all to replace all occurrences, or include more context to make the match unique.`
);
}
const updated = params.replace_all
? content.replaceAll(params.old_string, params.new_string)
: content.replace(params.old_string, params.new_string);
writeFileSync(resolved, updated, "utf-8");
return { path: params.path, replacements: params.replace_all ? count : 1 };
}),
});
}
export function FileDeleteTool(ctx: ToolContext) {
return tool({
name: "file_delete",
description:
"Delete a file. Path is relative to the repository root. " +
"Deletes to .git/ are blocked. Cannot delete directories.",
parameters: FileDeleteParams,
execute: execute(async (params) => {
const resolved = resolveWritePath(params.path, ctx.payload.shell);
unlinkSync(resolved);
return { path: params.path, deleted: true };
}),
});
}
export function ListDirectoryTool(_ctx: ToolContext) {
return tool({
name: "list_directory",
description:
"List files and directories. Path is relative to the repository root, or an absolute path " +
"to list tool result files from the temp directory. Returns entries sorted with directories first, then alphabetically.",
parameters: ListDirectoryParams,
execute: execute(async (params) => {
const resolved = resolveReadPath(params.path);
const entries = readdirSync(resolved, { withFileTypes: true });
const sorted = entries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
const listing = sorted.map((e) => (e.isDirectory() ? `[DIR] ${e.name}` : e.name)).join("\n");
return { listing };
}),
});
}
+5 -9
View File
@@ -149,9 +149,8 @@ export function PushBranchTool(ctx: ToolContext) {
}
try {
$git("push", pushArgs, {
await $git("push", pushArgs, {
token: ctx.gitToken,
restricted: ctx.payload.shell !== "enabled",
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -269,7 +268,7 @@ export function GitTool(ctx: ToolContext) {
}
}
const output = $("git", [subcommand, ...args]);
const output = $("git", [subcommand, ...args], { log: false });
return { success: true, output };
}),
});
@@ -290,9 +289,8 @@ export function GitFetchTool(ctx: ToolContext) {
if (params.depth !== undefined) {
fetchArgs.push(`--depth=${params.depth}`);
}
$git("fetch", fetchArgs, {
await $git("fetch", fetchArgs, {
token: ctx.gitToken,
restricted: ctx.payload.shell !== "enabled",
});
return { success: true, ref: params.ref };
}),
@@ -318,9 +316,8 @@ export function DeleteBranchTool(ctx: ToolContext) {
);
}
$git("push", ["origin", "--delete", params.branchName], {
await $git("push", ["origin", "--delete", params.branchName], {
token: ctx.gitToken,
restricted: ctx.payload.shell !== "enabled",
});
return { success: true, deleted: params.branchName };
}),
@@ -348,9 +345,8 @@ export function PushTagsTool(ctx: ToolContext) {
}
const pushArgs = [...(params.force ? ["-f"] : []), "origin", `refs/tags/${params.tag}`];
$git("push", pushArgs, {
await $git("push", pushArgs, {
token: ctx.gitToken,
restricted: ctx.payload.shell !== "enabled",
});
return { success: true, tag: params.tag };
}),
-2
View File
@@ -1,2 +0,0 @@
// re-export from external.ts for backward compatibility
export { ghPullfrogMcpName } from "../external.ts";
+2 -1
View File
@@ -1,4 +1,5 @@
import { type } from "arktype";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
@@ -25,7 +26,7 @@ export function IssueTool(ctx: ToolContext) {
owner: ctx.repo.owner,
repo: ctx.repo.name,
title: title,
body: body,
body: fixDoubleEscapedString(body),
labels: labels ?? [],
assignees: assignees ?? [],
});
+2 -15
View File
@@ -1,7 +1,6 @@
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec";
import { Ajv } from "ajv";
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
@@ -42,20 +41,8 @@ function jsonSchemaToStandardSchema({
}
function storeOutput(ctx: ToolContext, value: string) {
const selfId = ctx.toolState.selfSubagentId;
if (selfId) {
const subagent = ctx.toolState.subagents.get(selfId);
if (subagent) {
subagent.output = value;
log.debug(`set_output: routed to subagent ${selfId} (value=${value.slice(0, 80)})`);
return { success: true, routed: "subagent" as const };
}
log.warning(
`set_output: selfSubagentId=${selfId} but subagent not found in map — routing to action output`
);
}
ctx.toolState.output = value;
return { success: true, routed: "action_output" as const };
return { success: true };
}
export function SetOutputTool(ctx: ToolContext, outputSchema?: JsonSchema) {
@@ -74,7 +61,7 @@ export function SetOutputTool(ctx: ToolContext, outputSchema?: JsonSchema) {
return tool({
name: "set_output",
description:
"Set the action output. When called by a subagent, returns a summary result to the orchestrator — this is the ONLY way to pass results back. When called by the orchestrator in standalone mode (trigger: unknown), exposes the value as the 'result' GitHub Action output for downstream workflow steps. Do NOT use this for progress reporting — use report_progress instead.",
"Set the action output. Exposes the value as the 'result' GitHub Action output for downstream workflow steps. Do NOT use this for progress reporting — use report_progress instead.",
parameters: SetOutputParams,
execute: execute(async (params) => {
return storeOutput(ctx, params.value);
+2 -2
View File
@@ -1,6 +1,7 @@
import { type } from "arktype";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { $ } from "../utils/shell.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
@@ -17,13 +18,12 @@ export const PullRequest = type({
function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
const footer = buildPullfrogFooter({
triggeredBy: true,
agent: { displayName: ctx.agent.displayName, url: ctx.agent.url },
workflowRun: ctx.runId
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
: undefined,
});
const bodyWithoutFooter = stripExistingFooter(body);
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
return `${bodyWithoutFooter}${footer}`;
}
+32 -307
View File
@@ -4,9 +4,16 @@ import { apiFetch } from "../utils/apiFetch.ts";
import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
function isStatusError(err: unknown): err is { status: number; message?: string } {
return (
typeof err === "object" && err !== null && "status" in err && typeof err.status === "number"
);
}
// one-shot review tool
export const CreatePullRequestReview = type({
pull_number: type.number.describe("The pull request number to review"),
@@ -70,6 +77,8 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
" Put feedback about code outside the diff in 'body' instead.",
parameters: CreatePullRequestReview,
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
if (body) body = fixDoubleEscapedString(body);
// set issue context (PRs are issues)
ctx.toolState.issueNumber = pull_number;
@@ -99,7 +108,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
if (comments.length > 0) {
type ReviewComment = (typeof params.comments & {})[number];
params.comments = comments.map((comment) => {
let commentBody = comment.body || "";
let commentBody = fixDoubleEscapedString(comment.body || "");
if (comment.suggestion !== undefined) {
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
@@ -120,14 +129,28 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
// no body → single-step createReview (no footer needed)
// has body → pending + submit so we can build footer with Fix links using review ID
const result = body
? await createAndSubmitWithFooter(ctx, params, {
body,
approved: approved ?? false,
hasComments: comments.length > 0,
})
: await ctx.octokit.rest.pulls.createReview(params);
let result;
try {
result = body
? await createAndSubmitWithFooter(ctx, params, {
body,
approved: approved ?? false,
hasComments: comments.length > 0,
})
: await ctx.octokit.rest.pulls.createReview(params);
} catch (err: unknown) {
if (isStatusError(err) && err.status === 422 && params.comments?.length) {
const paths = [...new Set(params.comments.map((comment) => comment.path))];
throw new Error(
`${err.message ?? "422 Unprocessable Entity"}. ` +
`The review had ${params.comments.length} inline comment(s) targeting these paths: ${paths.join(", ")}. ` +
`GitHub cannot resolve one or more of these paths in the PR diff (common when the PR has >100 changed files and some are truncated). ` +
`Fix: remove the failing comment(s) and retry. Put their feedback in the review body instead.`
);
}
throw err;
}
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id) {
throw new Error(`createReview returned invalid data: ${JSON.stringify(result.data)}`);
}
@@ -266,301 +289,3 @@ export async function reportReviewNodeId(ctx: ToolContext, reviewNodeId: string)
}
}
}
// =============================================================================
// COMMENTED OUT: Three-step review flow (start_review, add_review_comment, submit_review)
// This approach used GraphQL to add comments to a pending review one-by-one,
// but GitHub's API was returning null for valid lines. Keeping for reference.
// =============================================================================
/*
// graphql mutation to add a comment thread to a pending review
// note: REST API doesn't support adding comments to an existing pending review
const ADD_PULL_REQUEST_REVIEW_THREAD = `
mutation AddPullRequestReviewThread($pullRequestReviewId: ID!, $path: String!, $line: Int!, $body: String!, $side: DiffSide, $subjectType: PullRequestReviewThreadSubjectType) {
addPullRequestReviewThread(input: {
pullRequestReviewId: $pullRequestReviewId,
path: $path,
line: $line,
body: $body,
side: $side,
subjectType: $subjectType
}) {
thread {
id
}
}
}
`;
type AddPullRequestReviewThreadResponse = {
addPullRequestReviewThread: {
thread: {
id: string;
};
};
};
// helper to find existing pending review for the authenticated user
async function findPendingReview(
ctx: ToolContext,
pull_number: number
): Promise<{ id: number; node_id: string } | null> {
const reviews = await ctx.octokit.rest.pulls.listReviews({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
per_page: 100,
});
// find a PENDING review from our bot
// note: authenticated user is the GitHub App, reviews show as "pullfrog[bot]"
const pendingReview = reviews.data.find((r) => r.state === "PENDING");
if (pendingReview) {
return { id: pendingReview.id, node_id: pendingReview.node_id };
}
return null;
}
// start_review tool
export const StartReview = type({
pull_number: type.number.describe("The pull request number to review"),
});
export function StartReviewTool(ctx: ToolContext) {
return tool({
name: "start_review",
description:
"Start a new review session for a pull request. Creates a pending review on GitHub. Must be called before add_review_comment.",
parameters: StartReview,
execute: execute(async ({ pull_number }) => {
// check if review already started in this session
if (ctx.toolState.review) {
throw new Error(
`Review session already in progress. Call submit_review first to finish it.`
);
}
// get the PR to get head commit SHA
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
});
let reviewId: number;
let reviewNodeId: string;
// try to create a new pending review (omitting 'event' creates PENDING state)
log.debug(`creating pending review for PR #${pull_number}...`);
try {
const result = await ctx.octokit.rest.pulls.createReview({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
commit_id: pr.data.head.sha,
// no 'event' = PENDING review
});
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id || !result.data.node_id) {
log.debug(result);
throw new Error(
`createReview returned invalid data: id=${result.data.id}, node_id=${result.data.node_id}`
);
}
reviewId = result.data.id;
reviewNodeId = result.data.node_id;
log.debug(`created new pending review: id=${reviewId}`);
} catch (error) {
// check for "already has pending review" error
const errorMessage = error instanceof Error ? error.message : String(error);
log.debug(`createReview failed: ${errorMessage}`);
if (errorMessage.includes("pending review")) {
// find the existing pending review
log.debug(`pending review already exists, fetching existing review...`);
const existing = await findPendingReview(ctx, pull_number);
if (!existing) {
throw new Error(
"GitHub says a pending review exists but we couldn't find it. Try again or check the PR reviews."
);
}
reviewId = existing.id;
reviewNodeId = existing.node_id;
log.debug(`reusing existing pending review: id=${reviewId}`);
} else {
throw error;
}
}
// set issue context (PRs are issues) and review state
ctx.toolState.issueNumber = pull_number;
ctx.toolState.review = {
nodeId: reviewNodeId,
id: reviewId,
};
log.debug(`review session started: id=${reviewId}, nodeId=${reviewNodeId}`);
return {
message: `Review session started for PR #${pull_number}. Add comments with add_review_comment, then submit with submit_review.`,
};
}),
});
}
// add_review_comment tool
export const AddReviewComment = type({
path: type.string.describe("The file path to comment on (relative to repo root)"),
line: type.number.describe(
"The line number in the file (use line numbers from the diff - the NEW file line number)"
),
body: type.string.describe("The comment text for this specific line"),
side: type
.enumerated("LEFT", "RIGHT")
.describe("Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT.")
.optional(),
});
export function AddReviewCommentTool(ctx: ToolContext) {
return tool({
name: "add_review_comment",
description:
"Add a comment to the current review session. Must call start_review first. Comments are stored in draft state until submit_review is called.",
parameters: AddReviewComment,
execute: execute(async ({ path, line, body, side }) => {
// check if review started
if (!ctx.toolState.review) {
throw new Error("No review session started. Call start_review first.");
}
const reviewNodeId = ctx.toolState.review.nodeId;
log.debug(
`adding review comment: reviewNodeId=${reviewNodeId}, path=${path}, line=${line}, side=${side || "RIGHT"}`
);
// add comment thread via GraphQL (REST doesn't support adding to existing pending review)
let result: AddPullRequestReviewThreadResponse;
try {
result = await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>(
ADD_PULL_REQUEST_REVIEW_THREAD,
{
pullRequestReviewId: reviewNodeId,
path,
line,
body,
side: side || "RIGHT",
subjectType: "LINE",
}
);
log.debug(`addPullRequestReviewThread response: ${JSON.stringify(result)}`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
log.debug(`addPullRequestReviewThread error: ${errorMsg}`);
throw new Error(
`Failed to add comment to ${path}:${line}. GraphQL error: ${errorMsg}. ` +
`Ensure the line is part of the diff and the path is correct.`
);
}
// check if the mutation succeeded - null means the line is not in the diff
if (!result) {
throw new Error(
`Failed to add comment to ${path}:${line}. GraphQL returned null response.`
);
}
if (!result.addPullRequestReviewThread) {
throw new Error(
`Failed to add comment to ${path}:${line}. addPullRequestReviewThread is null. Response: ${JSON.stringify(result)}`
);
}
if (!result.addPullRequestReviewThread.thread) {
throw new Error(
`Failed to add comment to ${path}:${line}. thread is null. The line must be part of the diff. Response: ${JSON.stringify(result)}`
);
}
const threadId = result.addPullRequestReviewThread.thread.id;
log.debug(`review comment added: threadId=${threadId}`);
return {
success: true,
message: `Comment added to ${path}:${line}`,
threadId,
};
}),
});
}
// submit_review tool
export const SubmitReview = type({
body: type.string
.describe(
"Review body text. Typically 1-3 sentences with high-level overview and urgency level. Action links are auto-appended."
)
.optional(),
});
export function SubmitReviewTool(ctx: ToolContext) {
return tool({
name: "submit_review",
description:
"Submit the current review session. All comments added via add_review_comment will be published. Must call start_review first.",
parameters: SubmitReview,
execute: execute(async ({ body }) => {
// check if review started
if (!ctx.toolState.review) {
throw new Error("No review session started. Call start_review first.");
}
if (ctx.toolState.issueNumber === undefined) {
throw new Error("No PR context. Call checkout_pr or start_review first.");
}
const reviewId = ctx.toolState.review.id;
log.debug(
`submitting review: id=${reviewId}, nodeId=${ctx.toolState.review.nodeId}, issueNumber=${ctx.toolState.issueNumber}`
);
// build quick links footer
const apiUrl = getApiUrl();
const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${ctx.toolState.issueNumber}?action=fix&review_id=${reviewId}`;
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${ctx.toolState.issueNumber}?action=fix-approved&review_id=${reviewId}`;
const footer = buildPullfrogFooter({
workflowRun: { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId },
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
});
const bodyWithFooter = (body || "") + footer;
// submit the pending review via REST
const result = await ctx.octokit.rest.pulls.submitReview({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number: ctx.toolState.issueNumber,
review_id: reviewId,
event: "COMMENT",
body: bodyWithFooter,
});
log.debug(`submitReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id) {
throw new Error(`submitReview returned invalid data: ${JSON.stringify(result.data)}`);
}
log.debug(`review submitted: reviewId=${result.data.id}, state=${result.data.state}`);
// clear review state
delete ctx.toolState.review;
// delete progress comment
await deleteProgressComment(ctx);
return {
success: true,
reviewId: result.data.id,
html_url: result.data.html_url,
state: result.data.state,
};
}),
});
}
*/
-127
View File
@@ -407,133 +407,6 @@ describe("git tool security - auth redirect", () => {
});
});
// ─── file tool security tests ───────────────────────────────────────────
const GIT_INTERPRETED_FILES = [".gitattributes", ".gitmodules"];
type ValidateWritePathResult = {
allowed: boolean;
error?: string;
};
// simplified path validation that mirrors the security checks in file.ts
// without requiring real filesystem operations (for unit testing)
function validateWritePathSecurity(
relative: string,
shellPermission: ShellPermission
): ValidateWritePathResult {
if (relative === ".git" || relative.startsWith(".git/")) {
return { allowed: false, error: `writing to .git is not allowed: ${relative}` };
}
// only blocked when shell is disabled
if (shellPermission === "disabled") {
const basename = relative.split("/").pop() || "";
if (GIT_INTERPRETED_FILES.includes(basename)) {
return {
allowed: false,
error: `writing to ${basename} is not allowed when shell is ${shellPermission}`,
};
}
}
return { allowed: true };
}
describe("file tool security - .git protection", () => {
it("blocks .git directory in all modes", () => {
const modes: ShellPermission[] = ["disabled", "restricted", "enabled"];
for (const mode of modes) {
const result = validateWritePathSecurity(".git", mode);
expect(result.allowed).toBe(false);
}
});
it("blocks .git/config", () => {
const result = validateWritePathSecurity(".git/config", "enabled");
expect(result.allowed).toBe(false);
});
it("blocks .git/hooks/pre-commit", () => {
const result = validateWritePathSecurity(".git/hooks/pre-commit", "enabled");
expect(result.allowed).toBe(false);
});
it("blocks deeply nested .git paths", () => {
const result = validateWritePathSecurity(".git/objects/ab/cd1234", "enabled");
expect(result.allowed).toBe(false);
});
});
describe("file tool security - git-interpreted files (disabled mode only)", () => {
it("blocks .gitattributes in disabled mode", () => {
const result = validateWritePathSecurity(".gitattributes", "disabled");
expect(result.allowed).toBe(false);
expect(result.error).toContain(".gitattributes");
});
it("allows .gitattributes in restricted mode (agent has shell)", () => {
const result = validateWritePathSecurity(".gitattributes", "restricted");
expect(result.allowed).toBe(true);
});
it("allows .gitattributes in enabled mode", () => {
const result = validateWritePathSecurity(".gitattributes", "enabled");
expect(result.allowed).toBe(true);
});
it("blocks .gitmodules in disabled mode", () => {
const result = validateWritePathSecurity(".gitmodules", "disabled");
expect(result.allowed).toBe(false);
});
it("allows .gitmodules in restricted mode", () => {
const result = validateWritePathSecurity(".gitmodules", "restricted");
expect(result.allowed).toBe(true);
});
it("allows .gitmodules in enabled mode", () => {
const result = validateWritePathSecurity(".gitmodules", "enabled");
expect(result.allowed).toBe(true);
});
it("blocks subdirectory .gitattributes in disabled mode", () => {
const result = validateWritePathSecurity("src/.gitattributes", "disabled");
expect(result.allowed).toBe(false);
});
it("blocks deeply nested .gitattributes in disabled mode", () => {
const result = validateWritePathSecurity("a/b/c/.gitattributes", "disabled");
expect(result.allowed).toBe(false);
});
it("allows subdirectory .gitattributes in restricted mode", () => {
const result = validateWritePathSecurity("src/.gitattributes", "restricted");
expect(result.allowed).toBe(true);
});
it("allows normal files in all modes", () => {
const files = ["README.md", "src/index.ts", "package.json", ".env", ".gitignore"];
const modes: ShellPermission[] = ["disabled", "restricted", "enabled"];
for (const file of files) {
for (const mode of modes) {
const result = validateWritePathSecurity(file, mode);
expect(result.allowed).toBe(true);
}
}
});
it("does not block .gitignore (not a code execution vector)", () => {
const result = validateWritePathSecurity(".gitignore", "disabled");
expect(result.allowed).toBe(true);
});
it("does not block .gitkeep (not a code execution vector)", () => {
const result = validateWritePathSecurity("dir/.gitkeep", "disabled");
expect(result.allowed).toBe(true);
});
});
// ─── dependency install security tests ──────────────────────────────────
// mirrors the logic in dependencies.ts startInstallation()
+95 -136
View File
@@ -21,211 +21,170 @@ function resolveMode(modes: Mode[], modeName: string): Mode | null {
const modeGuidance: Record<string, string> = {
Build: `### Checklist
1. **plan phase** (optional, for complex tasks): delegate a subagent to analyze the requirements, read AGENTS.md and relevant code, and produce a step-by-step implementation plan. Include \`${ghPullfrogMcpName}/set_output\` with the plan so it returns to you. Use mini or auto effort. You can also use \`ask_question\` for codebase questions/investigations.
1. **plan** (optional, for complex tasks): analyze requirements, read AGENTS.md and relevant code, produce a step-by-step implementation plan.
2. **setup** (your responsibility as orchestrator): before the build phase, checkout or create the branch:
2. **setup**: checkout or create the branch:
- **PR event, modifying the existing PR**: call \`${ghPullfrogMcpName}/checkout_pr\`
- **new branch**: use \`${ghPullfrogMcpName}/git\` to create a branch (\`git checkout -b pullfrog/branch-name\`)
Subagents have no git/checkout tools — the working tree must be ready before delegation.
3. **build phase**: delegate a subagent with the implementation task. Include in its prompt:
- the plan (if you ran a plan phase)
- specific files to modify and why
- instruct the subagent to plan its approach before writing code: identify which files need to change, key design decisions, and edge cases. for non-trivial changes, consider whether there's a more elegant approach before committing to implementation.
- testing expectations: run relevant tests/lints before committing
- pre-commit quality check: instruct the subagent to review its own diff before committing — verify only intended changes are present, no debug artifacts or commented-out code remain, and no unrelated files were modified. the change should be clean enough that a senior engineer would approve it without hesitation. for non-trivial changes, ask whether there's a simpler way to achieve the same result.
3. **build**: implement changes using your native file and shell tools:
- follow the plan (if you ran a plan phase)
- plan your approach before writing code: identify which files need to change, key design decisions, and edge cases. for non-trivial changes, consider whether there's a more elegant approach.
- run relevant tests/lints before committing
- review your own diff before committing — verify only intended changes are present, no debug artifacts or commented-out code remain, and no unrelated files were modified. the change should be clean enough that a senior engineer would approve it without hesitation.
- commit locally via shell (\`git add . && git commit -m "..."\`)
- call \`${ghPullfrogMcpName}/set_output\` with a concise summary including the branch name (this is how results get back to you)
4. **review phase** (optional, for non-trivial changes): before pushing, delegate a review subagent to check the pending diff. Use \`ask_question\` for quick spot-checks, or delegate a full Review subagent for high-stakes changes. This catches issues before they're public.
5. **finalize** (your responsibility as orchestrator): after the build (and optional review) completes:
4. **finalize**:
- push the branch via \`${ghPullfrogMcpName}/push_branch\`
- create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
- call \`${ghPullfrogMcpName}/report_progress\` with the final summary including PR link
### Notes
For simple, well-defined tasks, a single build subagent is sufficient — skip the plan and review phases.
Your subagent receives ONLY what you write. Include file paths, constraints, conventions, and any context from AGENTS.md or the codebase directly in the prompt. Subagents have file ops, shell, and read-only GitHub tools — but NO git/checkout, dependency, GitHub-write, or remote-mutating tools.`,
For simple, well-defined tasks, skip the plan phase and go straight to build.`,
ResolveConflicts: `### Checklist
1. **Setup**:
- Call \${ghPullfrogMcpName}/checkout_pr to get the PR branch.
- Call \${ghPullfrogMcpName}/get_pull_request to identify the base branch (e.g., 'main').
- Call \${ghPullfrogMcpName}/git_fetch to fetch the base branch.
- Call \`${ghPullfrogMcpName}/checkout_pr\` to get the PR branch.
- Call \`${ghPullfrogMcpName}/get_pull_request\` to identify the base branch (e.g., 'main').
- Call \`${ghPullfrogMcpName}/git_fetch\` to fetch the base branch.
2. **Merge Attempt**:
- Run \`git merge origin/<base_branch>\` via shell.
- If it succeeds automatically: Great! Push via \${ghPullfrogMcpName}/push_branch and report success.
- If it fails (conflicts): You must resolve them.
- If it succeeds automatically, push via \`${ghPullfrogMcpName}/push_branch\` and report success.
- If it fails (conflicts), resolve them manually.
3. **Delegation (if conflicts exist)**:
3. **Resolve Conflicts**:
- Run \`git status\` or parse the merge output to find the list of conflicting files.
- Delegate to a subagent (or multiple in parallel if many files) to resolve the conflicts.
- **Instructions for subagent**:
- "You are resolving merge conflicts in these files: [list]."
- "For each file: read it, find the conflict markers (<<<<<<<, =======, >>>>>>>), understand the code context, and rewrite the file with the correct resolution. Remove all markers."
- "After resolving, verify the file syntax is correct."
- "Call \${ghPullfrogMcpName}/set_output with a summary of what you resolved."
- Note: Subagents cannot run git commands. They only edit the files.
- For each conflicting file: read it, find the conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`), understand the code context, and rewrite the file with the correct resolution. Remove all markers.
- Verify the file syntax is correct after resolution.
4. **Finalize**:
- After subagents return:
- Run a final verification (build/test) to ensure the resolution works.
- \`git add .\`
- \`git commit -m "Resolve merge conflicts"\`
- \${ghPullfrogMcpName}/push_branch
- \${ghPullfrogMcpName}/report_progress
`,
- \`git add . && git commit -m "resolve merge conflicts"\`
- Push via \`${ghPullfrogMcpName}/push_branch\`
- Call \`${ghPullfrogMcpName}/report_progress\` with a summary of what was resolved`,
AddressReviews: `### Checklist
1. Before delegating, checkout the PR branch yourself via \`${ghPullfrogMcpName}/checkout_pr\` — subagents have no git/checkout tools.
1. Checkout the PR branch via \`${ghPullfrogMcpName}/checkout_pr\`.
2. Include in its prompt:
- instruct it to fetch review comments via \`${ghPullfrogMcpName}/get_review_comments\` (subagents have read-only GitHub tools)
- for each comment: understand the feedback, make the code change, and record what was done
- test changes, then review the diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
- commit locally via shell (\`git add . && git commit -m "..."\`)
- call \`${ghPullfrogMcpName}/set_output\` with a JSON object: \`{ "summary": "...", "replies": [{ "comment_id": 123, "thread_id": "...", "reply": "Fixed by ..." }, ...] }\` — this is how results get back to you
2. Fetch review comments via \`${ghPullfrogMcpName}/get_review_comments\`.
3. After the subagent completes:
- push changes via \`${ghPullfrogMcpName}/push_branch\`
- reply to each comment using \`${ghPullfrogMcpName}/reply_to_review_comment\` with the subagent's suggested replies
- resolve addressed threads via \`${ghPullfrogMcpName}/resolve_review_thread\`
- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary
3. For each comment:
- understand the feedback
- make the code change using your native tools
- record what was done
### Effort
4. Quality check:
- test changes, then review the diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
- commit locally via shell (\`git add . && git commit -m "..."\`)
Use auto or max effort depending on review complexity.`,
5. Finalize:
- push changes via \`${ghPullfrogMcpName}/push_branch\`
- reply to each comment using \`${ghPullfrogMcpName}/reply_to_review_comment\`
- resolve addressed threads via \`${ghPullfrogMcpName}/resolve_review_thread\`
- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary`,
Review: `### Checklist
1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`. Read the diff to identify the major areas of change.
2. Delegate multiple subagents in a single \`${ghPullfrogMcpName}/delegate\` call, each focused on a specific area. For example, a PR touching action/, components/, and prisma/ might get three subagents: "action-review", "frontend-review", "schema-review".
3. After all subagents return, consolidate their findings into a single review.
### Crafting each task
2. For each area of change:
- read the diff and trace data flow, check boundaries, and verify assumptions
- plan your investigation: identify the highest-risk areas (tricky state transitions, boundary crossings, assumption chains) and prioritize depth over breadth
- use \`${ghPullfrogMcpName}/get_pull_request\` and other read-only GitHub tools for additional context
- if the PR removes features, deletes exports, renames concepts, or changes architectural patterns, run a dedicated impact analysis: list what changed, then use grep across code, tests, docs (\`docs/\`, \`wiki/\`), comments, configs, and UI to find stale references
- report impact-analysis findings in the summary body, ordered by severity (runtime breakage > incorrect docs > stale comments)
- draft inline comments with NEW line numbers from the diff — every comment must be actionable (2-3 sentences max)
- use GitHub permalink format for code references
Each task in the \`tasks\` array should include:
- the diff file path so the subagent can read it
- what specific area/aspect to focus on (e.g., "review the database migration and schema changes in prisma/")
- instruct it to read the diff, trace data flow, check boundaries, and verify assumptions within its area. subagents have read-only GitHub tools (\`${ghPullfrogMcpName}/get_pull_request\`, etc.) for fetching additional context.
- instruct it to plan its investigation before diving in: identify the highest-risk areas (tricky state transitions, boundary crossings, assumption chains) and prioritize depth over breadth
- draft inline comments with NEW line numbers from the diff — every comment must be actionable (2-3 sentences max)
- after drafting, instruct it to critique its own comments: drop any that are praise, style preferences, speculative/unverified claims, about pre-existing code unrelated to the PR, or not actionable
- use GitHub permalink format for code references
- call \`${ghPullfrogMcpName}/set_output\` with a JSON object: \`{ "summary": "...", "comments": [{ "path": "file.ts", "line": 42, "body": "..." }, ...] }\` — this is how findings get back to you
3. Self-critique: review all drafted comments and drop any that are praise, style preferences, speculative/unverified claims, about pre-existing code unrelated to the PR, or not actionable.
### Post-delegation
After all tasks complete, consolidate into a **single** review:
- merge the \`comments\` arrays from all subagent outputs
- if subagents found actionable issues: submit one \`${ghPullfrogMcpName}/create_pull_request_review\` with \`approved: false\`, the merged comments, and a unified summary body
- if no subagent found actionable issues: submit with \`approved: true\` and a brief positive summary (no inline comments)
- call \`${ghPullfrogMcpName}/report_progress\` with the summary
Use max effort for thorough reviews.`,
4. Submit a **single** review:
- call \`${ghPullfrogMcpName}/create_pull_request_review\` with all comments and a unified summary body
- call \`${ghPullfrogMcpName}/report_progress\` with the summary
- if no actionable issues found, skip the review — just call \`report_progress\` noting the PR was reviewed`,
IncrementalReview: `### Checklist
1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`. Read the diff to identify the major areas of change.
2. Generate the incremental diff using the \`before_sha\` from EVENT DATA: \`git diff <before_sha>...HEAD\`. This isolates only the new commits. If the command fails (e.g., force-push rewrote history), fall back to reviewing the full PR diff.
3. Fetch previous reviews via \`${ghPullfrogMcpName}/list_pull_request_reviews\`. For the most recent Pullfrog review, call \`${ghPullfrogMcpName}/get_review_comments\` with the review ID to retrieve specific prior line-level feedback. Include the prior review summary and comment details when crafting subagent tasks.
4. Delegate multiple subagents in a single \`${ghPullfrogMcpName}/delegate\` call, each focused on a specific area of the new changes. Provide both the full diff path and the incremental diff.
5. After all subagents return, consolidate their findings into a single review.
### Crafting each task
3. Fetch previous reviews via \`${ghPullfrogMcpName}/list_pull_request_reviews\`. For the most recent Pullfrog review, call \`${ghPullfrogMcpName}/get_review_comments\` with the review ID to retrieve specific prior line-level feedback.
Each task in the \`tasks\` array should include:
- the full diff file path AND the incremental diff (so the subagent can see both new changes and full context)
- what specific area/aspect to focus on
- instruct it to prioritize reviewing code in the incremental diff while using the full diff for context and to catch any changes not covered by the incremental diff
- include the prior review comments (from step 3) so the subagent knows what feedback was already given — instruct it to avoid repeating prior issues and to note whether prior feedback was addressed by the new commits
- instruct it to actively hunt for problems: trace data flow, check boundaries, explore failure modes, verify assumptions, consider lifecycle, spot performance issues
- draft inline comments with NEW line numbers from the full PR diff — every comment must be actionable (2-3 sentences max)
- call \`${ghPullfrogMcpName}/set_output\` with a JSON object: \`{ "summary": "...", "comments": [{ "path": "file.ts", "line": 42, "body": "..." }, ...] }\`
4. For each area of the new changes:
- review the incremental diff while using the full diff for context
- check whether prior review feedback was addressed by the new commits
- trace data flow, check boundaries, verify assumptions, consider lifecycle, spot performance issues
- if the new commits remove, rename, or deprecate anything, run impact analysis with grep across code/tests/docs/comments/configs to find stale references and include those findings in the summary body
- never repeat prior feedback. if the author did not address an earlier comment, assume it was intentionally declined; only comment on genuinely new issues introduced by the new commits
- draft inline comments with NEW line numbers from the full PR diff — every comment must be actionable (2-3 sentences max)
### Post-delegation
5. Self-critique: drop any comments that are praise, style preferences, speculative, about pre-existing code, or not actionable.
After all tasks complete, consolidate into a **single** review:
- merge the \`comments\` arrays from all subagent outputs
- if subagents found actionable issues: submit one \`${ghPullfrogMcpName}/create_pull_request_review\` with \`approved: false\`, the merged comments, and an **empty body** (do NOT include a summary — inline comments speak for themselves and a top-level comment clutters the PR conversation on every re-review)
- if no subagent found actionable issues: submit with \`approved: true\` and an **empty body** (no inline comments, no summary)
- do NOT call \`${ghPullfrogMcpName}/report_progress\` — incremental reviews should be silent
Use max effort for thorough reviews.`,
6. Submit a **single** review:
- if actionable issues found: call \`${ghPullfrogMcpName}/create_pull_request_review\` with \`approved: false\`, all comments, and an **empty body** (do NOT include a summary — inline comments speak for themselves and a top-level comment clutters the PR conversation on every re-review)
- if no actionable issues found: submit with \`approved: true\` and an **empty body** (no inline comments, no summary)
- do NOT call \`${ghPullfrogMcpName}/report_progress\` — incremental reviews should be silent`,
Plan: `### Checklist
1. Include in its prompt:
- the task to plan for
- relevant codebase context (file paths, architecture notes from AGENTS.md)
- instruct it to produce a structured, actionable plan with clear milestones
- IMPORTANT: instruct it to return the full plan text via \`${ghPullfrogMcpName}/set_output\` as well-structured markdown — do NOT create plan files, do NOT save to disk
2. After the subagent completes, call \`${ghPullfrogMcpName}/report_progress\` with the full plan text from the subagent's output. The progress comment must contain the complete plan — not a file path or summary.
1. Analyze the task and gather context:
- read AGENTS.md and relevant codebase files
- understand the architecture and constraints
### Effort
2. Produce a structured, actionable plan with clear milestones.
Use mini or auto effort.`,
3. Call \`${ghPullfrogMcpName}/report_progress\` with the plan.`,
PlanEdit: `### Checklist (editing existing plan)
An existing plan comment was found for this issue. Update that comment with the revised plan — do not create a new plan comment.
1. Use \`previousPlanBody\` from this response as the plan to revise; do not call \`get_issue\` or \`get_issue_comments\`.
2. When delegating, the subagent prompt must contain:
- the current plan (\`previousPlanBody\`) and the user's revision request
- relevant codebase context (file paths, architecture notes from AGENTS.md)
- instructions to produce a structured plan with clear milestones and to return the full plan via \`${ghPullfrogMcpName}/set_output\` as markdown (do not create plan files or save to disk)
3. After the subagent completes, call \`${ghPullfrogMcpName}/report_progress\` with the full plan text and \`{ target_plan_comment: true }\` so the revised plan updates the existing plan comment (not the progress comment).
4. Then post a short note to the progress comment (e.g. "Plan has been updated in the comment above.") via \`${ghPullfrogMcpName}/report_progress\` so it is not left as "Leaping...".
### Effort
Use mini or auto effort.`,
2. Revise the plan based on the user's request:
- incorporate the current plan (\`previousPlanBody\`) and the user's revision request
- gather relevant codebase context (file paths, architecture notes from AGENTS.md)
- produce a structured plan with clear milestones
3. Call \`${ghPullfrogMcpName}/report_progress\` with the full revised plan text and \`{ target_plan_comment: true }\` so it updates the existing plan comment (not the progress comment).
4. Then post a short note to the progress comment (e.g. "Plan has been updated in the comment above.") via \`${ghPullfrogMcpName}/report_progress\` so it is not left as "Leaping...".`,
Fix: `### Checklist
1. Before delegating, checkout the PR branch yourself via \`${ghPullfrogMcpName}/checkout_pr\` — subagents have no git/checkout tools.
1. Checkout the PR branch via \`${ghPullfrogMcpName}/checkout_pr\`.
2. Delegate a single fix subagent with:
- the check_suite_id to fetch logs via \`${ghPullfrogMcpName}/get_check_suite_logs\` (subagents have read-only GitHub tools)
- the PR diff file path (from checkout_pr result) so it can understand what the PR changed
- CRITICAL: instruct it to verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report.
- instruct it to read the workflow file, reproduce locally with the EXACT same commands CI runs
- fix the issue, then verify the fix by re-running the exact CI command
- pre-commit quality check: review the diff before committing — verify only the fix is present, no debug artifacts, no unrelated changes. the fix should be clean enough that a senior engineer would approve it without hesitation.
- commit locally via shell (\`git add . && git commit -m "..."\`)
- call \`${ghPullfrogMcpName}/set_output\` with a concise summary: what failed, why, and the fix applied (this is how results get back to you)
2. Fetch check suite logs via \`${ghPullfrogMcpName}/get_check_suite_logs\`.
3. After the subagent completes:
- push changes via \`${ghPullfrogMcpName}/push_branch\`
- call \`${ghPullfrogMcpName}/report_progress\` with the diagnosis and fix summary
3. **CRITICAL**: verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report.
### Effort
4. Diagnose and fix:
- read the workflow file, reproduce locally with the EXACT same commands CI runs
- fix the issue using your native file and shell tools
- verify the fix by re-running the exact CI command
- review the diff before committing — verify only the fix is present, no debug artifacts, no unrelated changes. the fix should be clean enough that a senior engineer would approve without hesitation.
- commit locally via shell (\`git add . && git commit -m "..."\`)
Use auto effort.`,
5. Finalize:
- push changes via \`${ghPullfrogMcpName}/push_branch\`
- call \`${ghPullfrogMcpName}/report_progress\` with the diagnosis and fix summary`,
Task: `### Checklist
1. Handle this general-purpose task. For simple operations (labeling, commenting, answering questions, running a single command), you can often handle it directly without delegation.
2. When the task involves **substantial work** — code changes across multiple files, multi-step investigations, or tasks that benefit from focused context — use \`delegate\` and \`ask_question\` liberally:
- \`ask_question\`: quick codebase research, finding files, understanding architecture. Use freely — multiple calls in sequence is fine.
- \`delegate\`: research, local coding tasks, and codebase investigations. Each subagent gets dedicated context, so break complex work into focused subtasks and delegate each one. For independent subtasks, batch them in a single \`${ghPullfrogMcpName}/delegate\` call to run in parallel.
3. Include in each task's prompt:
- the full subtask description with all relevant context
- exactly what information to return. the subagent's output is your only way to get results back — be precise about what you need.
- if code changes are needed: branch naming, testing, commit instructions (do NOT instruct to push or create PR)
- if code changes are needed: instruct it to review its own diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
4. Post-delegation:
1. Analyze the task. For simple operations (labeling, commenting, answering questions, running a single command), handle directly.
2. For substantial work — code changes across multiple files, multi-step investigations:
- plan your approach before starting
- use native file and shell tools for local operations
- use ${ghPullfrogMcpName} MCP tools for GitHub/git operations
- if code changes are needed: review your own diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
3. Finalize:
- call \`${ghPullfrogMcpName}/report_progress\` with results
- if the task involved code changes, push via \`${ghPullfrogMcpName}/push_branch\` and create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
- if the task involved labeling, commenting, or other GitHub operations, perform those directly
5. Use mini effort for simple research tasks, auto for typical tasks, max for complex multi-file changes.`,
- if the task involved labeling, commenting, or other GitHub operations, perform those directly`,
};
type OrchestratorGuidance = {
@@ -282,7 +241,7 @@ export function SelectModeTool(ctx: ToolContext) {
return tool({
name: "select_mode",
description:
"Select a mode and receive orchestrator-level guidance on how to handle it, including suggested delegation flows and prompt-crafting tips. Call this ONCE before delegating. Mode selection is final — you cannot switch modes after selecting.",
"Select a mode and receive step-by-step guidance on how to handle the task. Call this to understand the best workflow for the current mode.",
parameters: SelectModeParams,
execute: execute(async (params) => {
if (ctx.toolState.selectedMode) {
+37 -126
View File
@@ -2,12 +2,46 @@
import "./arkConfig.ts";
import { createServer } from "node:net";
import { FastMCP, type Tool } from "fastmcp";
import type { Agent, AgentUsage } from "../agents/index.ts";
import type { AgentUsage } from "../agents/index.ts";
import { ghPullfrogMcpName } from "../external.ts";
import type { Mode } from "../modes.ts";
import type { PrepResult } from "../prep/index.ts";
import { log } from "../utils/cli.ts";
import type { OctokitWithPlugins } from "../utils/github.ts";
import type { ResolvedPayload } from "../utils/payload.ts";
import type { RunContextData } from "../utils/runContextData.ts";
import { CheckoutPrTool } from "./checkout.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
import {
CreateCommentTool,
EditCommentTool,
ReplyToReviewCommentTool,
ReportProgressTool,
} from "./comment.ts";
import { CommitInfoTool } from "./commitInfo.ts";
import {
AwaitDependencyInstallationTool,
StartDependencyInstallationTool,
} from "./dependencies.ts";
import { DeleteBranchTool, GitFetchTool, GitTool, PushBranchTool, PushTagsTool } from "./git.ts";
import { IssueTool } from "./issue.ts";
import { GetIssueCommentsTool } from "./issueComments.ts";
import { GetIssueEventsTool } from "./issueEvents.ts";
import { IssueInfoTool } from "./issueInfo.ts";
import { AddLabelsTool } from "./labels.ts";
import { SetOutputTool } from "./output.ts";
import { CreatePullRequestTool, UpdatePullRequestBodyTool } from "./pr.ts";
import { PullRequestInfoTool } from "./prInfo.ts";
import { CreatePullRequestReviewTool } from "./review.ts";
import {
GetReviewCommentsTool,
ListPullRequestReviewsTool,
ResolveReviewThreadTool,
} from "./reviewComments.ts";
import { SelectModeTool } from "./selectMode.ts";
import { addTools } from "./shared.ts";
import { KillBackgroundTool, ShellTool } from "./shell.ts";
import { UploadFileTool } from "./upload.ts";
export type BackgroundProcess = {
pid: number;
@@ -21,20 +55,6 @@ export type StoredPushDest = {
localBranch: string;
};
export type SubagentStatus = "running" | "completed" | "failed";
export type SubagentState = {
id: string;
label: string;
status: SubagentStatus;
mode: string;
stdoutFilePath: string;
output: string | undefined;
usage: AgentUsage | undefined;
startedAt: number;
keepAliveInterval: ReturnType<typeof setInterval> | undefined;
};
export interface ToolState {
// where we're allowed to push - base repo initially, fork URL for fork PRs
// set by setupGit, updated by checkout_pr. always set before push validation.
@@ -47,11 +67,6 @@ export interface ToolState {
// PR HEAD sha at checkout time — used to detect new commits pushed during a review
checkoutSha?: string;
selectedMode?: string;
// per-subagent lifecycle tracking (keyed by subagent uuid)
subagents: Map<string, SubagentState>;
// only set on subagent shallow copies — routes set_output to the owning subagent.
// never set on the orchestrator's shared state.
selfSubagentId: string | undefined;
backgroundProcesses: Map<string, BackgroundProcess>;
review?: {
id: number;
@@ -88,8 +103,6 @@ export function initToolState(params: InitToolStateParams): ToolState {
return {
progressCommentId: resolvedId,
subagents: new Map(),
selfSubagentId: undefined,
backgroundProcesses: new Map(),
usageEntries: [],
};
@@ -102,7 +115,6 @@ export interface ToolContext {
githubInstallationToken: string;
gitToken: string;
apiToken: string;
agent: Agent;
modes: Mode[];
postCheckoutScript: string | null;
prApproveEnabled: boolean;
@@ -110,55 +122,10 @@ export interface ToolContext {
toolState: ToolState;
runId: number | undefined;
jobId: string | undefined;
// set after MCP server starts — used by delegate tool to pass URL to subagents
mcpServerUrl: string;
tmpdir: string;
}
import { log } from "../utils/cli.ts";
import type { RunContextData } from "../utils/runContextData.ts";
import { AskQuestionTool } from "./askQuestion.ts";
import { CheckoutPrTool } from "./checkout.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
import {
CreateCommentTool,
EditCommentTool,
ReplyToReviewCommentTool,
ReportProgressTool,
} from "./comment.ts";
import { CommitInfoTool } from "./commitInfo.ts";
import { DelegateTool } from "./delegate.ts";
import {
AwaitDependencyInstallationTool,
StartDependencyInstallationTool,
} from "./dependencies.ts";
import {
FileDeleteTool,
FileEditTool,
FileReadTool,
FileWriteTool,
ListDirectoryTool,
} from "./file.ts";
import { DeleteBranchTool, GitFetchTool, GitTool, PushBranchTool, PushTagsTool } from "./git.ts";
import { IssueTool } from "./issue.ts";
import { GetIssueCommentsTool } from "./issueComments.ts";
import { GetIssueEventsTool } from "./issueEvents.ts";
import { IssueInfoTool } from "./issueInfo.ts";
import { AddLabelsTool } from "./labels.ts";
import { SetOutputTool } from "./output.ts";
import { CreatePullRequestTool, UpdatePullRequestBodyTool } from "./pr.ts";
import { PullRequestInfoTool } from "./prInfo.ts";
import { CreatePullRequestReviewTool } from "./review.ts";
import {
GetReviewCommentsTool,
ListPullRequestReviewsTool,
ResolveReviewThreadTool,
} from "./reviewComments.ts";
import { SelectModeTool } from "./selectMode.ts";
import { addTools } from "./shared.ts";
import { KillBackgroundTool, ShellTool } from "./shell.ts";
import { UploadFileTool } from "./upload.ts";
const mcpPortStart = 3764;
const mcpPortAttempts = 100;
const mcpHost = "127.0.0.1";
@@ -198,7 +165,6 @@ function isAddressInUse(error: unknown): boolean {
type JsonSchema = Record<string, unknown>;
// tools shared by both orchestrator and subagent servers
function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any, any>[] {
const tools: Tool<any, any>[] = [
StartDependencyInstallationTool(ctx),
@@ -223,17 +189,9 @@ function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any
GitFetchTool(ctx),
UploadFileTool(ctx),
SetOutputTool(ctx, outputSchema),
FileReadTool(ctx),
FileWriteTool(ctx),
FileEditTool(ctx),
FileDeleteTool(ctx),
ListDirectoryTool(ctx),
];
// only add ShellTool when shell is "restricted"
// - "enabled": native shell only (no MCP shell needed)
// - "restricted": MCP shell only (native blocked, env filtered)
// - "disabled": no shell at all
// MCP shell with filtered env (no secrets leaked to child processes)
if (ctx.payload.shell === "restricted") {
tools.push(ShellTool(ctx));
tools.push(KillBackgroundTool(ctx));
@@ -242,14 +200,11 @@ function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any
return tools;
}
// orchestrator gets common tools + delegation + remote-mutating tools
function buildOrchestratorTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any, any>[] {
return [
...buildCommonTools(ctx, outputSchema),
ReportProgressTool(ctx),
SelectModeTool(ctx),
DelegateTool(ctx),
AskQuestionTool(ctx),
PushBranchTool(ctx),
PushTagsTool(ctx),
DeleteBranchTool(ctx),
@@ -258,11 +213,6 @@ function buildOrchestratorTools(ctx: ToolContext, outputSchema?: JsonSchema): To
];
}
// subagent gets only common tools (no delegation, no remote mutation)
function buildSubagentTools(ctx: ToolContext): Tool<any, any>[] {
return buildCommonTools(ctx);
}
type McpStartResult = {
server: FastMCP;
url: string;
@@ -367,7 +317,7 @@ type McpHttpServerOptions = {
};
/**
* Start the orchestrator MCP HTTP server (has all tools including push/PR/delegation).
* Start the MCP HTTP server.
*/
export async function startMcpHttpServer(
ctx: ToolContext,
@@ -384,42 +334,3 @@ export async function startMcpHttpServer(
},
};
}
export type ManagedMcpServer = {
url: string;
stop: () => Promise<void>;
toolState: ToolState;
};
type StartSubagentMcpServerParams = {
ctx: ToolContext;
subagentId: string;
};
/**
* Start a per-subagent MCP server (common tools only — no push/PR/delegation).
* Each subagent gets its own server; call stop() when the subagent completes.
*
* The subagent gets its own shallow copy of toolState so scalar writes
* (pushUrl, pushDest, selectedMode, etc.) don't mutate the orchestrator's state.
* selfSubagentId is set on the copy so set_output routes to the correct subagent.
* Shared references (subagents Map, usageEntries array, dependencyInstallation)
* are intentionally shared for coordination (set_output routing, usage tracking).
*/
export async function startSubagentMcpServer(
params: StartSubagentMcpServerParams
): Promise<ManagedMcpServer> {
const subagentToolState: ToolState = {
...params.ctx.toolState,
selfSubagentId: params.subagentId,
backgroundProcesses: new Map(),
};
const subagentCtx: ToolContext = { ...params.ctx, toolState: subagentToolState };
const tools = buildSubagentTools(subagentCtx);
const startResult = await selectMcpPort(subagentCtx, tools);
return {
url: startResult.url,
stop: () => startResult.server.stop(),
toolState: subagentToolState,
};
}
+3 -135
View File
@@ -1,4 +1,4 @@
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec";
import type { StandardSchemaV1 } from "@standard-schema/spec";
import { encode as toonEncode } from "@toon-format/toon";
import type { FastMCP, Tool } from "fastmcp";
import { formatJsonValue, log } from "../utils/cli.ts";
@@ -61,141 +61,9 @@ export const execute = <T, R extends Record<string, any> | string>(
return _fn;
};
/**
* Sanitize JSON schema to remove problematic fields that Gemini CLI/API can't handle
* - Removes $schema field (causes "no schema with key or ref" errors)
* - Converts $defs to definitions (draft-07 compatibility)
* - Removes any draft-2020-12 specific features
* - Converts any_of with enum values to direct STRING enum (Google API requirement)
*/
function sanitizeSchema(schema: any): any {
if (!schema || typeof schema !== "object") {
return schema;
}
if (Array.isArray(schema)) {
return schema.map(sanitizeSchema);
}
// handle any_of with enum values - convert to direct STRING enum for Google API
// Google API requires: {type: "string", enum: [...]} not {anyOf: [{enum: [...]}, {enum: [...]}]}
if (schema.anyOf && Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
const enumValues: string[] = [];
let allAreEnumObjects = true;
for (const item of schema.anyOf) {
if (item && typeof item === "object" && Array.isArray(item.enum)) {
// collect enum values (only strings)
const stringEnums = item.enum.filter((v: any) => typeof v === "string");
if (stringEnums.length > 0) {
enumValues.push(...stringEnums);
} else {
allAreEnumObjects = false;
break;
}
} else {
allAreEnumObjects = false;
break;
}
}
// if all any_of items are enum objects with string values, convert to direct STRING enum
if (allAreEnumObjects && enumValues.length > 0) {
const uniqueEnums = [...new Set(enumValues)];
// preserve other properties from the original schema (like description)
const result: any = {
type: "string",
enum: uniqueEnums,
};
if (schema.description) {
result.description = schema.description;
}
return result;
}
}
const sanitized: any = {};
for (const [key, value] of Object.entries(schema)) {
// skip $schema field entirely
if (key === "$schema") {
continue;
}
// skip any_of if we already converted it above
if (key === "anyOf" && schema.anyOf) {
continue;
}
// convert $defs to definitions for draft-07 compatibility
if (key === "$defs") {
sanitized.definitions = sanitizeSchema(value);
continue;
}
// recursively sanitize nested objects
sanitized[key] = sanitizeSchema(value);
}
return sanitized;
}
/**
* Wrap a schema to sanitize its JSON Schema output for Gemini/OpenCode compatibility.
* xsschema calls ~standard.jsonSchema.input() for schemas that implement StandardJSONSchemaV1
* (i.e. have ~standard.jsonSchema), which includes arktype and our AJV-backed JSON schema wrapper.
* Schemas without ~standard.jsonSchema are returned unchanged (sanitization skipped).
*/
function wrapSchema(
schema: StandardSchemaV1<any> & {
"~standard": Partial<StandardJSONSchemaV1<any>["~standard"]>;
}
): StandardSchemaV1<any> {
const standardProps = schema["~standard"];
if (!("jsonSchema" in standardProps)) {
return schema;
}
const jsonSchema = standardProps.jsonSchema;
const wrapped: StandardSchemaV1<any> & StandardJSONSchemaV1<any> = {
...schema,
"~standard": {
...standardProps,
jsonSchema: {
input: (options) => sanitizeSchema(jsonSchema.input(options)),
output: (options) => sanitizeSchema(jsonSchema.output(options)),
},
},
};
return wrapped;
}
/**
* Transform tool to sanitize its parameter schema for Gemini CLI compatibility
*/
function sanitizeTool<T extends Tool<any, any>>(tool: T): T {
if (!tool.parameters) {
return tool;
}
const wrappedSchema = wrapSchema(tool.parameters);
// create a new tool with wrapped schema
return {
...tool,
parameters: wrappedSchema,
} as T;
}
export const addTools = (ctx: ToolContext, server: FastMCP<any>, tools: Tool<any, any>[]) => {
// sanitize schemas for gemini agent and opencode (when using Google API)
// both have issues with draft-2020-12 schemas and any_of enum constructs
const shouldSanitize = ctx.agent.name === "gemini" || ctx.agent.name === "opencode";
export const addTools = (_ctx: ToolContext, server: FastMCP<any>, tools: Tool<any, any>[]) => {
for (const tool of tools) {
const processedTool = shouldSanitize ? sanitizeTool(tool) : tool;
server.addTool(processedTool);
server.addTool(tool);
}
return server;
};
+1 -1
View File
@@ -113,7 +113,7 @@ function spawnShell(params: SpawnParams): ChildProcess {
}
// drop back to original user after PROC_CLEANUP so files aren't owned by root.
// sudo is only needed for unshare; the actual command should run as the normal user
// to avoid ownership mismatches with file_write/file_edit (which run in the Node.js parent).
// to avoid ownership mismatches with files created by the Node.js parent process.
const username = userInfo().username;
const escaped = params.command.replace(/'/g, "'\\''");
return spawn(
-178
View File
@@ -1,178 +0,0 @@
import { execSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { markActivity } from "../utils/activity.ts";
import type { ResolvedInstructions } from "../utils/instructions.ts";
import { withLogPrefix } from "../utils/log.ts";
import { type SubagentState, startSubagentMcpServer, type ToolContext } from "./server.ts";
type CreateSubagentParams = {
ctx: ToolContext;
mode: string;
label: string;
};
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 60);
}
export function createSubagentState(params: CreateSubagentParams): SubagentState {
const id = randomUUID();
const slug = slugify(params.label);
const stdoutFilePath = join(params.ctx.tmpdir, `subagent-${slug || id}.log`);
const state: SubagentState = {
id,
label: params.label,
status: "running",
mode: params.mode,
stdoutFilePath,
output: undefined,
usage: undefined,
startedAt: Date.now(),
keepAliveInterval: undefined,
};
params.ctx.toolState.subagents.set(id, state);
return state;
}
type CompleteSubagentParams = {
ctx: ToolContext;
subagent: SubagentState;
success: boolean;
};
function completeSubagent(params: CompleteSubagentParams): void {
params.subagent.status = params.success ? "completed" : "failed";
if (params.subagent.keepAliveInterval) {
clearInterval(params.subagent.keepAliveInterval);
params.subagent.keepAliveInterval = undefined;
}
if (params.subagent.usage) {
params.ctx.toolState.usageEntries.push(params.subagent.usage);
}
}
export function hasRunningSubagents(ctx: ToolContext): boolean {
for (const s of ctx.toolState.subagents.values()) {
if (s.status === "running") return true;
}
return false;
}
const subagentSystemPreamble = `You are a focused subagent. Complete the task autonomously — no follow-up questions. Minimize token usage.
## Tools
Your tools are limited to:
- **File operations**: \`${ghPullfrogMcpName}/file_read\`, \`file_write\`, \`file_edit\`, \`file_delete\`, \`list_directory\`. Native file tools (Read, Write, StrReplace, etc.) are disabled — use the MCP versions.
- **Shell**: \`${ghPullfrogMcpName}/shell\` (if available). Use this for local git operations (\`git add\`, \`git commit\`, \`git diff\`, \`git log\`, \`git status\`), running tests, builds, and linters.
- **Read-only GitHub**: \`get_pull_request\`, \`get_issue\`, \`get_issue_comments\`, \`get_issue_events\`, \`get_review_comments\`, \`list_pull_request_reviews\`, \`get_check_suite_logs\`, \`get_commit_info\`.
- **Output**: \`${ghPullfrogMcpName}/upload_file\`, \`${ghPullfrogMcpName}/set_output\`.
## Output
When you finish, you MUST call \`${ghPullfrogMcpName}/set_output\` with your results. This is how your work gets back to the orchestrator — if you don't call it, your output is lost. Structure output as the instructions request. For research tasks, use well-organized markdown.`;
type BuildSubagentInstructionsParams = {
ctx: ToolContext;
label: string;
instructions: string;
};
function buildResolvedContext(params: BuildSubagentInstructionsParams): string {
let branch = "unknown";
try {
branch = execSync("git branch --show-current", { encoding: "utf-8", stdio: "pipe" }).trim();
} catch {
// git not available
}
const lines = [
`repo: ${params.ctx.repo.owner}/${params.ctx.repo.name}`,
`branch: ${branch}`,
`working_directory: ${process.cwd()}`,
`subagent_label: ${params.label}`,
];
return `[CONTEXT]\n${lines.join("\n")}`;
}
export function buildSubagentInstructions(
params: BuildSubagentInstructionsParams
): ResolvedInstructions {
const resolvedContext = buildResolvedContext(params);
const full = `${resolvedContext}\n\n${subagentSystemPreamble}\n\n---\n\n${params.instructions}`;
return {
full,
system: subagentSystemPreamble,
user: params.instructions,
eventInstructions: "",
event: "",
runtime: "",
};
}
type RunSubagentParams = {
ctx: ToolContext;
subagent: SubagentState;
effort: Effort;
instructions: string;
};
type RunSubagentResult = {
success: boolean;
error: string | undefined;
};
export async function runSubagent(params: RunSubagentParams): Promise<RunSubagentResult> {
return withLogPrefix(`[${params.subagent.label}]`, async () => {
params.subagent.keepAliveInterval = setInterval(markActivity, 30_000);
const mcpServer = await startSubagentMcpServer({
ctx: params.ctx,
subagentId: params.subagent.id,
});
// each subagent gets its own tmpdir so parallel agents don't clobber config files
const subagentTmpdir = join(params.ctx.tmpdir, params.subagent.id);
mkdirSync(subagentTmpdir, { recursive: true });
try {
const subagentPayload = { ...params.ctx.payload, effort: params.effort };
const subagentInstructions = buildSubagentInstructions({
ctx: params.ctx,
label: params.subagent.label,
instructions: params.instructions,
});
const result = await params.ctx.agent.run({
payload: subagentPayload,
mcpServerUrl: mcpServer.url,
tmpdir: subagentTmpdir,
instructions: subagentInstructions,
});
params.subagent.usage = result.usage;
writeFileSync(params.subagent.stdoutFilePath, result.output ?? "", "utf-8");
completeSubagent({ ctx: params.ctx, subagent: params.subagent, success: result.success });
return { success: result.success, error: result.error };
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
try {
writeFileSync(params.subagent.stdoutFilePath, "", "utf-8");
} catch {
// best-effort
}
completeSubagent({ ctx: params.ctx, subagent: params.subagent, success: false });
return { success: false, error: errorMessage };
} finally {
// propagate review metadata to orchestrator (even on failure — the review is on GitHub)
if (mcpServer.toolState.review) {
params.ctx.toolState.review = mcpServer.toolState.review;
}
await mcpServer.stop();
}
});
}
+21 -87
View File
@@ -5,30 +5,6 @@ import { type } from "arktype";
import { FastMCP } from "fastmcp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { execute, tool } from "./shared.ts";
import { buildSubagentInstructions } from "./subagent.ts";
describe("buildSubagentInstructions", () => {
it("includes system preamble, resolved context, and orchestrator prompt", () => {
const prompt = "Read file.ts and fix the type error.";
const ctx = {
repo: { owner: "test-owner", name: "test-repo" },
} as any;
const instructions = buildSubagentInstructions({
ctx,
label: "test-task",
instructions: prompt,
});
expect(instructions.user).toBe(prompt);
expect(instructions.full).toContain("[CONTEXT]");
expect(instructions.full).toContain("test-owner/test-repo");
expect(instructions.full).toContain("subagent_label: test-task");
expect(instructions.full).toContain("set_output");
expect(instructions.full).toContain(prompt);
});
});
// ─── per-server tool isolation integration test ─────────────────────────
// demonstrates the architecture: orchestrator and subagent get separate servers
function getRandomPort(): Promise<number> {
return new Promise((resolve, reject) => {
@@ -59,44 +35,27 @@ function mockTool(name: string, description: string) {
});
}
describe("per-server tool isolation - integration", () => {
let orchestratorServer: FastMCP;
let subagentServer: FastMCP;
let orchestratorUrl: string;
let subagentUrl: string;
describe("MCP server tool registration - integration", () => {
let server: FastMCP;
let serverUrl: string;
const clients: Client[] = [];
beforeAll(async () => {
const [orchestratorPort, subagentPort] = await Promise.all([getRandomPort(), getRandomPort()]);
orchestratorUrl = `http://127.0.0.1:${orchestratorPort}/mcp`;
subagentUrl = `http://127.0.0.1:${subagentPort}/mcp`;
const port = await getRandomPort();
serverUrl = `http://127.0.0.1:${port}/mcp`;
// orchestrator gets ALL tools (common + delegation + remote mutation)
orchestratorServer = new FastMCP({ name: "orchestrator", version: "0.0.1" });
orchestratorServer.addTool(mockTool("file_read", "read a file"));
orchestratorServer.addTool(mockTool("git", "run git commands"));
orchestratorServer.addTool(mockTool("set_output", "set output"));
orchestratorServer.addTool(mockTool("select_mode", "select a mode"));
orchestratorServer.addTool(mockTool("delegate", "delegate a task"));
orchestratorServer.addTool(mockTool("ask_question", "ask a question"));
orchestratorServer.addTool(mockTool("push_branch", "push branch"));
orchestratorServer.addTool(mockTool("create_pull_request", "create PR"));
server = new FastMCP({ name: "test-server", version: "0.0.1" });
server.addTool(mockTool("shell", "run shell commands"));
server.addTool(mockTool("git", "run git commands"));
server.addTool(mockTool("set_output", "set output"));
server.addTool(mockTool("select_mode", "select a mode"));
server.addTool(mockTool("push_branch", "push branch"));
server.addTool(mockTool("create_pull_request", "create PR"));
// subagent gets ONLY file ops, shell, read-only GitHub, upload, set_output
subagentServer = new FastMCP({ name: "subagent", version: "0.0.1" });
subagentServer.addTool(mockTool("file_read", "read a file"));
subagentServer.addTool(mockTool("set_output", "set output"));
await Promise.all([
orchestratorServer.start({
transportType: "httpStream",
httpStream: { port: orchestratorPort, host: "127.0.0.1", endpoint: "/mcp" },
}),
subagentServer.start({
transportType: "httpStream",
httpStream: { port: subagentPort, host: "127.0.0.1", endpoint: "/mcp" },
}),
]);
await server.start({
transportType: "httpStream",
httpStream: { port, host: "127.0.0.1", endpoint: "/mcp" },
});
});
afterAll(async () => {
@@ -107,45 +66,20 @@ describe("per-server tool isolation - integration", () => {
// best-effort cleanup
}
}
await Promise.all([orchestratorServer.stop(), subagentServer.stop()]);
await server.stop();
});
it("orchestrator sees all tools including delegation and mutation", async () => {
const client = await connectMcpClient(orchestratorUrl);
it("server exposes all registered tools", async () => {
const client = await connectMcpClient(serverUrl);
clients.push(client);
const result = await client.listTools();
const names = result.tools.map((t) => t.name);
expect(names).toContain("select_mode");
expect(names).toContain("delegate");
expect(names).toContain("ask_question");
expect(names).toContain("push_branch");
expect(names).toContain("create_pull_request");
expect(names).toContain("file_read");
expect(names).toContain("shell");
expect(names).toContain("git");
expect(names).toContain("set_output");
expect(names.length).toBe(8);
});
it("subagent cannot see orchestrator-only tools", async () => {
const client = await connectMcpClient(subagentUrl);
clients.push(client);
const result = await client.listTools();
const names = result.tools.map((t) => t.name);
expect(names).not.toContain("select_mode");
expect(names).not.toContain("delegate");
expect(names).not.toContain("ask_question");
expect(names).not.toContain("push_branch");
expect(names).not.toContain("create_pull_request");
expect(names).not.toContain("git");
});
it("subagent sees only file ops, read-only tools, and set_output", async () => {
const client = await connectMcpClient(subagentUrl);
clients.push(client);
const result = await client.listTools();
const names = result.tools.map((t) => t.name);
expect(names).toContain("file_read");
expect(names).toContain("set_output");
expect(names.length).toBe(2);
expect(names.length).toBe(6);
});
});