feat(agent): sandbox conversation analysis
Generic Container CI/CD / test-build-publish (push) Successful in 2m44s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m44s

This commit is contained in:
2026-08-25 16:08:33 +08:00
parent ce04704af2
commit 80cfc1f2ab
27 changed files with 2387 additions and 125 deletions
+93
View File
@@ -1,10 +1,29 @@
import { describe, expect, it } from "bun:test";
import { readFile } from "node:fs/promises";
import sandboxBash from "../../.opencode/tools/bash.js";
import tjwaterCli from "../../.opencode/tools/tjwater_cli.js";
import storeRenderRef, {
resolveStoreRenderFilePath,
} from "../../.opencode/tools/store_render_ref.js";
describe("internal OpenCode permissions", () => {
it("pins the runtime, SDK, plugin, and image to OpenCode 1.18.13", async () => {
const [rootPackageText, toolPackageText, dockerfile] = await Promise.all([
readFile("package.json", "utf8"),
readFile(".opencode/package.json", "utf8"),
readFile("Dockerfile", "utf8"),
]);
const rootPackage = JSON.parse(rootPackageText) as {
dependencies?: Record<string, string>;
};
const toolPackage = JSON.parse(toolPackageText) as {
dependencies?: Record<string, string>;
};
expect(rootPackage.dependencies?.["@opencode-ai/sdk"]).toBe("1.18.13");
expect(toolPackage.dependencies?.["@opencode-ai/plugin"]).toBe("1.18.13");
expect(dockerfile.startsWith("FROM smanx/opencode:1.18.13@")).toBe(true);
});
it("keeps protected paths denied in every approval mode", async () => {
const config = JSON.parse(await readFile("opencode.json", "utf8")) as {
permission?: Record<string, string | Record<string, string>>;
@@ -89,3 +108,77 @@ describe("store_render_ref arguments", () => {
});
});
});
describe("sandbox bash tool", () => {
it("forwards commands to the authenticated sandbox endpoint", async () => {
const originalFetch = globalThis.fetch;
const permissionRequests: unknown[] = [];
let requestUrl = "";
let requestBody: unknown;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
requestUrl = String(input);
requestBody = JSON.parse(String(init?.body));
return new Response('{"ok":true,"stdout":"done"}');
}) as unknown as typeof fetch;
try {
const definition = sandboxBash as unknown as {
execute: (args: unknown, context: unknown) => Promise<unknown>;
};
await definition.execute(
{ command: "python3 analysis.py", timeout: 300 },
{
sessionID: "session-test",
ask: async (input: unknown) => permissionRequests.push(input),
} as never,
);
} finally {
globalThis.fetch = originalFetch;
}
expect(requestUrl).toEndWith("/internal/tools/sandbox-shell");
expect(permissionRequests).toEqual([
{
permission: "bash",
patterns: ["python3 analysis.py"],
always: ["python3 analysis.py"],
metadata: {
command: "python3 analysis.py",
},
},
]);
expect(requestBody).toEqual({
session_id: "session-test",
command: "python3 analysis.py",
timeout: 300,
});
});
});
describe("tjwater_cli storage request", () => {
it("forwards store_result so small workflow inputs can be staged", async () => {
const originalFetch = globalThis.fetch;
let requestBody: unknown;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
requestBody = JSON.parse(String(init?.body));
return new Response('{"ok":true,"data_file":{"file_path":"/tmp/test"}}');
}) as unknown as typeof fetch;
try {
const definition = tjwaterCli as unknown as {
execute: (args: unknown, context: unknown) => Promise<unknown>;
};
await definition.execute(
{
command: "network get-all-reservoirs-properties",
reason: "prepare workflow input",
store_result: true,
},
{ sessionID: "session-test" } as never,
);
} finally {
globalThis.fetch = originalFetch;
}
expect(requestBody).toMatchObject({
session_id: "session-test",
store_result: true,
});
});
});
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdir, readFile, rm, stat } from "node:fs/promises";
import { resolve } from "node:path";
import { config } from "../../src/config.js";
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import {
buildLargeCliResult,
stageLargeToolOutput,
} from "../../src/runtime/toolOutputStaging.js";
const createdPaths: string[] = [];
afterEach(async () => {
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { force: true, recursive: true })),
);
});
const createContext = async (): Promise<RuntimeSessionContext> => {
const importRoot = resolve(config.RESULT_REF_IMPORT_DIR);
await mkdir(importRoot, { recursive: true });
const workspaceDirectory = resolve(
importRoot,
`conversation-staging-${crypto.randomUUID()}`,
);
await mkdir(workspaceDirectory, { mode: 0o700 });
createdPaths.push(workspaceDirectory);
return {
actorKey: "actor-1",
clientSessionId: "client-1",
projectKey: "project-1",
sessionId: "runtime-1",
traceId: "trace-1",
workspaceDirectory,
};
};
describe("tool output staging", () => {
it("keeps small output inline unless storage is forced", async () => {
const context = await createContext();
await expect(
stageLargeToolOutput(context, '{"ok":true}', {
contentType: "application/json",
extension: "json",
prefix: "cli",
}),
).resolves.toBeNull();
const stored = await stageLargeToolOutput(context, '{"ok":true}', {
contentType: "application/json",
extension: "json",
force: true,
prefix: "cli",
});
expect(stored?.file_path).toContain("/tool-data/cli-");
expect(await readFile(stored!.file_path, "utf8")).toBe('{"ok":true}');
expect((await stat(stored!.file_path)).mode & 0o777).toBe(0o600);
});
it("stages output above the inline threshold and returns a compact descriptor", async () => {
const context = await createContext();
const content = JSON.stringify({
ok: true,
schema_version: "tjwater-cli/v1",
data: "x".repeat(config.MAX_INLINE_RESULT_BYTES),
});
const dataFile = await stageLargeToolOutput(context, content, {
contentType: "application/json",
extension: "json",
prefix: "cli",
});
expect(dataFile?.bytes).toBe(Buffer.byteLength(content));
const stored = dataFile!;
expect(buildLargeCliResult(stored)).toEqual({
ok: true,
schema_version: "tjwater-cli/v1",
summary: "CLI 结果已保存到当前对话工作区",
data_file: stored,
});
});
it("does not stage large output for legacy sessions without a workspace", async () => {
await expect(
stageLargeToolOutput(
{
actorKey: "actor-1",
clientSessionId: "client-1",
projectKey: "project-1",
sessionId: "runtime-1",
traceId: "trace-1",
},
"x".repeat(config.MAX_INLINE_RESULT_BYTES + 1),
{ contentType: "text/plain", extension: "txt", prefix: "shell" },
),
).rejects.toThrow("requires a conversation workspace");
});
});