Add background mode to bash tool (#122)
* Implement background bash * Tweaks
This commit is contained in:
committed by
pullfrog[bot]
parent
f65cb4d2e3
commit
fa01f9c06d
+103
-12
@@ -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 { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
@@ -8,6 +11,7 @@ export const BashParams = type({
|
||||
description: "string",
|
||||
"timeout?": "number",
|
||||
"working_directory?": "string",
|
||||
"background?": "boolean",
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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
|
||||
* to prevent child from reading /proc/$PPID/environ (only for public repos)
|
||||
*/
|
||||
function spawnSandboxed(
|
||||
command: string,
|
||||
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 };
|
||||
function spawnSandboxed(params: SpawnSandboxedParams): ChildProcess {
|
||||
const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
|
||||
// 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
|
||||
? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts)
|
||||
: spawn("bash", ["-c", command], spawnOpts);
|
||||
? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", params.command], spawnOpts)
|
||||
: spawn("bash", ["-c", params.command], spawnOpts);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const isPublicRepo = !ctx.repo.repo.private;
|
||||
|
||||
@@ -84,10 +100,46 @@ Use this tool to:
|
||||
execute: execute(async (params) => {
|
||||
const timeout = Math.min(params.timeout ?? 120000, 600000);
|
||||
const cwd = params.working_directory ?? process.cwd();
|
||||
const proc = spawnSandboxed(params.command, {
|
||||
env: filterEnv(isPublicRepo),
|
||||
const 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,
|
||||
isPublicRepo,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
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
@@ -10,10 +10,17 @@ import type { OctokitWithPlugins } from "../utils/github.ts";
|
||||
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||
import type { RepoData } from "../utils/repoData.ts";
|
||||
|
||||
export type BackgroundProcess = {
|
||||
pid: number;
|
||||
outputPath: string;
|
||||
pidPath: string;
|
||||
};
|
||||
|
||||
export interface ToolState {
|
||||
prNumber?: number;
|
||||
issueNumber?: number;
|
||||
selectedMode?: string;
|
||||
backgroundProcesses: Map<string, BackgroundProcess>;
|
||||
review?: {
|
||||
id: number;
|
||||
nodeId: string;
|
||||
@@ -45,6 +52,7 @@ export function initToolState(ctx: InitToolStateParams): ToolState {
|
||||
id: Number.isNaN(progressCommentId) ? null : progressCommentId,
|
||||
wasUpdated: false,
|
||||
},
|
||||
backgroundProcesses: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,7 +68,7 @@ export interface ToolContext {
|
||||
jobId: string | undefined;
|
||||
}
|
||||
|
||||
import { BashTool } from "./bash.ts";
|
||||
import { BashTool, KillBackgroundTool } from "./bash.ts";
|
||||
import { CheckoutPrTool } from "./checkout.ts";
|
||||
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
||||
import {
|
||||
@@ -116,6 +124,27 @@ async function findAvailablePort(startPort: number): Promise<number> {
|
||||
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
|
||||
*/
|
||||
@@ -153,13 +182,14 @@ export async function startMcpHttpServer(
|
||||
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
|
||||
// - "restricted": MCP bash only (native blocked by agent)
|
||||
// - "disabled": no bash at all
|
||||
const bash = ctx.payload.bash ?? "enabled";
|
||||
if (bash !== "disabled") {
|
||||
tools.push(BashTool(ctx));
|
||||
tools.push(KillBackgroundTool(ctx));
|
||||
}
|
||||
|
||||
tools.push(ReportProgressTool(ctx));
|
||||
@@ -184,6 +214,7 @@ export async function startMcpHttpServer(
|
||||
return {
|
||||
url,
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
await killBackgroundProcesses(ctx.toolState);
|
||||
await server.stop();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user