feat(agent): migrate OpenCode 2 runtime and sandbox tools
Upgrade the runtime and native plugin API to OpenCode 2.0.24. Restore session-scoped learning, credential renewal, typed project queries and controlled frontend actions from the legacy Agent. Replace host Shell execution with an approved Landlock/seccomp executor and persistent per-session workspaces. Add typed Bocha search and Tianditu geocoding bridges using the owning session credential. Require a working kernel sandbox before startup and persist workspace files in deployments. Validation completed before this commit: TypeScript checks, 150 Bun tests, Docker build and UID 10001 sandbox execution, native Shell approval and session recovery. Tianditu succeeded; Bocha currently reports exhausted account quota. Unrelated investigation-report contract work is excluded.
This commit is contained in:
@@ -19,7 +19,7 @@ describe("agent model config", () => {
|
||||
});
|
||||
|
||||
it("validates supported model ids", () => {
|
||||
expect(isSupportedModel("deepseek/deepseek-v4-flash")).toBe(true);
|
||||
expect(isSupportedModel(supportedModels[0]!)).toBe(true);
|
||||
expect(isSupportedModel("unknown/model")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import plugin from "../../.opencode/plugins/tjwater-tools.js";
|
||||
|
||||
import { frontendActionCallIdPlugin } from "../../.opencode/plugins/frontend-action-call-id.js";
|
||||
|
||||
describe("frontend action call ID plugin", () => {
|
||||
it("injects call IDs only for SCADA frontend actions", async () => {
|
||||
const hooks = await frontendActionCallIdPlugin({} as never);
|
||||
const before = hooks["tool.execute.before"];
|
||||
if (!before) throw new Error("before hook missing");
|
||||
const actionArgs: Record<string, unknown> = { items: [] };
|
||||
const actionOutput: { args: Record<string, unknown> } = { args: actionArgs };
|
||||
await before(
|
||||
{ tool: "render_scada_analysis", callID: "call-1", sessionID: "s" },
|
||||
actionOutput,
|
||||
);
|
||||
expect(actionOutput.args).toBe(actionArgs);
|
||||
expect(actionArgs).toEqual({
|
||||
items: [],
|
||||
__frontend_action_call_id: "call-1",
|
||||
});
|
||||
const regularArgs = { query: "SCADA" };
|
||||
const regularOutput = { args: regularArgs };
|
||||
await before(
|
||||
{ tool: "session_search", callID: "call-2", sessionID: "s" },
|
||||
regularOutput,
|
||||
);
|
||||
expect(regularOutput.args).toBe(regularArgs);
|
||||
expect(regularArgs).toEqual({ query: "SCADA" });
|
||||
describe("OpenCode 2 business tool registration", () => {
|
||||
it("registers business tools for direct dispatch, forwards the native call ID and validates inputs", async () => {
|
||||
const tools = new Map<string, any>();
|
||||
const storage = new Map<string, unknown>();
|
||||
let rpcList: (() => Promise<unknown>) | undefined;
|
||||
await plugin.setup({
|
||||
tool: { transform: async (fn: (editor: unknown) => void) => fn({ add: (tool: any) => tools.set(tool.name, tool), remove: (name: string) => tools.delete(name), update: () => {} }), list: async () => Array.from(tools, ([id, tool]) => ({ ...tool, id })) },
|
||||
shell: { hook: async () => {} },
|
||||
permission: { hook: async () => {} },
|
||||
session: { hook: async () => {} },
|
||||
rpc: { register: async (_definition: unknown, handlers: { list: () => Promise<unknown> }) => { rpcList = handlers.list; } },
|
||||
storage: { set: async (key: string, value: unknown) => storage.set(key, value), get: async (key: string) => storage.get(key) },
|
||||
} as never);
|
||||
expect(tools.get("render_scada_analysis").options).toEqual({ codemode: false, permission: "render_scada_analysis" });
|
||||
const render = tools.get("render_scada_analysis");
|
||||
await expect(render.execute({ reason: "test", items: [{ device_id: "x", level: "wrong" }] }, { sessionID: "s", id: "call-native" })).rejects.toThrow();
|
||||
const todo = tools.get("todowrite");
|
||||
const todos = [{ content: "check", status: "pending" }];
|
||||
await todo.execute({ todos }, { sessionID: "s" });
|
||||
expect(await tools.get("todoread").execute({}, { sessionID: "s" })).toEqual({ content: JSON.stringify(todos) });
|
||||
expect(await tools.get("todoread").execute({}, { sessionID: "another" })).toEqual({ content: "[]" });
|
||||
const discovery = await rpcList!();
|
||||
expect(JSON.parse(JSON.stringify(discovery))).toEqual(discovery);
|
||||
});
|
||||
|
||||
it("exports only plugin functions so OpenCode can load the module", async () => {
|
||||
const pluginModule = await import("../../.opencode/plugins/frontend-action-call-id.js");
|
||||
expect(Object.keys(pluginModule)).toEqual(["frontendActionCallIdPlugin"]);
|
||||
expect(Object.values(pluginModule).every((value) => typeof value === "function")).toBeTrue();
|
||||
it("exports a single native plugin entrypoint", async () => {
|
||||
const module = await import("../../.opencode/plugins/tjwater-tools.js");
|
||||
expect(Object.keys(module)).toEqual(["default"]);
|
||||
expect(module.default.id).toBe("tjwater.tools");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,11 +32,9 @@ const start = (
|
||||
};
|
||||
|
||||
describe("FrontendActionCoordinator", () => {
|
||||
it("registers only the two view-risk SCADA actions with 15 second expiry", () => {
|
||||
expect(frontendActionRegistry).toEqual([
|
||||
expect.objectContaining({ id: "render_scada_analysis", risk: "view", timeoutMs: 15_000 }),
|
||||
expect.objectContaining({ id: "clear_scada_analysis", risk: "view", timeoutMs: 15_000 }),
|
||||
]);
|
||||
it("registers the finite view actions with 15 second expiry", () => {
|
||||
expect(frontendActionRegistry.map((action) => action.id).sort()).toEqual(["clear_scada_analysis", "locate_features", "render_scada_analysis", "show_chart", "view_history", "zoom_to_map"]);
|
||||
expect(frontendActionRegistry.every((action) => action.risk === "view" && action.timeoutMs === 15_000)).toBeTrue();
|
||||
});
|
||||
|
||||
it("emits once and resolves with validated browser output", async () => {
|
||||
|
||||
@@ -2,11 +2,9 @@ import { describe, expect, it } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const disabledServerTools = [
|
||||
"geocode",
|
||||
"get_project_context",
|
||||
"list_scada_assets",
|
||||
"query_scada_readings",
|
||||
"web_search",
|
||||
];
|
||||
|
||||
describe("disabled opencode tools", () => {
|
||||
@@ -15,10 +13,12 @@ describe("disabled opencode tools", () => {
|
||||
expect(existsSync(`.opencode/tools/${toolName}.ts`)).toBeFalse();
|
||||
}
|
||||
expect(existsSync(".opencode/tools/server_api_shared.ts")).toBeFalse();
|
||||
expect(existsSync(".opencode/tools/zoom_to_map.ts")).toBeFalse();
|
||||
expect(existsSync(".opencode/tools/web_search.ts")).toBeTrue();
|
||||
expect(existsSync(".opencode/tools/geocode.ts")).toBeTrue();
|
||||
expect(existsSync(".opencode/tools/zoom_to_map.ts")).toBeTrue();
|
||||
expect(existsSync(".opencode/tools/frontend_action.ts")).toBeTrue();
|
||||
expect(existsSync(".opencode/tools/render_scada_analysis.ts")).toBeTrue();
|
||||
expect(existsSync(".opencode/tools/clear_scada_analysis.ts")).toBeTrue();
|
||||
expect(existsSync(".opencode/plugins/frontend-action-call-id.ts")).toBeTrue();
|
||||
expect(existsSync(".opencode/plugins/tjwater-tools.ts")).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import express from "express";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { type Server } from "node:http";
|
||||
import { registerLearningToolBridge } from "../../src/routes/learningToolBridge.js";
|
||||
import { MemoryStore } from "../../src/memory/store.js";
|
||||
import { SkillStore } from "../../src/skills/store.js";
|
||||
import { getRuntimeSessionContext, removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
|
||||
let server: Server | undefined;
|
||||
let directory: string | undefined;
|
||||
afterEach(async () => {
|
||||
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
if (directory) await rm(directory, { recursive: true, force: true });
|
||||
removeRuntimeSessionContext("learning-bridge-session");
|
||||
});
|
||||
describe("learning tools across process boundaries", () => {
|
||||
it("uses the parent session scope for list-before-write and rejects forged scope or missing context", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "learning-bridge-"));
|
||||
const memoryStore = new MemoryStore(join(directory, "memory"), join(directory, "backup"));
|
||||
await memoryStore.initialize();
|
||||
setRuntimeSessionContext({ sessionId: "learning-bridge-session", clientSessionId: "client", actorKey: "owner", projectKey: "project", traceId: "test", allowLearningWrite: true });
|
||||
const app = express(); app.use(express.json());
|
||||
registerLearningToolBridge(app, { internalToken: "TEST_ONLY", memoryStore, skillStore: new SkillStore(join(directory, "skills"), join(directory, "skills-backup")) });
|
||||
server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server!.once("listening", resolve));
|
||||
const address = server.address() as { port: number };
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
const child = Bun.spawn([process.execPath, "-e", [
|
||||
'import memory from "./.opencode/tools/memory_manager.ts";',
|
||||
'import skill from "./.opencode/tools/skill_manager.ts";',
|
||||
'const ctx = { sessionID: "learning-bridge-session" };',
|
||||
'const call = (action, content) => memory.execute({ action, scope: "user", reason: "test", content }, ctx).then(JSON.parse);',
|
||||
'const before = await call("add", "用户偏好简洁答复");',
|
||||
'await call("list"); const added = await call("add", "用户偏好简洁答复");',
|
||||
'const skills = JSON.parse(await skill.execute({ action: "list", skill_path: "__root__", reason: "test" }, ctx));',
|
||||
'process.stdout.write(JSON.stringify({ before, added, skills }));',
|
||||
].join("\n")], { cwd: process.cwd(), env: { ...process.env, AGENT_INTERNAL_TOKEN: "TEST_ONLY", TJWATER_AGENT_INTERNAL_BASE_URL: baseUrl }, stdout: "pipe", stderr: "pipe" });
|
||||
const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
||||
expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" });
|
||||
const result = JSON.parse(stdout);
|
||||
expect(result.before.decision).toBe("rejected");
|
||||
expect(result.added.decision).toBe("accepted");
|
||||
expect(result.skills.kind).toBe("skill");
|
||||
expect(getRuntimeSessionContext("learning-bridge-session")?.memoryListReadScopes?.user).toBeTrue();
|
||||
expect(await memoryStore.list("user", "owner")).toHaveLength(1);
|
||||
expect(await memoryStore.list("user", "other-owner")).toHaveLength(0);
|
||||
const post = (sessionId: string, input: unknown, token = "TEST_ONLY") => fetch(`${baseUrl}/internal/tools/memory-manager`, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": token }, body: JSON.stringify({ session_id: sessionId, input }) });
|
||||
expect((await post("missing", { action: "list", scope: "user" })).status).toBe(404);
|
||||
expect((await post("learning-bridge-session", { action: "list", scope: "user", actorKey: "other-owner" })).status).toBe(400);
|
||||
expect((await post("learning-bridge-session", { action: "list", scope: "user" }, "wrong")).status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,10 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createSkillManagerTool } from "../../.opencode/tools/skill_manager.js";
|
||||
import { executeSkillManager, type SkillManagerInput } from "../../src/learning/toolManagers.js";
|
||||
const createSkillManagerTool = (store: SkillStore, reader: { read(sessionId: string): RuntimeSessionContext | null }, _ready: Promise<unknown>) => ({
|
||||
execute: async (args: SkillManagerInput & { reason: string }, context: { sessionID: string }) => JSON.stringify(await executeSkillManager(store, reader.read(context.sessionID)!, args)),
|
||||
});
|
||||
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
import { SkillStore } from "../../src/skills/store.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { CredentialRefreshCoordinator, runWithCredentialRefresh, bindSessionCredential, credentialRefreshCoordinator, refreshSessionCredential, releaseSessionCredential, requestWithSessionCredential } from "../../src/product/credential-refresh.js";
|
||||
|
||||
describe("credential renewal", () => {
|
||||
it("rejects stale replies and stops repeated requests when the renewed credential is still rejected", async () => {
|
||||
const session = "rejected-credential-session";
|
||||
bindSessionCredential(session, { authorization: "Bearer OLD", projectId: "p" });
|
||||
let calls = 0;
|
||||
let failures = 0;
|
||||
const unsubscribe = credentialRefreshCoordinator.subscribe(session, (event) => {
|
||||
if (event.type === "credential_refresh_required") {
|
||||
expect(refreshSessionCredential(session, { authorization: "Bearer NEW", projectId: "other" }, event.requestId)).toBeFalse();
|
||||
expect(refreshSessionCredential(session, { authorization: "Bearer NEW", projectId: "p" }, "stale")).toBeFalse();
|
||||
refreshSessionCredential(session, { authorization: "Bearer NEW", projectId: "p" }, event.requestId);
|
||||
} else if (event.type === "credential_refresh_failed") failures += 1;
|
||||
});
|
||||
try {
|
||||
expect((await requestWithSessionCredential(session, async () => { calls += 1; return new Response(null, { status: 401 }); })).status).toBe(401);
|
||||
await expect(requestWithSessionCredential(session, async () => { calls += 1; return new Response(null, { status: 200 }); })).rejects.toThrow("renewal failed");
|
||||
expect(calls).toBe(2);
|
||||
expect(failures).toBe(1);
|
||||
} finally { unsubscribe(); releaseSessionCredential(session); }
|
||||
});
|
||||
it("coalesces concurrent 401 responses, resumes both calls, and retries only once", async () => {
|
||||
const coordinator = new CredentialRefreshCoordinator(1000);
|
||||
const old = { authorization: "Bearer TEST_OLD", projectId: "p" };
|
||||
const renewed = { authorization: "Bearer TEST_NEW", projectId: "p" };
|
||||
let requests = 0;
|
||||
coordinator.subscribe("s", (event) => {
|
||||
if (event.type === "credential_refresh_required") {
|
||||
requests += 1;
|
||||
queueMicrotask(() => coordinator.resolve("s", event.requestId, renewed));
|
||||
}
|
||||
});
|
||||
const calls: string[] = [];
|
||||
const execute = async (credential: typeof old) => {
|
||||
calls.push(credential.authorization);
|
||||
return { status: credential.authorization === old.authorization ? 401 : 200 };
|
||||
};
|
||||
expect((await Promise.all([runWithCredentialRefresh(coordinator, "s", old, execute), runWithCredentialRefresh(coordinator, "s", old, execute)])).map((result) => result.status)).toEqual([200, 200]);
|
||||
expect(requests).toBe(1);
|
||||
expect(calls).toHaveLength(4);
|
||||
});
|
||||
it("does not refresh 403 and fails when refresh is unavailable or cancelled", async () => {
|
||||
const coordinator = new CredentialRefreshCoordinator(1000);
|
||||
const credential = { authorization: "Bearer TEST", projectId: "p" };
|
||||
expect((await runWithCredentialRefresh(coordinator, "s", credential, async () => ({ status: 403 }))).status).toBe(403);
|
||||
await expect(runWithCredentialRefresh(coordinator, "s", credential, async () => ({ status: 401 }))).rejects.toThrow("unavailable");
|
||||
coordinator.enableBackground("s");
|
||||
const pending = coordinator.request("s", "access_token_expired");
|
||||
coordinator.cancelSession("s");
|
||||
await expect(pending).rejects.toThrow("cancelled");
|
||||
});
|
||||
it("renews before near-expiry execution and exposes the same pending request for reconnect", async () => {
|
||||
const coordinator = new CredentialRefreshCoordinator(1000);
|
||||
const old = { authorization: "Bearer OLD", projectId: "p", expiresAt: new Date(Date.now() + 1000).toISOString() };
|
||||
coordinator.subscribe("s", (event) => {
|
||||
if (event.type === "credential_refresh_required") {
|
||||
expect(coordinator.getPendingEvent("s")?.requestId).toBe(event.requestId);
|
||||
coordinator.resolve("s", event.requestId, { authorization: "Bearer NEW", projectId: "p" });
|
||||
}
|
||||
});
|
||||
expect(await runWithCredentialRefresh(coordinator, "s", old, async (credential) => ({ status: 200, authorization: credential.authorization }))).toMatchObject({ authorization: "Bearer NEW" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { ExternalInformationClient, geocodeInput, webSearchInput } from "../../src/product/external-information-client.js";
|
||||
import { bindSessionCredential, credentialRefreshCoordinator, refreshSessionCredential, releaseSessionCredential } from "../../src/product/credential-refresh.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const session = "external-information-test";
|
||||
afterEach(() => { globalThis.fetch = originalFetch; releaseSessionCredential(session); });
|
||||
describe("typed external information", () => {
|
||||
it("uses the fixed Server endpoint and trusted project, translating freshness", async () => {
|
||||
bindSessionCredential(session, { authorization: "Bearer TEST", projectId: "trusted" });
|
||||
globalThis.fetch = (async (url: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
expect(String(url)).toBe("http://server.test/api/v1/web-searches");
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer TEST");
|
||||
expect(new Headers(init?.headers).get("X-Project-Id")).toBe("trusted");
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ query: "供水政策", freshness: "oneWeek", summary: true, count: 10, include: ["gov.cn"] });
|
||||
return Response.json({ data: { webPages: { value: [{ url: "https://gov.cn/example", name: "example" }] } } });
|
||||
}) as unknown as typeof fetch;
|
||||
const result = await new ExternalInformationClient("http://server.test").webSearch(session, webSearchInput.parse({ query: "供水政策", freshness: "one_week", include: ["gov.cn"] }));
|
||||
expect(result).toHaveProperty("data.webPages.value");
|
||||
});
|
||||
|
||||
it("normalizes valid Tianditu coordinates for zoom_to_map and rejects fabricated coordinates", async () => {
|
||||
bindSessionCredential(session, { authorization: "Bearer TEST", projectId: "trusted" });
|
||||
globalThis.fetch = (async (url: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
expect(new URL(String(url)).pathname).toBe("/api/v1/geocoding-requests");
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ keyword: "北京市人民政府" });
|
||||
return Response.json({ status: "0", location: { lon: "116.4", lat: "39.9", level: 1 } });
|
||||
}) as unknown as typeof fetch;
|
||||
expect(await new ExternalInformationClient("http://server.test").geocode(session, { keyword: "北京市人民政府" })).toMatchObject({ source_crs: "EPSG:4326", center: [116.4, 39.9], location: { lon: 116.4, lat: 39.9 } });
|
||||
for (const data of [null, { status: "0", location: { lon: "", lat: " " } }, { status: "1" }, { status: "0", location: { lon: 181, lat: 39 } }, { status: "0", location: { lon: null, lat: null } }]) {
|
||||
globalThis.fetch = (async () => Response.json(data)) as unknown as typeof fetch;
|
||||
await expect(new ExternalInformationClient("http://server.test").geocode(session, { keyword: "北京市人民政府" })).rejects.toThrow("no valid coordinate");
|
||||
}
|
||||
});
|
||||
|
||||
it("renews a rejected credential once and never turns a 403 into a renewal", async () => {
|
||||
bindSessionCredential(session, { authorization: "Bearer OLD", projectId: "trusted" });
|
||||
let requests = 0;
|
||||
const unsubscribe = credentialRefreshCoordinator.subscribe(session, (event) => {
|
||||
if (event.type === "credential_refresh_required") refreshSessionCredential(session, { authorization: "Bearer NEW", projectId: "trusted" }, event.requestId);
|
||||
});
|
||||
try {
|
||||
globalThis.fetch = (async (_url: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
requests++;
|
||||
return new Headers(init?.headers).get("Authorization") === "Bearer OLD" ? new Response(null, { status: 401 }) : Response.json({ status: "0", location: { lon: 116, lat: 39 } });
|
||||
}) as unknown as typeof fetch;
|
||||
await new ExternalInformationClient("http://server.test").geocode(session, { keyword: "北京" });
|
||||
expect(requests).toBe(2);
|
||||
globalThis.fetch = (async () => { requests++; return new Response(null, { status: 403 }); }) as unknown as typeof fetch;
|
||||
await expect(new ExternalInformationClient("http://server.test").geocode(session, { keyword: "北京" })).rejects.toMatchObject({ status: 403 });
|
||||
expect(requests).toBe(3);
|
||||
} finally { unsubscribe(); }
|
||||
});
|
||||
|
||||
it("rejects scope, URL overrides, empty queries and invalid counts before dispatch", async () => {
|
||||
expect(webSearchInput.safeParse({ query: "test", url: "http://untrusted" }).success).toBeFalse();
|
||||
expect(webSearchInput.safeParse({ query: "test", project_id: "other" }).success).toBeFalse();
|
||||
expect(webSearchInput.safeParse({ query: "test", count: 51 }).success).toBeFalse();
|
||||
expect(webSearchInput.safeParse({ query: "test", include: ["https://example.com/path"] }).success).toBeFalse();
|
||||
expect(geocodeInput.safeParse({ keyword: " " }).success).toBeFalse();
|
||||
await expect(new ExternalInformationClient().geocode(session, { keyword: "北京" })).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { ProjectDataClient, projectDataQuerySchema } from "../../src/product/project-data-client.js";
|
||||
import { bindSessionCredential, releaseSessionCredential } from "../../src/product/credential-refresh.js";
|
||||
import { historyChart } from "../../src/product/history-chart.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => { globalThis.fetch = originalFetch; releaseSessionCredential("query-session"); });
|
||||
describe("controlled project queries", () => {
|
||||
it("dispatches SCADA history to the existing fields endpoint using monitored_value", async () => {
|
||||
bindSessionCredential("query-session", { authorization: "Bearer TEST", projectId: "trusted-project" });
|
||||
globalThis.fetch = (async (url) => {
|
||||
const parsed = new URL(String(url));
|
||||
expect(parsed.pathname).toBe("/api/v1/timeseries/scada-readings/fields");
|
||||
expect(parsed.searchParams.get("field")).toBe("monitored_value");
|
||||
expect(parsed.searchParams.get("device_ids")).toBe("D1,D2");
|
||||
return Response.json([{ device_id: "D1", value: 20 }]);
|
||||
}) as typeof fetch;
|
||||
await new ProjectDataClient("http://server.test").query("query-session", { source: "scada_history", device_ids: ["D1", "D2"], field: "monitored_value", start: "2026-09-01T00:00:00Z", end: "2026-09-01T01:00:00Z" });
|
||||
});
|
||||
it("uses fixed routes and the bound project credential, including encoded asset IDs", async () => {
|
||||
bindSessionCredential("query-session", { authorization: "Bearer TEST", projectId: "trusted-project" });
|
||||
let requested: URL | undefined;
|
||||
globalThis.fetch = (async (url, init) => {
|
||||
requested = new URL(String(url));
|
||||
expect(new Headers(init?.headers).get("X-Project-Id")).toBe("trusted-project");
|
||||
return Response.json({ pressure: 20 });
|
||||
}) as typeof fetch;
|
||||
expect(await new ProjectDataClient("http://server.test").query("query-session", { source: "element_properties", element_type: "junction", element_id: "J&network=other" })).toEqual({ pressure: 20 });
|
||||
expect(requested?.pathname).toBe("/api/v1/junctions/properties");
|
||||
expect(requested?.searchParams.get("junction")).toBe("J&network=other");
|
||||
expect(requested?.searchParams.has("network")).toBeFalse();
|
||||
});
|
||||
it("rejects arbitrary routes, project overrides and excessive time windows before dispatch", () => {
|
||||
expect(projectDataQuerySchema.safeParse({ source: "scada_devices", project_id: "other" }).success).toBeFalse();
|
||||
expect(projectDataQuerySchema.safeParse({ source: "http", url: "http://other" }).success).toBeFalse();
|
||||
expect(projectDataQuerySchema.safeParse({ source: "scada_history", device_ids: ["D1"], field: "monitored_value", start: "2026-09-01T00:00:00Z", end: "2026-09-03T00:00:00Z" }).success).toBeFalse();
|
||||
});
|
||||
it("keeps returned units, drops null samples and never manufactures missing history", () => {
|
||||
expect(() => historyChart({ series: [] }, "J1")).toThrow("NO_HISTORY_DATA");
|
||||
const chart = historyChart({ series: [{ element_id: "J1", metric: "pressure", source: "scada_raw", source_unit: "m", points: [{ time: "t1", value: null }, { time: "t2", value: 20 }] }] }, "J1");
|
||||
expect(chart.title).toContain("(m)");
|
||||
expect(chart.x_data).toEqual(["t2"]);
|
||||
expect(chart.series[0].data).toEqual([20]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { createBusinessStageTracker } from "../../src/routes/chatBusinessStages.js";
|
||||
import { streamPromptResponse } from "../../src/routes/chatStream.js";
|
||||
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
|
||||
describe("business stages and final answer", () => {
|
||||
it("keeps a stage running while parallel actions remain, then preserves errors", () => {
|
||||
const events: Record<string, unknown>[] = [];
|
||||
const track = createBusinessStageTracker((_event, data) => events.push(data));
|
||||
const part = (id: string, status: string) => ({ id, type: "tool", tool: "query_project_data", state: { status } }) as Parameters<typeof track>[0];
|
||||
track(part("a", "running")); track(part("b", "running")); track(part("a", "completed"));
|
||||
expect(events.at(-1)?.status).toBe("running");
|
||||
track(part("b", "error"));
|
||||
expect(events.at(-1)).toMatchObject({ id: "business-data", status: "error" });
|
||||
});
|
||||
it("buffers final_answer until the runtime succeeds and does not end the run at the tool", async () => {
|
||||
const output: string[] = [];
|
||||
const events = [
|
||||
{ type: "message.part.updated", properties: { sessionID: "s", part: { id: "f", type: "tool", tool: "final_answer", state: { status: "completed", input: { answer: "最终结论" }, output: JSON.stringify({ answer: "最终结论" }) } } } },
|
||||
{ type: "message.part.updated", properties: { sessionID: "s", part: { id: "q", type: "tool", tool: "query_project_data", state: { status: "completed", input: { source: "scada_devices" }, output: "{}" } } } },
|
||||
{ type: "session.idle", properties: { sessionID: "s" } },
|
||||
];
|
||||
const runtime = { prompt: async () => undefined, subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { yield* events; } }), messages: async () => { throw new Error("final answer needs no text fallback"); } } as unknown as OpencodeRuntimeAdapter;
|
||||
const result = await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "c", message: "test", write: (event, data) => { output.push(event); if (event === "final_answer") expect(data.content).toBe("最终结论"); } });
|
||||
expect(result.failed).toBeFalse();
|
||||
expect(output.indexOf("tool_call")).toBeLessThan(output.indexOf("final_answer"));
|
||||
expect(output.at(-1)).toBe("done");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,26 @@ const createEventStream = (events: unknown[]) => ({
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe("streamPromptResponse", () => {
|
||||
it("keeps the first pending event when the V2 prompt acknowledgement arrives first", async () => {
|
||||
const emitted: { event: string; data: Record<string, unknown> }[] = [];
|
||||
const runtime = {
|
||||
prompt: async () => undefined,
|
||||
subscribeEvents: async () => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
await sleep(5);
|
||||
yield { type: "message.part.updated", properties: { sessionID: "s", part: { id: "text", type: "text", text: "", sessionID: "s", messageID: "m" } } };
|
||||
yield { type: "message.part.delta", properties: { sessionID: "s", partID: "text", field: "text", delta: "流事件完整" } };
|
||||
yield { type: "session.idle", properties: { sessionID: "s" } };
|
||||
},
|
||||
}),
|
||||
messages: async () => { throw new Error("lost streamed events caused a fallback read"); },
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const result = await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "test", write: (event, data) => emitted.push({ event, data }) });
|
||||
expect(result.failed).toBeFalse();
|
||||
expect(emitted.filter((e) => e.event === "token").map((e) => e.data.content).join("")).toBe("流事件完整");
|
||||
expect(emitted.at(-1)?.event).toBe("done");
|
||||
});
|
||||
|
||||
it("does not split short Chinese text only because it has punctuation", () => {
|
||||
expect(detectTokenSmoothingChunk("SCADA 液位异常,需要继续分析", "zh")).toBeNull();
|
||||
expect(detectTokenSmoothingChunk("SCADA 液位异常需要继续分析。", "zh")).toBeNull();
|
||||
@@ -222,7 +242,7 @@ describe("streamPromptResponse", () => {
|
||||
} satisfies Partial<PermissionRequestPayload>);
|
||||
});
|
||||
|
||||
it("auto replies always when approval mode is always", async () => {
|
||||
it("auto approves ordinary business tools in always mode", async () => {
|
||||
const replies: Array<Record<string, unknown>> = [];
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
@@ -232,7 +252,7 @@ describe("streamPromptResponse", () => {
|
||||
properties: {
|
||||
id: "perm-1",
|
||||
sessionID: "runtime-session-1",
|
||||
permission: "bash",
|
||||
permission: "web_search",
|
||||
patterns: ["npm test"],
|
||||
metadata: { command: "npm test" },
|
||||
always: ["npm test"],
|
||||
@@ -277,6 +297,51 @@ describe("streamPromptResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires manual Shell approval even in always mode", async () => {
|
||||
const replies: Array<Record<string, unknown>> = [];
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-1",
|
||||
sessionID: "runtime-session-1",
|
||||
permission: "bash",
|
||||
patterns: ["npm test"],
|
||||
metadata: { command: "npm test" },
|
||||
always: ["npm test"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
messages: async () => [],
|
||||
replyPermission: async (options: Record<string, unknown>) => {
|
||||
replies.push(options);
|
||||
},
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
await streamPromptResponse({
|
||||
runtime,
|
||||
sessionId: "runtime-session-1",
|
||||
clientSessionId: "client-session-1",
|
||||
message: "run tests",
|
||||
approvalMode: "always",
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
|
||||
expect(replies).toEqual([]);
|
||||
expect(events.some((item) => item.event === "permission_request")).toBe(true);
|
||||
expect(events.some((item) => item.event === "permission_response")).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards opencode v2 permission requests as SSE payloads", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import express from "express";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { attachTrustedAgentContext } from "../../src/context/localContext.js";
|
||||
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
|
||||
import { bindSessionCredential, credentialRefreshCoordinator, releaseSessionCredential } from "../../src/product/credential-refresh.js";
|
||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
||||
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
|
||||
import type { ActiveRun } from "../../src/routes/chatUiState.js";
|
||||
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
|
||||
describe("credential refresh HTTP route", () => {
|
||||
it("binds fresh headers only after validating the session owner, project and pending request", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "credential-route-"));
|
||||
const originalFetch = globalThis.fetch;
|
||||
const metadata = new SessionMetadataStore(join(directory, "metadata"));
|
||||
await metadata.initialize();
|
||||
const projectId = "10000000-0000-4000-8000-000000000001";
|
||||
await metadata.ensure({ sessionId: "credential-route-session", actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${projectId}`), projectId, userId: "owner" });
|
||||
globalThis.fetch = (async (_url, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
return Response.json({ user_id: headers.get("Authorization") === "Bearer TEST_OWNER_NEW" ? "owner" : "other", project_id: headers.get("X-Project-Id"), network: "supply", token_expires_at: new Date(Date.now() + 300000).toISOString() });
|
||||
}) as typeof fetch;
|
||||
const app = express(); app.use(express.json());
|
||||
const router = express.Router();
|
||||
router.use((req, res, next) => { void attachTrustedAgentContext(req).then(() => next()).catch(() => res.sendStatus(401)); });
|
||||
const activeRuns = new Map<string, ActiveRun>([["credential-route-session", { clientSessionId: "credential-route-session", controller: new AbortController(), messages: [], pendingPermissions: new Map(), pendingQuestions: new Map(), subscribers: new Set(), status: "running" }]]);
|
||||
registerChatInteractionRoutes(router, { activeRuns, runtime: {} as OpencodeRuntimeAdapter, sessionMetadataStore: metadata, sessionUiStateStore: new SessionUiStateStore(join(directory, "ui")) });
|
||||
app.use(router);
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
bindSessionCredential("credential-route-session", { authorization: "Bearer TEST_OLD", projectId });
|
||||
const unsubscribe = credentialRefreshCoordinator.subscribe("credential-route-session", () => {});
|
||||
const pending = credentialRefreshCoordinator.request("credential-route-session", "access_token_rejected");
|
||||
const requestId = credentialRefreshCoordinator.getPendingRequestId("credential-route-session")!;
|
||||
const post = (authorization: string, id = requestId, project = projectId) => originalFetch(`${base}/session/credential-route-session/credentials`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: authorization, "X-Project-Id": project }, body: JSON.stringify({ request_id: id }) });
|
||||
try {
|
||||
expect((await post("Bearer TEST_OTHER")).status).toBe(404);
|
||||
expect((await post("Bearer TEST_OWNER_NEW", requestId, "other-project")).status).toBe(404);
|
||||
expect((await post("Bearer TEST_OWNER_NEW", "stale-request")).status).toBe(409);
|
||||
expect((await post("Bearer TEST_OWNER_NEW")).status).toBe(202);
|
||||
expect(await pending).toMatchObject({ authorization: "Bearer TEST_OWNER_NEW", projectId });
|
||||
} finally {
|
||||
unsubscribe(); releaseSessionCredential("credential-route-session");
|
||||
await pending.catch(() => undefined);
|
||||
globalThis.fetch = originalFetch;
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import express from "express";
|
||||
import { type Server } from "node:http";
|
||||
import { registerExternalInformationBridge } from "../../src/routes/externalInformationBridge.js";
|
||||
import { ExternalInformationClient } from "../../src/product/external-information-client.js";
|
||||
import { bindSessionCredential, releaseSessionCredential } from "../../src/product/credential-refresh.js";
|
||||
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
|
||||
let agent: Server | undefined;
|
||||
let backend: Server | undefined;
|
||||
const session = "external-information-bridge-test";
|
||||
afterEach(async () => {
|
||||
for (const server of [agent, backend]) if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
releaseSessionCredential(session); removeRuntimeSessionContext(session);
|
||||
});
|
||||
describe("external information bridge", () => {
|
||||
it("calls search and geocode with the owning session and rejects browser, scope and URL injection", async () => {
|
||||
let calls = 0;
|
||||
const product = express(); product.use(express.json());
|
||||
product.post(["/api/v1/web-searches", "/api/v1/geocoding-requests"], (req, res) => {
|
||||
calls++;
|
||||
expect(req.header("Authorization")).toBe("Bearer TEST_ONLY");
|
||||
expect(req.header("X-Project-Id")).toBe("trusted-project");
|
||||
res.json(req.path.endsWith("web-searches") ? { data: { results: [req.body.query] } } : { status: "0", location: { lon: "116.4", lat: "39.9" } });
|
||||
});
|
||||
backend = product.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => backend!.once("listening", resolve));
|
||||
const app = express(); app.use(express.json());
|
||||
registerExternalInformationBridge(app, { internalToken: "TEST_ONLY", client: new ExternalInformationClient(`http://127.0.0.1:${(backend.address() as { port: number }).port}`) });
|
||||
agent = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => agent!.once("listening", resolve));
|
||||
const base = `http://127.0.0.1:${(agent.address() as { port: number }).port}`;
|
||||
const post = (name: string, body: unknown, headers: Record<string, string> = {}) => fetch(`${base}/internal/tools/${name}`, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "TEST_ONLY", ...headers }, body: JSON.stringify(body) });
|
||||
setRuntimeSessionContext({ sessionId: session, clientSessionId: session, actorKey: "owner", projectKey: "trusted-project", traceId: "test", purpose: "ordinary_chat" });
|
||||
bindSessionCredential(session, { authorization: "Bearer TEST_ONLY", projectId: "trusted-project" });
|
||||
expect((await post("web-search", { session_id: session, input: { query: "政策" } })).status).toBe(200);
|
||||
const coordinates = await post("geocode", { session_id: session, input: { keyword: "北京" } });
|
||||
expect(await coordinates.json()).toMatchObject({ center: [116.4, 39.9] });
|
||||
expect((await post("web-search", { session_id: session, input: { query: "test" } }, { Origin: "http://browser" })).status).toBe(403);
|
||||
expect((await post("web-search", { session_id: "missing", input: { query: "test" } })).status).toBe(403);
|
||||
expect((await post("web-search", { session_id: session, input: { query: "test", url: "http://untrusted" } })).status).toBe(400);
|
||||
expect((await post("geocode", { session_id: session, input: { keyword: "北京" }, project_id: "other" })).status).toBe(400);
|
||||
expect(calls).toBe(2);
|
||||
releaseSessionCredential(session);
|
||||
expect((await post("geocode", { session_id: session, input: { keyword: "北京" } })).status).toBe(401);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import express from "express";
|
||||
import { type Server } from "node:http";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { config } from "../../src/config.js";
|
||||
import { createConversationWorkspace } from "../../src/runtime/conversationWorkspace.js";
|
||||
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
import { registerSandboxToolBridge } from "../../src/routes/sandboxToolBridge.js";
|
||||
|
||||
let server: Server | undefined;
|
||||
let root: string | undefined;
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
const session = "sandbox-bridge-test";
|
||||
afterEach(async () => {
|
||||
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
config.CONVERSATION_WORKSPACE_ROOT = previousRoot;
|
||||
removeRuntimeSessionContext(session);
|
||||
});
|
||||
describe("sandbox execution bridge", () => {
|
||||
it("executes only after approval and cannot override the owning workspace", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "sandbox-bridge-test-"));
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
const directory = await createConversationWorkspace(root);
|
||||
setRuntimeSessionContext({ sessionId: session, clientSessionId: session, actorKey: "owner", projectKey: "project", traceId: "test" });
|
||||
let approve!: () => void;
|
||||
let approved = false;
|
||||
let ready!: () => void;
|
||||
const requested = new Promise<void>((resolve) => { ready = resolve; });
|
||||
const app = express(); app.use(express.json());
|
||||
registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: {
|
||||
ensureSessionWorkspace: async (id) => { expect(id).toBe(session); return directory; },
|
||||
approveSandboxShell: async (input) => { if (approved) return; expect(input.command).toContain("approved.txt"); ready(); await new Promise<void>((resolve) => { approve = resolve; }); approved = true; },
|
||||
} });
|
||||
server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server!.once("listening", resolve));
|
||||
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/internal/tools/sandbox-shell`;
|
||||
const body = { session_id: session, call_id: "call-test", message_id: "message-test", command: "printf approved > approved.txt; cat approved.txt" };
|
||||
const post = (input: unknown, headers: Record<string, string> = {}) => fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "TEST_ONLY", ...headers }, body: JSON.stringify(input) });
|
||||
expect((await post(body, { Origin: "http://browser" })).status).toBe(403);
|
||||
expect((await post({ ...body, workspace_directory: "/tmp/other" })).status).toBe(400);
|
||||
expect((await post({ ...body, session_id: "missing" })).status).toBe(404);
|
||||
expect((await post({ ...body, command: "cat /app/.env" })).status).toBe(403);
|
||||
const response = post(body);
|
||||
await requested;
|
||||
expect(await Bun.file(join(directory, "files", "approved.txt")).exists()).toBeFalse();
|
||||
approve();
|
||||
expect(await (await response).json()).toMatchObject({ stdout: "approved", exitCode: 0, cwd: join(directory, "files") });
|
||||
expect(await readFile(join(directory, "files", "approved.txt"), "utf8")).toBe("approved");
|
||||
const large = await (await post({ ...body, command: "python3 -c 'print(\"x\" * 10000)'" })).json() as { stdout: string; stdout_file: string };
|
||||
expect(Buffer.byteLength(large.stdout)).toBe(3000);
|
||||
expect(large.stdout_file.startsWith(join(directory, "files"))).toBeTrue();
|
||||
expect(await readFile(large.stdout_file, "utf8")).toBe(`${"x".repeat(10000)}\n`);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
import { type OpenCodeClient as OpencodeClient } from "@opencode/client";
|
||||
import { createServer } from "node:net";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { config } from "../../src/config.js";
|
||||
import {
|
||||
@@ -62,7 +65,7 @@ describe("OpencodeRuntimeAdapter.revertToUserMessage", () => {
|
||||
|
||||
expect(calls).toEqual({
|
||||
reverted: ["user-2"],
|
||||
removed: ["assistant-2", "user-2"],
|
||||
removed: ["user-2"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -94,19 +97,9 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
|
||||
describe("OpencodeRuntimeAdapter.resolveAvailableModel", () => {
|
||||
it("selects the first preferred model actually exposed by OpenCode", async () => {
|
||||
const client = {
|
||||
config: {
|
||||
providers: async () => ({
|
||||
data: {
|
||||
providers: [{
|
||||
id: "deepseek",
|
||||
models: {
|
||||
"deepseek-flash": {},
|
||||
"deepseek-v4-pro": {},
|
||||
},
|
||||
}],
|
||||
default: {},
|
||||
},
|
||||
}),
|
||||
rpc: { call: async () => ({ output: [] }) },
|
||||
model: {
|
||||
list: async () => ({ data: [{ providerID: "deepseek", id: "deepseek-v4-pro", enabled: true, capabilities: { tools: true } }] }),
|
||||
},
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
@@ -114,7 +107,7 @@ describe("OpencodeRuntimeAdapter.resolveAvailableModel", () => {
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
|
||||
await expect(runtime.resolveAvailableModel([
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"deepseek/deepseek-flash",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
])).resolves.toBe("deepseek/deepseek-v4-pro");
|
||||
});
|
||||
@@ -147,45 +140,81 @@ describe("resolveEmbeddedOpencodePort", () => {
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
it("initializes the project session and model tools before reporting ready", async () => {
|
||||
const toolIds = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"];
|
||||
async function createWarmupRuntime(missing: string[] = []) {
|
||||
const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-"));
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
const calls: string[] = [];
|
||||
const [providerID, id] = config.OPENCODE_MODEL.split("/");
|
||||
const client = {
|
||||
global: {
|
||||
health: async () => {
|
||||
calls.push("health");
|
||||
return { data: { healthy: true, version: "test" } };
|
||||
},
|
||||
},
|
||||
server: { info: async () => { calls.push("health"); return { version: "2.0.24" }; } },
|
||||
session: {
|
||||
create: async () => {
|
||||
calls.push("session.create");
|
||||
return { data: { id: "warmup-session" } };
|
||||
},
|
||||
delete: async ({ sessionID }: { sessionID: string }) => {
|
||||
calls.push(`session.delete:${sessionID}`);
|
||||
return { data: true };
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
list: async (model: { provider: string; model: string }) => {
|
||||
calls.push(`tool.list:${model.provider}/${model.model}`);
|
||||
return { data: [] };
|
||||
},
|
||||
create: async (input: { location: { directory: string } }) => { calls.push("session.create"); return { id: "warmup-session", location: input.location }; },
|
||||
remove: async () => { calls.push("session.remove"); },
|
||||
},
|
||||
model: { list: async () => { calls.push("model.list"); return { data: [{ providerID, id, enabled: true, capabilities: { tools: true } }] }; } },
|
||||
rpc: { call: async () => { calls.push("tool.list"); return { output: toolIds.filter((id) => !missing.includes(id)).map((id) => ({ id, direct: true, sandboxed: id === "shell", workspaceRoot: root })) }; } },
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
clientPromise: null,
|
||||
closeServer: null,
|
||||
ensureClient: async () => client,
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
||||
return { runtime, calls, cleanup: async () => { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true, force: true }); } };
|
||||
}
|
||||
it("verifies business tools and model before reporting ready and removes the warmup session", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime();
|
||||
try { await runtime.warmup();
|
||||
expect(calls).toEqual(["health", "session.create", "tool.list", "model.list", "session.remove"]); } finally { await cleanup(); }
|
||||
});
|
||||
it("fails readiness if a business tool was lost and still cleans up", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime(["render_scada_analysis"]);
|
||||
try { await expect(runtime.warmup()).rejects.toThrow("business tools missing: render_scada_analysis");
|
||||
expect(calls.at(-1)).toBe("session.remove"); } finally { await cleanup(); }
|
||||
});
|
||||
});
|
||||
|
||||
await runtime.warmup();
|
||||
describe("conversation workspace binding", () => {
|
||||
it("coalesces old-session migration and restores its persisted directory", async () => {
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
const root = await mkdtemp(join(tmpdir(), "runtime-workspace-test-"));
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
let directory = process.cwd();
|
||||
let moves = 0;
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ id: "old-session", location: { directory } }),
|
||||
move: async (input: { directory: string }) => { moves++; directory = input.directory; },
|
||||
},
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
||||
try {
|
||||
const [first, second] = await Promise.all([runtime.ensureSessionWorkspace("old-session"), runtime.ensureSessionWorkspace("old-session")]);
|
||||
expect(first).toBe(second);
|
||||
expect(first).not.toBe(process.cwd());
|
||||
expect(moves).toBe(1);
|
||||
const restored = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
||||
expect(await restored.ensureSessionWorkspace("old-session")).toBe(first);
|
||||
expect(moves).toBe(1);
|
||||
directory = "/tmp";
|
||||
await expect(restored.ensureSessionWorkspace("foreign-session")).rejects.toThrow("inside");
|
||||
} finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
"health",
|
||||
"session.create",
|
||||
`tool.list:${config.OPENCODE_MODEL}`,
|
||||
"session.delete:warmup-session",
|
||||
]);
|
||||
describe("explicit native Shell approval", () => {
|
||||
it("ties the pending decision to the full command and originating native call", async () => {
|
||||
const events = [
|
||||
{ type: "connected" },
|
||||
{ type: "permission.replied", data: { sessionID: "other", requestID: "perm-test", reply: "once" } },
|
||||
{ type: "permission.replied", data: { sessionID: "session-test", requestID: "perm-test", reply: "once" } },
|
||||
];
|
||||
let created: unknown;
|
||||
const client = {
|
||||
event: { subscribe: () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) },
|
||||
permission: { create: async (input: unknown) => { created = input; return { id: "perm-test", effect: "ask" }; } },
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
||||
await runtime.approveSandboxShell({ sessionId: "session-test", command: "printf hello", callId: "call-test", messageId: "message-test" }, new AbortController().signal);
|
||||
expect(created).toMatchObject({ action: "shell", resources: ["printf hello"], save: [], source: { type: "tool", id: "call-test", messageID: "message-test" } });
|
||||
events[2] = { type: "permission.replied", data: { sessionID: "session-test", requestID: "perm-test", reply: "reject" } };
|
||||
await expect(runtime.approveSandboxShell({ sessionId: "session-test", command: "printf hello", callId: "call-test", messageId: "message-test" }, new AbortController().signal)).rejects.toThrow("rejected");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { V2Event, FormInfo } from "@opencode/client";
|
||||
import { formAnswers, Opencode2EventNormalizer } from "../../src/runtime/opencode2-events.js";
|
||||
import { normalizeQuestionToolPayload } from "../../src/routes/chatStreamEvents.js";
|
||||
|
||||
const event = (type: string, data: unknown) => ({ type, data, created: 123 }) as V2Event;
|
||||
|
||||
describe("OpenCode 2 event contract", () => {
|
||||
it("ends the product stream on native execution completion", () => {
|
||||
const normalizer = new Opencode2EventNormalizer();
|
||||
expect(normalizer.normalize(event("session.execution.succeeded", { sessionID: "s" }))).toMatchObject([
|
||||
{ type: "session.status", properties: { sessionID: "s", status: { type: "idle" } } },
|
||||
{ type: "session.idle", properties: { sessionID: "s" } },
|
||||
]);
|
||||
});
|
||||
it("preserves tool identity, structured output and todo updates", () => {
|
||||
const normalizer = new Opencode2EventNormalizer();
|
||||
const data = { sessionID: "s", assistantMessageID: "m", id: "call-1" };
|
||||
normalizer.normalize(event("session.tool.input.started", { ...data, name: "todowrite" }));
|
||||
normalizer.normalize(event("session.tool.called", { ...data, input: { todos: [{ content: "check", status: "pending" }] } }));
|
||||
const result = normalizer.normalize(event("session.tool.success", { ...data, content: [{ type: "text", text: "[]" }] }));
|
||||
expect(result[0]).toMatchObject({ properties: { part: { callID: "call-1", tool: "todowrite", state: { status: "completed", output: "[]" } } } });
|
||||
expect(result[1]).toMatchObject({ type: "todo.updated", properties: { sessionID: "s", todos: [{ content: "check" }] } });
|
||||
});
|
||||
it("keeps permissions tied to the native session and call", () => {
|
||||
const normalizer = new Opencode2EventNormalizer();
|
||||
expect(normalizer.normalize(event("permission.asked", { id: "p", sessionID: "s", action: "shell", resources: ["echo ok"], source: { type: "tool", id: "call-2", messageID: "m" } }))[0]).toMatchObject({
|
||||
type: "permission.v2.asked", properties: { sessionID: "s", action: "shell", tool: { callID: "call-2", messageID: "m" } },
|
||||
});
|
||||
});
|
||||
it("waits for the native form ID instead of exposing a tool call ID as a question request", () => {
|
||||
const normalizer = new Opencode2EventNormalizer();
|
||||
const data = { sessionID: "s", assistantMessageID: "m", id: "call-q" };
|
||||
normalizer.normalize(event("session.tool.input.started", { ...data, name: "question" }));
|
||||
const events = normalizer.normalize(event("session.tool.called", { ...data, input: { questions: [{ header: "验证", question: "是否通过?", options: [] }] } }));
|
||||
const part = (events[0] as any).properties.part;
|
||||
expect(normalizeQuestionToolPayload(part, part.state.input, "client")).toBeUndefined();
|
||||
const forms = normalizer.normalize(event("form.created", { form: { id: "frm_test", sessionID: "s", title: "验证", fields: [{ key: "answer", type: "string", title: "是否通过?", options: [{ value: "ok", label: "通过" }] }] } }));
|
||||
expect(forms[0]).toMatchObject({ type: "question.v2.asked", properties: { id: "frm_test", questions: [{ question: "是否通过?" }] } });
|
||||
});
|
||||
it("maps displayed choices back to form values and preserves multiple selections", () => {
|
||||
const form = { id: "form-1", sessionID: "s", title: "选项", fields: [
|
||||
{ key: "mode", type: "string", options: [{ label: "快速", value: "fast" }] },
|
||||
{ key: "areas", type: "multiselect", options: [{ label: "东区", value: "east" }], custom: true },
|
||||
] } as FormInfo;
|
||||
expect(formAnswers(form, [["快速"], ["东区", "自选区域"]])).toEqual({ mode: "fast", areas: ["east", "自选区域"] });
|
||||
});
|
||||
});
|
||||
@@ -1,38 +1,23 @@
|
||||
import { describe, expect, it, test } from "bun:test";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
describe("internal OpenCode permissions", () => {
|
||||
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>>;
|
||||
};
|
||||
const permission = config.permission ?? {};
|
||||
const bash = permission.bash as Record<string, string> | undefined;
|
||||
const edit = permission.edit as Record<string, string> | undefined;
|
||||
const read = permission.read as Record<string, string> | undefined;
|
||||
|
||||
expect(permission["*"]).toBe("ask");
|
||||
expect(permission.external_directory).toBe("deny");
|
||||
expect(permission.task).toBe("deny");
|
||||
expect(permission.question).toBe("allow");
|
||||
expect(permission.todowrite).toBe("allow");
|
||||
expect(read?.["*"]).toBe("allow");
|
||||
expect(read?.["data/**"]).toBe("deny");
|
||||
expect(read?.["**/logs/**"]).toBe("deny");
|
||||
expect(edit?.["*"]).toBe("ask");
|
||||
expect(edit?.["data/**"]).toBe("deny");
|
||||
expect(edit?.["**/logs/**"]).toBe("deny");
|
||||
expect(bash?.["*"]).toBe("ask");
|
||||
expect(bash?.["*.env*"]).toBe("deny");
|
||||
expect(bash?.["*data/*"]).toBe("deny");
|
||||
expect(bash?.["*logs/*"]).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode runtime config", () => {
|
||||
test("continues the agent loop after a question or permission is denied", async () => {
|
||||
const config = await Bun.file(new URL("../../opencode.json", import.meta.url)).json();
|
||||
|
||||
expect(config.experimental?.continue_loop_on_deny).toBe(true);
|
||||
describe("OpenCode 2 permissions", () => {
|
||||
it("keeps protected paths denied, shell/edit gated and subagents disabled", async () => {
|
||||
const config = JSON.parse(await readFile("opencode.json", "utf8"));
|
||||
const rules = config.permissions as { action: string; resource: string; effect: string }[];
|
||||
const effect = (action: string, resource: string) => rules.find((rule) => rule.action === action && rule.resource === resource)?.effect;
|
||||
expect(effect("*", "*")).toBe("ask");
|
||||
expect(effect("external_directory", "*")).toBe("deny");
|
||||
expect(effect("subagent", "*")).toBe("deny");
|
||||
expect(effect("question", "*")).toBe("allow");
|
||||
expect(effect("todowrite", "*")).toBe("allow");
|
||||
for (const action of ["read", "edit"]) {
|
||||
for (const path of [".env", "*.env", "data/**", "**/logs/**"]) expect(effect(action, path)).toBe("deny");
|
||||
}
|
||||
expect(effect("edit", "*")).toBe("ask");
|
||||
expect(effect("shell", "*")).toBe("ask");
|
||||
for (const pattern of ["*.env*", "*data/*", "*logs/*"]) expect(effect("shell", pattern)).toBe("deny");
|
||||
expect(config.permission).toBeUndefined();
|
||||
expect(config.experimental?.continue_loop_on_deny).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { config } from "../../src/config.js";
|
||||
import { createConversationWorkspace, resolveConversationWorkspace } from "../../src/runtime/conversationWorkspace.js";
|
||||
import { executeSandboxCommand, probeLandlockSandbox } from "../../src/sandbox/landlockSandbox.js";
|
||||
import { canAccessConversationPath } from "../../.opencode/session-sandbox.js";
|
||||
import { canAutomaticallyApprove } from "../../src/routes/chatPermissionPolicy.js";
|
||||
|
||||
describe("conversation OS sandbox", () => {
|
||||
let root: string;
|
||||
let workspace: string;
|
||||
let other: string;
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "tjwater-sandbox-test-"));
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
workspace = await createConversationWorkspace(root);
|
||||
other = await createConversationWorkspace(root);
|
||||
await writeFile(join(other, "files", "private.txt"), "other-session");
|
||||
});
|
||||
afterAll(async () => { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true, force: true }); });
|
||||
|
||||
it("supports Landlock and runs local file computation with a sanitized environment", async () => {
|
||||
expect((await probeLandlockSandbox()).landlockAbi).toBeGreaterThanOrEqual(4);
|
||||
const result = await executeSandboxCommand(workspace, "printf sandbox-ok > result.txt; cat result.txt; python3 -c 'import os; assert not any(k in os.environ for k in [\"AGENT_INTERNAL_TOKEN\",\"DEEPSEEK_API_KEY\",\"OPENCODE_CLIENT_PASSWORD\"]); print(os.getcwd())'", 10);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("sandbox-ok");
|
||||
expect(result.stdout).toContain(join(workspace, "files"));
|
||||
expect(await readFile(join(workspace, "files", "result.txt"), "utf8")).toBe("sandbox-ok");
|
||||
});
|
||||
|
||||
it("denies other sessions, checkout files, symlink escape and config writes", async () => {
|
||||
await symlink(join(other, "files", "private.txt"), join(workspace, "files", "escape"));
|
||||
await symlink(join(other, "files", "not-created.txt"), join(workspace, "files", "broken-escape"));
|
||||
for (const command of [
|
||||
`cat '${other}/files/private.txt'`,
|
||||
`cat '${process.cwd()}/src/config.ts'`,
|
||||
"cat escape",
|
||||
"printf malicious > ../opencode.json",
|
||||
"mkdir ../.opencode",
|
||||
"printf denied > /usr/tjwater-sandbox-test",
|
||||
]) {
|
||||
const result = await executeSandboxCommand(workspace, command, 5);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("Permission denied");
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks TCP, UDP, Unix sockets, process inspection and process-group escape", async () => {
|
||||
for (const command of [
|
||||
"python3 -c 'import socket; socket.socket(socket.AF_INET, socket.SOCK_STREAM)'",
|
||||
"python3 -c 'import socket; socket.socket(socket.AF_INET, socket.SOCK_DGRAM)'",
|
||||
"python3 -c 'import socket; socket.socket(socket.AF_UNIX)'",
|
||||
"python3 -c 'import os; os.setsid()'",
|
||||
`python3 -c 'import os; os.kill(${process.pid}, 0)'`,
|
||||
]) {
|
||||
const result = await executeSandboxCommand(workspace, command, 5);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("Operation not permitted");
|
||||
}
|
||||
});
|
||||
|
||||
it("kills children on timeout and client cancellation", async () => {
|
||||
const timed = await executeSandboxCommand(workspace, "(sleep 2; printf leaked > timeout-leak.txt) & wait", 1);
|
||||
expect(timed.outcome).toBe("timeout");
|
||||
const controller = new AbortController();
|
||||
const cancelled = executeSandboxCommand(workspace, "(sleep 2; printf leaked > cancel-leak.txt) & wait", 10, controller.signal);
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
expect((await cancelled).outcome).toBe("cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2100));
|
||||
expect(await Bun.file(join(workspace, "files", "timeout-leak.txt")).exists()).toBeFalse();
|
||||
expect(await Bun.file(join(workspace, "files", "cancel-leak.txt")).exists()).toBeFalse();
|
||||
});
|
||||
|
||||
it("reaps background children immediately when the foreground shell exits", async () => {
|
||||
const result = await executeSandboxCommand(workspace, "(sleep 1; printf leaked > background-leak.txt) & printf foreground", 5);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.outcome).toBe("completed");
|
||||
expect(result.stdout).toBe("foreground");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
expect(await Bun.file(join(workspace, "files", "background-leak.txt")).exists()).toBeFalse();
|
||||
});
|
||||
|
||||
it("bounds output and rejects untrusted workspace paths", async () => {
|
||||
const result = await executeSandboxCommand(workspace, "python3 -c 'print(\"x\" * 2000000)'", 5);
|
||||
expect(result.outcome).toBe("output_limit");
|
||||
expect(result.stdout).toBe("");
|
||||
await expect(resolveConversationWorkspace(root, root)).rejects.toThrow("direct child");
|
||||
const link = join(root, "conversation-link");
|
||||
await symlink(workspace, link);
|
||||
await expect(resolveConversationWorkspace(link, root)).rejects.toThrow("symbolic link");
|
||||
});
|
||||
|
||||
it("constrains native file tools and keeps arbitrary execution interactive", () => {
|
||||
expect(canAccessConversationPath(workspace, "files/result.txt", true)).toBeTrue();
|
||||
expect(canAccessConversationPath(workspace, "opencode.json", true)).toBeFalse();
|
||||
expect(canAccessConversationPath(workspace, "files/escape")).toBeFalse();
|
||||
expect(canAccessConversationPath(workspace, "files/broken-escape", true)).toBeFalse();
|
||||
expect(canAccessConversationPath(workspace, "../other")).toBeFalse();
|
||||
expect(canAccessConversationPath(workspace, "files/.env")).toBeFalse();
|
||||
for (const action of ["shell", "edit", "external_directory", "execute"]) expect(canAutomaticallyApprove("always", action)).toBeFalse();
|
||||
expect(canAutomaticallyApprove("always", "web_search")).toBeTrue();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user