Add background mode to bash tool (#122)

* Implement background bash

* Tweaks
This commit is contained in:
Colin McDonnell
2026-01-19 17:47:01 +00:00
committed by pullfrog[bot]
parent f65cb4d2e3
commit fa01f9c06d
4 changed files with 285 additions and 59 deletions
+144 -43
View File
@@ -117237,11 +117237,15 @@ var Effort = type.enumerated("mini", "auto", "max");
// mcp/bash.ts // mcp/bash.ts
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { randomUUID as randomUUID2 } from "node:crypto";
import { closeSync, openSync, writeFileSync } from "node:fs";
import { join } from "node:path";
var BashParams = type({ var BashParams = type({
command: "string", command: "string",
description: "string", description: "string",
"timeout?": "number", "timeout?": "number",
"working_directory?": "string" "working_directory?": "string",
"background?": "boolean"
}); });
var SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i]; var SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
function isSensitive(key) { function isSensitive(key) {
@@ -117259,11 +117263,10 @@ function filterEnv(isPublicRepo) {
} }
return filtered; return filtered;
} }
function spawnSandboxed(command, options) { function spawnSandboxed(params) {
const stdio = ["ignore", "pipe", "pipe"]; const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true }; const useNamespaceIsolation = process.env.CI === "true" && params.isPublicRepo;
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo; return useNamespaceIsolation ? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", params.command], spawnOpts) : spawn("bash", ["-c", params.command], spawnOpts);
return useNamespaceIsolation ? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts) : spawn("bash", ["-c", command], spawnOpts);
} }
async function killProcessGroup(proc) { async function killProcessGroup(proc) {
if (!proc.pid) return; if (!proc.pid) return;
@@ -117278,6 +117281,13 @@ async function killProcessGroup(proc) {
} }
} }
} }
function getTempDir() {
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
throw new Error("PULLFROG_TEMP_DIR not set");
}
return tempDir;
}
function BashTool(ctx) { function BashTool(ctx) {
const isPublicRepo = !ctx.repo.repo.private; const isPublicRepo = !ctx.repo.repo.private;
return tool({ return tool({
@@ -117294,10 +117304,45 @@ Use this tool to:
execute: execute(async (params) => { execute: execute(async (params) => {
const timeout = Math.min(params.timeout ?? 12e4, 6e5); const timeout = Math.min(params.timeout ?? 12e4, 6e5);
const cwd2 = params.working_directory ?? process.cwd(); const cwd2 = params.working_directory ?? process.cwd();
const proc = spawnSandboxed(params.command, { const env3 = filterEnv(isPublicRepo);
env: filterEnv(isPublicRepo), if (params.background) {
const tempDir = getTempDir();
const handle = `bg-${randomUUID2().slice(0, 8)}`;
const outputPath = join(tempDir, `${handle}.log`);
const pidPath = join(tempDir, `${handle}.pid`);
const logFd = openSync(outputPath, "a");
let proc2;
try {
proc2 = spawnSandboxed({
command: params.command,
env: env3,
cwd: cwd2,
isPublicRepo,
stdio: ["ignore", logFd, logFd]
});
} finally {
closeSync(logFd);
}
if (!proc2.pid) {
throw new Error("failed to start background process");
}
proc2.unref();
writeFileSync(pidPath, `${proc2.pid}
`);
ctx.toolState.backgroundProcesses.set(handle, { pid: proc2.pid, outputPath, pidPath });
return {
handle,
outputPath,
pidPath,
message: `started background process ${handle} (pid ${proc2.pid})`
};
}
const proc = spawnSandboxed({
command: params.command,
env: env3,
cwd: cwd2, cwd: cwd2,
isPublicRepo isPublicRepo,
stdio: ["ignore", "pipe", "pipe"]
}); });
let stdout = "", stderr = "", timedOut = false, exited = false; let stdout = "", stderr = "", timedOut = false, exited = false;
proc.stdout?.on("data", (chunk) => { proc.stdout?.on("data", (chunk) => {
@@ -117334,10 +117379,43 @@ ${stderr}` : stderr : stdout;
}) })
}); });
} }
var KillBackgroundParams = type({
handle: type.string.describe("The handle of the background process to kill (e.g., bg-a1b2c3d4)")
});
function KillBackgroundTool(ctx) {
return tool({
name: "kill_background",
description: `Kill a background process by its handle. Use this to stop dev servers or other long-running processes started with bash({ background: true }).`,
parameters: KillBackgroundParams,
execute: execute(async (params) => {
const proc = ctx.toolState.backgroundProcesses.get(params.handle);
if (!proc) {
return {
success: false,
message: `no background process with handle ${params.handle}`
};
}
try {
process.kill(-proc.pid, "SIGTERM");
} catch {
}
await new Promise((resolve2) => setTimeout(resolve2, 200));
try {
process.kill(-proc.pid, "SIGKILL");
} catch {
}
ctx.toolState.backgroundProcesses.delete(params.handle);
return {
success: true,
message: `killed background process ${params.handle} (pid ${proc.pid})`
};
})
});
}
// mcp/checkout.ts // mcp/checkout.ts
import { writeFileSync } from "node:fs"; import { writeFileSync as writeFileSync2 } from "node:fs";
import { join } from "node:path"; import { join as join2 } from "node:path";
// utils/shell.ts // utils/shell.ts
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
@@ -117530,8 +117608,8 @@ ${diffPreview}`);
"PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context" "PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
); );
} }
const diffPath = join(tempDir, `pr-${pull_number}.diff`); const diffPath = join2(tempDir, `pr-${pull_number}.diff`);
writeFileSync(diffPath, diffContent); writeFileSync2(diffPath, diffContent);
log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`); log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`);
return { return {
success: true, success: true,
@@ -117652,7 +117730,7 @@ function DebugShellCommandTool(_ctx) {
// prep/installNodeDependencies.ts // prep/installNodeDependencies.ts
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { join as join2 } from "node:path"; import { join as join3 } from "node:path";
// node_modules/.pnpm/@ark+util@0.53.0/node_modules/@ark/util/out/domain.js // node_modules/.pnpm/@ark+util@0.53.0/node_modules/@ark/util/out/domain.js
var domainDescriptions2 = { var domainDescriptions2 = {
@@ -118239,7 +118317,7 @@ async function isCommandAvailable(command) {
return result.exitCode === 0; return result.exitCode === 0;
} }
function getPackageManagerFromPackageJson() { function getPackageManagerFromPackageJson() {
const packageJsonPath = join2(process.cwd(), "package.json"); const packageJsonPath = join3(process.cwd(), "package.json");
try { try {
const content = readFileSync(packageJsonPath, "utf-8"); const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content); const pkg = JSON.parse(content);
@@ -118270,7 +118348,7 @@ async function installPackageManager(name, installSpec) {
return result.stderr || `failed to install ${name}`; return result.stderr || `failed to install ${name}`;
} }
if (name === "deno") { if (name === "deno") {
const denoPath = join2(process.env.HOME || "", ".deno", "bin"); const denoPath = join3(process.env.HOME || "", ".deno", "bin");
process.env.PATH = `${denoPath}:${process.env.PATH}`; process.env.PATH = `${denoPath}:${process.env.PATH}`;
} }
log.info(`\xBB installed ${name}`); log.info(`\xBB installed ${name}`);
@@ -118279,7 +118357,7 @@ async function installPackageManager(name, installSpec) {
var installNodeDependencies = { var installNodeDependencies = {
name: "installNodeDependencies", name: "installNodeDependencies",
shouldRun: () => { shouldRun: () => {
const packageJsonPath = join2(process.cwd(), "package.json"); const packageJsonPath = join3(process.cwd(), "package.json");
return existsSync(packageJsonPath); return existsSync(packageJsonPath);
}, },
run: async () => { run: async () => {
@@ -118347,7 +118425,7 @@ ${errorMessage}`]
// prep/installPythonDependencies.ts // prep/installPythonDependencies.ts
import { existsSync as existsSync2 } from "node:fs"; import { existsSync as existsSync2 } from "node:fs";
import { join as join3 } from "node:path"; import { join as join4 } from "node:path";
var PYTHON_CONFIGS = [ var PYTHON_CONFIGS = [
{ {
file: "requirements.txt", file: "requirements.txt",
@@ -118419,11 +118497,11 @@ var installPythonDependencies = {
return false; return false;
} }
const cwd2 = process.cwd(); const cwd2 = process.cwd();
return PYTHON_CONFIGS.some((config4) => existsSync2(join3(cwd2, config4.file))); return PYTHON_CONFIGS.some((config4) => existsSync2(join4(cwd2, config4.file)));
}, },
run: async () => { run: async () => {
const cwd2 = process.cwd(); const cwd2 = process.cwd();
const config4 = PYTHON_CONFIGS.find((c) => existsSync2(join3(cwd2, c.file))); const config4 = PYTHON_CONFIGS.find((c) => existsSync2(join4(cwd2, c.file)));
if (!config4) { if (!config4) {
return { return {
language: "python", language: "python",
@@ -119213,8 +119291,8 @@ function CreatePullRequestReviewTool(ctx) {
} }
// mcp/reviewComments.ts // mcp/reviewComments.ts
import { writeFileSync as writeFileSync2 } from "node:fs"; import { writeFileSync as writeFileSync3 } from "node:fs";
import { join as join4 } from "node:path"; import { join as join5 } from "node:path";
var GetReviewComments = type({ var GetReviewComments = type({
pull_number: type.number.describe("The pull request number"), pull_number: type.number.describe("The pull request number"),
review_id: type.number.describe("The review ID to get comments for"), review_id: type.number.describe("The review ID to get comments for"),
@@ -119281,8 +119359,8 @@ function GetReviewCommentsTool(ctx) {
throw new Error("PULLFROG_TEMP_DIR not set"); throw new Error("PULLFROG_TEMP_DIR not set");
} }
const filename = approved_by ? `review-${review_id}-approved-by-${approved_by}.txt` : `review-${review_id}-comments.txt`; const filename = approved_by ? `review-${review_id}-approved-by-${approved_by}.txt` : `review-${review_id}-comments.txt`;
const commentsPath = join4(tempDir, filename); const commentsPath = join5(tempDir, filename);
writeFileSync2(commentsPath, content); writeFileSync3(commentsPath, content);
log.debug(`wrote ${reviewComments.length} comments to ${commentsPath}`); log.debug(`wrote ${reviewComments.length} comments to ${commentsPath}`);
return { return {
review_id, review_id,
@@ -119360,7 +119438,8 @@ function initToolState(ctx) {
progressComment: { progressComment: {
id: Number.isNaN(progressCommentId) ? null : progressCommentId, id: Number.isNaN(progressCommentId) ? null : progressCommentId,
wasUpdated: false wasUpdated: false
} },
backgroundProcesses: /* @__PURE__ */ new Map()
}; };
} }
async function findAvailablePort(startPort) { async function findAvailablePort(startPort) {
@@ -119387,6 +119466,24 @@ async function findAvailablePort(startPort) {
} }
throw new Error(`Could not find available port starting from ${startPort}`); throw new Error(`Could not find available port starting from ${startPort}`);
} }
async function killBackgroundProcesses(toolState) {
const backgroundProcesses = toolState.backgroundProcesses;
if (backgroundProcesses.size === 0) return;
for (const proc of backgroundProcesses.values()) {
try {
process.kill(-proc.pid, "SIGTERM");
} catch {
}
}
await new Promise((resolve2) => setTimeout(resolve2, 200));
for (const proc of backgroundProcesses.values()) {
try {
process.kill(-proc.pid, "SIGKILL");
} catch {
}
}
backgroundProcesses.clear();
}
async function startMcpHttpServer(ctx) { async function startMcpHttpServer(ctx) {
const server = new FastMCP({ const server = new FastMCP({
name: ghPullfrogMcpName, name: ghPullfrogMcpName,
@@ -119419,6 +119516,7 @@ async function startMcpHttpServer(ctx) {
const bash = ctx.payload.bash ?? "enabled"; const bash = ctx.payload.bash ?? "enabled";
if (bash !== "disabled") { if (bash !== "disabled") {
tools.push(BashTool(ctx)); tools.push(BashTool(ctx));
tools.push(KillBackgroundTool(ctx));
} }
tools.push(ReportProgressTool(ctx)); tools.push(ReportProgressTool(ctx));
addTools(ctx, server, tools); addTools(ctx, server, tools);
@@ -119437,6 +119535,7 @@ async function startMcpHttpServer(ctx) {
return { return {
url: url4, url: url4,
[Symbol.asyncDispose]: async () => { [Symbol.asyncDispose]: async () => {
await killBackgroundProcesses(ctx.toolState);
await server.stop(); await server.stop();
} }
}; };
@@ -119593,7 +119692,7 @@ function computeModes() {
var modes = computeModes(); var modes = computeModes();
// node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.2.7_zod@4.3.5/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs // node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.2.7_zod@4.3.5/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs
import { join as join5 } from "path"; import { join as join52 } from "path";
import { fileURLToPath as fileURLToPath2 } from "url"; import { fileURLToPath as fileURLToPath2 } from "url";
import { setMaxListeners } from "events"; import { setMaxListeners } from "events";
import { spawn as spawn3 } from "child_process"; import { spawn as spawn3 } from "child_process";
@@ -119605,11 +119704,11 @@ import { homedir } from "os";
import { dirname, join as join22 } from "path"; import { dirname, join as join22 } from "path";
import { cwd } from "process"; import { cwd } from "process";
import { realpathSync as realpathSync2 } from "fs"; import { realpathSync as realpathSync2 } from "fs";
import { randomUUID as randomUUID2 } from "crypto"; import { randomUUID as randomUUID3 } from "crypto";
import { randomUUID as randomUUID22 } from "crypto"; import { randomUUID as randomUUID22 } from "crypto";
import { appendFileSync as appendFileSync2, existsSync as existsSync22, mkdirSync as mkdirSync2 } from "fs"; import { appendFileSync as appendFileSync2, existsSync as existsSync22, mkdirSync as mkdirSync2 } from "fs";
import { join as join32 } from "path"; import { join as join32 } from "path";
import { randomUUID as randomUUID3 } from "crypto"; import { randomUUID as randomUUID32 } from "crypto";
var __create3 = Object.create; var __create3 = Object.create;
var __getProtoOf3 = Object.getPrototypeOf; var __getProtoOf3 = Object.getPrototypeOf;
var __defProp3 = Object.defineProperty; var __defProp3 = Object.defineProperty;
@@ -126417,7 +126516,7 @@ function getInitialState() {
tokenCounter: null, tokenCounter: null,
codeEditToolDecisionCounter: null, codeEditToolDecisionCounter: null,
activeTimeCounter: null, activeTimeCounter: null,
sessionId: randomUUID2(), sessionId: randomUUID3(),
loggerProvider: null, loggerProvider: null,
eventLogger: null, eventLogger: null,
meterProvider: null, meterProvider: null,
@@ -127729,7 +127828,7 @@ var Query = class {
} }
const controlRequest = { const controlRequest = {
type: "control_request", type: "control_request",
request_id: randomUUID3(), request_id: randomUUID32(),
request: { request: {
subtype: "mcp_message", subtype: "mcp_message",
server_name: serverName, server_name: serverName,
@@ -135989,8 +136088,8 @@ function query({
let pathToClaudeCodeExecutable = rest.pathToClaudeCodeExecutable; let pathToClaudeCodeExecutable = rest.pathToClaudeCodeExecutable;
if (!pathToClaudeCodeExecutable) { if (!pathToClaudeCodeExecutable) {
const filename = fileURLToPath2(import.meta.url); const filename = fileURLToPath2(import.meta.url);
const dirname2 = join5(filename, ".."); const dirname2 = join52(filename, "..");
pathToClaudeCodeExecutable = join5(dirname2, "cli.js"); pathToClaudeCodeExecutable = join52(dirname2, "cli.js");
} }
process.env.CLAUDE_AGENT_SDK_VERSION = "0.2.7"; process.env.CLAUDE_AGENT_SDK_VERSION = "0.2.7";
const { const {
@@ -136554,7 +136653,7 @@ var messageHandlers = {
}; };
// agents/codex.ts // agents/codex.ts
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs"; import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "node:fs";
import { join as join8 } from "node:path"; import { join as join8 } from "node:path";
// node_modules/.pnpm/@openai+codex-sdk@0.80.0/node_modules/@openai/codex-sdk/dist/index.js // node_modules/.pnpm/@openai+codex-sdk@0.80.0/node_modules/@openai/codex-sdk/dist/index.js
@@ -136929,7 +137028,7 @@ url = "${ctx.mcpServerUrl}"`];
} }
const featuresSection = features.length > 0 ? `[features] const featuresSection = features.length > 0 ? `[features]
${features.join("\n")}` : ""; ${features.join("\n")}` : "";
writeFileSync3( writeFileSync4(
configPath, configPath,
`# written by pullfrog `# written by pullfrog
${featuresSection} ${featuresSection}
@@ -137097,7 +137196,7 @@ var messageHandlers2 = {
// agents/cursor.ts // agents/cursor.ts
import { spawn as spawn5 } from "node:child_process"; import { spawn as spawn5 } from "node:child_process";
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs"; import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync5 } from "node:fs";
import { homedir as homedir2 } from "node:os"; import { homedir as homedir2 } from "node:os";
import { join as join9 } from "node:path"; import { join as join9 } from "node:path";
var cursorEffortModels = { var cursorEffortModels = {
@@ -137289,7 +137388,7 @@ function configureCursorMcpServers(ctx) {
const mcpServers = { const mcpServers = {
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl } [ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl }
}; };
writeFileSync4(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8"); writeFileSync5(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8");
log.info(`\xBB MCP config written to ${mcpConfigPath}`); log.info(`\xBB MCP config written to ${mcpConfigPath}`);
} }
function configureCursorTools(ctx) { function configureCursorTools(ctx) {
@@ -137314,12 +137413,12 @@ function configureCursorTools(ctx) {
networkAccess: "allowlist" networkAccess: "allowlist"
}; };
} }
writeFileSync4(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8"); writeFileSync5(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8");
log.info(`\xBB CLI config written to ${cliConfigPath}`, JSON.stringify(config4, null, 2)); log.info(`\xBB CLI config written to ${cliConfigPath}`, JSON.stringify(config4, null, 2));
} }
// agents/gemini.ts // agents/gemini.ts
import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs"; import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync6 } from "node:fs";
import { homedir as homedir3 } from "node:os"; import { homedir as homedir3 } from "node:os";
import { join as join10 } from "node:path"; import { join as join10 } from "node:path";
var geminiEffortConfig = { var geminiEffortConfig = {
@@ -137530,7 +137629,7 @@ function configureGeminiSettings(ctx) {
// v0.3.0+ nested format // v0.3.0+ nested format
...exclude.length > 0 && { tools: { exclude } } ...exclude.length > 0 && { tools: { exclude } }
}; };
writeFileSync5(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); writeFileSync6(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
log.info(`\xBB Gemini settings written to ${settingsPath}`); log.info(`\xBB Gemini settings written to ${settingsPath}`);
if (exclude.length > 0) { if (exclude.length > 0) {
log.info(`\xBB excluded tools: ${exclude.join(", ")}`); log.info(`\xBB excluded tools: ${exclude.join(", ")}`);
@@ -137539,7 +137638,7 @@ function configureGeminiSettings(ctx) {
} }
// agents/opencode.ts // agents/opencode.ts
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs"; import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync7 } from "node:fs";
import { join as join11 } from "node:path"; import { join as join11 } from "node:path";
async function installOpencode() { async function installOpencode() {
return await installFromNpmTarball({ return await installFromNpmTarball({
@@ -137686,7 +137785,7 @@ function configureOpenCode(ctx) {
}; };
const configJson = JSON.stringify(config4, null, 2); const configJson = JSON.stringify(config4, null, 2);
try { try {
writeFileSync6(configPath, configJson, "utf-8"); writeFileSync7(configPath, configJson, "utf-8");
} catch (error50) { } catch (error50) {
log.error( log.error(
`failed to write OpenCode config to ${configPath}: ${error50 instanceof Error ? error50.message : String(error50)}` `failed to write OpenCode config to ${configPath}: ${error50 instanceof Error ? error50.message : String(error50)}`
@@ -137997,13 +138096,14 @@ function buildRuntimeContext(ctx) {
return lines.join("\n"); return lines.join("\n");
} }
function getShellInstructions(bash) { function getShellInstructions(bash) {
const backgroundInstructions = `For long-running processes (dev servers, watchers), use \`bash({ command, background: true })\` which returns a handle. Use \`${ghPullfrogMcpName}/kill_background\` to stop background processes by handle.`;
switch (bash) { switch (bash) {
case "disabled": case "disabled":
return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`; return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`;
case "restricted": case "restricted":
return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`; return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security. ${backgroundInstructions}`;
case "enabled": case "enabled":
return `**Shell commands**: Use your native bash/shell tool for shell command execution.`; return `**Shell commands**: Use your native bash/shell tool for shell command execution. ${backgroundInstructions}`;
default: { default: {
const _exhaustive = bash; const _exhaustive = bash;
return _exhaustive; return _exhaustive;
@@ -138039,6 +138139,7 @@ Your code is focused, elegant, and production-ready.
You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so. You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so.
You adapt your writing style to match existing patterns in the codebase (commit messages, PR descriptions, code comments) while never being unprofessional. You adapt your writing style to match existing patterns in the codebase (commit messages, PR descriptions, code comments) while never being unprofessional.
You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions. You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions.
You are running inside a GitHub Actions ephemeral environment. All processes and resources will be cleaned up at the end of the run.
You make assumptions when details are missing by preferring the most common convention unless repo-specific patterns exist. Fail with an explicit error only if critical information is missing (e.g. user asks to review a PR but does not provide a link or ID). You make assumptions when details are missing by preferring the most common convention unless repo-specific patterns exist. Fail with an explicit error only if critical information is missing (e.g. user asks to review a PR but does not provide a link or ID).
Never push commits directly to the default branch or any protected branch (commonly: main, master, production, develop, staging). Always create a feature branch. Branch names must follow the pattern: \`pullfrog/<issue-number>-<kebab-case-description>\` (e.g., \`pullfrog/123-fix-login-bug\`). Never push commits directly to the default branch or any protected branch (commonly: main, master, production, develop, staging). Always create a feature branch. Branch names must follow the pattern: \`pullfrog/<issue-number>-<kebab-case-description>\` (e.g., \`pullfrog/123-fix-login-bug\`).
Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages. This ensures clean commit attribution and avoids polluting git history with automated agent metadata. Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages. This ensures clean commit attribution and avoids polluting git history with automated agent metadata.
+103 -12
View File
@@ -1,4 +1,7 @@
import { type ChildProcess, spawn } from "node:child_process"; import { type ChildProcess, type StdioOptions, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { closeSync, openSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
@@ -8,6 +11,7 @@ export const BashParams = type({
description: "string", description: "string",
"timeout?": "number", "timeout?": "number",
"working_directory?": "string", "working_directory?": "string",
"background?": "boolean",
}); });
// patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes // patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes
@@ -34,21 +38,25 @@ function filterEnv(isPublicRepo: boolean): Record<string, string> {
return filtered; return filtered;
} }
type SpawnSandboxedParams = {
command: string;
env: Record<string, string>;
cwd: string;
isPublicRepo: boolean;
stdio: StdioOptions;
};
/** /**
* spawn command with filtered env. in CI, also use PID namespace isolation * spawn command with filtered env. in CI, also use PID namespace isolation
* to prevent child from reading /proc/$PPID/environ (only for public repos) * to prevent child from reading /proc/$PPID/environ (only for public repos)
*/ */
function spawnSandboxed( function spawnSandboxed(params: SpawnSandboxedParams): ChildProcess {
command: string, const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
options: { env: Record<string, string>; cwd: string; isPublicRepo: boolean }
): ChildProcess {
const stdio: ["ignore", "pipe", "pipe"] = ["ignore", "pipe", "pipe"];
const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true };
// only use PID namespace isolation for public repos in CI // only use PID namespace isolation for public repos in CI
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo; const useNamespaceIsolation = process.env.CI === "true" && params.isPublicRepo;
return useNamespaceIsolation return useNamespaceIsolation
? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts) ? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", params.command], spawnOpts)
: spawn("bash", ["-c", command], spawnOpts); : spawn("bash", ["-c", params.command], spawnOpts);
} }
/** kill process and its entire process group */ /** kill process and its entire process group */
@@ -67,6 +75,14 @@ async function killProcessGroup(proc: ChildProcess): Promise<void> {
} }
} }
function getTempDir(): string {
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
throw new Error("PULLFROG_TEMP_DIR not set");
}
return tempDir;
}
export function BashTool(ctx: ToolContext) { export function BashTool(ctx: ToolContext) {
const isPublicRepo = !ctx.repo.repo.private; const isPublicRepo = !ctx.repo.repo.private;
@@ -84,10 +100,46 @@ Use this tool to:
execute: execute(async (params) => { execute: execute(async (params) => {
const timeout = Math.min(params.timeout ?? 120000, 600000); const timeout = Math.min(params.timeout ?? 120000, 600000);
const cwd = params.working_directory ?? process.cwd(); const cwd = params.working_directory ?? process.cwd();
const proc = spawnSandboxed(params.command, { const env = filterEnv(isPublicRepo);
env: filterEnv(isPublicRepo),
if (params.background) {
const tempDir = getTempDir();
const handle = `bg-${randomUUID().slice(0, 8)}`;
const outputPath = join(tempDir, `${handle}.log`);
const pidPath = join(tempDir, `${handle}.pid`);
const logFd = openSync(outputPath, "a");
let proc: ChildProcess;
try {
proc = spawnSandboxed({
command: params.command,
env,
cwd,
isPublicRepo,
stdio: ["ignore", logFd, logFd],
});
} finally {
closeSync(logFd);
}
if (!proc.pid) {
throw new Error("failed to start background process");
}
proc.unref();
writeFileSync(pidPath, `${proc.pid}\n`);
ctx.toolState.backgroundProcesses.set(handle, { pid: proc.pid, outputPath, pidPath });
return {
handle,
outputPath,
pidPath,
message: `started background process ${handle} (pid ${proc.pid})`,
};
}
const proc = spawnSandboxed({
command: params.command,
env,
cwd, cwd,
isPublicRepo, isPublicRepo,
stdio: ["ignore", "pipe", "pipe"],
}); });
let stdout = "", let stdout = "",
@@ -132,3 +184,42 @@ Use this tool to:
}), }),
}); });
} }
export const KillBackgroundParams = type({
handle: type.string.describe("The handle of the background process to kill (e.g., bg-a1b2c3d4)"),
});
export function KillBackgroundTool(ctx: ToolContext) {
return tool({
name: "kill_background",
description: `Kill a background process by its handle. Use this to stop dev servers or other long-running processes started with bash({ background: true }).`,
parameters: KillBackgroundParams,
execute: execute(async (params) => {
const proc = ctx.toolState.backgroundProcesses.get(params.handle);
if (!proc) {
return {
success: false,
message: `no background process with handle ${params.handle}`,
};
}
try {
process.kill(-proc.pid, "SIGTERM");
} catch {
// already dead
}
await new Promise((resolve) => setTimeout(resolve, 200));
try {
process.kill(-proc.pid, "SIGKILL");
} catch {
// already dead
}
ctx.toolState.backgroundProcesses.delete(params.handle);
return {
success: true,
message: `killed background process ${params.handle} (pid ${proc.pid})`,
};
}),
});
}
+33 -2
View File
@@ -10,10 +10,17 @@ import type { OctokitWithPlugins } from "../utils/github.ts";
import type { ResolvedPayload } from "../utils/payload.ts"; import type { ResolvedPayload } from "../utils/payload.ts";
import type { RepoData } from "../utils/repoData.ts"; import type { RepoData } from "../utils/repoData.ts";
export type BackgroundProcess = {
pid: number;
outputPath: string;
pidPath: string;
};
export interface ToolState { export interface ToolState {
prNumber?: number; prNumber?: number;
issueNumber?: number; issueNumber?: number;
selectedMode?: string; selectedMode?: string;
backgroundProcesses: Map<string, BackgroundProcess>;
review?: { review?: {
id: number; id: number;
nodeId: string; nodeId: string;
@@ -45,6 +52,7 @@ export function initToolState(ctx: InitToolStateParams): ToolState {
id: Number.isNaN(progressCommentId) ? null : progressCommentId, id: Number.isNaN(progressCommentId) ? null : progressCommentId,
wasUpdated: false, wasUpdated: false,
}, },
backgroundProcesses: new Map(),
}; };
} }
@@ -60,7 +68,7 @@ export interface ToolContext {
jobId: string | undefined; jobId: string | undefined;
} }
import { BashTool } from "./bash.ts"; import { BashTool, KillBackgroundTool } from "./bash.ts";
import { CheckoutPrTool } from "./checkout.ts"; import { CheckoutPrTool } from "./checkout.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts"; import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
import { import {
@@ -116,6 +124,27 @@ async function findAvailablePort(startPort: number): Promise<number> {
throw new Error(`Could not find available port starting from ${startPort}`); throw new Error(`Could not find available port starting from ${startPort}`);
} }
async function killBackgroundProcesses(toolState: ToolState): Promise<void> {
const backgroundProcesses = toolState.backgroundProcesses;
if (backgroundProcesses.size === 0) return;
for (const proc of backgroundProcesses.values()) {
try {
process.kill(-proc.pid, "SIGTERM");
} catch {
// already dead
}
}
await new Promise((resolve) => setTimeout(resolve, 200));
for (const proc of backgroundProcesses.values()) {
try {
process.kill(-proc.pid, "SIGKILL");
} catch {
// already dead
}
}
backgroundProcesses.clear();
}
/** /**
* Start the MCP HTTP server and return the URL and close function * Start the MCP HTTP server and return the URL and close function
*/ */
@@ -153,13 +182,14 @@ export async function startMcpHttpServer(
PushBranchTool(ctx), PushBranchTool(ctx),
]; ];
// only add BashTool if bash is not disabled // only add BashTool and KillBackgroundTool if bash is not disabled
// - "enabled": native bash + MCP bash // - "enabled": native bash + MCP bash
// - "restricted": MCP bash only (native blocked by agent) // - "restricted": MCP bash only (native blocked by agent)
// - "disabled": no bash at all // - "disabled": no bash at all
const bash = ctx.payload.bash ?? "enabled"; const bash = ctx.payload.bash ?? "enabled";
if (bash !== "disabled") { if (bash !== "disabled") {
tools.push(BashTool(ctx)); tools.push(BashTool(ctx));
tools.push(KillBackgroundTool(ctx));
} }
tools.push(ReportProgressTool(ctx)); tools.push(ReportProgressTool(ctx));
@@ -184,6 +214,7 @@ export async function startMcpHttpServer(
return { return {
url, url,
[Symbol.asyncDispose]: async () => { [Symbol.asyncDispose]: async () => {
await killBackgroundProcesses(ctx.toolState);
await server.stop(); await server.stop();
}, },
}; };
+5 -2
View File
@@ -45,13 +45,15 @@ function buildRuntimeContext(ctx: InstructionsContext): string {
} }
function getShellInstructions(bash: ResolvedPayload["bash"]): string { function getShellInstructions(bash: ResolvedPayload["bash"]): string {
const backgroundInstructions = `For long-running processes (dev servers, watchers), use \`bash({ command, background: true })\` which returns a handle. Use \`${ghPullfrogMcpName}/kill_background\` to stop background processes by handle.`;
switch (bash) { switch (bash) {
case "disabled": case "disabled":
return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`; return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`;
case "restricted": case "restricted":
return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`; return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security. ${backgroundInstructions}`;
case "enabled": case "enabled":
return `**Shell commands**: Use your native bash/shell tool for shell command execution.`; return `**Shell commands**: Use your native bash/shell tool for shell command execution. ${backgroundInstructions}`;
default: { default: {
const _exhaustive: never = bash; const _exhaustive: never = bash;
return _exhaustive satisfies never; return _exhaustive satisfies never;
@@ -107,6 +109,7 @@ Your code is focused, elegant, and production-ready.
You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so. You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so.
You adapt your writing style to match existing patterns in the codebase (commit messages, PR descriptions, code comments) while never being unprofessional. You adapt your writing style to match existing patterns in the codebase (commit messages, PR descriptions, code comments) while never being unprofessional.
You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions. You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions.
You are running inside a GitHub Actions ephemeral environment. All processes and resources will be cleaned up at the end of the run.
You make assumptions when details are missing by preferring the most common convention unless repo-specific patterns exist. Fail with an explicit error only if critical information is missing (e.g. user asks to review a PR but does not provide a link or ID). You make assumptions when details are missing by preferring the most common convention unless repo-specific patterns exist. Fail with an explicit error only if critical information is missing (e.g. user asks to review a PR but does not provide a link or ID).
Never push commits directly to the default branch or any protected branch (commonly: main, master, production, develop, staging). Always create a feature branch. Branch names must follow the pattern: \`pullfrog/<issue-number>-<kebab-case-description>\` (e.g., \`pullfrog/123-fix-login-bug\`). Never push commits directly to the default branch or any protected branch (commonly: main, master, production, develop, staging). Always create a feature branch. Branch names must follow the pattern: \`pullfrog/<issue-number>-<kebab-case-description>\` (e.g., \`pullfrog/123-fix-login-bug\`).
Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages. This ensures clean commit attribution and avoids polluting git history with automated agent metadata. Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages. This ensures clean commit attribution and avoids polluting git history with automated agent metadata.