fix(agent): stabilize large tool results
Generic Container CI/CD / test-build-publish (push) Successful in 1m53s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m53s

This commit is contained in:
2026-08-25 12:02:48 +08:00
parent 774f39cbbe
commit 004c9bb72d
17 changed files with 721 additions and 62 deletions
+28 -13
View File
@@ -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);
+6
View File
@@ -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);
+93
View File
@@ -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,
});
});
});