fix(agent): stabilize large tool results
This commit is contained in:
@@ -21,7 +21,8 @@ const context: RuntimeSessionContext = {
|
||||
const run = (
|
||||
command: string,
|
||||
options: {
|
||||
maxOutputBytes?: number;
|
||||
maxStderrBytes?: number;
|
||||
maxStdoutBytes?: number;
|
||||
terminationGraceMs?: number;
|
||||
timeoutSec?: number;
|
||||
} = {},
|
||||
@@ -29,13 +30,14 @@ const run = (
|
||||
executeCliCommand(context, command, options.timeoutSec ?? 1, {
|
||||
apiBaseUrl: "http://127.0.0.1:8000",
|
||||
cliPath,
|
||||
maxOutputBytes: options.maxOutputBytes ?? 64,
|
||||
maxStderrBytes: options.maxStderrBytes ?? 8,
|
||||
maxStdoutBytes: options.maxStdoutBytes ?? 64,
|
||||
terminationGraceMs: options.terminationGraceMs ?? 20,
|
||||
});
|
||||
|
||||
describe("executeCliCommand", () => {
|
||||
test("accepts output at the byte limit", async () => {
|
||||
await expect(run("stdout 123456", { maxOutputBytes: 6 })).resolves.toMatchObject({
|
||||
await expect(run("stdout 123456", { maxStdoutBytes: 6 })).resolves.toMatchObject({
|
||||
outcome: "completed",
|
||||
exitCode: 0,
|
||||
status: 200,
|
||||
@@ -43,8 +45,16 @@ describe("executeCliCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("does not treat the OpenCode 12000-byte inline threshold as a CLI limit", async () => {
|
||||
const stdout = "x".repeat(12_001);
|
||||
const result = await run(`stdout ${stdout}`, { maxStdoutBytes: 128 * 1024 * 1024 });
|
||||
|
||||
expect(result.outcome).toBe("completed");
|
||||
expect(result.stdout).toBe(stdout);
|
||||
});
|
||||
|
||||
test("rejects multibyte output above the byte limit without returning a partial body", async () => {
|
||||
await expect(run("stdout 水水", { maxOutputBytes: 5 })).resolves.toMatchObject({
|
||||
await expect(run("stdout 水水", { maxStdoutBytes: 5 })).resolves.toMatchObject({
|
||||
outcome: "output_limit",
|
||||
exceededStream: "stdout",
|
||||
status: 502,
|
||||
@@ -53,13 +63,16 @@ describe("executeCliCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("limits stderr independently", async () => {
|
||||
await expect(run("stderr 1234567", { maxOutputBytes: 6 })).resolves.toMatchObject({
|
||||
outcome: "output_limit",
|
||||
exceededStream: "stderr",
|
||||
status: 502,
|
||||
stderr: "",
|
||||
stdout: "",
|
||||
test("truncates stderr independently without terminating a successful command", async () => {
|
||||
await expect(
|
||||
run("stderr-success 123456789", { maxStderrBytes: 6 }),
|
||||
).resolves.toMatchObject({
|
||||
outcome: "completed",
|
||||
exitCode: 0,
|
||||
status: 200,
|
||||
stderr: "123456",
|
||||
stderrTruncated: true,
|
||||
stdout: '{"ok":true}',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +107,8 @@ describe("executeCliCommand", () => {
|
||||
executeCliCommand(largeContext, "ignore-term", 0.25, {
|
||||
apiBaseUrl: "http://127.0.0.1:8000",
|
||||
cliPath,
|
||||
maxOutputBytes: 64,
|
||||
maxStderrBytes: 8,
|
||||
maxStdoutBytes: 64,
|
||||
terminationGraceMs: 20,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
@@ -114,7 +128,8 @@ describe("executeCliCommand", () => {
|
||||
executeCliCommand(largeContext, "closed-stdin", 1, {
|
||||
apiBaseUrl: "http://127.0.0.1:8000",
|
||||
cliPath,
|
||||
maxOutputBytes: 64,
|
||||
maxStderrBytes: 8,
|
||||
maxStdoutBytes: 64,
|
||||
terminationGraceMs: 20,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(Error);
|
||||
|
||||
Vendored
+6
@@ -15,6 +15,12 @@ if (command === "stderr") {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (command === "stderr-success") {
|
||||
process.stderr.write(value);
|
||||
process.stdout.write('{"ok":true}');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "term") {
|
||||
process.on("SIGTERM", () => {
|
||||
setTimeout(() => process.exit(0), 30);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import {
|
||||
canAutoApprovePermission,
|
||||
@@ -9,10 +12,100 @@ describe("permission approval policy", () => {
|
||||
it.each([
|
||||
"show_chart",
|
||||
"web_search",
|
||||
"skill",
|
||||
])("allows low-risk permission %s", (permission) => {
|
||||
expect(canAutoApprovePermission(permission)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows structured searches within the workspace", () => {
|
||||
const workspaceRoot = process.cwd();
|
||||
const context = {
|
||||
workspaceRoot,
|
||||
metadata: { path: join(workspaceRoot, "src"), include: "*.ts" },
|
||||
patterns: ["*.ts"],
|
||||
};
|
||||
expect(canAutoApprovePermission("glob", context)).toBe(true);
|
||||
expect(canAutoApprovePermission("grep", context)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a glob with an explicit safe prefix from the workspace root", () => {
|
||||
expect(
|
||||
canAutoApprovePermission("glob", {
|
||||
workspaceRoot: process.cwd(),
|
||||
metadata: { path: process.cwd(), pattern: "src/**/*.ts" },
|
||||
patterns: ["src/**/*.ts"],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ metadata: { path: dirname(process.cwd()) }, patterns: ["*"] },
|
||||
{ metadata: { path: join(process.cwd(), ".local.env") }, patterns: ["*"] },
|
||||
{ metadata: { path: join(process.cwd(), "data") }, patterns: ["*"] },
|
||||
{ metadata: { path: join(process.cwd(), "src") }, patterns: ["../logs/**"] },
|
||||
{ metadata: { path: process.cwd() }, patterns: ["**/*.env"] },
|
||||
{ metadata: { path: process.cwd() }, patterns: ["**/*"] },
|
||||
{ metadata: { path: join(process.cwd(), "src") }, patterns: [".[e]nv"] },
|
||||
])("keeps protected or external searches interactive", (request) => {
|
||||
expect(
|
||||
canAutoApprovePermission("glob", {
|
||||
workspaceRoot: process.cwd(),
|
||||
...request,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps grep from the workspace root interactive", () => {
|
||||
expect(
|
||||
canAutoApprovePermission("grep", {
|
||||
workspaceRoot: process.cwd(),
|
||||
metadata: { path: process.cwd(), include: "*.ts" },
|
||||
patterns: ["secret"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a workspace symlink that resolves outside the workspace", async () => {
|
||||
const workspaceRoot = await mkdtemp(join(tmpdir(), "permission-workspace-"));
|
||||
const externalRoot = await mkdtemp(join(tmpdir(), "permission-external-"));
|
||||
try {
|
||||
await mkdir(join(externalRoot, "src"));
|
||||
const linkedPath = join(workspaceRoot, "linked");
|
||||
await symlink(join(externalRoot, "src"), linkedPath, "dir");
|
||||
|
||||
expect(
|
||||
canAutoApprovePermission("grep", {
|
||||
workspaceRoot,
|
||||
metadata: { path: linkedPath, include: "*.ts" },
|
||||
patterns: ["secret"],
|
||||
}),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
rm(workspaceRoot, { force: true, recursive: true }),
|
||||
rm(externalRoot, { force: true, recursive: true }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects protected descendants below an otherwise safe search root", async () => {
|
||||
const workspaceRoot = await mkdtemp(join(tmpdir(), "permission-descendant-"));
|
||||
try {
|
||||
const sourceRoot = join(workspaceRoot, "src");
|
||||
await mkdir(join(sourceRoot, "data"), { recursive: true });
|
||||
|
||||
expect(
|
||||
canAutoApprovePermission("grep", {
|
||||
workspaceRoot,
|
||||
metadata: { path: sourceRoot, include: "*.ts" },
|
||||
patterns: ["secret"],
|
||||
}),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await rm(workspaceRoot, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"bash",
|
||||
"edit",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
utimes,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { cleanupExpiredToolOutputs } from "../../src/runtime/opencodeToolOutputCleanup.js";
|
||||
|
||||
describe("cleanupExpiredToolOutputs", () => {
|
||||
test("removes only expired regular tool output files", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-tool-output-cleanup-"));
|
||||
const expired = join(directory, "tool_expired");
|
||||
const current = join(directory, "tool_current");
|
||||
const unrelated = join(directory, "keep.txt");
|
||||
const toolDirectory = join(directory, "tool_directory");
|
||||
const toolSymlink = join(directory, "tool_symlink");
|
||||
try {
|
||||
await Promise.all([
|
||||
writeFile(expired, "expired"),
|
||||
writeFile(current, "current"),
|
||||
writeFile(unrelated, "unrelated"),
|
||||
mkdir(toolDirectory),
|
||||
]);
|
||||
await symlink(unrelated, toolSymlink);
|
||||
const now = Date.now();
|
||||
const old = new Date(now - 8 * 24 * 60 * 60 * 1000);
|
||||
await utimes(expired, old, old);
|
||||
|
||||
const result = await cleanupExpiredToolOutputs(
|
||||
directory,
|
||||
7 * 24 * 60 * 60 * 1000,
|
||||
now,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ removed: 1, scanned: 5 });
|
||||
await expect(stat(expired)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await readFile(current, "utf8")).toBe("current");
|
||||
expect(await readFile(unrelated, "utf8")).toBe("unrelated");
|
||||
expect((await stat(toolDirectory)).isDirectory()).toBe(true);
|
||||
expect((await lstat(toolSymlink)).isSymbolicLink()).toBe(true);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("treats a missing tool output directory as empty", async () => {
|
||||
const directory = join(tmpdir(), `missing-tool-output-${crypto.randomUUID()}`);
|
||||
await expect(cleanupExpiredToolOutputs(directory, 1_000)).resolves.toEqual({
|
||||
removed: 0,
|
||||
scanned: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user