Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions mcp/src/repo/__tests__/confinement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,38 @@ describe("acquireEphemeralWorktree", () => {
if (!result.ok) assert.match(result.error, /Path traversal rejected/);
});
});

// ---------------------------------------------------------------------------
// 5. web_search withheld on confined runs
// ---------------------------------------------------------------------------

describe("web_search server tool on confined runs", () => {
async function buildTools(options: GetToolsOptions | undefined) {
return get_tools(
baseCloneDir,
"fake-api-key",
FAKE_PAT,
{ bash: true, web_search: true },
"anthropic",
undefined, undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, undefined, undefined,
options,
);
}

it("unconfined run: web_search is registered", async () => {
const tools = await buildTools(undefined);
assert.ok((tools as any).web_search, "web_search must be present on an unconfined anthropic run");
});

it("ephemeral run: no web_search — a server tool in the request is what let a gateway's tool rewrite surface an unregistered code_execution call", async () => {
const tools = await buildTools({ ephemeral: true, baseCheckoutPath: "/tmp/owner/repo" });
assert.equal((tools as any).web_search, undefined);
assert.ok((tools as any).bash?.execute, "bash stays available");
});

it("prMode run: no web_search", async () => {
const tools = await buildTools(PR_MODE_OPTS(baseCloneDir));
assert.equal((tools as any).web_search, undefined);
});
});
140 changes: 140 additions & 0 deletions mcp/src/repo/__tests__/unresolved_tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Tests for the tool-loop exit guard (utils.ts):
* unresolvedToolCalls — which tool calls on a step the loop could not resolve
* stripToolCallParts — removing those calls from a replayable transcript
*
* Background: the AI SDK ends its tool loop on a step whose client-side call
* and output counts disagree, without an error or a stop condition. Seen in
* production when an LLM gateway rewrote a server tool's version and the API
* answered with a server_tool_use (code_execution) the run never registered.
*
* Harness: node:test (tsx --test). Pure functions, no fixtures.
*/

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { ModelMessage, StepResult, ToolSet } from "ai";

import { unresolvedToolCalls, stripToolCallParts } from "../utils.js";

const TOOLS = ["bash", "web_search"];

function step(content: unknown[]): StepResult<ToolSet> {
return { content, finishReason: "tool-calls" } as unknown as StepResult<ToolSet>;
}

describe("unresolvedToolCalls", () => {
it("returns nothing for an undefined step", () => {
assert.deepEqual(unresolvedToolCalls(undefined, TOOLS), []);
});

it("a registered tool with a result is resolved", () => {
const s = step([
{ type: "tool-call", toolCallId: "a", toolName: "bash", input: {} },
{ type: "tool-result", toolCallId: "a", toolName: "bash", output: "ok" },
]);
assert.deepEqual(unresolvedToolCalls(s, TOOLS), []);
});

it("a registered tool whose execution errored is still resolved", () => {
const s = step([
{ type: "tool-call", toolCallId: "a", toolName: "bash", input: {} },
{ type: "tool-error", toolCallId: "a", toolName: "bash", error: "boom" },
]);
assert.deepEqual(unresolvedToolCalls(s, TOOLS), []);
});

it("the production shape: an invalid provider-executed call next to a resolved bash call", () => {
// What the SDK recorded on 2026-09-15: code_execution (never registered)
// marked invalid with a tool-error, plus the bash edit that did run.
const s = step([
{ type: "text", text: "Now let's apply the edit." },
{
type: "tool-call",
toolCallId: "srv_1",
toolName: "code_execution",
input: { type: "text_editor_code_execution", command: "str_replace" },
providerExecuted: true,
dynamic: true,
invalid: true,
},
{ type: "tool-error", toolCallId: "srv_1", toolName: "code_execution", error: "No such tool", dynamic: true },
{ type: "tool-call", toolCallId: "b", toolName: "bash", input: { command: "python3 - <<'EOF' ..." } },
{ type: "tool-result", toolCallId: "b", toolName: "bash", output: "done" },
]);
assert.deepEqual(unresolvedToolCalls(s, TOOLS), [
{ toolCallId: "srv_1", toolName: "code_execution" },
]);
});

it("an unknown tool name is unresolved even when it has a result", () => {
const s = step([
{ type: "tool-call", toolCallId: "x", toolName: "apply_patch", input: {} },
{ type: "tool-result", toolCallId: "x", toolName: "apply_patch", output: "ok" },
]);
assert.deepEqual(unresolvedToolCalls(s, TOOLS), [{ toolCallId: "x", toolName: "apply_patch" }]);
});

it("a registered tool with no output is unresolved", () => {
const s = step([{ type: "tool-call", toolCallId: "a", toolName: "bash", input: {} }]);
assert.deepEqual(unresolvedToolCalls(s, TOOLS), [{ toolCallId: "a", toolName: "bash" }]);
});

it("accepts a ToolSet as well as a name list", () => {
const tools = { bash: {}, web_search: {} } as unknown as ToolSet;
const s = step([
{ type: "tool-call", toolCallId: "a", toolName: "bash", input: {} },
{ type: "tool-result", toolCallId: "a", toolName: "bash", output: "ok" },
{ type: "tool-call", toolCallId: "z", toolName: "nope", input: {} },
{ type: "tool-result", toolCallId: "z", toolName: "nope", output: "ok" },
]);
assert.deepEqual(unresolvedToolCalls(s, tools), [{ toolCallId: "z", toolName: "nope" }]);
});
});

describe("stripToolCallParts", () => {
const messages: ModelMessage[] = [
{
role: "assistant",
content: [
{ type: "text", text: "Now let's apply the edit." },
{ type: "tool-call", toolCallId: "srv_1", toolName: "code_execution", input: {} },
{ type: "tool-call", toolCallId: "b", toolName: "bash", input: { command: "true" } },
],
},
{
role: "tool",
content: [
{ type: "tool-result", toolCallId: "srv_1", toolName: "code_execution", output: { type: "error-text", value: "no such tool" } },
{ type: "tool-result", toolCallId: "b", toolName: "bash", output: { type: "text", value: "done" } },
],
},
] as ModelMessage[];

it("returns the input untouched when there is nothing to strip", () => {
assert.equal(stripToolCallParts(messages, new Set()), messages);
});

it("removes the call and its result, keeping everything else in order", () => {
const out = stripToolCallParts(messages, new Set(["srv_1"]));
assert.equal(out.length, 2);
const asst = out[0].content as Array<{ type: string; toolCallId?: string }>;
assert.deepEqual(asst.map((p) => p.type), ["text", "tool-call"]);
assert.equal(asst[1].toolCallId, "b");
const tool = out[1].content as Array<{ toolCallId?: string }>;
assert.deepEqual(tool.map((p) => p.toolCallId), ["b"]);
});

it("drops a message left with no content", () => {
const only: ModelMessage[] = [
{ role: "assistant", content: [{ type: "tool-call", toolCallId: "srv_1", toolName: "code_execution", input: {} }] },
{ role: "tool", content: [{ type: "tool-result", toolCallId: "srv_1", toolName: "code_execution", output: { type: "text", value: "" } }] },
] as ModelMessage[];
assert.deepEqual(stripToolCallParts(only, new Set(["srv_1"])), []);
});

it("leaves string-content messages alone", () => {
const msgs: ModelMessage[] = [{ role: "user", content: "hi" }];
assert.deepEqual(stripToolCallParts(msgs, new Set(["srv_1"])), msgs);
});
});
167 changes: 167 additions & 0 deletions mcp/src/repo/__tests__/worktree_diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Tests for captureWorktreeDiff (git_pr.ts): the ground-truth diff of an
* ephemeral preview worktree, read before releaseWorktree discards it.
*
* Harness: node:test (tsx --test). Real git fixtures, no mocks. The secret
* scan shells out to gitleaks and fails closed when the binary is absent,
* so the success-path assertions branch on whether it is installed.
*/

import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "fs";
import path from "path";
import os from "os";
import { execSync } from "child_process";
import { randomUUID } from "crypto";

import {
acquireEphemeralWorktree,
releaseWorktree,
captureWorktreeDiff,
type WorktreeHandle,
} from "../git_pr.js";

function localEnv(): NodeJS.ProcessEnv {
return {
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
HOME: os.tmpdir(),
GIT_TERMINAL_PROMPT: "0",
GIT_ASKPASS: "",
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_SYSTEM: "/dev/null",
GIT_AUTHOR_NAME: "Test",
GIT_AUTHOR_EMAIL: "test@example.com",
GIT_COMMITTER_NAME: "Test",
GIT_COMMITTER_EMAIL: "test@example.com",
};
}

function hasGitleaks(): boolean {
try {
execSync("gitleaks version", { stdio: "ignore" });
return true;
} catch {
return false;
}
}

let testRootDir: string;
let baseCloneDir: string;

before(() => {
testRootDir = fs.mkdtempSync(path.join(os.tmpdir(), "worktree_diff_test_"));
baseCloneDir = path.join(testRootDir, "base-clone");
fs.mkdirSync(baseCloneDir, { recursive: true });
const env = localEnv();
execSync("git init -b main .", { cwd: baseCloneDir, env, stdio: "ignore" });
fs.writeFileSync(path.join(baseCloneDir, "README.md"), "# worktree diff test repo\n");
fs.writeFileSync(path.join(baseCloneDir, ".gitignore"), "ignored.log\n");
execSync("git add .", { cwd: baseCloneDir, env, stdio: "ignore" });
execSync('git commit -m "Initial commit"', { cwd: baseCloneDir, env, stdio: "ignore" });
});

after(() => {
fs.rmSync(testRootDir, { recursive: true, force: true });
});

async function withWorktree<T>(fn: (handle: WorktreeHandle) => Promise<T>): Promise<T> {
const result = await acquireEphemeralWorktree({
baseDir: baseCloneDir,
owner: "owner",
repo: "repo",
runId: randomUUID(),
});
assert.ok(result.ok, `acquire failed: ${(result as any).error}`);
try {
return await fn(result.handle);
} finally {
await releaseWorktree(result.handle);
}
}

describe("captureWorktreeDiff", () => {
it("reports no_changes for an untouched worktree", async () => {
await withWorktree(async (handle) => {
const out = await captureWorktreeDiff(handle);
assert.equal(out.ok, false);
if (!out.ok) assert.equal(out.failure, "no_changes");
});
});

it("includes a modified file AND a brand-new untracked file, ignoring .gitignore'd ones", async () => {
await withWorktree(async (handle) => {
fs.writeFileSync(path.join(handle.worktreePath, "README.md"), "# edited\n");
fs.mkdirSync(path.join(handle.worktreePath, "src"), { recursive: true });
fs.writeFileSync(path.join(handle.worktreePath, "src", "new.test.ts"), "export const x = 1;\n");
fs.writeFileSync(path.join(handle.worktreePath, "ignored.log"), "noise\n");

const out = await captureWorktreeDiff(handle);

if (hasGitleaks()) {
assert.ok(out.ok, `expected ok, got ${JSON.stringify(out)}`);
if (out.ok) {
assert.equal(out.filesChanged, 2);
assert.ok(out.diff.includes("+++ b/README.md"), "modified file present");
assert.ok(out.diff.includes("+++ b/src/new.test.ts"), "new untracked file present");
assert.ok(out.diff.includes("new file mode"), "new file recorded as such");
assert.ok(!out.diff.includes("ignored.log"), "ignored file excluded");
}
} else {
// Fails closed on the scan, but only after staging succeeded and the
// size checks passed — so the failure is the scan, nothing earlier.
assert.equal(out.ok, false);
if (!out.ok) {
assert.equal(out.failure, "secrets_detected");
assert.match(out.error, /gitleaks/);
}
}

// The base checkout is never touched.
const baseStatus = execSync("git status --porcelain", {
cwd: baseCloneDir,
env: localEnv(),
encoding: "utf8",
}).trim();
assert.equal(baseStatus, "");
});
});

it("enforces the file cap before reading the diff", async () => {
await withWorktree(async (handle) => {
fs.writeFileSync(path.join(handle.worktreePath, "a.txt"), "a\n");
fs.writeFileSync(path.join(handle.worktreePath, "b.txt"), "b\n");
const out = await captureWorktreeDiff(handle, { maxFiles: 1 });
assert.equal(out.ok, false);
if (!out.ok) assert.equal(out.failure, "change_too_large");
});
});

it("enforces the byte cap", async () => {
await withWorktree(async (handle) => {
fs.writeFileSync(path.join(handle.worktreePath, "big.txt"), "x".repeat(4096) + "\n");
const out = await captureWorktreeDiff(handle, { maxBytes: 1024 });
assert.equal(out.ok, false);
if (!out.ok) assert.equal(out.failure, "change_too_large");
});
});

it("reports git_failed when the worktree is already gone", async () => {
const handle = {
worktreePath: path.join(testRootDir, "does-not-exist"),
baseDir: baseCloneDir,
baseSha: "",
baseName: "main",
branch: "",
owner: "owner",
repo: "repo",
runId: randomUUID(),
runHome: os.tmpdir(),
_children: new Set(),
_released: false,
} as unknown as WorktreeHandle;
const out = await captureWorktreeDiff(handle);
assert.equal(out.ok, false);
if (!out.ok) assert.equal(out.failure, "git_failed");
});
});
Loading
Loading