feat(agent): unify MCP tools and adopt Responses delivery

Move project and host queries into native MCP with host-owned OAuth/context,
while retaining memory, skills, actions and publication plugins. Add nearby-model
queries and explicit chat/task/learning model defaults.

Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped
session ownership, pending input edits and investigation communication.

Disable local-provider discovery: Server /health allowed native vLLM to probe
/v1/models on port 8000. A native before/after regression prevents recurrence.

Validation: 170 focused Bun tests; service/plugin typechecks; native discovery,
provider-discovery and input-inbox smoke checks; no paid model inference.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent f5c3cde2c2
commit 6a49d2209e
164 changed files with 12286 additions and 2368 deletions
+169
View File
@@ -0,0 +1,169 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AgentMessageCoordinator } from "../../src/chat/agent-message-coordinator.js";
import { AgentMessageClient, type AgentMessage, type MessageCredential } from "../../src/product/agent-messages.js";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
const projectId = "61000000-0000-4000-8000-000000000001";
const taskId = "61000000-0000-4000-8000-000000000002";
const runId = "61000000-0000-4000-8000-000000000003";
const owner = "conversation-owner";
const roots: string[] = [];
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); });
const message = (extra: Partial<AgentMessage> = {}): AgentMessage => ({
message_id: "61000000-0000-4000-8000-000000000004", project_id: projectId, task_id: taskId, run_id: runId,
request_id: "61000000-0000-4000-8000-000000000005", source_session_id: "main", source_owner_id: owner,
conversation_owner_id: owner, target_session_id: "investigation", direction: "to_task", in_reply_to: null,
text: "Check this additional question", status: "pending", native_message_id: "msg-coordinator-stable",
failure_detail: null, created_at: "2026-10-13T00:00:00Z", updated_at: "2026-10-13T00:00:00Z", delivered_at: null,
...extra,
});
async function fixture(initial = message()) {
const root = await mkdtemp(join(tmpdir(), "tjwater-message-coordinator-")); roots.push(root);
const sessions = new SessionMetadataStore(root);
await sessions.initialize();
const context = { actorKey: toActorKey(`product:${owner}`), projectKey: toProjectKey(`product:${projectId}`), projectId, userId: owner };
const main = (await sessions.ensure({ ...context, sessionId: "main", purpose: "ordinary_chat" })).record;
const investigation = (await sessions.ensure({ ...context, sessionId: "investigation", purpose: "task_execution", taskId, runId })).record;
const records = new Map([[initial.message_id, initial]]);
const native = new Map<string, "missing" | "enqueued" | "delivered">();
const injections: Array<{ target: string; id: string; text: string; metadata: unknown }> = [];
const receipts: Array<{ id: string; status: string; target: string | null }> = [];
const accesses: Array<{ credential: MessageCredential; id: string }> = [];
let run = { task_id: taskId, run_id: runId, status: "running", agent_session_id: "investigation" as string | null };
let receiptFailure = false;
let denyRecipient = false;
const pages: Array<{ items: AgentMessage[]; next_cursor: string | null }> = [];
const pageCalls: unknown[] = [];
const client = Object.assign(Object.create(AgentMessageClient.prototype), {
get: async (credential: MessageCredential, id: string) => {
accesses.push({ credential, id });
if (credential === "main" && denyRecipient) throw new Error("recipient authorization expired");
return records.get(id)!;
},
request: async () => run,
receipt: async (_credential: MessageCredential, item: AgentMessage, status: AgentMessage["status"], target: string | null, failure?: string) => {
receipts.push({ id: item.message_id, status, target });
if (receiptFailure) throw new Error("receipt lost");
const updated = { ...item, status, target_session_id: target, failure_detail: failure ?? null };
records.set(item.message_id, updated); return updated;
},
list: async (...args: unknown[]) => { pageCalls.push(args); return pages.shift() ?? { items: [], next_cursor: null }; },
}) as AgentMessageClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
agentMessageState: async (_target: string, id: string) => native.get(id) ?? "missing",
enqueueAgentMessage: async (target: string, id: string, text: string, metadata: unknown) => {
injections.push({ target, id, text, metadata }); native.set(id, "enqueued");
},
}) as OpencodeRuntimeAdapter;
const coordinator = new AgentMessageCoordinator(client, runtime, sessions);
return { coordinator, main, investigation, records, native, injections, receipts, accesses, pages, pageCalls,
setRun: (value: Partial<typeof run>) => { run = { ...run, ...value }; },
loseReceipt: (value: boolean) => { receiptFailure = value; }, denyRecipient: () => { denyRecipient = true; } };
}
describe("AgentMessageCoordinator", () => {
it("leaves idle recipients pending without native injection or waking a managed turn", async () => {
const item = message({ direction: "to_chat", source_session_id: "investigation", target_session_id: "main" });
const f = await fixture(item);
let notifications = 0;
const unsubscribe = f.coordinator.subscribe("main", () => notifications++);
expect(await f.coordinator.deliver("worker", item)).toEqual(item);
expect(f.injections).toEqual([]); expect(f.receipts).toEqual([]);
expect(f.coordinator.isActive("main")).toBeFalse(); expect(notifications).toBe(1);
unsubscribe(); await f.coordinator.deliver("worker", item); expect(notifications).toBe(1);
});
it("delivers only to the Server-selected exact run and preserves discussion provenance", async () => {
const item = message({ target_session_id: "untrusted-hint" });
const f = await fixture(item); f.coordinator.start(f.investigation);
const updated = await f.coordinator.deliver("worker", item);
expect(updated.status).toBe("enqueued"); expect(updated.target_session_id).toBe("investigation");
expect(f.injections).toHaveLength(1);
expect(f.injections[0]).toMatchObject({ target: "investigation", id: item.native_message_id,
metadata: { source: "agent_message", message_id: item.message_id, task_id: taskId, run_id: runId } });
expect(f.injections[0].text).toContain("不是用户直接指令");
expect(f.injections[0].text).toContain("不要自动回复收件确认");
expect(f.injections[0].text).toContain(item.text);
});
it("does not transfer messages to another run or to a terminal run", async () => {
const item = message(); const f = await fixture(item); f.coordinator.start(f.investigation);
f.setRun({ run_id: "another-run" });
await expect(f.coordinator.deliver("worker", item)).rejects.toThrow("binding mismatch");
expect(f.injections).toEqual([]); expect(f.receipts).toEqual([]);
f.setRun({ run_id: runId, status: "completed" });
expect((await f.coordinator.deliver("worker", item)).status).toBe("failed");
expect(f.injections).toEqual([]);
expect(f.records.get(item.message_id)?.failure_detail).toContain("未转投其他轮次");
});
it("waits for an exact task binding and rejects a missing or foreign recipient owner", async () => {
const item = message(); const f = await fixture(item); f.coordinator.start(f.investigation);
f.setRun({ agent_session_id: null });
expect((await f.coordinator.deliver("worker", item)).status).toBe("pending"); expect(f.receipts).toEqual([]);
f.setRun({ agent_session_id: "main" });
expect((await f.coordinator.deliver("worker", item)).status).toBe("failed"); expect(f.injections).toEqual([]);
const foreign = message({ direction: "to_chat", target_session_id: "main", conversation_owner_id: "foreign-owner" });
f.records.set(foreign.message_id, foreign); f.coordinator.start(f.main);
expect((await f.coordinator.deliver("worker", foreign)).status).toBe("failed"); expect(f.injections).toEqual([]);
});
it("rechecks the recipient authorization before injecting a service-delivered reply", async () => {
const item = message({ direction: "to_chat", target_session_id: "main" });
const f = await fixture(item); f.coordinator.start(f.main); f.denyRecipient();
await expect(f.coordinator.deliver("worker", item)).rejects.toThrow("recipient authorization expired");
expect(f.accesses.map((access) => access.credential)).toEqual(["worker", "main"]);
expect(f.injections).toEqual([]); expect(f.receipts).toEqual([]);
});
it("rejects investigation messages with a missing or mismatched sender owner", async () => {
const item = message(); const f = await fixture(item); f.coordinator.start(f.investigation);
for (const invalid of [{ ...item, source_session_id: "unknown-main" }, { ...item, source_owner_id: "another-owner" },
{ ...item, source_session_id: "investigation" }]) {
f.records.set(item.message_id, invalid);
expect((await f.coordinator.deliver("worker", invalid)).status).toBe("failed");
expect(f.injections).toEqual([]);
expect(f.records.get(item.message_id)?.failure_detail).toContain("来源会话");
}
});
it("recovers a lost receipt, serializes duplicate delivery and never injects twice", async () => {
const item = message(); const f = await fixture(item); f.coordinator.start(f.investigation); f.loseReceipt(true);
await expect(f.coordinator.deliver("worker", item)).rejects.toThrow("receipt lost");
expect(f.injections).toHaveLength(1); expect(f.records.get(item.message_id)?.status).toBe("pending");
f.loseReceipt(false);
const result = await Promise.all([f.coordinator.deliver("worker", item), f.coordinator.deliver("worker", item)]);
expect(result.map((record) => record.status)).toEqual(["enqueued", "enqueued"]);
expect(f.injections).toHaveLength(1);
f.native.set(item.native_message_id, "delivered"); f.coordinator.stop("investigation");
expect((await f.coordinator.deliver("worker", item)).status).toBe("delivered");
expect(f.injections).toHaveLength(1); expect(f.coordinator.isActive("investigation")).toBeFalse();
});
it("recovers a lost native enqueue only when the exact recipient is active", async () => {
const item = message({ status: "enqueued" }); const f = await fixture(item);
expect((await f.coordinator.deliver("worker", item)).status).toBe("enqueued"); expect(f.injections).toEqual([]);
f.coordinator.start(f.investigation);
expect((await f.coordinator.deliver("worker", item)).status).toBe("enqueued"); expect(f.injections).toHaveLength(1);
});
it("flushes paginated incoming pending messages only for the current run without creating or restarting a run", async () => {
const item = message({ target_session_id: null }); const f = await fixture(item); f.coordinator.start(f.investigation);
const foreignRun = message({ message_id: "61000000-0000-4000-8000-000000000006", target_session_id: null, run_id: "other-run" });
const outgoing = message({ message_id: "61000000-0000-4000-8000-000000000007", direction: "to_chat", target_session_id: "main" });
f.pages.push({ items: [foreignRun, outgoing], next_cursor: "next-page" }, { items: [item], next_cursor: null });
expect(await f.coordinator.flush("investigation")).toBeTrue();
expect(f.injections).toHaveLength(1); expect(f.injections[0].id).toBe(item.native_message_id);
expect(f.pageCalls).toEqual([["investigation", "investigation", undefined, 50, true], ["investigation", "investigation", "next-page", 50, true]]);
f.native.set(item.native_message_id, "delivered");
f.pages.push({ items: [f.records.get(item.message_id)!], next_cursor: null });
expect(await f.coordinator.flush("investigation")).toBeFalse(); expect(f.injections).toHaveLength(1);
});
});
+20
View File
@@ -1,3 +1,8 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { describe, expect, it } from "bun:test";
import { parseAnswerUiInput } from "../../src/chat/answer-ui.js";
import { streamPromptResponse } from "../../src/routes/chatStream.js";
@@ -37,6 +42,21 @@ describe("locally rendered answers", () => {
if (!suppressAnswerUi) expect(emitted.findIndex((event) => event.event === "token")).toBeLessThan(emitted.findIndex((event) => event.event === "answer_ui"));
}
});
it("persists rich parts in the owned chat store and restores them without cross-scope access", async () => {
const root = await mkdtemp(join(tmpdir(), "tjwater-rich-answer-"));
try {
const owner = { actorKey: "actor-answer", projectKey: "project-answer", sessionId: "answer-session" };
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const store = new SessionUiStateStore(join(root, "legacy"), workspaces);
const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍待核验。" }];
const messages = [{ id: "a", role: "assistant", parts }];
await store.write(owner, { sessionId: owner.sessionId, messages });
const restored = new SessionUiStateStore(join(root, "legacy"), new SessionWorkspaceStore(workspaces.root));
expect((await restored.read(owner))?.messages).toEqual(messages);
expect(await restored.read({ ...owner, actorKey: "actor-other" })).toBeNull();
expect(await restored.read({ ...owner, projectKey: "project-other" })).toBeNull();
} finally { await rm(root, { recursive: true, force: true }); }
});
it("preserves UI-only answers and ordered rich parts in task collaboration history", () => {
const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍需核验。" }, { type: "data-final_answer", data: { content: "结论待确认。" } }];
const messages = [{ id: "a", role: "assistant", parts: [...parts, { type: "data-progress", data: { title: "查询" } }] }, { id: "b", role: "assistant", parts: [parts[1]] }] as unknown as UIMessage[];
+84 -4
View File
@@ -11,6 +11,23 @@ import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
import { modelCheckTargets } from "../../src/runtime/model-check.js";
describe("agent model config", () => {
it("disables native local-provider discovery while preserving explicitly configured local models", () => {
const providers = Object.fromEntries(["vllm", "lmstudio", "ollama"].map((id) => [id, {
name: id, api_url: "http://127.0.0.1:18000/v1", models: { chat: {} },
}]));
const configuration = parseAgentModelConfig({
defaults: { chat: "vllm/chat", task: "lmstudio/chat", learning_gate: "ollama/chat" }, providers,
});
const native = serviceOpencodeConfig({ plugins: ["opencode.provider.vllm"] }, configuration);
for (const id of Object.keys(providers)) {
expect(native.plugins).toContain(`-opencode.provider.${id}`);
expect(native.providers[id]?.models.chat).toBeDefined();
}
expect(native.plugins).toContain("*");
expect(native.agents.instruction.model).toBe("vllm/chat");
expect(native.agents.investigation.model).toBe("lmstudio/chat");
});
it("keeps every exposed option in the supported model list", () => {
expect(agentModelOptions.map((model) => model.id)).toEqual(supportedModels);
expect(
@@ -34,7 +51,7 @@ describe("agent model config", () => {
it("generates native providers and the public catalog from one custom provider definition", () => {
const configuration = parseAgentModelConfig({
default_model: "custom/chat", task_model: "custom/org/model", learning_gate_model: "custom/chat",
defaults: { chat: "custom/chat", task: "custom/org/model", learning_gate: "custom/chat" },
providers: { custom: { name: "自定义服务", api_url: "https://example.com/v1", api_key: "{env:CUSTOM_API_KEY}", models: {
chat: { name: "自定义", description: "自定义模型", icon: "bolt", model_id: "wire-chat" },
"org/model": {}, hidden: { selectable: false }, image: { disabled: true, capabilities: { tools: false, output: ["image"] } },
@@ -59,9 +76,71 @@ describe("agent model config", () => {
expect(Object.keys(service.providers)).toEqual(["custom"]);
});
it("requires an explicit nonempty model for every role without inheriting chat", () => {
const input = {
defaults: { chat: "custom/chat", task: "custom/chat", learning_gate: "custom/chat" },
providers: { custom: { name: "Custom", api_url: "https://example.com/v1", models: { chat: {} } } },
};
for (const role of ["chat", "task", "learning_gate"] as const) {
const { [role]: omitted, ...remaining } = input.defaults;
expect(omitted).toBe("custom/chat");
expect(() => parseAgentModelConfig({ ...input, defaults: remaining })).toThrow("invalid agent model configuration structure");
for (const value of ["", " ", null, undefined]) {
expect(() => parseAgentModelConfig({ ...input, defaults: { ...input.defaults, [role]: value } })).toThrow("invalid agent model configuration structure");
}
}
const configuration = parseAgentModelConfig(input);
expect(configuration.defaults).toEqual({ chat: "custom/chat", task: "custom/chat", learning_gate: "custom/chat" });
const native = serviceOpencodeConfig({}, configuration);
expect(native.agents.instruction.model).toBe("custom/chat");
expect(native.agents.investigation.model).toBe("custom/chat");
});
it("routes same-name models across providers with independent limits and role overrides", () => {
const configuration = parseAgentModelConfig({
defaults: { chat: "primary/chat", task: "secondary/chat", learning_gate: "secondary/gate" },
providers: {
primary: { name: "Primary", api_url: "https://primary.example.com/v1", models: { chat: { limit: { context: 32000 } } } },
secondary: { name: "Secondary", api_url: "https://secondary.example.com/v1", models: {
chat: { model_id: "upstream/chat", limit: { context: 64000 } }, gate: { selectable: false },
} },
},
});
expect(getAgentModelOptions(configuration).map(({ id }) => id)).toEqual(["primary/chat", "secondary/chat"]);
expect(configuration.defaults.learning_gate).toBe("secondary/gate");
const native = serviceOpencodeConfig({}, configuration);
expect(native.model).toBe("primary/chat");
expect(native.agents.instruction.model).toBe("primary/chat");
expect(native.agents.investigation.model).toBe("secondary/chat");
expect(native.providers.primary.models.chat.limit?.context).toBe(32000);
expect(native.providers.secondary.models.chat).toMatchObject({ modelID: "upstream/chat", limit: { context: 64000 } });
expect(modelCheckTargets(configuration).models.map(({ id, wire_id }) => ({ id, wire_id }))).toEqual([
{ id: "primary/chat", wire_id: "chat" },
{ id: "secondary/chat", wire_id: "upstream/chat" },
{ id: "secondary/gate", wire_id: "gate" },
]);
});
it("requires grouped defaults and rejects hidden chat defaults and unknown role names", () => {
const providers = { custom: { name: "Custom", api_url: "https://example.com/v1", models: { chat: {}, hidden: { selectable: false } } } };
const defaults = { chat: "custom/chat", task: "custom/chat", learning_gate: "custom/chat" };
for (const input of [
{ providers },
{ defaults: {}, providers },
{ defaults: { ...defaults, typo: "custom/chat" }, providers },
{ default_model: "custom/chat", providers },
{ defaults, task_model: "custom/chat", providers },
]) expect(() => parseAgentModelConfig(input)).toThrow("invalid agent model configuration structure");
expect(() => parseAgentModelConfig({ defaults: { ...defaults, chat: "custom/hidden" }, providers })).toThrow("defaults.chat must be selectable");
});
it("rejects unknown, disabled or non-tool role models and invalid endpoints without echoing keys", () => {
const base = { default_model: "custom/chat", providers: { custom: { name: "Custom", api_url: "https://example.com/v1", api_key: "TEST_ONLY_SECRET", models: { chat: {} } } } };
for (const role of ["default_model", "task_model", "learning_gate_model"]) expect(() => parseAgentModelConfig({ ...base, [role]: "custom/missing" })).toThrow("enabled configured text model");
const base = { defaults: { chat: "custom/chat", task: "custom/chat", learning_gate: "custom/chat" }, providers: { custom: { name: "Custom", api_url: "https://example.com/v1", api_key: "TEST_ONLY_SECRET", models: { chat: {}, disabled: { disabled: true }, no_tools: { capabilities: { tools: false } }, image: { capabilities: { output: ["image"] } } } } } };
for (const role of ["chat", "task", "learning_gate"]) {
for (const model of ["custom/missing", "custom/disabled", "custom/no_tools", "custom/image"]) {
expect(() => parseAgentModelConfig({ ...base, defaults: { ...base.defaults, [role]: model } })).toThrow(`defaults.${role} must name an enabled configured text model`);
}
}
for (const model of [{ disabled: true }, { capabilities: { tools: false } }, { capabilities: { output: ["image"] } }]) {
expect(() => parseAgentModelConfig({ ...base, providers: { custom: { ...base.providers.custom, models: { chat: model } } } })).toThrow();
}
@@ -89,6 +168,7 @@ describe("agent model config", () => {
const native = serviceOpencodeConfig({});
expect(Object.keys(native.providers)).toEqual(["waternetwork"]);
expect(native.model).toBe("waternetwork/gpt-6-luna");
for (const model of Object.values(native.providers.waternetwork.models)) expect(model.limit?.context).toBe(272000);
expect(native.providers.waternetwork.models["gpt-6-luna"].limit?.context).toBe(272000);
expect(native.providers.waternetwork.models["gpt-6.1-sol"].limit?.context).toBe(475000);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "bun:test";
import { createPromptSuggestionService, parseGeneratedSuggestions, promptSuggestionRequestSchema } from "../../src/chat/prompt-suggestions.js";
import type { LocalAgentContext } from "../../src/context/localContext.js";
const activity = { id: "alert:pressure", kind: "alert" as const, title: "城南压力告警", occurred_at: "2026-10-09T07:00:00Z", origin: "backend" as const };
const context = { actorKey: "actor-a", projectKey: "project-a", projectId: "project-a", userId: "user-a", network: "supply", traceId: "trace-a" } satisfies LocalAgentContext;
const output = JSON.stringify({ suggestions: [{ question: "核对城南压力告警的监测证据与影响范围", activity_id: activity.id }] });
describe("Agent prompt suggestions", () => {
it("accepts only bounded activity snapshots and suggestions referencing supplied records", () => {
expect(promptSuggestionRequestSchema.safeParse({ activities: Array.from({ length: 13 }, (_, i) => ({ ...activity, id: String(i) })) }).success).toBe(false);
expect(promptSuggestionRequestSchema.safeParse({ activities: [activity, activity] }).success).toBe(false);
expect(parseGeneratedSuggestions(`\`\`\`json\n${output}\n\`\`\``, [activity])).toHaveLength(1);
expect(() => parseGeneratedSuggestions(output.replace(activity.id, "other-project-alert"), [activity])).toThrow("unknown activity ID");
expect(() => parseGeneratedSuggestions(JSON.stringify({ suggestions: [JSON.parse(output).suggestions[0], JSON.parse(output).suggestions[0]] }), [activity])).toThrow("duplicate suggestion");
});
it("coalesces inference for the same actor, project, model and activity without sharing other scopes", async () => {
const calls: string[] = [];
const generate = createPromptSuggestionService({ generateText: async (prompt) => { calls.push(prompt); return output; } });
const request = { activities: [activity] };
const [a, b] = await Promise.all([generate(context, request, "test/model"), generate(context, request, "test/model")]);
expect(a).toEqual(b);
expect(calls).toHaveLength(1);
expect(calls[0]).toContain(activity.title);
await generate({ ...context, actorKey: "actor-b" }, request, "test/model");
await generate({ ...context, projectKey: "project-b" }, request, "test/model");
await generate(context, request, "test/other-model");
await generate(context, { activities: [{ ...activity, title: "城北压力告警" }] }, "test/model");
expect(calls).toHaveLength(5);
});
it("does not cache malformed model output and supports generic questions without inventing activity references", async () => {
let calls = 0;
const generate = createPromptSuggestionService({ generateText: async () => ++calls === 1 ? "invalid JSON" : '{"suggestions":[{"question":"分析供水管网需要优先核对的监测指标","activity_id":null}]}' });
await expect(generate(context, { activities: [] }, "test/model")).rejects.toThrow();
expect((await generate(context, { activities: [] }, "test/model")).suggestions[0].activity_id).toBeNull();
expect(calls).toBe(2);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { expect, it, mock } from "bun:test";
import { ResponseInputTracker, type ResponseInput } from "../../src/chat/response-inputs.js";
const input = (id: number): ResponseInput => ({ id: `msg_${String(id).padStart(32, "0")}`, text: `Input ${id}`, delivery: "steer", status: "pending" });
it("waits for admission at completion, deduplicates concurrent retries and closes only after native consumption", async () => {
let release = () => {};
const gate = new Promise<void>((resolve) => { release = resolve; });
const runtime = { listPendingUserInputs: async () => [], enqueueUserInput: mock(async () => { await gate; }) };
const changed = mock((_input: ResponseInput) => {});
const tracker = new ResponseInputTracker(runtime, "owned", changed);
tracker.markReady();
const first = tracker.submit(input(1));
const retry = tracker.submit(input(1));
const closing = tracker.canClose();
release();
expect((await first).status).toBe("pending");
expect((await retry).status).toBe("pending");
expect(runtime.enqueueUserInput).toHaveBeenCalledTimes(1);
expect(await closing).toBe(false);
tracker.observe("session.inbox.delivery.changed", { inboxID: input(1).id, delivery: "queue" });
expect(changed.mock.calls.at(-1)?.[0]).toMatchObject({ delivery: "queue" });
tracker.observe("session.inbox.delivered", { inboxID: input(1).id });
expect(changed.mock.calls.at(-1)?.[0]).toMatchObject({ status: "delivered" });
expect(await tracker.canClose()).toBe(true);
await expect(tracker.submit(input(2))).rejects.toMatchObject({ status: 409 });
});
it("rejects a failed admission and permits a later retry without acknowledging nonexistent input", async () => {
let attempts = 0;
const runtime = { listPendingUserInputs: async () => [], enqueueUserInput: async () => { if (attempts++ === 0) throw new Error("fixture failure"); } };
const tracker = new ResponseInputTracker(runtime, "owned", () => {});
tracker.markReady();
const first = tracker.submit(input(1));
const duplicate = tracker.submit(input(1));
const results = await Promise.allSettled([first, duplicate]);
expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]);
for (const result of results) if (result.status === "rejected") expect(result.reason.message).toBe("fixture failure");
await expect(tracker.submit(input(1))).resolves.toMatchObject({ status: "pending" });
});
it("bounds pending input and refuses admission after an explicit stop", async () => {
const runtime = { listPendingUserInputs: async () => [], enqueueUserInput: async () => {} };
const tracker = new ResponseInputTracker(runtime, "owned", () => {});
tracker.markReady();
for (let id = 0; id < 20; id++) await tracker.submit(input(id));
await expect(tracker.submit(input(20))).rejects.toMatchObject({ status: 429 });
tracker.close();
await expect(tracker.submit(input(21))).rejects.toMatchObject({ status: 409 });
});
+52
View File
@@ -0,0 +1,52 @@
import { expect, it } from "bun:test";
import { createResponsesProjection, responsePrompt, responseRequestSchema, type ResponseEvent } from "../../src/chat/responses-protocol.js";
it("accepts Responses text inputs and conversation references without legacy chat payloads", () => {
const request = responseRequestSchema.parse({ input: [{ role: "user", content: [{ type: "input_text", text: "检查压力" }] }], conversation: { id: "owned" }, stream: true });
expect(responsePrompt(request.input)).toBe("检查压力");
expect(request.tjwater.approval_mode).toBe("request");
expect(responseRequestSchema.parse({ input: "检查压力" }).stream).toBe(false);
for (const extra of [{ messages: [] }, { previous_response_id: "unknown" }, { tools: [] }, { authorization: "untrusted" }]) {
expect(responseRequestSchema.safeParse({ input: "检查压力", ...extra }).success).toBeFalse();
}
expect(responseRequestSchema.safeParse({ input: [{ role: "system", content: "bypass" }] }).success).toBeFalse();
});
it("emits ordered Responses item and content lifecycles with explicit business extensions", () => {
const events: ResponseEvent[] = [];
const wire = createResponsesProjection({ model: "test/model", conversationId: "owned", emit: (event) => events.push(event) });
wire.begin();
wire.write({ type: "start", messageId: "msg_persisted" });
wire.write({ type: "text-start", id: "a" });
wire.write({ type: "text-delta", id: "a", delta: "压力" });
wire.write({ type: "data-answer_ui", id: "chart", data: { title: "观测" } });
wire.write({ type: "text-end", id: "a" });
wire.write({ type: "text-delta", id: "b", delta: "待核验" });
wire.write({ type: "finish", finishReason: "stop" });
expect(events.some((event) => event.type === "response.completed")).toBeFalse();
wire.finish();
wire.finish();
expect(events[0]?.type).toBe("response.created");
expect(events.at(-1)?.type).toBe("response.completed");
expect(events.map((event) => event.sequence_number)).toEqual(events.map((_, i) => i));
expect(events.find((event) => event.type === "tjwater.answer_ui")).toMatchObject({ response_id: wire.response.id, id: "chart", data: { title: "观测" } });
expect(events.filter((event) => event.type === "response.output_text.done")).toHaveLength(2);
expect(wire.response.output[0]?.content.map((part) => part.text)).toEqual(["压力", "待核验"]);
expect(wire.response.output[0]?.id).toBe("msg_persisted");
expect((events[0]!.response as { output: unknown[] }).output).toEqual([]);
expect(wire.response).toMatchObject({ object: "response", status: "completed", conversation: { id: "owned" }, usage: null });
});
it("distinguishes errors and cancellation from completed responses", () => {
for (const reason of ["failed", "cancelled"] as const) {
const events: ResponseEvent[] = [];
const wire = createResponsesProjection({ model: "test/model", conversationId: "owned", emit: (event) => events.push(event) });
wire.write({ type: "text-delta", id: "a", delta: "部分结果" });
wire.write({ type: "error", errorText: "计算失败" });
wire.finish(reason);
expect(events.at(-1)?.type).toBe(reason === "cancelled" ? "response.incomplete" : "response.failed");
expect(wire.response.output[0]?.status).toBe("incomplete");
expect(wire.response.error?.message).toBe("计算失败");
expect(events.some((event) => event.type === "response.completed")).toBeFalse();
}
});
+39
View File
@@ -8,6 +8,8 @@ import {
attachTrustedAgentContext,
getLocalAgentContext,
ProductContextError,
getTrustedProductCredential,
isTrustedTaskDispatchRequest,
} from "../../src/context/localContext.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
@@ -35,6 +37,43 @@ const authResponse = (userId: string, projectId: string) => ({
});
describe("trusted product chat context", () => {
it("authorizes message dispatch only for verified service accounts with execution permission", async () => {
const request = requestWithHeaders({ authorization: "Bearer verified", "x-project-id": "project-a" });
for (const [role, permissions, allowed] of [
["user", ["operational_task.execute"], false],
["service", ["operational_task.view"], false],
["service", ["operational_task.execute"], true],
["user", [], false],
] as const) {
globalThis.fetch = (async () => Response.json({ ...authResponse("actor", "project-a"), role, permissions })) as unknown as typeof fetch;
await attachTrustedAgentContext(request);
expect(isTrustedTaskDispatchRequest(request)).toBe(allowed);
}
});
it("verifies personal chats by Bearer without assigning a shared project or business credential", async () => {
const requests: Array<{ url: string; headers: Headers }> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: String(input), headers: new Headers(init?.headers) });
return Response.json({ user_id: requests.length === 1 ? "user-a" : "user-b" });
}) as unknown as typeof fetch;
const first = requestWithHeaders({ authorization: "Bearer personal-a", "x-agent-local-user": "spoofed" });
const second = requestWithHeaders({ authorization: "Bearer personal-b" });
await attachTrustedAgentContext(first);
await attachTrustedAgentContext(second);
expect(requests[0]!.url).toEndWith("/api/v1/access-context");
expect(requests[0]!.headers.has("X-Project-Id")).toBeFalse();
expect(getLocalAgentContext(first).userId).toBe("user-a");
expect(getLocalAgentContext(first).projectId).toBeUndefined();
expect(getLocalAgentContext(first).projectKey).not.toBe(getLocalAgentContext(second).projectKey);
expect(getTrustedProductCredential(first)).toBeNull();
});
it("does not turn a project header or expired personal token into anonymous access", async () => {
globalThis.fetch = (async () => Response.json({ detail: "expired" }, { status: 401 })) as unknown as typeof fetch;
await expect(attachTrustedAgentContext(requestWithHeaders({ "x-project-id": "project-a" }))).rejects.toBeInstanceOf(ProductContextError);
await expect(attachTrustedAgentContext(requestWithHeaders({ authorization: "Bearer expired-personal" }))).rejects.toBeInstanceOf(ProductContextError);
});
it("uses verified product users and projects instead of spoofable local headers", async () => {
const identities = [authResponse("product-user-1", "project-1"), authResponse("product-user-2", "project-2")];
globalThis.fetch = (async () => Response.json(identities.shift())) as unknown as typeof fetch;
-27
View File
@@ -1,27 +0,0 @@
import { Plugin, Rpc } from "../../.opencode/node_modules/@opencode/plugin/dist/promise/index.js";
import { z } from "../../.opencode/node_modules/zod/index.js";
// Loaded only by the opt-in isolated smoke script, never by the Agent service.
export default Plugin.define({
id: "tjwater.mcp-fixture",
async setup(ctx) {
// Materialize the native session toolbox, then stop before any model call.
await ctx.session.hook("context", () => { throw new Error("MCP fixture stops before model request"); });
const evaluated: string[] = [];
await ctx.permission.hook("evaluate", (event) => { evaluated.push(event.action); });
await ctx.rpc.register(Rpc.define({
id: "tjwater.mcp-fixture", events: {}, methods: {
call: { input: z.object({ session_id: z.string(), tool: z.enum(["mcp_stdio_echo", "mcp_http_write"]), value: z.string() }), output: z.object({ content: z.unknown(), evaluated: z.array(z.string()), error: z.string().optional() }) },
},
}), {
call: async (input, context) => {
const tool = (await ctx.tool.list()).find((tool) => tool.id === input.tool);
if (!tool) throw new Error("fixture tool missing");
try {
const result = await tool.execute({ value: input.value }, { sessionID: input.session_id as never, id: "call-mcp-fixture" as never, messageID: "msg-mcp-fixture" as never, agent: "instruction" as never, signal: context.signal, progress: async () => {} });
return { content: result.content ?? [], evaluated };
} catch (error) { return { content: [], evaluated, error: String(error) }; }
},
});
},
});
+8 -3
View File
@@ -6,23 +6,28 @@ describe("OpenCode 2 business tool registration", () => {
const tools = new Map<string, any>();
const storage = new Map<string, unknown>();
let rpcList: (() => Promise<unknown>) | undefined;
let readTodos: ((input: { session_id: string }) => Promise<unknown>) | undefined;
await plugin.setup({
tool: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => Array.from(tools, ([id, tool]) => ({ ...tool, id })), add: (tool: any) => tools.set(tool.name, tool), remove: (name: string) => tools.delete(name), update: () => {} }), list: async () => Array.from(tools, ([id, tool]) => ({ ...tool, id })) },
mcp: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => [] }), list: async () => ({ data: [] }) },
shell: { hook: async () => {} },
permission: { hook: async () => {} },
session: { hook: async () => {} },
rpc: { register: async (_definition: unknown, handlers: { list: () => Promise<unknown> }) => { rpcList = handlers.list; } },
rpc: { register: async (_definition: unknown, handlers: { list: () => Promise<unknown>; read_todos: (input: { session_id: string }) => Promise<unknown> }) => { rpcList = handlers.list; readTodos = handlers.read_todos; } },
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" });
for (const name of ["publish_task_progress", "publish_task_presentation"]) {
expect(tools.get(name).options).toEqual({ codemode: false, permission: name });
}
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: "[]" });
expect(tools.has("todoread")).toBeFalse();
expect(await readTodos!({ session_id: "s" })).toEqual(todos);
expect(await readTodos!({ session_id: "another" })).toEqual([]);
const discovery = await rpcList!();
expect(JSON.parse(JSON.stringify(discovery))).toEqual(discovery);
});
+119
View File
@@ -0,0 +1,119 @@
// Opt-in native inbox smoke. A deterministic localhost SSE fixture is the only
// model endpoint; no paid inference, product writes or running service restarts.
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import express from "express";
import { parseAgentModelConfig } from "../../src/chat/modelConfig.js";
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
import { startOpencode2 } from "../../src/runtime/opencode2-server.js";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import { bindScopedConversationWorkspace, createConversationWorkspace } from "../../src/runtime/conversationWorkspace.js";
import { registerTaskAgentBridge } from "../../src/routes/task-agent-bridge.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { startQueryMcpServer } from "../../src/mcp/query-server.js";
import { writeQueryMcpConnection } from "../../src/runtime/query-mcp-connection.js";
import { registerMcpPolicyBridge } from "../../src/routes/mcpRoutes.js";
import { QUERY_MCP_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
import { TASK_AGENT, TASK_READER_AGENT } from "../../src/runtime/task-agent-policy.js";
const root = await mkdtemp(join(tmpdir(), "tjwater-agent-messages-smoke-"));
const previousConnectionPath = process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
const workspaceRoot = join(root, "workspaces");
const token = randomBytes(32).toString("hex");
let native: Awaited<ReturnType<typeof startOpencode2>> | undefined;
const ids: string[] = [];
const modelInputs: Array<{ messages?: Array<{ role: string; content: unknown }> }> = [];
const fixture = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) {
if (new URL(request.url).pathname !== "/v1/chat/completions") return new Response("local fixture route not found", { status: 404 });
modelInputs.push(await request.json());
const chunk = (delta: object, finishReason: string | null) => JSON.stringify({ id: "chatcmpl-local-fixture", object: "chat.completion.chunk",
created: 1, model: "fake", choices: [{ index: 0, delta, finish_reason: finishReason }] });
return new Response(`data: ${chunk({ role: "assistant", content: "Local inbox fixture completed." }, null)}\n\ndata: ${chunk({}, "stop")}\n\ndata: [DONE]\n\n`,
{ headers: { "Content-Type": "text/event-stream" } });
} });
const app = express(); app.use(express.json());
registerTaskAgentBridge(app, { internalToken: token, getSession: (sessionID) => native!.client.session.get({ sessionID }), hasActiveRun: () => false });
registerMcpPolicyBridge(app, { internalToken: token, runtime: { mcpReadonlyTools: () => QUERY_MCP_TOOL_IDS } });
const queryMcp = startQueryMcpServer({ internalToken: token, productClient: null, productTools: [], resolveReader: async () => null, executeLocal: async () => ({ items: [] }) });
writeQueryMcpConnection(join(root, "sessions"), queryMcp.url);
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((done) => server.once("listening", done));
try {
const models = parseAgentModelConfig({ defaults: { chat: "local-smoke/fake", task: "local-smoke/fake", learning_gate: "local-smoke/fake" }, providers: { "local-smoke": {
name: "Deterministic local smoke fixture", api_url: `http://127.0.0.1:${fixture.port}/v1`, api_key: "SMOKE_ONLY",
models: { fake: { capabilities: { tools: true }, limit: { context: 32768, output: 1024 } } },
} } });
const input = JSON.parse(await readFile("opencode.json", "utf8"));
native = await startOpencode2({ command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000,
config: serviceOpencodeConfig(input, models, workspaceRoot),
env: { AGENT_INTERNAL_TOKEN: token, TJWATER_AGENT_WORKSPACE_ROOT: workspaceRoot,
TJWATER_AGENT_MCP_CONNECTION_PATH: process.env.TJWATER_AGENT_MCP_CONNECTION_PATH!,
TJWATER_AGENT_INTERNAL_BASE_URL: `http://127.0.0.1:${(server.address() as { port: number }).port}`,
OPENCODE_DB: join(root, "native-data", "opencode.db"), XDG_DATA_HOME: join(root, "native-data"),
XDG_CONFIG_HOME: join(root, "native-config"), XDG_CACHE_HOME: join(root, "native-cache"), XDG_STATE_HOME: join(root, "native-state") },
});
const initial = await createConversationWorkspace(workspaceRoot);
const session = await native.client.session.create({ agent: "instruction", location: { directory: initial } }); ids.push(session.id);
const directory = await bindScopedConversationWorkspace(initial, workspaceRoot, { actorKey: "actor-smoke", projectKey: "project-smoke", sessionId: session.id });
await native.client.session.move({ sessionID: session.id, directory });
setRuntimeSessionContext({ sessionId: session.id, clientSessionId: session.id, actorKey: "actor-smoke", projectKey: "project-smoke",
projectId: "test-project", traceId: "test", purpose: "ordinary_chat" });
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => native!.client }) as OpencodeRuntimeAdapter;
const required = ["mcp_tjwater_list_investigation_tasks", "mcp_tjwater_get_investigation_task", "send_task_message", "mcp_tjwater_read_agent_messages", "reply_agent_message"];
let discovered: Array<{ id: string; direct: boolean }> = [];
const catalogDeadline = Date.now() + 10_000;
do {
discovered = (await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } })).output as typeof discovered;
if (required.every((id) => discovered.some((tool) => tool.id === id && tool.direct))) break;
await new Promise((resolve) => setTimeout(resolve, 100));
} while (Date.now() < catalogDeadline);
for (const id of required) {
assert.ok(discovered.some((tool) => tool.id === id && tool.direct), `missing direct registered tool ${id}`);
assert.equal((await native.client.permission.create({ sessionID: session.id, agent: "instruction", action: id, resources: ["*"], save: [] })).effect, "allow");
assert.equal((await native.client.permission.create({ sessionID: session.id, agent: TASK_AGENT, action: id, resources: ["*"], save: [] })).effect,
["mcp_tjwater_read_agent_messages", "reply_agent_message"].includes(id) ? "allow" : "deny");
assert.equal((await native.client.permission.create({ sessionID: session.id, agent: TASK_READER_AGENT, action: id, resources: ["*"], save: [] })).effect, "deny");
}
assert.ok(!discovered.some((tool) => ["message", "send_message", "session_message"].includes(tool.id)), "arbitrary-session message tool exposed");
const nativeId = `msg_${randomBytes(16).toString("hex")}`;
assert.equal(await runtime.agentMessageState(session.id, nativeId), "missing");
const originalMessage = { message_id: "62000000-0000-4000-8000-000000000001", source_session_id: "investigation-source", message: "Local cooperation provenance marker" };
await runtime.enqueueAgentMessage(session.id, nativeId, JSON.stringify({ originalMessage }), { source: "agent_message", message_id: originalMessage.message_id });
assert.equal(await runtime.agentMessageState(session.id, nativeId), "enqueued");
assert.equal((await native.client.session.inbox.list({ sessionID: session.id })).filter((item) => item.id === nativeId).length, 1);
assert.ok(!(session.id in await native.client.session.active()), "resume:false unexpectedly started the idle main session");
assert.equal(modelInputs.length, 0, "idle synthetic enqueue invoked a model");
// Admission recovery checks state before retrying. The inbox must retain one
// stable entry, while native duplicate admission is rejected or idempotent.
try { await runtime.enqueueAgentMessage(session.id, nativeId, JSON.stringify({ originalMessage }), { source: "agent_message" }); }
catch (error) { assert.ok(error instanceof Error || typeof error === "object", "invalid duplicate rejection"); }
assert.equal((await native.client.session.inbox.list({ sessionID: session.id })).filter((item) => item.id === nativeId).length, 1);
assert.equal(modelInputs.length, 0);
await native.client.session.prompt({ sessionID: session.id, text: "Consume the local inbox fixture and finish." });
await native.client.session.wait({ sessionID: session.id }, { signal: AbortSignal.timeout(30_000) });
assert.equal(await runtime.agentMessageState(session.id, nativeId), "delivered");
const stored = await native.client.session.message.get({ sessionID: session.id, messageID: nativeId });
assert.equal(stored.type, "synthetic");
assert.ok(JSON.stringify(stored).includes("originalMessage"), "native synthetic lost originalMessage provenance");
assert.equal((await native.client.session.inbox.list({ sessionID: session.id })).filter((item) => item.id === nativeId).length, 0);
assert.ok(modelInputs.length > 0, "local fake provider was not invoked to consume inbox");
const fixtureText = JSON.stringify(modelInputs);
assert.ok(fixtureText.includes("Local cooperation provenance marker"), "recipient did not receive synthetic discussion");
assert.ok(fixtureText.includes("originalMessage"), "model context lost sender provenance");
const receivedDiscussion = modelInputs.flatMap((input) => input.messages ?? []).filter((message) =>
message.role !== "system" && JSON.stringify(message.content).includes("Local cooperation provenance marker"));
assert.ok(receivedDiscussion.length > 0);
for (const message of receivedDiscussion) assert.ok(!JSON.stringify(message.content).includes("<userinstruction>"), "synthetic discussion upgraded to userinstruction");
assert.ok(!(session.id in await native.client.session.active()), "local fixture did not return to idle");
console.log("Native controlled tool discovery, primary permissions, missing/enqueued/delivered state, stable-ID dedup and resume:false idle behavior passed. Only a local fake SSE provider was used.");
} finally {
for (const id of ids.reverse()) { removeRuntimeSessionContext(id); if (native) await native.client.session.remove({ sessionID: id }).catch(() => {}); }
native?.close(); if (native) await native.exited;
await new Promise<void>((done) => server.close(() => done())); fixture.stop(true);
await queryMcp.close();
if (previousConnectionPath === undefined) delete process.env.TJWATER_AGENT_MCP_CONNECTION_PATH; else process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = previousConnectionPath;
await rm(root, { recursive: true, force: true });
}
+71
View File
@@ -0,0 +1,71 @@
// Opt-in: MCP_TEST_PYTHON=<server env python> MCP_TEST_SERVER_ROOT=<server repo> bun tests/integration/mcp-query-smoke.ts
import { resolve } from "node:path";
import { McpQueryClient, type McpHttpFetch } from "../../src/product/mcp-query-client.js";
import { bindSessionCredential, releaseSessionCredential } from "../../src/product/credential-refresh.js";
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { startQueryMcpServer } from "../../src/mcp/query-server.js";
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
const python = process.env.MCP_TEST_PYTHON;
const root = process.env.MCP_TEST_SERVER_ROOT;
if (!python || !root) throw new Error("MCP smoke requires an explicit isolated Server environment");
// The fixture owns its OAuth/resource settings. Local Agent MCP configuration
// (notably a private development issuer) must not override its loopback setup.
const fixtureEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("TJWATER_MCP_")));
const processHandle = Bun.spawn([python, resolve(root, "tests/fixtures/mcp_query_app.py")], { cwd: root, env: { ...fixtureEnv, PYTHONPATH: root }, stdout: "pipe", stderr: "pipe" });
const session = "fixture-host-session";
let gateway: ReturnType<typeof startQueryMcpServer> | undefined;
let nativeClient: Client | undefined;
try {
const reader = processHandle.stdout.getReader();
const output = await Promise.race([reader.read(), Bun.sleep(10_000).then(() => { throw new Error("MCP fixture did not start"); })]);
reader.releaseLock();
const resource = new TextDecoder().decode(output.value).trim().split("\n").at(-1)!;
if (!/^http:\/\/127\.0\.0\.1:\d+\/mcp$/.test(resource)) throw new Error("MCP fixture endpoint invalid");
const requests: string[] = [];
const grants: string[] = [];
const issuer = "http://localhost/realms/test";
const tokenEndpoint = "http://localhost/fixture/oauth/token";
const fixtureJwt = (kind: string) => `e30.${Buffer.from(JSON.stringify({ sub: "be3b27c8-0e58-4e3a-9df5-e62bc43f4e51", iss: "http://localhost/realms/test", aud: "tjwater-mcp", azp: "tjwater-agent-mcp", fixture: kind })).toString("base64url")}.fixture`;
const http: McpHttpFetch = async (input, init) => {
const url = String(input);
if (url === "http://localhost/.well-known/oauth-authorization-server/realms/test") return Response.json({
issuer, authorization_endpoint: "http://localhost/fixture/oauth/authorize", token_endpoint: tokenEndpoint,
response_types_supported: ["code"], token_endpoint_auth_methods_supported: ["client_secret_post"],
});
if (url === tokenEndpoint) {
const body = new URLSearchParams(String(init?.body));
if (body.get("resource") !== resource) throw new Error("OAuth grant omitted its resource indicator");
const grant = body.get("grant_type"); grants.push(grant ?? "");
return Response.json({ access_token: fixtureJwt(grant === "client_credentials" ? "catalog" : "user"), token_type: "Bearer", expires_in: 300 });
}
if (url === resource) requests.push(new Headers(init?.headers).get("MCP-Method") ?? init?.method ?? "GET");
return fetch(input, init);
};
const client = new McpQueryClient({ issuer: "http://localhost/realms/test", resource, audience: "tjwater-mcp", clientId: "tjwater-agent-mcp", clientSecret: "fixture" }, http);
const catalog = await client.prepare();
if (catalog.length !== 35) throw new Error("Query catalog discovery failed");
if (!catalog.some((tool) => tool.name === "run_simulation" && tool.inputSchema.properties?.start_time && tool.inputSchema.properties?.output_node_ids)) throw new Error("Standard hydraulic simulation schema missing");
bindSessionCredential(session, { authorization: `Bearer ${fixtureJwt("source")}`, projectId: "d44a9241-de48-4e60-9348-867dc466a031" });
setRuntimeSessionContext({ sessionId: session, clientSessionId: session, actorKey: "fixture", traceId: "fixture", projectId: "d44a9241-de48-4e60-9348-867dc466a031", projectKey: "fixture-project", purpose: "ordinary_chat" });
gateway = startQueryMcpServer({ internalToken: "MCP_TEST_ONLY", productClient: client, productTools: catalog, resolveReader: async () => null, executeLocal: async () => { throw new Error("unused fixture query"); } });
nativeClient = new Client({ name: "fixture-native", version: "1" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
await nativeClient.connect(new StreamableHTTPClientTransport(new URL(gateway.url), { requestInit: { headers: { "x-agent-internal-token": "MCP_TEST_ONLY" } } }));
if ((await nativeClient.listTools()).tools.length !== 41) throw new Error("Merged MCP query catalog discovery failed");
const result = await nativeClient.callTool({ name: "list_elements", arguments: { element_type: "pipe", limit: 1 }, _meta: { "ai.opencode/sessionID": session } });
if (result.isError || JSON.stringify((result.structuredContent as { data?: unknown } | undefined)?.data) !== '[{"id":"P-1"}]') throw new Error("Typed query execution failed");
const nearbyTool = catalog.find((tool) => tool.name === "query_nearby_elements");
if (!nearbyTool?.inputSchema.properties?.center) throw new Error("Nearby model tool schema missing");
const nearby = await nativeClient.callTool({ name: "query_nearby_elements", arguments: { center: [121.5, 30.9], radius_m: 500, limit: 5 }, _meta: { "ai.opencode/sessionID": session } });
if (nearby.isError) throw new Error("Nearby model query failed through merged MCP");
if (!requests.includes("server/discover") || !requests.includes("tools/call") || requests.includes("initialize") || requests.includes("GET")) throw new Error("Transport used unexpected protocol requests");
if (grants.join(",") !== "client_credentials,urn:ietf:params:oauth:grant-type:token-exchange") throw new Error("OAuth grants were not validated");
console.log("PASS: local MCP 41-tool discovery, real Server schemas, OAuth discovery, resource-bound grants, host binding, query and revocation over MCP HTTP");
} finally {
await nativeClient?.close();
await gateway?.close();
removeRuntimeSessionContext(session);
releaseSessionCredential(session);
processHandle.kill("SIGTERM");
await processHandle.exited;
}
+3 -2
View File
@@ -14,7 +14,7 @@ const root = await mkdtemp(join(tmpdir(), "tjwater-mcp-smoke-"));
const pluginDirectory = join(root, "fixture-plugin");
await mkdir(pluginDirectory);
await writeFile(join(pluginDirectory, "package.json"), JSON.stringify({ name: "tjwater-mcp-fixture", type: "module", exports: "./index.ts" }));
await writeFile(join(pluginDirectory, "index.ts"), `export { default } from ${JSON.stringify(resolve("tests/fixtures/mcp-call-plugin.ts"))};\n`);
await writeFile(join(pluginDirectory, "index.ts"), `export { default } from ${JSON.stringify(resolve(".opencode/test-fixtures/mcp-call-plugin.ts"))};\n`);
const app = express(); app.use(express.json());
registerMcpPolicyBridge(app, { internalToken: "MCP_TEST_ONLY", runtime: { mcpReadonlyTools: () => ["mcp_stdio_echo"] } });
const wire: { method: string; version?: string; header?: string }[] = [];
@@ -43,7 +43,8 @@ try {
}, permissions: [...source.permissions, { action: "mcp_stdio_echo", resource: "*", effect: "allow" }] });
native = await startOpencode2({ command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000, config: nativeConfig, env: {
AGENT_INTERNAL_TOKEN: "MCP_TEST_ONLY", TJWATER_AGENT_INTERNAL_BASE_URL: baseUrl, TJWATER_AGENT_WORKSPACE_ROOT: join(root, "workspaces"),
DEEPSEEK_API_KEY: "TEST_ONLY_NEVER_USED", XDG_DATA_HOME: join(root, "data"), XDG_CONFIG_HOME: join(root, "config"), XDG_CACHE_HOME: join(root, "cache"), XDG_STATE_HOME: join(root, "state"),
TJWATER_AGENT_MCP_CONNECTION_PATH: join(root, "absent-query-mcp.json"),
DEEPSEEK_API_KEY: "TEST_ONLY_NEVER_USED", OPENCODE_DB: join(root, "data", "opencode.db"), XDG_DATA_HOME: join(root, "data"), XDG_CONFIG_HOME: join(root, "config"), XDG_CACHE_HOME: join(root, "cache"), XDG_STATE_HOME: join(root, "state"),
} });
const session = await native.client.session.create({ location: { directory: process.cwd() }, agent: "instruction" });
sessionId = session.id;
@@ -0,0 +1,15 @@
import { createPromptSuggestionService } from "../../src/chat/prompt-suggestions.js";
import { config } from "../../src/config.js";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
// Opt-in feature smoke: one real inference, no persisted chat or business tools.
const runtime = new OpencodeRuntimeAdapter();
try {
const generate = createPromptSuggestionService(runtime);
const result = await generate({ actorKey: "suggestion-smoke", projectKey: "suggestion-smoke", projectId: "suggestion-smoke", userId: "suggestion-smoke", network: "supply", traceId: "suggestion-smoke" }, {
activities: [{ id: "demo:pressure", kind: "task", title: "演示压力监测数据核查", occurred_at: new Date().toISOString(), summary: "仅为功能验证的演示活动,不代表真实告警。", origin: "demo", status: "pending" }],
}, config.OPENCODE_MODEL);
console.log(JSON.stringify({ model: config.OPENCODE_MODEL, count: result.suggestions.length, questions: result.suggestions.map((item) => item.question) }));
} finally {
await runtime.dispose();
}
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseAgentModelConfig } from "../../src/chat/modelConfig.js";
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
import { startOpencode2 } from "../../src/runtime/opencode2-server.js";
// No inference or live business requests: redirect every local discovery probe
// to a fixture that behaves like TJWater Server (/health exists, /v1/models does not).
const root = await mkdtemp(join(tmpdir(), "tjwater-provider-discovery-"));
const originalDirectory = process.cwd();
const requests: string[] = [];
const localProviders = ["vllm", "lmstudio", "ollama"];
const fixture = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) {
const path = new URL(request.url).pathname;
requests.push(path);
if (path === "/health") return Response.json({ status: "ok" });
if (path === "/api/v1/models" || path === "/api/tags") return Response.json({ models: [] });
return new Response(null, { status: 404 });
} });
try {
const models = parseAgentModelConfig({
defaults: { chat: "fixture/fake", task: "fixture/fake", learning_gate: "fixture/fake" },
providers: Object.fromEntries(["fixture", ...localProviders].map((id) => [id, {
name: id, api_url: `http://127.0.0.1:${fixture.port}/v1`, models: { fake: {} },
}])),
});
const generated = serviceOpencodeConfig({}, models);
// Keep the service's actual discovery filters, without loading unrelated
// TJWater query/action plugins in this isolated provider-only test.
const filters = generated.plugins.filter((plugin) => typeof plugin === "string" && plugin.startsWith("-opencode.provider."));
process.chdir(root);
for (const fixed of [false, true]) {
const location = join(root, fixed ? "fixed" : "baseline");
await mkdir(location, { recursive: true });
const start = requests.length;
const runtime = await startOpencode2({
command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000,
config: { model: generated.model, providers: generated.providers, plugins: ["*", ...(fixed ? filters : [])] },
env: {
OPENCODE_DB: join(location, "native.db"), OPENCODE_CONFIG_DIR: join(location, "config"),
XDG_DATA_HOME: join(location, "data"), XDG_CONFIG_HOME: join(location, "config"),
XDG_CACHE_HOME: join(location, "cache"), XDG_STATE_HOME: join(location, "state"),
},
});
try {
const catalog = await runtime.client.model.list({ location: { directory: location } });
await Bun.sleep(1500);
const observed = requests.slice(start);
console.log(fixed ? "Fixed discovery requests:" : "Baseline discovery requests:", JSON.stringify(observed));
if (fixed) {
assert.deepEqual(observed, [], "native local-provider discovery still makes unsolicited HTTP requests");
for (const providerID of ["fixture", ...localProviders]) {
assert.ok(catalog.data.some((model) => model.providerID === providerID && model.id === "fake" && model.enabled),
`configured model unavailable: ${providerID}/fake`);
}
} else {
assert.ok(observed.includes("/health"), "baseline did not perform the vLLM health probe");
assert.ok(observed.includes("/v1/models"), "baseline did not reproduce the unwanted model request");
assert.ok(observed.includes("/api/v1/models"), "baseline did not probe LM Studio");
assert.ok(observed.includes("/api/tags"), "baseline did not probe Ollama");
}
} finally { runtime.close(); await runtime.exited; }
}
console.log("Native provider discovery regression passed; configured local models retained, no inference.");
} finally {
process.chdir(originalDirectory);
fixture.stop(true);
await rm(root, { recursive: true, force: true });
}
+129
View File
@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import express from "express";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { startOpencode2 } from "../../src/runtime/opencode2-server.js";
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
import { assertWebSearchReady } from "../../src/runtime/web-search-check.js";
import { QUERY_MCP_TOOL_IDS, LOCAL_QUERY_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
import { writeQueryMcpConnection } from "../../src/runtime/query-mcp-connection.js";
import { startQueryMcpServer } from "../../src/mcp/query-server.js";
import { registerMcpPolicyBridge } from "../../src/routes/mcpRoutes.js";
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
// Native discovery/execution only. Its fixture context hook stops all inference.
const root = await mkdtemp(join(tmpdir(), "tjwater-native-query-mcp-"));
const previousPath = process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
const longCalculation = process.argv.includes("--long-calculation");
const pluginDirectory = join(root, "fixture-plugin");
await mkdir(pluginDirectory);
await writeFile(join(pluginDirectory, "package.json"), JSON.stringify({ name: "tjwater-query-fixture", type: "module", exports: "./index.ts" }));
await writeFile(join(pluginDirectory, "index.ts"), `export { default } from ${JSON.stringify(resolve(".opencode/test-fixtures/mcp-call-plugin.ts"))};\n`);
const app = express(); app.use(express.json());
registerMcpPolicyBridge(app, { internalToken: "MCP_TEST_ONLY", runtime: { mcpReadonlyTools: () => QUERY_MCP_TOOL_IDS } });
const bridge = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => bridge.once("listening", resolve));
let native: Awaited<ReturnType<typeof startOpencode2>> | undefined;
let sessionId: string | undefined;
const receivedSessions: string[] = [];
const query = startQueryMcpServer({ internalToken: "MCP_TEST_ONLY",
productClient: longCalculation ? { query: async (_id, _owner, name, args) => {
assert.equal(name, "run_simulation");
assert.equal(args.timeout_seconds, 90);
await Bun.sleep(65_000);
return { content: [{ type: "text", text: "isolated long calculation completed" }] };
} } : null,
productTools: longCalculation ? [{ name: "run_simulation", inputSchema: { type: "object", properties: {
timeout_seconds: { type: "integer", minimum: 1, maximum: 120, default: 120 },
}, additionalProperties: false } }] : [], resolveReader: async () => null,
executeLocal: async (name, args, context, signal) => {
receivedSessions.push(context.sessionId);
if (name === "todoread") {
return (await native!.client.rpc.call({ rpcID: "tjwater.tools", method: "read_todos", input: { session_id: context.sessionId }, location: { directory: context.workspaceDirectory! } }, { signal })).output;
}
return { query: args.query, actor: context.actorKey };
},
});
writeQueryMcpConnection(join(root, "sessions"), query.url, query.executionTimeoutMs);
try {
const source = JSON.parse(await readFile("opencode.json", "utf8"));
if (longCalculation) source.permissions = [...(source.permissions ?? []), {
action: "mcp_tjwater_run_simulation", resource: "*", effect: "allow",
}];
native = await startOpencode2({ command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000,
config: serviceOpencodeConfig({ ...source, plugins: [pluginDirectory], mcp: { servers: {} }, websearch: { provider: "fixture-search" } }),
env: { AGENT_INTERNAL_TOKEN: "MCP_TEST_ONLY", TJWATER_AGENT_INTERNAL_BASE_URL: `http://127.0.0.1:${(bridge.address() as { port: number }).port}`,
TJWATER_AGENT_MCP_CONNECTION_PATH: process.env.TJWATER_AGENT_MCP_CONNECTION_PATH!, TJWATER_AGENT_WORKSPACE_ROOT: join(root, "workspaces"),
OPENCODE_DB: join(root, "data", "opencode.db"), XDG_DATA_HOME: join(root, "data"), XDG_CONFIG_HOME: join(root, "config"), XDG_CACHE_HOME: join(root, "cache"), XDG_STATE_HOME: join(root, "state") },
});
const session = await native.client.session.create({ location: { directory: process.cwd() }, agent: "instruction" });
sessionId = session.id;
setRuntimeSessionContext({ sessionId, clientSessionId: sessionId, actorKey: "fixture-actor", projectKey: "personal-fixture", traceId: "test", purpose: "ordinary_chat", workspaceDirectory: session.location.directory });
await native.client.session.prompt({ sessionID: sessionId, text: "Materialize fixture tools; stop before inference." });
await native.client.session.wait({ sessionID: sessionId }, { signal: AbortSignal.timeout(20_000) });
const deadline = Date.now() + 10_000;
let tools: { id: string; direct: boolean }[] = [];
do {
tools = (await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: session.location })).output as typeof tools;
if (LOCAL_QUERY_TOOL_IDS.every((id) => tools.some((tool) => tool.id === id && tool.direct))) break;
await new Promise((resolve) => setTimeout(resolve, 100));
} while (Date.now() < deadline);
assert(LOCAL_QUERY_TOOL_IDS.every((id) => tools.some((tool) => tool.id === id && tool.direct)), "native MCP catalog must expose every independent query");
assert(tools.some((tool) => tool.id === "websearch"), "native websearch must remain registered");
await assertWebSearchReady(native.client, session.location.directory);
assert(!tools.some((tool) => tool.id.startsWith("browser_")), "unused native browser automation tools must not be registered");
const plugins = await native.client.plugin.list({ location: session.location });
assert(!plugins.data.some((plugin) => plugin.id === "opencode.browser" && plugin.state.status === "active"), "native browser plugin must be disabled");
for (const action of ["browser", "browser_click"]) {
assert.equal((await native.client.permission.create({ sessionID: sessionId, action, resources: ["*"], save: [] })).effect, "deny");
}
for (const action of ["present_answer", "show_chart"]) {
assert.equal((await native.client.permission.create({ sessionID: sessionId, action, resources: ["*"], save: [] })).effect, "allow");
}
for (const id of ["opencode_models", "opencode_session_move", "opencode_session_rename",
"opencode_worktree_create", "opencode_worktree_list", "opencode_worktree_remove"]) {
assert(tools.some((tool) => tool.id === id), `${id} must remain registered`);
}
assert(!tools.some((tool) => tool.id === "mcp_tjwater_web_search"), "web search must not have a duplicate MCP tool");
for (const id of ["query_project_data", "query_operational_task_data", "get_operational_task_context", "session_search", "web_search", "geocode", "todoread", "list_investigation_tasks", "get_investigation_task", "read_agent_messages"]) assert(!tools.some((tool) => tool.id === id), `${id} must not remain a standalone plugin query`);
for (const id of ["memory_manager", "skill_manager", "send_task_message", "reply_agent_message", "todowrite",
"present_answer", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history",
"render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report",
"publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence",
"request_task_data_access"]) assert(tools.some((tool) => tool.id === id), `${id} must remain a plugin tool`);
for (const tool of ["mcp_tjwater_session_search", "mcp_tjwater_todoread"]) {
const result = await native.client.rpc.call({ rpcID: "tjwater.mcp-fixture", method: "call", input: { session_id: sessionId, tool, arguments: tool.endsWith("session_search") ? { query: "native-mcp" } : {} }, location: session.location }, { signal: AbortSignal.timeout(10_000) });
assert.equal((result.output as { error?: string }).error, undefined, JSON.stringify(result.output));
assert((result.output as { evaluated: string[] }).evaluated.includes(tool));
}
assert.deepEqual(receivedSessions, [sessionId, sessionId], "OpenCode must supply the invoking session in native MCP metadata");
const search = await native.client.rpc.call({ rpcID: "tjwater.mcp-fixture", method: "call", input: {
session_id: sessionId, tool: "websearch", arguments: { query: "native-search-fixture" },
}, location: session.location }, { signal: AbortSignal.timeout(10_000) });
assert.equal((search.output as { error?: string }).error, undefined, JSON.stringify(search.output));
assert(JSON.stringify(search.output).includes("https://example.test/search-result"), "native websearch must execute through its selected provider");
if (longCalculation) {
setRuntimeSessionContext({ sessionId, clientSessionId: sessionId, actorKey: "fixture-actor", projectKey: "fixture-project", projectId: "fixture-project", traceId: "test", purpose: "ordinary_chat", workspaceDirectory: session.location.directory });
const started = performance.now();
console.log("Starting isolated native MCP delay: requested budget 90 seconds, fixture delay 65 seconds.");
const result = await native.client.rpc.call({ rpcID: "tjwater.mcp-fixture", method: "call", input: {
session_id: sessionId, tool: "mcp_tjwater_run_simulation", arguments: { timeout_seconds: 90 },
}, location: session.location }, { signal: AbortSignal.timeout(110_000) });
const output = result.output as { error?: string; content: unknown };
assert(!output.error, output.error);
assert(JSON.stringify(output.content).includes("isolated long calculation completed"));
const elapsed = performance.now() - started;
assert(elapsed >= 60_000, `Long-call fixture returned too early (${Math.round(elapsed)}ms)`);
console.log(`Native MCP long calculation passed after ${Math.round(elapsed)}ms: a 90-second Agent budget crosses the old 30/35/60-second limits; no real simulation or model inference.`);
}
console.log("Native query MCP smoke passed: browser automation absent; business UI and managers retained; discovery, permissions, session metadata, checklist RPC and native websearch verified; no model inference or external search.");
} finally {
if (sessionId) { removeRuntimeSessionContext(sessionId); await native?.client.session.remove({ sessionID: sessionId }).catch(() => {}); }
native?.close();
if (native) await native.exited;
await query.close();
await new Promise<void>((resolve) => bridge.close(() => resolve()));
if (previousPath === undefined) delete process.env.TJWATER_AGENT_MCP_CONNECTION_PATH; else process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = previousPath;
await rm(root, { recursive: true, force: true });
}
+89
View File
@@ -0,0 +1,89 @@
// Native inbox acceptance and delivery changes against a local deterministic model.
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { startOpencode2 } from "../../src/runtime/opencode2-server.js";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import { ResponseInputTracker } from "../../src/chat/response-inputs.js";
import { streamPromptResponse } from "../../src/routes/chatStream.js";
import { config } from "../../src/config.js";
const root = await mkdtemp(join(tmpdir(), "tjwater-response-input-"));
config.LLM_REQUEST_AUDIT_LOG_PATH = join(root, "fixture-audit.log");
let release = () => {};
const gate = new Promise<void>((resolve) => { release = resolve; });
let releaseSecond = () => {};
const secondGate = new Promise<void>((resolve) => { releaseSecond = resolve; });
const prompts: unknown[] = [];
const fixture = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) {
if (new URL(request.url).pathname !== "/v1/chat/completions") return new Response(null, { status: 404 });
prompts.push(await request.json());
if (prompts.length === 1) await gate;
if (prompts.length === 2) await secondGate;
const chunk = (delta: object, finish_reason: string | null) => JSON.stringify({ id: "fixture", object: "chat.completion.chunk", created: 1,
model: "fake", choices: [{ index: 0, delta, finish_reason }] });
return new Response(`data: ${chunk({ role: "assistant", content: "Local completed." }, null)}\n\ndata: ${chunk({}, "stop")}\n\ndata: [DONE]\n\n`,
{ headers: { "Content-Type": "text/event-stream" } });
} });
let native: Awaited<ReturnType<typeof startOpencode2>> | undefined;
const wait = async (label: string, predicate: () => boolean | Promise<boolean>) => {
const deadline = Date.now() + 15_000;
while (!await predicate()) { if (Date.now() > deadline) throw new Error(`native input smoke timed out: ${label}, fixture requests=${prompts.length}`); await Bun.sleep(20); }
};
try {
native = await startOpencode2({ command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000,
config: { model: "fixture/fake", agents: { instruction: { mode: "primary", model: "fixture/fake", system: "Reply with a short local fixture answer." } },
providers: { fixture: { name: "Fixture", package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: `http://127.0.0.1:${fixture.port}/v1`, apiKey: "LOCAL_FIXTURE" },
models: { fake: { capabilities: { tools: true }, limit: { context: 32768, output: 1024 } } } } } },
env: { OPENCODE_DB: join(root, "native.db"), XDG_DATA_HOME: join(root, "data"), XDG_CONFIG_HOME: join(root, "config"),
XDG_CACHE_HOME: join(root, "cache"), XDG_STATE_HOME: join(root, "state"), TJWATER_AGENT_MCP_CONNECTION_PATH: join(root, "no-connection") },
});
const client = native.client;
const session = await client.session.create({ title: "Inbox smoke fixture", location: { directory: root } });
await client.session.switchModel({ sessionID: session.id, model: { providerID: "fixture", id: "fake" } });
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
const consumed: string[] = [];
const tracker = new ResponseInputTracker(runtime, session.id, (input) => { if (input.status === "delivered") consumed.push(input.id); });
const tokens: string[] = [];
let finished = false;
const running = streamPromptResponse({ runtime, sessionId: session.id, clientSessionId: session.id, message: "Original local task", model: "fixture/fake",
beforePrompt: () => tracker.restore(), onPromptDispatched: () => tracker.markReady(),
onInboxEvent: (type, data) => tracker.observe(type, data), waitForUserInput: async () => !await tracker.canClose(),
write: (event, data) => { if (event === "token") tokens.push(String(data.content)); } });
void running.catch(() => {});
void running.then(() => { finished = true; }, () => { finished = true; });
await wait("first local generation", () => prompts.length >= 1);
const item = await tracker.submit({ id: `msg_${"1".repeat(32)}`, text: "Additional local user input", delivery: "queue", status: "pending" });
assert.equal(item.delivery, "queue");
assert.equal((await client.session.inbox.list({ sessionID: session.id })).find((entry) => entry.id === item.id)?.delivery, "queue");
await client.session.inbox.update({ sessionID: session.id, inboxID: item.id, delivery: "steer" });
assert.equal((await client.session.inbox.list({ sessionID: session.id })).find((entry) => entry.id === item.id)?.delivery, "steer");
await client.session.inbox.update({ sessionID: session.id, inboxID: item.id, delivery: "queue" });
release();
await wait("queued continuation", () => prompts.length >= 2);
await Bun.sleep(100);
assert.equal(finished, false, "managed stream closed while queued generation was running");
releaseSecond();
let finishTimer: ReturnType<typeof setTimeout> | undefined;
const result = await Promise.race([running, new Promise<never>((_resolve, reject) => {
finishTimer = setTimeout(() => reject(new Error("managed stream did not finish")), 10_000);
finishTimer.unref();
})]).finally(() => clearTimeout(finishTimer));
assert.equal(result.failed, false);
assert.equal(result.aborted, false);
assert.ok(consumed.includes(item.id));
assert.ok(tokens.join("").split("Local completed.").length >= 3, "managed stream closed before queued answer");
assert.ok(JSON.stringify(prompts[1]).includes("Additional local user input"));
assert.ok(!(await client.session.inbox.list({ sessionID: session.id })).some((entry) => entry.id === item.id));
await client.session.remove({ sessionID: session.id });
console.log("Native queue delivery, pending steer/queue changes and automatic continuation passed; local fixture only.");
} finally {
release();
releaseSecond();
native?.close();
if (native) await native.exited;
fixture.stop(true);
await rm(root, { recursive: true, force: true });
}
+74
View File
@@ -0,0 +1,74 @@
// Native configuration and permission checks only, no model or production calls.
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import express from "express";
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
import { startOpencode2 } from "../../src/runtime/opencode2-server.js";
import { bindScopedConversationWorkspace, createConversationWorkspace } from "../../src/runtime/conversationWorkspace.js";
import { registerTaskAgentBridge } from "../../src/routes/task-agent-bridge.js";
import { registerMcpPolicyBridge } from "../../src/routes/mcpRoutes.js";
import { QUERY_MCP_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { TASK_AGENT, TASK_READER_AGENT } from "../../src/runtime/task-agent-policy.js";
const root = await mkdtemp(join(tmpdir(), "tjwater-task-agent-smoke-"));
const workspaceRoot = join(root, "workspaces");
const token = randomBytes(32).toString("hex");
let native: Awaited<ReturnType<typeof startOpencode2>> | undefined;
const ids: string[] = [];
const active = new Set<string>();
const app = express(); app.use(express.json());
const readContext = registerTaskAgentBridge(app, { internalToken: token,
getSession: (sessionID) => native!.client.session.get({ sessionID }), hasActiveRun: (id) => active.has(id) });
registerMcpPolicyBridge(app, { internalToken: token, runtime: { mcpReadonlyTools: () => QUERY_MCP_TOOL_IDS }, resolveReader: readContext });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((done) => server.once("listening", done));
try {
const input = JSON.parse(await readFile("opencode.json", "utf8"));
input.plugins = [...(input.plugins ?? []), resolve(".opencode/test-fixtures/task-agent-probe-plugin")];
native = await startOpencode2({ command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000,
config: serviceOpencodeConfig(input, undefined, workspaceRoot),
env: { AGENT_INTERNAL_TOKEN: token, TJWATER_AGENT_WORKSPACE_ROOT: workspaceRoot,
TJWATER_AGENT_MCP_CONNECTION_PATH: join(root, "absent-query-mcp.json"),
TJWATER_AGENT_INTERNAL_BASE_URL: `http://127.0.0.1:${(server.address() as { port: number }).port}`,
WATERNETWORK_API_KEY: "TEST_ONLY", OPENCODE_DB: join(root, "native-data", "opencode.db"),
XDG_DATA_HOME: join(root, "native-data"), XDG_CONFIG_HOME: join(root, "native-config"),
XDG_CACHE_HOME: join(root, "native-cache"), XDG_STATE_HOME: join(root, "native-state") },
});
const initial = await createConversationWorkspace(workspaceRoot);
const parent = await native.client.session.create({ agent: TASK_AGENT, location: { directory: initial } }); ids.push(parent.id);
const directory = await bindScopedConversationWorkspace(initial, workspaceRoot, { actorKey: "actor-smoke", projectKey: "project-smoke", sessionId: parent.id });
await native.client.session.move({ sessionID: parent.id, directory });
const context = { sessionId: parent.id, clientSessionId: parent.id, actorKey: "actor-smoke", projectKey: "project-smoke", projectId: "test-project", taskId: "test-task", traceId: "test", purpose: "task_execution" as const };
setRuntimeSessionContext(context); active.add(parent.id);
await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } });
const fields = (await native.client.rpc.call({ rpcID: "test.task-agent-probe", method: "subagent_input", input: {}, location: { directory } })).output as string[];
assert.ok(fields.includes("agent") && fields.includes("background") && fields.includes("sessionID"), `unexpected native subagent input: ${fields}`);
const decision = async (sessionID: string, action: string, resource = "*") => {
const session = await native!.client.session.get({ sessionID });
return (await native!.client.permission.create({ sessionID, agent: session.agent, action, resources: [resource], save: [] })).effect;
};
assert.equal(await decision(parent.id, "subagent", TASK_READER_AGENT), "allow");
assert.equal(await decision(parent.id, "subagent", "general"), "deny");
const child = await native.client.session.create({ parentID: parent.id, agent: TASK_READER_AGENT, location: { directory } }); ids.push(child.id);
assert.equal((await readContext(child.id))?.runtimeSessionId, parent.id);
assert.equal(await decision(child.id, "mcp_tjwater_query_scada_readings"), "allow");
for (const action of ["subagent", "publish_task_report", "propose_task_action", "shell", "execute", "edit"]) assert.equal(await decision(child.id, action), "deny");
await native.client.session.switchAgent({ sessionID: parent.id, agent: "instruction" });
assert.equal(await decision(parent.id, "subagent", TASK_READER_AGENT), "deny");
await native.client.session.switchAgent({ sessionID: parent.id, agent: TASK_AGENT });
setRuntimeSessionContext({ ...context, purpose: "task_collaboration" });
assert.equal(await decision(parent.id, "subagent", TASK_READER_AGENT), "deny");
setRuntimeSessionContext(context); active.clear();
assert.equal(await readContext(child.id), null);
assert.equal(await decision(parent.id, "subagent", TASK_READER_AGENT), "deny");
console.log("Native task/main profiles, subagent input, read-only permissions, parent binding and revocation passed. No model inference.");
} finally {
for (const id of ids.reverse()) { removeRuntimeSessionContext(id); if (native) await native.client.session.remove({ sessionID: id }).catch(() => {}); }
native?.close(); if (native) await native.exited;
await new Promise<void>((done) => server.close(() => done()));
await rm(root, { recursive: true, force: true });
}
+76
View File
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { lstat, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bindScopedConversationWorkspace, createConversationWorkspace } from "../../src/runtime/conversationWorkspace.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
import { startOpencode2 } from "../../src/runtime/opencode2-server.js";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import { config } from "../../src/config.js";
import { isSessionNotFoundError } from "@opencode/client";
// Native permissions only; uses temporary data and no model inference.
const root = await mkdtemp(join(tmpdir(), "tjwater-workspace-smoke-"));
const workspaceRoot = join(root, "data", "session-workspaces");
const previousWorkspaceRoot = config.CONVERSATION_WORKSPACE_ROOT;
config.CONVERSATION_WORKSPACE_ROOT = workspaceRoot;
let directory = await createConversationWorkspace(workspaceRoot);
let other = await createConversationWorkspace(workspaceRoot);
await mkdir(join(root, "data", "session-metadata"));
let native: Awaited<ReturnType<typeof startOpencode2>> | undefined;
let sessionID: string | undefined;
let otherSessionID: string | undefined;
try {
native = await startOpencode2({
command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000,
config: serviceOpencodeConfig(JSON.parse(await readFile("opencode.json", "utf8")), undefined, workspaceRoot),
env: { AGENT_INTERNAL_TOKEN: randomBytes(32).toString("hex"), TJWATER_AGENT_WORKSPACE_ROOT: workspaceRoot,
WATERNETWORK_API_KEY: "WORKSPACE_TEST_ONLY", XDG_DATA_HOME: join(root, "native-data"),
OPENCODE_DB: join(root, "native-data", "opencode", "opencode.db"),
XDG_CONFIG_HOME: join(root, "native-config"), XDG_CACHE_HOME: join(root, "native-cache"), XDG_STATE_HOME: join(root, "native-state") },
});
const session = await native.client.session.create({ agent: "instruction", location: { directory } });
sessionID = session.id;
const owner = { actorKey: "actor-smoke", projectKey: "project-smoke", sessionId: session.id };
directory = await bindScopedConversationWorkspace(directory, workspaceRoot, owner);
await new SessionWorkspaceStore(workspaceRoot).ensure(owner);
await native.client.session.move({ sessionID, directory });
const otherSession = await native.client.session.create({ agent: "instruction", location: { directory: other } });
otherSessionID = otherSession.id;
other = await bindScopedConversationWorkspace(other, workspaceRoot,
{ actorKey: "actor-other", projectKey: "personal-actor-other", sessionId: otherSession.id });
await native.client.session.move({ sessionID: otherSessionID, directory: other });
await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } });
const decision = async (action: string, resource: string) => (await native!.client.permission.create({ sessionID: session.id, action, resources: [resource], save: [] })).effect;
const file = join(directory, "files", "result.json");
await writeFile(file, '{"value":7}');
assert.equal(await decision("read", file), "allow", "own workspace results must be readable under data/");
assert.equal(await decision("edit", file), "ask", "own workspace writes must require approval");
assert.equal(await decision("read", join(root, "data", "session-metadata", "private.json")), "deny");
assert.equal(await decision("read", join(other, "files", "result.json")), "deny");
assert.equal(await decision("read", join(directory, "files", ".local.env")), "deny");
assert.equal(await decision("read", join(directory, "..", "chat", "owner.json")), "deny", "chat records must stay outside the model workspace");
assert.equal(await decision("edit", join(directory, "..", "chat", "messages.json")), "deny");
await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory: other } });
await native.client.session.move({ sessionID, directory: other });
assert.equal(await decision("read", join(other, "files", "result.json")), "deny", "a moved session cannot impersonate the owner of another workspace");
await native.client.session.move({ sessionID, directory });
const runtime = Object.assign(new OpencodeRuntimeAdapter(), { ensureClient: async () => native!.client });
await runtime.removeSession(sessionID, owner);
await assert.rejects(native.client.session.get({ sessionID }), isSessionNotFoundError);
await assert.rejects(lstat(directory), { code: "ENOENT" });
assert.equal((await native.client.session.get({ sessionID: otherSessionID })).id, otherSessionID);
assert.equal((await lstat(other)).isDirectory(), true);
assert.equal(JSON.parse(await readFile(join(directory, "..", "chat", "owner.json"), "utf8")).sessionId, sessionID);
await runtime.removeSession(sessionID, owner);
console.log("Native workspace permissions and session cleanup passed; other sessions and host chat records preserved. No inference requests.");
} finally {
if (native && sessionID) await native.client.session.remove({ sessionID }).catch(() => {});
if (native && otherSessionID) await native.client.session.remove({ sessionID: otherSessionID }).catch(() => {});
native?.close();
if (native) await native.exited;
config.CONVERSATION_WORKSPACE_ROOT = previousWorkspaceRoot;
await rm(root, { recursive: true, force: true });
}
+161
View File
@@ -0,0 +1,161 @@
import { afterEach, expect, it, mock } from "bun:test";
import { Client, StreamableHTTPClientTransport, type Tool } from "@modelcontextprotocol/client";
import { startQueryMcpServer } from "../../src/mcp/query-server.js";
import { createLocalQueryExecutor } from "../../src/mcp/local-query-tools.js";
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { LOCAL_QUERY_TOOLS, PRODUCT_MCP_ANALYSIS_TOOLS, queryMcpToolId } from "../../src/runtime/product-mcp-tools.js";
import type { QueryOwner } from "../../src/product/mcp-query-client.js";
import type { SessionTranscriptStore } from "../../src/sessions/transcriptStore.js";
const owner = { sessionId: "query-owner", clientSessionId: "chat-owner", actorKey: "actor-a", projectKey: "project-a", projectId: "project-a", traceId: "test", purpose: "ordinary_chat" as const };
const servers: ReturnType<typeof startQueryMcpServer>[] = [];
const clients: Client[] = [];
afterEach(async () => {
removeRuntimeSessionContext(owner.sessionId);
await Promise.all(clients.splice(0).map((client) => client.close()));
await Promise.all(servers.splice(0).map((server) => server.close()));
});
const productTool: Tool = { name: "get_task_context", description: "Bound task", inputSchema: { type: "object", properties: { report_revision: { type: "integer", minimum: 1 } }, additionalProperties: false } };
it("advertises simulation budgets and derives the native connection ceiling from the catalog", async () => {
const simulation: Tool = { name: "run_simulation", inputSchema: { type: "object", properties: {
timeout_seconds: { type: "integer", minimum: 1, maximum: 3600, default: 120 },
}, additionalProperties: false } };
const { server, client, call, query } = await setup({ productTools: [simulation] });
setRuntimeSessionContext(owner);
expect(server.executionTimeoutMs).toBe(3_665_000);
expect((await client.listTools()).tools[0].inputSchema.properties?.timeout_seconds).toMatchObject({ maximum: 3600 });
expect((await call("run_simulation", { timeout_seconds: 900 })).isError).not.toBeTrue();
expect((query.mock.calls[0] as unknown as unknown[])[3]).toEqual({ timeout_seconds: 900 });
try { await call("run_simulation", { timeout_seconds: 3601 }); } catch { /* SDK schema rejection */ }
expect(query).toHaveBeenCalledTimes(1);
});
it("discovers calculations natively but denies verified readers before upstream dispatch", async () => {
const context = { ...owner, purpose: "task_execution" as const, taskId: "task-a" };
const productTools = PRODUCT_MCP_ANALYSIS_TOOLS.map((name) => ({ name, inputSchema: { type: "object" as const, properties: {}, additionalProperties: false } }));
const { client, call, query } = await setup({ productTools, resolveReader: async () => ({ context, runtimeSessionId: owner.sessionId, taskId: "task-a" }) });
setRuntimeSessionContext(context);
expect((await client.listTools()).tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([...PRODUCT_MCP_ANALYSIS_TOOLS]));
for (const name of PRODUCT_MCP_ANALYSIS_TOOLS) {
expect((await call(name)).isError).not.toBeTrue();
expect((await call(name, {}, "verified-reader")).isError).toBeTrue();
}
expect(query).toHaveBeenCalledTimes(PRODUCT_MCP_ANALYSIS_TOOLS.length);
});
async function setup(extra: Partial<Parameters<typeof startQueryMcpServer>[0]> = {}) {
const executeLocal = mock(async () => ({ safe: true }));
const query = mock(async () => ({ content: [{ type: "text" as const, text: "evidence" }], structuredContent: { evidence_id: "E1", data: { value: 1 } } }));
const server = startQueryMcpServer({ internalToken: "MCP_TEST_ONLY", productClient: { query }, productTools: [productTool], resolveReader: async () => null, executeLocal, ...extra });
servers.push(server);
const client = new Client({ name: "query-test", version: "1" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
clients.push(client);
await client.connect(new StreamableHTTPClientTransport(new URL(server.url), { requestInit: { headers: { "x-agent-internal-token": "MCP_TEST_ONLY" } } }));
const call = (name: string, args: Record<string, unknown> = {}, id: string | undefined = owner.sessionId) => client.callTool({ name, arguments: args, ...(id ? { _meta: { "ai.opencode/sessionID": id } } : {}) });
return { server, client, call, executeLocal, query };
}
it("discovers native MCP schemas without duplicate plugins, hidden identity or mixed managers", async () => {
const { client, server } = await setup();
const catalog = await client.listTools();
expect(catalog.tools.map((tool) => tool.name).sort()).toEqual([...LOCAL_QUERY_TOOLS, "get_task_context"].sort());
for (const tool of catalog.tools) {
expect(tool.inputSchema.properties).not.toHaveProperty("session_id");
expect(tool.inputSchema.properties).not.toHaveProperty("authorization");
}
expect(JSON.stringify(catalog)).not.toContain("memory_manager");
expect(JSON.stringify(catalog)).not.toContain("skill_manager");
expect(catalog.tools.some((tool) => tool.name === "web_search")).toBeFalse();
for (const headers of [{}, { "x-agent-internal-token": "wrong" }, { "x-agent-internal-token": "MCP_TEST_ONLY", Origin: "http://localhost" }]) {
expect((await fetch(server.url, { method: "POST", headers: headers as Record<string, string>, body: "{}" })).status).toBe(403);
}
});
it("requires host-authenticated session metadata and blocks personal, review and unbound project queries", async () => {
const { call, executeLocal, query } = await setup();
setRuntimeSessionContext(owner);
expect((await call("session_search", { query: "pressure" })).isError).not.toBeTrue();
expect((await call("session_search", { query: "pressure" }, "foreign")).isError).toBeTrue();
expect((await call("session_search", { query: "pressure" }, "")).isError).toBeTrue();
setRuntimeSessionContext({ ...owner, projectId: undefined, projectKey: "personal-actor-a" });
expect((await call("get_task_context")).isError).toBeTrue();
expect((await call("geocode", { keyword: "北京" })).isError).toBeTrue();
expect((await call("session_search", { query: "pressure" })).isError).not.toBeTrue();
setRuntimeSessionContext({ ...owner, learningMode: "review" });
expect((await call("session_search", { query: "pressure" })).isError).toBeTrue();
expect(executeLocal).toHaveBeenCalledTimes(2);
expect(query).not.toHaveBeenCalled();
});
it("delegates only product reads to a verified child and retains parent revision and evidence", async () => {
const taskOwner = { ...owner, purpose: "task_execution" as const, taskId: "task-a", reportRevision: 3 };
const { call, query, executeLocal } = await setup({ resolveReader: async (id) => id === "verified-child" ? { context: taskOwner, runtimeSessionId: owner.sessionId, taskId: "task-a" } : null });
const result = await call("get_task_context", {}, "verified-child");
expect(result.structuredContent).toEqual({ evidence_id: "E1", data: { value: 1 } });
const args = query.mock.calls[0] as unknown as [string, QueryOwner, string, unknown];
expect(args[0]).toBe("verified-child");
expect(args[1].runtimeSessionId).toBe(owner.sessionId);
expect(args[3]).toEqual({ report_revision: 3 });
expect((await call("session_search", { query: "private" }, "verified-child")).isError).toBeTrue();
expect((await call("read_agent_messages", {}, "verified-child")).isError).toBeTrue();
expect(executeLocal).not.toHaveBeenCalled();
});
it("rejects identity overrides, arbitrary URLs and oversized queries before executing", async () => {
setRuntimeSessionContext(owner);
const { call, executeLocal } = await setup();
for (const [name, args] of [
["session_search", { query: "x", session_id: "foreign" }],
["geocode", { keyword: "北京", url: "https://foreign.test" }],
["geocode", { keyword: "x".repeat(501) }],
["session_search", { query: "x", max_results: 51 }],
["list_investigation_tasks", { offset: 10001 }],
["get_investigation_task", { task_id: "made-up" }],
["read_agent_messages", { limit: 0 }],
] as const) {
let rejected = false;
try { rejected = (await call(name, args)).isError === true; } catch { rejected = true; }
expect(rejected).toBeTrue();
}
expect(executeLocal).not.toHaveBeenCalled();
});
it("searches only the owning transcript scope and pages the owning native checklist", async () => {
const search = mock(async () => []);
const readTodos = mock(async () => Array.from({ length: 25 }, (_, id) => ({ id })));
const executeLocal = createLocalQueryExecutor({ transcripts: { search } as unknown as SessionTranscriptStore, readTodos, messageQuery: async () => ({}) });
const { call } = await setup({ executeLocal });
setRuntimeSessionContext(owner);
await call("session_search", { query: "pressure" });
expect(search.mock.calls[0] as unknown).toEqual([{ actorKey: "actor-a", projectKey: "project-a" }, "pressure", 20]);
const first = await call("todoread");
const body = JSON.parse((first.content[0] as { text: string }).text);
expect(body.items).toHaveLength(20);
expect(body.page).toEqual({ limit: 20, offset: 0, total: 25, has_more: true });
expect((readTodos.mock.calls[0] as unknown as unknown[])[0]).toBe(owner.sessionId);
});
it("returns safe MCP errors instead of leaking upstream credentials", async () => {
setRuntimeSessionContext(owner);
const { call } = await setup({ executeLocal: async () => { throw Object.assign(new Error("Bearer PRIVATE_TOKEN https://private.test"), { status: 401 }); } });
const result = await call("session_search", { query: "x" });
expect(result.isError).toBeTrue();
expect(JSON.stringify(result)).toContain("401");
expect(JSON.stringify(result)).not.toContain("PRIVATE_TOKEN");
expect(queryMcpToolId("session_search")).toBe("mcp_tjwater_session_search");
});
it("replaces unavailable evidence resources with the authorized paging tool without changing provenance", async () => {
setRuntimeSessionContext(owner);
const uri = "tjwater://tasks/task-a/evidence/E1";
const upstream = { content: [{ type: "text" as const, text: `Read ${uri}` }, { type: "resource_link" as const, name: "query-evidence", uri }],
structuredContent: { source: "query_scada_readings", source_ref: "saved:1", evidence_id: "E1", origin: "synthetic", truncated: true,
data: { resource_uri: uri, preview: "The complete query snapshot is saved in task evidence" },
data_gaps: ["Inline result abbreviated; read the saved evidence resource"] } };
const { call } = await setup({ productClient: { query: async () => upstream } });
const result = await call("get_task_context");
expect(JSON.stringify(result)).not.toContain("tjwater://");
expect(result.content.some((item) => item.type === "resource_link")).toBeFalse();
expect(result.structuredContent).toMatchObject({ source_ref: "saved:1", evidence_id: "E1", origin: "synthetic", truncated: true,
data: { evidence_read: { tool: "mcp_tjwater_get_task_evidence", arguments: { evidence_id: "E1", limit: 20, offset: 0 } } } });
expect(upstream.structuredContent.data.resource_uri).toBe(uri);
});
+26
View File
@@ -0,0 +1,26 @@
import { expect, it } from "bun:test";
import type { Tool } from "@modelcontextprotocol/client";
import { nativeQueryTimeout, queryTimeouts } from "../../src/mcp/query-timeouts.js";
const simulation: Tool = { name: "run_simulation", inputSchema: { type: "object", properties: {
timeout_seconds: { type: "integer", minimum: 1, maximum: 3600, default: 120 },
} } };
it("budgets calculations independently from their simulated duration and from ordinary reads", () => {
expect(queryTimeouts(simulation, { duration: 86400, timeout_seconds: 900 })).toEqual({ executionMs: 900_000, callMs: 905_000, requestMs: 960_000 });
expect(queryTimeouts(simulation, {})).toEqual({ executionMs: 120_000, callMs: 125_000, requestMs: 180_000 });
expect(queryTimeouts({ name: "list_elements", inputSchema: { type: "object" } }, {})).toEqual({ executionMs: 30_000, callMs: 30_000, requestMs: 35_000 });
});
it("honors the effective advertised maximum and rejects invalid per-call budgets", () => {
for (const timeout_seconds of [0, -1, 1.5, 3601, "900", null, true, NaN, Infinity]) {
expect(() => queryTimeouts(simulation, { timeout_seconds })).toThrow();
}
const limited = { ...simulation, inputSchema: { type: "object" as const, properties: {
timeout_seconds: { type: "integer", maximum: 120, default: 120 },
} } };
expect(queryTimeouts(limited, { timeout_seconds: 120 }).executionMs).toBe(120_000);
expect(() => queryTimeouts(limited, { timeout_seconds: 121 })).toThrow();
expect(nativeQueryTimeout([simulation, limited])).toBe(3_665_000);
expect(nativeQueryTimeout([limited])).toBe(185_000);
});
+130
View File
@@ -0,0 +1,130 @@
import { beforeEach, afterEach, describe, expect, it } from "bun:test";
import plugin from "../../.opencode/plugins/tjwater-tools.js";
import { AGENT_MESSAGE_TOOLS, registerAgentMessagePolicy } from "../../.opencode/agent-message-policy.js";
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
import { TASK_AGENT, TASK_READER_AGENT } from "../../src/runtime/task-agent-policy.js";
const originalConnectionPath = process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
beforeEach(() => { process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = `/tmp/tjwater-mcp-absent-${process.pid}.json`; });
afterEach(() => {
if (originalConnectionPath === undefined) delete process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
else process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = originalConnectionPath;
});
const taskId = "60000000-0000-4000-8000-000000000001";
const runId = "60000000-0000-4000-8000-000000000002";
const messageId = "60000000-0000-4000-8000-000000000003";
const requestId = "60000000-0000-4000-8000-000000000004";
let bridgeServer: ReturnType<typeof Bun.serve> | undefined;
afterEach(() => { bridgeServer?.stop(true); bridgeServer = undefined; });
async function runTools(script: string) {
const child = Bun.spawn([process.execPath, "-e", script], {
cwd: process.cwd(),
env: { ...process.env, AGENT_INTERNAL_TOKEN: "MESSAGE_TEST_ONLY", TJWATER_AGENT_INTERNAL_TOKEN: "",
TJWATER_AGENT_INTERNAL_BASE_URL: `http://127.0.0.1:${bridgeServer!.port}` },
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: "" });
return JSON.parse(stdout);
}
describe("controlled investigation message tools", () => {
it("registers exact direct tools and rejects identity or arbitrary session targets at dispatch", async () => {
const tools = new Map<string, any>();
await plugin.setup({
tool: { transform: async (fn: any) => fn({ list: () => [...tools].map(([id, tool]) => ({ ...tool, id })),
add: (tool: any) => tools.set(tool.name, tool), remove: (name: string) => tools.delete(name), update: () => {} }),
list: async () => [...tools].map(([id, tool]) => ({ ...tool, id })) },
mcp: { transform: async (fn: any) => fn({ list: () => [] }), list: async () => ({ data: [] }) },
shell: { hook: async () => {} }, permission: { hook: async () => {} }, session: { hook: async () => {} },
rpc: { register: async () => {} }, storage: { set: async () => {}, get: async () => undefined },
} as never);
for (const name of ["send_task_message", "reply_agent_message"]) expect(tools.get(name).options).toEqual({ codemode: false, permission: name });
for (const name of AGENT_MESSAGE_TOOLS.filter((name) => name.startsWith("mcp_"))) expect(tools.has(name)).toBeFalse();
expect(tools.has("memory_manager")).toBeTrue();
expect(tools.has("skill_manager")).toBeTrue();
const send = tools.get("send_task_message");
for (const extra of [{ session_id: "other" }, { project_id: "other" }, { authorization: "secret" }, { operation: "reply" }]) {
await expect(send.execute({ task_id: taskId, run_id: runId, message: "question", request_id: requestId, ...extra },
{ sessionID: "native-owner", id: "native-call" })).rejects.toThrow();
}
expect(tools.has("message")).toBeFalse();
});
it("passes each fixed operation through the authenticated bridge without rewriting delivery or provenance", async () => {
const calls: Array<{ operation: string; token: string | null; method: string; body: unknown }> = [];
bridgeServer = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) {
const operation = new URL(request.url).pathname;
calls.push({ operation, token: request.headers.get("x-agent-internal-token"), method: request.method, body: await request.json() });
return Response.json({ status: "queued", message_id: messageId, originalMessage: { role: "agent", message: "source retained" } });
} });
const inputs = [{ task_id: taskId, run_id: runId, message: "check current evidence", request_id: requestId },
{ message_id: messageId, message: "evidence reviewed", request_id: requestId }];
const output = await runTools([
`const names = ${JSON.stringify(["send_task_message", "reply_agent_message"])}; const inputs = ${JSON.stringify(inputs)};`,
'const results = []; for (let i = 0; i < names.length; i++) { const { default: tool } = await import(`./.opencode/tools/${names[i]}.ts`); results.push(JSON.parse(await tool.execute(inputs[i], { sessionID: "native-owner" }))); }',
"process.stdout.write(JSON.stringify(results));",
].join("\n"));
expect(calls).toEqual(["send", "reply"].map((operation, index) => ({
operation: `/internal/agent-messages/${operation}`, token: "MESSAGE_TEST_ONLY", method: "POST",
body: { session_id: "native-owner", input: inputs[index] },
})));
expect(output).toEqual(inputs.map(() => ({ status: "queued", message_id: messageId, originalMessage: { role: "agent", message: "source retained" } })));
});
it("validates UUIDs, bounds, empty messages and forbidden fields before network access and honors cancellation", async () => {
let received = 0;
bridgeServer = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() { received++; return Response.json({}); } });
const cases = [
["send_task_message", { task_id: taskId, run_id: runId, message: "", request_id: requestId }],
["send_task_message", { task_id: taskId, run_id: runId, message: "x".repeat(8001), request_id: requestId }],
["send_task_message", { task_id: taskId, run_id: "invalid", message: "q", request_id: requestId }],
["reply_agent_message", { message_id: "invalid", message: "a", request_id: requestId }],
["reply_agent_message", { message_id: messageId, message: "a", request_id: "invalid" }],
["reply_agent_message", { message_id: messageId, message: "a", request_id: requestId, session_id: "foreign" }],
];
const output = await runTools([
`const cases = ${JSON.stringify(cases)}; let rejected = 0;`,
'for (const [name, input] of cases) { const { default: tool } = await import(`./.opencode/tools/${name}.ts`); try { await tool.execute(input, { sessionID: "owner" }); } catch { rejected++; } }',
'const { default: read } = await import("./.opencode/tools/reply_agent_message.ts"); const controller = new AbortController(); controller.abort();',
`let cancelled = false; try { await read.execute(${JSON.stringify({ message_id: messageId, message: "a", request_id: requestId })}, { sessionID: "owner", signal: controller.signal }); } catch { cancelled = true; }`,
"process.stdout.write(JSON.stringify({ rejected, cancelled }));",
].join("\n"));
expect(output).toEqual({ rejected: cases.length, cancelled: true });
expect(received).toBe(0);
});
it("fails a denied bridge response rather than claiming a message was queued", async () => {
bridgeServer = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() { return Response.json({ error: "TASK_RUN_NOT_ACTIVE" }, { status: 409 }); } });
const output = await runTools([
'import send from "./.opencode/tools/send_task_message.ts";',
`let error = ""; try { await send.execute(${JSON.stringify({ task_id: taskId, run_id: runId, message: "q", request_id: requestId })}, { sessionID: "owner" }); } catch (caught) { error = caught.message; }`,
"process.stdout.write(JSON.stringify({ error }));",
].join("\n"));
expect(output.error).toContain("agent message send failed (409)");
expect(output.error).toContain("TASK_RUN_NOT_ACTIVE");
});
it("limits static profiles and catalogs while preserving ordinary-chat subagent denial and administrator rules", async () => {
const administratorDeny = { action: "send_task_message", resource: "*", effect: "deny" };
const config = serviceOpencodeConfig({ agents: { instruction: { permissions: [administratorDeny] } } });
for (const action of AGENT_MESSAGE_TOOLS) {
expect(config.agents.instruction.permissions.find((rule) => rule.action === action)?.effect).toBe("allow");
expect(config.agents[TASK_AGENT].permissions.find((rule) => rule.action === action)?.effect)
.toBe(["mcp_tjwater_read_agent_messages", "reply_agent_message"].includes(action) ? "allow" : "deny");
}
expect(config.agents.instruction.permissions.at(-2)).toEqual(administratorDeny);
expect(config.agents.instruction.permissions.at(-1)).toEqual({ action: "subagent", resource: "*", effect: "deny" });
expect(config.agents[TASK_READER_AGENT].permissions.some((rule) => (AGENT_MESSAGE_TOOLS as readonly string[]).includes(rule.action))).toBeFalse();
let hook: (event: any) => Promise<void> = async () => {};
await registerAgentMessagePolicy({ session: { hook: async (_name: string, fn: typeof hook) => { hook = fn; } } } as never);
for (const [agent, expected] of [["instruction", AGENT_MESSAGE_TOOLS], [TASK_AGENT, AGENT_MESSAGE_TOOLS.slice(3)], [TASK_READER_AGENT, []]] as const) {
const event = { agent, sessionID: "owner", tools: Object.fromEntries(AGENT_MESSAGE_TOOLS.map((name) => [name, {}])) };
await hook(event);
expect(Object.keys(event.tools)).toEqual([...expected]);
}
});
});
+2 -2
View File
@@ -13,8 +13,8 @@ describe("disabled opencode tools", () => {
expect(existsSync(`.opencode/tools/${toolName}.ts`)).toBeFalse();
}
expect(existsSync(".opencode/tools/server_api_shared.ts")).toBeFalse();
expect(existsSync(".opencode/tools/web_search.ts")).toBeTrue();
expect(existsSync(".opencode/tools/geocode.ts")).toBeTrue();
expect(existsSync(".opencode/tools/web_search.ts")).toBeFalse();
expect(existsSync(".opencode/tools/geocode.ts")).toBeFalse();
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();
+6 -7
View File
@@ -15,10 +15,9 @@ describe("OpenCode internal bridge tools", () => {
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url);
receivedToken = request.headers.get("x-agent-internal-token");
receivedSessionId = url.searchParams.get("session_id");
return Response.json({ task: { task_id: "task-1" } });
receivedSessionId = null;
return request.json().then((body) => { receivedSessionId = body.session_id; return Response.json({ task: { task_id: "task-1" } }); });
},
});
@@ -26,8 +25,8 @@ describe("OpenCode internal bridge tools", () => {
processExecPath(),
"-e",
[
'import tool from "./.opencode/tools/get_operational_task_context.ts";',
'const result = await tool.execute({}, { sessionID: "runtime-session-1" });',
'import tool from "./.opencode/tools/memory_manager.ts";',
'const result = await tool.execute({ action: "list", scope: "user", reason: "review existing memory" }, { sessionID: "runtime-session-1" });',
"process.stdout.write(result);",
].join("\n"),
], {
@@ -58,8 +57,8 @@ describe("OpenCode internal bridge tools", () => {
processExecPath(),
"-e",
[
'import tool from "./.opencode/tools/get_operational_task_context.ts";',
'await tool.execute({}, { sessionID: "runtime-session-1" });',
'import tool from "./.opencode/tools/memory_manager.ts";',
'await tool.execute({ action: "list", scope: "user", reason: "review existing memory" }, { sessionID: "runtime-session-1" });',
].join("\n"),
], {
cwd: process.cwd(),
+12 -2
View File
@@ -1,10 +1,18 @@
import { describe, expect, it } from "bun:test";
import { beforeEach, afterEach, describe, expect, it } from "bun:test";
import express from "express";
import type { Plugin } from "../../.opencode/node_modules/@opencode/plugin/dist/promise/index.js";
import { registerMcpPolicy } from "../../.opencode/mcp-policy.js";
import { registerMcpPolicyBridge } from "../../src/routes/mcpRoutes.js";
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
const originalConnectionPath = process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
beforeEach(() => { process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = `/tmp/tjwater-mcp-absent-${process.pid}.json`; });
afterEach(() => {
if (originalConnectionPath === undefined) delete process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
else process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = originalConnectionPath;
});
type Context = Parameters<Plugin.Plugin["setup"]>[0];
type Evaluation = Parameters<Parameters<Context["permission"]["hook"]>[1]>[0];
type Tool = Awaited<ReturnType<Context["tool"]["list"]>>[number];
@@ -77,6 +85,7 @@ describe("MCP session enforcement", () => {
it("disables invalid global servers and reports only safe connection fields", async () => {
const servers = new Map([
["mcp_fixture", { type: "remote", url: "https://example.com/private", headers: { Authorization: "TEST_ONLY" } }],
["mcp_tjwater_query", { type: "remote", url: "https://example.com/collision" }],
["invalid https://secret", { type: "remote", url: "file:///secret" }],
]);
const report = await registerMcpPolicy({
@@ -88,6 +97,7 @@ describe("MCP session enforcement", () => {
permission: { hook: async () => {} }, session: { hook: async () => {} },
} as never);
expect(servers.get("invalid https://secret")).toMatchObject({ disabled: true });
expect(await report()).toEqual({ configuration_errors: ["invalid_server_name"], servers: [{ name: "mcp_fixture", type: "remote", status: "failed", configured_protocol: "auto", tools: ["mcp_fixture_read"] }] });
expect(servers.get("mcp_tjwater_query")).toMatchObject({ disabled: true });
expect(await report()).toEqual({ configuration_errors: ["invalid_server_name", "mcp_tjwater_query"], servers: [{ name: "mcp_fixture", type: "remote", status: "failed", configured_protocol: "auto", tools: ["mcp_fixture_read"] }] });
});
});
+27
View File
@@ -0,0 +1,27 @@
import { afterEach, expect, it } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { queryMcpConnection } from "../../.opencode/query-mcp.js";
import { writeQueryMcpConnection } from "../../src/runtime/query-mcp-connection.js";
const previous = { path: process.env.TJWATER_AGENT_MCP_CONNECTION_PATH, token: process.env.AGENT_INTERNAL_TOKEN };
const roots: string[] = [];
afterEach(() => {
for (const [key, value] of [["TJWATER_AGENT_MCP_CONNECTION_PATH", previous.path], ["AGENT_INTERNAL_TOKEN", previous.token]]) {
if (value === undefined) delete process.env[key!]; else process.env[key!] = value;
}
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
it("configures a pinned native MCP connection using the host-owned endpoint and private channel credential", () => {
const root = mkdtempSync(join(tmpdir(), "query-connection-")); roots.push(root);
process.env.AGENT_INTERNAL_TOKEN = "MCP_TEST_ONLY";
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp");
expect(queryMcpConnection()).toMatchObject({ type: "remote", protocol: "2026-07-28", oauth: false, codemode: false, headers: { "x-agent-internal-token": "MCP_TEST_ONLY" } });
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp", 3_665_000);
expect(queryMcpConnection()?.timeout?.execution).toBe(3_665_000);
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp", Infinity);
expect(() => queryMcpConnection()).toThrow("timeout");
writeQueryMcpConnection(join(root, "sessions"), "https://foreign.test/mcp");
expect(() => queryMcpConnection()).toThrow("loopback");
});
+11
View File
@@ -1,7 +1,18 @@
import { describe, expect, it } from "bun:test";
import { CredentialRefreshCoordinator, runWithCredentialRefresh, bindSessionCredential, credentialRefreshCoordinator, refreshSessionCredential, releaseSessionCredential, requestWithSessionCredential } from "../../src/product/credential-refresh.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
describe("credential renewal", () => {
it("requires a project before personal chats can call business APIs", async () => {
const sessionId = "personal-business-access";
let calls = 0;
setRuntimeSessionContext({ actorKey: "actor-personal", projectKey: "personal-actor-personal", sessionId,
clientSessionId: sessionId, traceId: "test" });
try {
await expect(requestWithSessionCredential(sessionId, async () => { calls++; return Response.json({}); })).rejects.toMatchObject({ status: 403, code: "PROJECT_CONTEXT_REQUIRED" });
expect(calls).toBe(0);
} finally { removeRuntimeSessionContext(sessionId); }
});
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" });
@@ -1,28 +1,17 @@
import { afterEach, describe, expect, it } from "bun:test";
import { ExternalInformationClient, geocodeInput, webSearchInput } from "../../src/product/external-information-client.js";
import { ExternalInformationClient, geocodeInput } 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(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({ keyword: "北京市人民政府" });
return Response.json({ status: "0", location: { lon: "116.4", lat: "39.9", level: 1 } });
}) as unknown as typeof fetch;
@@ -52,11 +41,9 @@ describe("typed external information", () => {
} 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();
it("rejects scope, URL overrides and empty keywords before dispatch", async () => {
expect(geocodeInput.safeParse({ keyword: "北京", url: "http://untrusted" }).success).toBeFalse();
expect(geocodeInput.safeParse({ keyword: "北京", project_id: "other" }).success).toBeFalse();
expect(geocodeInput.safeParse({ keyword: " " }).success).toBeFalse();
await expect(new ExternalInformationClient().geocode(session, { keyword: "北京" })).rejects.toMatchObject({ status: 401 });
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "bun:test";
import { historyChart, queryHistoryChart } from "../../src/product/history-chart.js";
describe("history chart", () => {
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]);
});
});
const series = { element_id: "J1", metric: "pressure", source: "raw", source_unit: "m", device_id: "D1" };
it("continues past a null first page to use real later samples", async () => {
const pages: number[][] = [];
const chart = await queryHistoryChart("J1", async (limit, offset) => {
pages.push([limit, offset]);
return { content: [], structuredContent: { page: { limit, offset, has_more: offset === 0 }, truncated: offset === 0,
data: { series: [{ ...series, points: offset === 0 ? Array.from({ length: limit }, (_, i) => ({ time: String(i), value: null })) : [{ time: "500", value: 25 }] }] } } };
});
expect(pages).toEqual([[500, 0], [500, 500]]);
expect(chart.series[0].data).toEqual([25]);
});
it("stops within a fixed budget and distinguishes unread history from absent history", async () => {
let calls = 0;
await expect(queryHistoryChart("J1", async (limit, offset) => {
calls++;
return { content: [], structuredContent: { page: { limit, offset, has_more: true }, data: { series: [{ ...series, points: [{ time: String(offset), value: null }] }] } } };
})).rejects.toThrow("仍有未读取数据");
expect(calls).toBe(2);
});
it("shrinks oversized pages without skipping data or retrying unrelated failures", async () => {
const calls: number[][] = [];
const chart = await queryHistoryChart("J1", async (limit, offset) => {
calls.push([limit, offset]);
if (limit > 125) return { isError: true, content: [], structuredContent: { error: { code: "RESULT_TOO_LARGE" } } };
return { content: [], structuredContent: { page: { limit, offset, has_more: offset === 0 }, data: {
series: [{ ...series, points: [{ time: String(offset), value: offset === 0 ? null : 25 }] }],
} } };
});
expect(calls).toEqual([[500, 0], [250, 0], [125, 0], [125, 125]]);
expect(chart.series[0].data).toEqual([25]);
let failures = 0;
await expect(queryHistoryChart("J1", async () => {
failures++;
return { isError: true, content: [], structuredContent: { error: { code: "ACCESS_DENIED" } } };
})).rejects.toThrow("历史查询暂不可用");
expect(failures).toBe(1);
});
it("bounds displayed values and identifies a partial query", async () => {
let calls = 0;
const chart = await queryHistoryChart("J1", async (limit, offset) => {
calls++;
return { content: [], structuredContent: { page: { limit, offset, has_more: true },
data: { series: [{ ...series, points: Array.from({ length: 100 }, (_, i) => ({ time: String(i), value: i })) }] } } };
});
expect(calls).toBe(1);
expect(chart.series[0].data).toHaveLength(100);
expect(chart.title).toContain("未覆盖完整时间窗");
});
+32 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { taskReportSchema } from "../../src/product/investigation-report.js";
import { taskReportSchema, taskProgressSchema } from "../../src/product/investigation-report.js";
const report = { title: "调查报告", current_conclusion: "无法确定原因", conclusion_boundary: "仅有上游数据",
evidence_refs: ["query-1"], recommendations: [], pending_items: [], stage: "final", outcome: "insufficient_data",
@@ -34,4 +34,35 @@ describe("new investigation report writes", () => {
expect(() => taskReportSchema.parse({ ...report, ...changes })).toThrow();
}
});
it("shares explicit node activity and supply feature targets with progress", () => {
const diagram = { title: "当前调查", mermaid: "flowchart LR\nQUERY --> HUMAN",
node_refs: [{ node_id: "QUERY", label: "核对监测", status: "running", evidence_refs: ["query-1"],
map_targets: [{ source_id: "scada", feature_id: "MP01" }] },
{ node_id: "HUMAN", label: "现场核验", status: "waiting_human" }] };
const progress = taskProgressSchema.parse({ summary: "正在核对投诉时段的实际监测记录", diagram });
expect(progress.diagram?.node_refs[0]).toMatchObject({ status: "running", map_targets: [{ source_id: "scada", feature_id: "MP01" }] });
expect(taskReportSchema.parse({ ...report, diagram }).diagram).toEqual(progress.diagram);
expect(taskProgressSchema.parse({ summary: "调整调查方向", diagram: null }).diagram).toBeNull();
for (const change of [{ status: "waiting" }, { map_targets: [{ source_id: "drainage", feature_id: "X" }] },
{ map_targets: [{ source_id: "scada", feature_id: "" }] }]) {
expect(() => taskProgressSchema.parse({ summary: "核对", diagram: {
...diagram, node_refs: [{ ...diagram.node_refs[0], ...change }],
} })).toThrow();
}
});
it("preserves parallel branches with independent activity and a pending join", () => {
const mermaid = 'flowchart TD\nSTART[受理投诉] --> SPLIT{并行核查}\nSPLIT --> TICKETS[工单核查]\nSPLIT --> SCADA[监测核查]\nTICKETS --> JOIN[汇总证据]\nSCADA --> JOIN';
const diagram = { title: "并行调查", mermaid, node_refs: [
{ node_id: "TICKETS", label: "工单核查", status: "running" },
{ node_id: "SCADA", label: "监测核查", status: "running" },
{ node_id: "JOIN", label: "汇总证据", status: "pending" },
] };
const progress = taskProgressSchema.parse({ summary: "两个独立核查正在执行", diagram });
expect(progress.diagram?.mermaid).toBe(mermaid);
expect(progress.diagram?.node_refs.map((node) => node.status)).toEqual(["running", "running", "pending"]);
const updated = taskProgressSchema.parse({ summary: "工单核查已完成,监测核查仍在执行", diagram: {
...diagram, node_refs: [{ ...diagram.node_refs[0], status: "completed", evidence_refs: ["query-1"] }, ...diagram.node_refs.slice(1)],
} });
expect(updated.diagram?.node_refs.map((node) => node.status)).toEqual(["completed", "running", "pending"]);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { expect, it } from "bun:test";
import { McpAuthDiscovery, type McpHttpFetch } from "../../src/product/mcp-auth-discovery.js";
const settings = { resource: "https://water.example/mcp", issuer: "https://identity.example/realm/water" };
const metadata = { issuer: settings.issuer, authorization_endpoint: "https://identity.example/authorize", response_types_supported: ["code"], token_endpoint: "https://identity.example/custom/token", token_endpoint_auth_methods_supported: ["client_secret_post"] };
it("uses the challenge resource metadata and discovers a non-Keycloak endpoint", async () => {
const requests: string[] = [];
const discovery = new McpAuthDiscovery(settings, (async (url, init) => {
requests.push(String(url));
expect(new Headers(init?.headers).has("Authorization")).toBeFalse();
expect(init?.body ? String(init.body).includes("secret") : false).toBeFalse();
if (String(url) === settings.resource) return new Response(null, { status: 401, headers: { "WWW-Authenticate": 'Basic realm="gateway", Bearer resource_metadata="https://water.example/auth/metadata", scope="mcp.catalog.read"' } });
if (String(url).endsWith("/auth/metadata")) return Response.json({ resource: settings.resource, authorization_servers: [settings.issuer] });
if (String(url).includes("oauth-authorization-server")) return Response.json(metadata);
throw new Error("unexpected endpoint");
}) as McpHttpFetch);
expect(await discovery.resolve()).toEqual({ tokenEndpoint: metadata.token_endpoint, authMethod: "client_secret_post" });
await discovery.resolve();
expect(requests).toEqual([settings.resource, "https://water.example/auth/metadata", "https://identity.example/.well-known/oauth-authorization-server/realm/water"]);
});
it("falls back through resource path, resource root and OAuth/OIDC issuer metadata", async () => {
const requests: string[] = [];
const discovery = new McpAuthDiscovery(settings, (async (url) => {
const path = String(url); requests.push(path);
if (path === settings.resource) return new Response(null, { status: 401 });
if (path === "https://water.example/.well-known/oauth-protected-resource") return Response.json({ resource: settings.resource, authorization_servers: [settings.issuer] });
if (path.endsWith("/realm/water/.well-known/openid-configuration")) return Response.json({ ...metadata, authorization_endpoint: "https://identity.example/authorize", jwks_uri: "https://identity.example/keys", response_types_supported: ["code"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"] });
return new Response(null, { status: 404 });
}) as McpHttpFetch);
expect((await discovery.resolve()).tokenEndpoint).toBe(metadata.token_endpoint);
expect(requests).toEqual([
settings.resource, "https://water.example/.well-known/oauth-protected-resource/mcp", "https://water.example/.well-known/oauth-protected-resource",
"https://identity.example/.well-known/oauth-authorization-server/realm/water", "https://identity.example/.well-known/openid-configuration/realm/water", "https://identity.example/realm/water/.well-known/openid-configuration",
]);
});
it.each(["resource", "authority", "issuer", "endpoint", "method"])("rejects invalid %s metadata before credentials can be sent", async (kind) => {
const discovery = new McpAuthDiscovery(settings, (async (url) => {
if (String(url) === settings.resource) return new Response(null, { status: 401 });
if (String(url).includes("oauth-protected-resource")) return Response.json({ resource: kind === "resource" ? "https://other.example/mcp" : settings.resource, authorization_servers: [kind === "authority" ? "https://attacker.example" : settings.issuer] });
expect(String(url).startsWith("https://identity.example/")).toBeTrue();
return Response.json({ ...metadata, ...(kind === "issuer" ? { issuer: `${settings.issuer}/` } : {}), ...(kind === "endpoint" ? { token_endpoint: "http://attacker.example/token" } : {}), ...(kind === "method" ? { token_endpoint_auth_methods_supported: ["none"] } : {}) });
}) as McpHttpFetch);
await expect(discovery.resolve()).rejects.toMatchObject({ status: 503 });
});
it("rechecks the configured issuer after metadata invalidation", async () => {
let changed = false;
const discovery = new McpAuthDiscovery(settings, (async (url) => {
if (String(url) === settings.resource) return new Response(null, { status: 401 });
if (String(url).includes("oauth-protected-resource")) return Response.json({ resource: settings.resource, authorization_servers: [changed ? "https://new.example" : settings.issuer] });
return Response.json(metadata);
}) as McpHttpFetch);
await discovery.resolve();
changed = true; discovery.invalidate();
await expect(discovery.resolve()).rejects.toMatchObject({ status: 503 });
});
it.each(["allowed", "other-host", "other-port", "production"])("bounds development HTTP discovery: %s", async (kind) => {
const issuer = "http://192.168.1.114:8088/realms/tjwater";
let requests = 0;
const discovery = new McpAuthDiscovery({ ...settings, issuer, devHttpIssuer: issuer,
runtimeEnvironment: kind === "production" ? "production" : "development" }, (async (url, init) => {
requests++;
expect(new Headers(init?.headers).has("Authorization")).toBeFalse();
expect(init?.redirect).toBe("error");
if (String(url) === settings.resource) return new Response(null, { status: 401 });
if (String(url).includes("oauth-protected-resource")) return Response.json({ resource: settings.resource, authorization_servers: [issuer] });
expect(String(url).startsWith("http://192.168.1.114:8088/")).toBeTrue();
return Response.json({ ...metadata, issuer, token_endpoint: kind === "other-host" ? "http://192.168.1.115:8088/token" :
kind === "other-port" ? "http://192.168.1.114:8089/token" : `${issuer}/protocol/openid-connect/token` });
}) as McpHttpFetch);
if (kind === "allowed") expect((await discovery.resolve()).tokenEndpoint).toBe(`${issuer}/protocol/openid-connect/token`);
else await expect(discovery.resolve()).rejects.toMatchObject({ status: 503 });
if (kind === "production") expect(requests).toBe(0);
});
+198
View File
@@ -0,0 +1,198 @@
import { afterEach, describe, expect, it } from "bun:test";
import { McpQueryClient, validateMcpSettings, type McpHttpFetch, assertExchangedIdentity } from "../../src/product/mcp-query-client.js";
import { bindSessionCredential, credentialRefreshCoordinator, refreshSessionCredential, releaseSessionCredential } from "../../src/product/credential-refresh.js";
import { PRODUCT_MCP_TOOLS } from "../../src/runtime/product-mcp-tools.js";
const sessions: string[] = [];
afterEach(() => { for (const id of sessions.splice(0)) releaseSessionCredential(id); });
const settings = { issuer: "http://localhost/realms/test", resource: "http://localhost/mcp", audience: "tjwater-mcp", clientId: "tjwater-agent-mcp", clientSecret: "fixture-secret" };
const owner = (id: string) => ({ context: { sessionId: id, actorKey: "actor", clientSessionId: id, projectId: "project", projectKey: "project", traceId: "test", purpose: "ordinary_chat" as const }, runtimeSessionId: id });
const catalog = PRODUCT_MCP_TOOLS.map((name) => ({ name, inputSchema: { type: "object" as const, additionalProperties: false, properties: { context_handle: { type: "string" }, element_type: { type: "string" }, ...(name === "run_simulation" ? { timeout_seconds: { type: "integer", default: 120, maximum: 3600 } } : {}) }, required: ["context_handle", "element_type"] } }));
// Test through a transport-independent fake only where the concern is token ownership/renewal.
function fixture(http: McpHttpFetch, authMethod = "client_secret_post") {
const client = new McpQueryClient(settings, (async (url, init) => {
if (String(url) === settings.resource && !new Headers(init?.headers).has("Authorization")) return new Response(null, {
status: 401, headers: { "WWW-Authenticate": 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/mcp"' },
});
if (String(url).includes("oauth-protected-resource")) return Response.json({ resource: settings.resource, authorization_servers: [settings.issuer] });
if (String(url).includes(".well-known")) return Response.json({ issuer: settings.issuer, authorization_endpoint: `${settings.issuer}/authorize`, response_types_supported: ["code"], token_endpoint: `${settings.issuer}/protocol/openid-connect/token`, token_endpoint_auth_methods_supported: [authMethod] });
return http(url, init);
}) as McpHttpFetch);
Object.assign(client, { tools: catalog, withClient: async (_token: string, _project: string, _signal: AbortSignal, run: (client: unknown) => Promise<unknown>) => run({ listTools: async () => ({ tools: catalog }), callTool: async (params: unknown) => ({ content: [], structuredContent: params }) }) });
return client;
}
const jwt = (id: string, overrides: Record<string, unknown> = {}) => `e30.${Buffer.from(JSON.stringify({ sub: "test-subject", iss: settings.issuer, aud: settings.audience, azp: settings.clientId, jti: id, ...overrides })).toString("base64url")}.fixture`;
const resourceToken = jwt("resource");
const token = () => Response.json({ access_token: resourceToken, token_type: "Bearer", expires_in: 300 });
function credential(id: string) { sessions.push(id); bindSessionCredential(id, { authorization: `Bearer ${jwt(id)}`, projectId: "project" }); }
it("forwards the selected simulation budget through HTTP and SDK while rejecting excess before OAuth", async () => {
const id = "mcp-calculation-budget"; credential(id);
let requests = 0, transportTimeout = 0, sdkTimeout = 0;
const client = fixture((async (url, init) => {
requests++;
if (String(url).includes("/token")) return token();
if (init?.method === "POST") return Response.json({ context_handle: "x".repeat(43) });
return new Response(null, { status: 204 });
}) as McpHttpFetch);
Object.assign(client, { withClient: async (_token: string, _project: string, _signal: AbortSignal, run: (client: unknown) => Promise<unknown>, timeout: number) => {
transportTimeout = timeout;
return run({ listTools: async () => ({ tools: catalog }), callTool: async (params: unknown, options: { timeout: number }) => {
sdkTimeout = options.timeout;
return { content: [], structuredContent: params };
} });
} });
await expect(client.query(id, owner(id), "run_simulation", { timeout_seconds: 3601 }, "excess")).rejects.toMatchObject({ status: 400 });
expect(requests).toBe(0);
const result = await client.query(id, owner(id), "run_simulation", { timeout_seconds: 900 }, "long");
expect(transportTimeout).toBe(905_000);
expect(sdkTimeout).toBe(905_000);
expect(result.structuredContent).toMatchObject({ arguments: { timeout_seconds: 900, context_handle: "x".repeat(43) } });
});
it("requires HTTPS except loopback, and fixed resource paths", () => {
expect(() => validateMcpSettings({ ...settings, issuer: "http://remote/realm" })).toThrow();
expect(() => validateMcpSettings({ ...settings, resource: "http://localhost/api" })).toThrow();
expect(() => validateMcpSettings({ ...settings, resource: "https://user:password@example/mcp" })).toThrow();
});
it("allows only an explicit private development issuer while keeping MCP resources protected", () => {
const issuer = "http://192.168.1.114:8088/realms/tjwater";
const local = { ...settings, issuer, devHttpIssuer: issuer, runtimeEnvironment: "development" };
expect(() => validateMcpSettings(local)).not.toThrow();
expect(() => validateMcpSettings({ ...local, runtimeEnvironment: "production" })).toThrow();
expect(() => validateMcpSettings({ ...local, runtimeEnvironment: undefined })).toThrow();
expect(() => validateMcpSettings({ ...local, devHttpIssuer: undefined })).toThrow();
expect(() => validateMcpSettings({ ...local, issuer: `${issuer}/other` })).toThrow();
expect(() => validateMcpSettings({ ...local, resource: "http://192.168.1.114:8088/mcp" })).toThrow();
expect(() => validateMcpSettings({ ...local, issuer: "http://example.com/realm", devHttpIssuer: "http://example.com/realm" })).toThrow();
});
it("rejects personal queries and model supplied scope before any network call", async () => {
let count = 0;
const client = fixture((async () => { count++; return token(); }) as McpHttpFetch);
await expect(client.query("p", { ...owner("p"), context: { ...owner("p").context, projectKey: "personal-actor" } }, "list_elements", {}, "call")).rejects.toMatchObject({ status: 403 });
await expect(client.query("p", owner("p"), "list_elements", { context_handle: "MODEL", project_id: "other" }, "call")).rejects.toMatchObject({ status: 400 });
expect(count).toBe(0);
expect(client.catalog()[0].inputSchema.required).toEqual(["element_type"]);
expect(client.catalog()[0].inputSchema.properties).not.toHaveProperty("context_handle");
});
it("exchanges source credentials only with issuer and injects a host context into MCP", async () => {
const id = "mcp-owner"; credential(id); const requests: Array<{ url: string; init?: RequestInit }> = [];
const client = fixture((async (url, init) => {
requests.push({ url: String(url), init });
if (String(url).includes("/token")) return token();
if (init?.method === "POST") return Response.json({ context_handle: "x".repeat(43) });
return new Response(null, { status: 204 });
}) as McpHttpFetch);
const result = await client.query(id, owner(id), "list_elements", { element_type: "pipe" }, "native-call");
const exchange = new URLSearchParams(String(requests[0].init?.body));
expect(exchange.get("subject_token")).toBe(jwt(id));
expect(exchange.get("audience")).toBe("tjwater-mcp");
expect(exchange.get("resource")).toBe(settings.resource);
const bind = requests.find((request) => request.init?.method === "POST" && !request.url.includes("/token"))!;
expect(new Headers(bind.init?.headers).get("Authorization")).toBe(`Bearer ${resourceToken}`);
expect(JSON.parse(String(bind.init?.body))).toMatchObject({ purpose: "ordinary_chat", native_session_id: id });
expect(result.structuredContent).toMatchObject({ arguments: { context_handle: "x".repeat(43) }, _meta: { "io.tjwater/callId": "native-call" } });
expect(requests.at(-1)?.init?.method).toBe("DELETE");
});
it("coalesces concurrent exchange, isolates owners and discards cached tokens on release", async () => {
const a = "mcp-a", b = "mcp-b"; credential(a); credential(b); let exchanges = 0;
const client = fixture((async (url, init) => {
if (String(url).includes("/token")) { exchanges++; await Bun.sleep(5); return token(); }
if (init?.method === "POST") return Response.json({ context_handle: "x".repeat(43) });
return new Response(null, { status: 204 });
}) as McpHttpFetch);
await Promise.all([client.query(a, owner(a), "list_elements", {}, "one"), client.query(a, owner(a), "list_elements", {}, "two"), client.query(b, owner(b), "list_elements", {}, "one")]);
expect(exchanges).toBe(2);
client.dropSession(a);
await client.query(a, owner(a), "list_elements", {}, "three");
expect(exchanges).toBe(3);
});
it("renews an expired source token through existing credential events", async () => {
const id = "mcp-renew"; credential(id); let exchanges = 0, renewals = 0;
const unsubscribe = credentialRefreshCoordinator.subscribe(id, (event) => {
if (event.type === "credential_refresh_required") {
renewals++; refreshSessionCredential(id, { authorization: `Bearer ${jwt("renewed")}`, projectId: "project" }, event.requestId);
}
});
const client = fixture((async (url, init) => {
if (String(url).includes("/token")) {
exchanges++;
const subject = new URLSearchParams(String(init?.body)).get("subject_token");
return subject === jwt("renewed") ? token() : Response.json({ error: "invalid_grant" }, { status: 400 });
}
if (init?.method === "POST") return Response.json({ context_handle: "x".repeat(43) });
return new Response(null, { status: 204 });
}) as McpHttpFetch);
try { await client.query(id, owner(id), "list_elements", {}, "call"); expect(exchanges).toBe(2); expect(renewals).toBe(1); }
finally { unsubscribe(); }
});
it("does not refresh for scope rejection and renews resource authentication only once", async () => {
const id = "mcp-rejected"; credential(id); let exchanges = 0, binds = 0;
const client = fixture((async (url, init) => {
if (String(url).includes("/token")) { exchanges++; return token(); }
if (init?.method === "POST") { binds++; return Response.json({}, { status: 401 }); }
return new Response(null, { status: 204 });
}) as McpHttpFetch);
await expect(client.query(id, owner(id), "list_elements", {}, "call")).rejects.toMatchObject({ status: 401 });
expect(exchanges).toBe(2); expect(binds).toBe(2);
});
it("cancels a caller waiting for shared credential renewal", async () => {
const id = "mcp-cancel"; credential(id);
let resolve!: (response: Response) => void;
let notifyStarted!: () => void;
const started = new Promise<void>((done) => { notifyStarted = done; });
const client = fixture((async () => new Promise<Response>((done) => { resolve = done; notifyStarted(); })) as McpHttpFetch);
const controller = new AbortController();
const pending = client.query(id, owner(id), "list_elements", {}, "call", controller.signal);
await started;
controller.abort(new Error("cancelled"));
await expect(pending).rejects.toThrow("cancelled");
client.dropSession(id); resolve(token());
});
it("sends resource on both grants and supports advertised Basic client authentication", async () => {
const id = "mcp-basic"; credential(id);
const grants: Array<{ headers: Headers; body: URLSearchParams }> = [];
const client = fixture((async (url, init) => {
if (String(url).endsWith("/token")) {
grants.push({ headers: new Headers(init?.headers), body: new URLSearchParams(String(init?.body)) });
return token();
}
if (init?.method === "POST") return Response.json({ context_handle: "x".repeat(43) });
return new Response(null, { status: 204 });
}) as McpHttpFetch, "client_secret_basic");
await client.prepare();
await client.query(id, owner(id), "list_elements", {}, "call");
expect(grants.map((g) => g.body.get("grant_type"))).toEqual(["client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"]);
for (const grant of grants) {
expect(grant.body.get("resource")).toBe(settings.resource);
expect(grant.body.has("client_secret")).toBeFalse();
expect(grant.headers.get("Authorization")).toBe(`Basic ${Buffer.from(`${settings.clientId}:${settings.clientSecret}`).toString("base64")}`);
}
});
it("reports an unsupported resource indicator as configuration failure without a weaker retry", async () => {
const id = "mcp-invalid-resource"; credential(id);
let attempts = 0;
const client = fixture((async (_url, init) => {
attempts++;
expect(new URLSearchParams(String(init?.body)).get("resource")).toBe(settings.resource);
return Response.json({ error: "invalid_target" }, { status: 400 });
}) as McpHttpFetch);
await expect(client.query(id, owner(id), "list_elements", {}, "call")).rejects.toThrow("RFC 8707");
expect(attempts).toBe(1);
});
it("rejects an exchanged identity, issuer, client or audience that differs from the verified source", () => {
assertExchangedIdentity(jwt("source"), resourceToken, settings);
for (const overrides of [{ sub: "other" }, { aud: "api" }, { iss: "https://other/realm" }, { azp: "browser" }]) {
expect(() => assertExchangedIdentity(jwt("source"), jwt("resource", overrides), settings)).toThrow();
}
});
+30 -43
View File
@@ -166,6 +166,36 @@ describe("OperationalTaskClient", () => {
expect(JSON.stringify(body)).not.toContain("transient-secret");
});
it("publishes complete progress and presentation with the claimed run without browser receipts", async () => {
const requests: Request[] = [];
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
if (request.url.endsWith("/claim")) return Response.json(run);
if (request.url.endsWith("/presentation-events")) return Response.json({
recorded: true, event_seq: 12, run_id: runId, execution_generation: 1,
});
return Response.json({ task, plan_revision: null, report_revision: null, progress_revision: 3 });
}) as typeof fetch;
bindOperationalTaskCredential("runtime-1", { authorization: "Bearer transient-secret", projectId: "project-1" });
const client = new OperationalTaskClient("http://server.test");
await client.claimTask("runtime-1", taskId, runId, {
requestId: executionRequestId, expectedRevision: 1, sourceSessionId: "session-1",
});
const progress = { summary: "正在查询投诉时段", diagram: null };
expect(await client.publishArtifacts("runtime-1", taskId, {
requestId: executionRequestId, sourceSessionId: "session-1", progress,
})).toMatchObject({ progress_revision: 3 });
expect(await requests[1]!.json()).toMatchObject({ progress, run_id: runId, source_session_id: "session-1" });
const command = { action: "set_overlay" as const, overlay_id: "complaints", title: "投诉位置", mode: "geometry" as const, evidence_id: "query-1" };
expect(await client.publishPresentation("runtime-1", taskId, {
requestId: executionRequestId, sourceSessionId: "session-1", command,
})).toEqual({ recorded: true, event_seq: 12, run_id: runId, execution_generation: 1 });
expect(await requests[2]!.json()).toEqual({ request_id: executionRequestId, run_id: runId, source_session_id: "session-1", command });
expect(requests).toHaveLength(3);
expect(requests[2]!.headers.get("authorization")).toBe("Bearer transient-secret");
});
it("fails closed without a current product credential", async () => {
const client = new OperationalTaskClient("http://server.test");
expect(client.getAgentContext("runtime-1", "50000000-0000-4000-8000-000000000001"))
@@ -217,49 +247,6 @@ describe("OperationalTaskClient", () => {
});
});
it("returns the persisted bounded data snapshot with its evidence id", async () => {
const urls: string[] = [];
globalThis.fetch = (async (input) => {
const url = String(input);
urls.push(url);
return urls.length === 1
? Response.json({
evidence_id: "scada_readings:sha256:abc",
kind: "system_data",
summary: "scada_readings 数据快照",
source_label: "scada_readings",
source_ref: "scada_readings:sha256:abc",
observed_at: "2026-09-23T01:00:00Z",
})
: Response.json({
source: "scada_readings",
source_ref: "scada_readings:sha256:abc",
queried_at: "2026-09-23T01:00:00Z",
origin: "system_data",
data: { items: [{ value: 0.42 }] },
truncated: false,
});
}) as typeof fetch;
bindOperationalTaskCredential("runtime-1", {
authorization: "Bearer transient-secret",
projectId: "project-1",
});
const client = new OperationalTaskClient("http://server.test");
const result = await client.queryData("runtime-1", "50000000-0000-4000-8000-000000000001", {
source: "scada_readings",
device_ids: ["device-1"],
start: "2026-09-23T00:00:00Z",
end: "2026-09-23T01:00:00Z",
});
expect(result).toMatchObject({
evidence: { evidence_id: "scada_readings:sha256:abc" },
snapshot: { origin: "system_data", truncated: false, data: { items: [{ value: 0.42 }] } },
});
expect(urls[1]).toContain("/evidence/scada_readings%3Asha256%3Aabc/snapshot");
});
it("keeps the current task revision for heartbeats after an action-only response", async () => {
const requestBodies: Record<string, unknown>[] = [];
let call = 0;
-45
View File
@@ -1,45 +0,0 @@
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]);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "bun:test";
import publishPresentation from "../../.opencode/tools/publish_task_presentation.js";
import publishProgress from "../../.opencode/tools/publish_task_progress.js";
import { taskPublicationCommandSchema, taskPublicationProgressSchema } from "../../.opencode/tools/task-publication-schema.js";
import { taskProgressSchema } from "../../src/product/investigation-report.js";
import { taskPresentationCommandSchema } from "../../src/product/task-presentation.js";
describe("task presentation input boundaries", () => {
it("aligns native and product progress schemas for legacy and current diagrams", () => {
const progress = { summary: "实际读取监测资料", diagram: { title: "当前路线", mermaid: "flowchart TD\nQUERY --> REVIEW", node_refs: [
{ node_id: "QUERY", label: "查询", status: "running", map_targets: [{ source_id: "pipes", feature_id: "P-1" }] },
{ node_id: "REVIEW", label: "复核", status: null },
] } };
expect(taskProgressSchema.parse(taskPublicationProgressSchema.parse(progress))).toEqual(taskProgressSchema.parse(progress));
expect(taskProgressSchema.parse(taskPublicationProgressSchema.parse({ summary: "当前无图" }))).toEqual({ summary: "当前无图", diagram: null });
});
it("accepts finite display commands and rejects unsupported data, layers and chart evidence", () => {
const chart = { title: "压力", chart_type: "line", x_data: ["10:00"], series: [{ name: "MP01", data: [0.4] }] };
const commands = [
{ action: "highlight_features", layer: "scada", feature_ids: ["MP01"] },
{ action: "clear_highlights" },
{ action: "open_chart", chart_id: "pressure", chart, evidence_refs: ["reading-1"] },
{ action: "close_chart", chart_id: "pressure" },
{ action: "set_overlay", overlay_id: "complaints", title: "实际投诉", mode: "heatmap", evidence_id: "query-1" },
{ action: "clear_overlay", overlay_id: "complaints" },
];
for (const command of commands) {
expect(taskPresentationCommandSchema.parse(taskPublicationCommandSchema.parse(command))).toEqual(taskPresentationCommandSchema.parse(command));
}
for (const command of [
{ ...commands[0], layer: "drainage" },
{ ...commands[2], evidence_refs: [] },
{ ...commands[2], chart: { ...chart, series: [{ name: "MP01", data: [0.4, 0.5] }] } },
{ ...commands[4], data: { coordinates: [1, 2] } },
{ ...commands[4], script: "alert(1)" },
]) expect(() => taskPresentationCommandSchema.parse(command)).toThrow();
});
it("revalidates semantic chart and Mermaid inputs before sending a tool request", async () => {
const request_id = "60000000-0000-4000-8000-000000000001";
await expect(publishPresentation.execute({ request_id, command: {
action: "open_chart", chart_id: "pressure", evidence_refs: ["reading-1"],
chart: { title: "压力", chart_type: "line", x_data: ["10:00"], series: [{ name: "MP01", data: [0.4, 0.5] }] },
} }, { sessionID: "test" })).rejects.toThrow("series must align");
await expect(publishProgress.execute({ request_id, progress: {
summary: "查询", diagram: { title: "路线", mermaid: "flowchart TD; A-->B; click A callback", node_refs: [] },
} }, { sessionID: "test" })).rejects.toThrow("Mermaid");
});
});
-120
View File
@@ -1,120 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ResultReferenceResolver } from "../../src/results/resolver.js";
import {
RESULT_REFERENCE_KIND,
RESULT_REFERENCE_SOURCE,
ResultReferenceStore,
} from "../../src/results/store.js";
describe("ResultReferenceResolver", () => {
let tempDir: string;
let store: ResultReferenceStore;
let resolver: ResultReferenceResolver;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
store = new ResultReferenceStore(tempDir, 60_000);
resolver = new ResultReferenceResolver(store);
await store.initialize();
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
it("stores metadata for server API result refs and resolves them", async () => {
const record = await resolver.register({
actorKey: "actor-1",
clientSessionId: "client-1",
data: {
data: [{ asset_id: "asset-1", value: 1.23 }],
meta: { rows: 1 },
},
kind: RESULT_REFERENCE_KIND.serverApiPayload,
projectId: "project-1",
projectKey: "project-key-1",
schemaVersion: 1,
sessionId: "session-1",
source: RESULT_REFERENCE_SOURCE.serverApi,
traceId: "trace-1",
});
expect(record.kind).toBe(RESULT_REFERENCE_KIND.serverApiPayload);
expect(record.schemaVersion).toBe(1);
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.serverApi);
const result = await resolver.getFullAuthorized(
record.resultRef,
{
actorKey: "actor-1",
projectId: "project-1",
},
);
expect(result).not.toBeNull();
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.serverApiPayload);
expect(result?.schema_version).toBe(1);
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.serverApi);
expect(result?.data).toEqual({
data: [{ asset_id: "asset-1", value: 1.23 }],
meta: { rows: 1 },
});
});
it("rejects malformed refs and auth mismatches", async () => {
const malformedRef = "res-bbbbbbbbbbbbbbbb";
await writeFile(
join(tempDir, `${malformedRef}.json`),
JSON.stringify(
{
resultRef: malformedRef,
createdAt: "2026-05-21T00:00:00.000Z",
data: { value: 1 },
preview: {
count: 1,
fields: ["value"],
sample: { value: 1 },
summary: "object<1 fields>",
},
projectId: "project-1",
projectKey: "project-key-1",
sessionId: "session-1",
sizeBytes: 10,
traceId: "trace-1",
},
null,
2,
),
"utf8",
);
const malformed = await store.getAuthorizedRecord(malformedRef, {
actorKey: "actor-1",
projectId: "project-1",
});
expect(malformed).toBeNull();
const serverRecord = await resolver.register({
actorKey: "actor-2",
clientSessionId: "client-2",
data: { data: [{ asset_id: "asset-2" }] },
kind: RESULT_REFERENCE_KIND.serverApiPayload,
projectId: "project-2",
projectKey: "project-key-2",
schemaVersion: 1,
sessionId: "session-2",
source: RESULT_REFERENCE_SOURCE.serverApi,
traceId: "trace-2",
});
const wrongActor = await resolver.getFullAuthorized(serverRecord.resultRef, {
actorKey: "actor-other",
projectId: "project-2",
});
expect(wrongActor).toBeNull();
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it, mock } from "bun:test";
import express from "express";
import { createAgentMessageQuery, registerAgentMessageBridge } from "../../src/routes/agent-message-routes.js";
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import type { SessionMetadataStore, SessionRecord } from "../../src/sessions/metadataStore.js";
import type { AgentMessageCoordinator } from "../../src/chat/agent-message-coordinator.js";
const task = "81000000-0000-4000-8000-000000000001";
const run = "81000000-0000-4000-8000-000000000002";
const request = "81000000-0000-4000-8000-000000000003";
describe("scoped Agent message bridge", () => {
it("derives sender from runtime, validates arguments, and denies child/review/cross-run writes", async () => {
const records = new Map<string, SessionRecord>();
for (const [id, purpose] of [["main", "ordinary_chat"], ["investigator", "task_execution"], ["review", "ordinary_chat"]] as const) {
const record: SessionRecord = { sessionId: id, actorKey: "owner", projectKey: "project", projectId: task,
createdAt: "now", updatedAt: "now", status: "active", purpose, ...(purpose === "task_execution" ? { taskId: task, runId: run } : {}) };
records.set(id, record);
setRuntimeSessionContext({ ...record, clientSessionId: id, traceId: "test", learningMode: id === "review" ? "review" : "interactive" });
}
const send = mock(async (_session: string, input: unknown) => ({ input, target_session_id: null }));
const reply = mock(async () => ({}));
const read = mock(async () => ({ items: [], next_cursor: null }));
const coordinator = {
client: { send, reply, list: read, get: async () => ({ task_id: task, run_id: request, direction: "to_task" }) },
isActive: () => true, notify: () => {}, deliver: async () => {},
} as unknown as AgentMessageCoordinator;
const app = express(); app.use(express.json());
registerAgentMessageBridge(app, { internalToken: "test-only", messages: coordinator,
sessions: { get: async (_context: unknown, id: string) => records.get(id) ?? null } as unknown as SessionMetadataStore });
const 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/agent-messages`;
const call = (operation: string, session: string, input: unknown, token = "test-only") => fetch(`${url}/${operation}`, {
method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": token },
body: JSON.stringify({ session_id: session, input }),
});
const input = { task_id: task, run_id: run, request_id: request, message: "核对压力记录" };
try {
expect((await call("send", "main", input, "wrong")).status).toBe(403);
expect((await call("send", "child", input)).status).toBe(403);
expect((await call("send", "review", input)).status).toBe(403);
expect((await call("send", "investigator", input)).status).toBe(403);
expect((await call("send", "main", { ...input, source_session_id: "someone-else" })).status).toBe(400);
expect((await call("send", "main", input)).status).toBe(200);
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0]).toEqual(["main", input]);
expect((await call("reply", "investigator", { message_id: request, request_id: request, message: "回复" })).status).toBe(403);
expect(reply).not.toHaveBeenCalled();
expect((await call("read", "main", {})).status).toBe(404);
const query = createAgentMessageQuery({ sessions: { get: async (_context: unknown, id: string) => records.get(id) ?? null } as unknown as SessionMetadataStore, messages: coordinator });
await query("main", "read", {});
expect(read).toHaveBeenCalledWith("main", "main", undefined, 20);
await expect(query("investigator", "list", {})).rejects.toThrow();
await expect(query("child", "read", {})).rejects.toThrow();
} finally {
for (const id of records.keys()) removeRuntimeSessionContext(id);
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
+1 -1
View File
@@ -40,7 +40,7 @@ describe("agent event trigger", () => {
it("keeps event entry and action-tool instructions consistent with report-driven completion", () => {
const prompt = buildAgentTriggerPrompt(agentTriggerContextSchema.parse(trigger));
expect(prompt).toContain("get_operational_task_context");
expect(prompt).toContain("mcp_tjwater_get_task_context");
expect(prompt).toContain("final/insufficient_data");
expect(prompt).toContain("正常结束本轮");
expect(prompt).toContain("权限拒绝和接口错误不能证明数据缺失");
+1
View File
@@ -96,6 +96,7 @@ describe("generateSessionTitle", () => {
},
],
abortSession: async () => undefined,
removeSession: async () => undefined,
} as unknown as OpencodeRuntimeAdapter;
const title = await generateSessionTitle(runtime, {
+76 -8
View File
@@ -19,6 +19,32 @@ const createEventStream = (events: unknown[]) => ({
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("streamPromptResponse", () => {
it("flushes before prompting and resumes the same session when a message wins the completion race", async () => {
const order: string[] = [];
const emitted: string[] = [];
let idleChecks = 0;
const runtime = {
prompt: async (sessionId: string) => { order.push(`prompt:${sessionId}`); },
subscribeEvents: async () => createEventStream([1, 2].flatMap((round) => [
{ type: "message.part.updated", properties: { sessionID: "s", part: {
id: `text-${round}`, type: "text", text: `round ${round}`, sessionID: "s", messageID: `m-${round}`,
} } },
{ type: "session.idle", properties: { sessionID: "s" } },
])),
messages: async () => [],
} as unknown as OpencodeRuntimeAdapter;
const result = await streamPromptResponse({
runtime, sessionId: "s", clientSessionId: "client", message: "test",
beforePrompt: async () => { order.push("flush"); },
beforeIdle: async () => ++idleChecks === 2,
write: (event) => emitted.push(event),
});
expect(result.failed).toBeFalse();
expect(order).toEqual(["flush", "prompt:s", "prompt:s"]);
expect(idleChecks).toBe(2);
expect(emitted.filter((event) => event === "done")).toHaveLength(1);
});
it("keeps the first pending event when the V2 prompt acknowledgement arrives first", async () => {
const emitted: { event: string; data: Record<string, unknown> }[] = [];
const runtime = {
@@ -252,7 +278,7 @@ describe("streamPromptResponse", () => {
properties: {
id: "perm-1",
sessionID: "runtime-session-1",
permission: "web_search",
permission: "websearch",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
@@ -286,18 +312,60 @@ describe("streamPromptResponse", () => {
{
requestId: "perm-1",
sessionId: "runtime-session-1",
reply: "always",
reply: "once",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
session_id: "client-session-1",
request_id: "perm-1",
reply: "always",
reply: "once",
});
});
it("requires manual Shell approval even in always mode", async () => {
it.each(["permission.asked", "permission.v2.asked"])("returns to manual approval after an automatic round for %s", async (eventType) => {
let remembered = false;
let round = 0;
const replies: string[] = [];
const runtime = {
subscribeEvents: async () => {
round += 1;
return createEventStream([
...(!remembered ? [{
type: eventType,
properties: {
id: `permission-${round}`,
sessionID: "runtime-session-1",
...(eventType === "permission.asked"
? { permission: "webfetch", patterns: ["https://example.test/*"], always: ["https://example.test/*"] }
: { action: "webfetch", resources: ["https://example.test/"], save: ["https://example.test/*"] }),
},
}] : []),
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]);
},
prompt: async () => undefined,
messages: async () => [],
replyPermission: async ({ reply }: { reply: string }) => {
replies.push(reply);
remembered = reply === "always";
},
} as unknown as OpencodeRuntimeAdapter;
const automatic: Array<{ event: string; data: Record<string, unknown> }> = [];
const manual: Array<{ event: string; data: Record<string, unknown> }> = [];
const options = { runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "fetch a public reference" };
await streamPromptResponse({ ...options, approvalMode: "always", write: (event, data) => automatic.push({ event, data }) });
await streamPromptResponse({ ...options, approvalMode: "request", write: (event, data) => manual.push({ event, data }) });
expect(replies).toEqual(["once"]);
expect(automatic.find(({ event }) => event === "permission_response")?.data.reply).toBe("once");
expect(manual.find(({ event }) => event === "permission_request")?.data).toMatchObject({
request_id: "permission-2", permission: "webfetch",
});
});
it.each(["shell", "bash", "edit", "mcp_fixture_write"])("automatically approves %s in always mode", async (action) => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
@@ -307,7 +375,7 @@ describe("streamPromptResponse", () => {
properties: {
id: "perm-1",
sessionID: "runtime-session-1",
permission: "bash",
permission: action,
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
@@ -337,9 +405,9 @@ describe("streamPromptResponse", () => {
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);
expect(replies).toEqual([{ requestId: "perm-1", sessionId: "runtime-session-1", reply: "once" }]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data.reply).toBe("once");
});
it("forwards opencode v2 permission requests as SSE payloads", async () => {
@@ -1,48 +0,0 @@
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);
});
});
+6 -4
View File
@@ -12,7 +12,8 @@ import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
describe("MCP routes", () => {
it("protects the internal policy bridge against browser and context injection", async () => {
const app = express(); app.use(express.json());
registerMcpPolicyBridge(app, { internalToken: "MCP_TEST_ONLY", runtime: { mcpReadonlyTools: () => ["mcp_fixture_read"] } });
let builds = 0;
registerMcpPolicyBridge(app, { internalToken: "MCP_TEST_ONLY", runtime: { mcpReadonlyTools: () => { builds++; return ["mcp_fixture_read"]; } } });
const 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/mcp/session-policy`;
@@ -25,7 +26,8 @@ describe("MCP routes", () => {
expect((await post({ session_id: sessionID }, { "Sec-Fetch-Site": "same-origin" })).status).toBe(403);
expect((await post({ session_id: sessionID, project_id: "other" })).status).toBe(400);
expect((await post({ session_id: "missing" })).status).toBe(404);
expect(await (await post({ session_id: sessionID })).json()).toEqual({ mode: "readonly", readonly_tools: ["mcp_fixture_read"] });
expect(await (await post({ session_id: sessionID })).json()).toEqual({ mode: "readonly", readonly_tools: ["mcp_fixture_read"], query_tools: [] });
expect(builds).toBe(1);
} finally {
removeRuntimeSessionContext(sessionID);
await new Promise<void>((resolve) => server.close(() => resolve()));
@@ -61,10 +63,10 @@ describe("MCP routes", () => {
if (failed) throw new Error("SECRET_URL_AND_TOKEN");
return { configuration_errors: [], servers: [{ name: "mcp_fixture", type: "remote", status: "needs_auth", configured_protocol: "auto", tools: [] }] };
} } });
app.use("/api/v1/agent/chat", router);
app.use("/api/v1", router);
const 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}/api/v1/agent/chat/session/private-session/mcp`;
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/api/v1/session/private-session/mcp`;
try {
expect((await originalFetch(url)).status).toBe(401);
const headers = { Authorization: "Bearer OWNER", "X-Project-Id": "project" };
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, expect, test } from "bun:test";
import express from "express";
import type { Server } from "node:http";
import { buildChatRouter } from "../../src/routes/chat.js";
const originalFetch = globalThis.fetch;
let server: Server | undefined;
afterEach(async () => {
globalThis.fetch = originalFetch;
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
server = undefined;
});
test("suggestions inherit verified identity and project scope and reject unsupported requests before inference", async () => {
globalThis.fetch = (async (_input, init) => {
const headers = new Headers(init?.headers);
if (headers.get("Authorization") === "Bearer denied") return Response.json({ detail: "denied" }, { status: 403 });
return Response.json({ user_id: headers.get("Authorization") === "Bearer user-a" ? "user-a" : "user-b", project_id: headers.get("X-Project-Id"), network: "supply" });
}) as typeof fetch;
let generations = 0;
const app = express();
app.use(express.json());
app.use("/api/v1", buildChatRouter({
runtime: { generateText: async () => { generations += 1; return '{"suggestions":[{"question":"核对供水监测数据的完整性","activity_id":null}]}'; } },
frontendActionCoordinator: {}, learningOrchestrator: {}, memoryStore: {}, sessionBridge: {}, sessionMetadataStore: {}, sessionTranscriptStore: {}, sessionUiStateStore: {},
} as never));
server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server!.once("listening", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("missing test server address");
const post = (user: string, project = "10000000-0000-4000-8000-000000000001", body: unknown = { activities: [] }) => originalFetch(`http://127.0.0.1:${address.port}/api/v1/suggestions`, {
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${user}`, "X-Project-Id": project, Connection: "close" }, body: JSON.stringify(body),
});
expect((await post("user-a")).status).toBe(200);
expect((await post("user-a")).status).toBe(200);
expect(generations).toBe(1);
expect((await post("user-b")).status).toBe(200);
expect((await post("user-a", "10000000-0000-4000-8000-000000000002")).status).toBe(200);
expect(generations).toBe(3);
expect((await post("denied")).status).toBe(403);
expect((await post("user-a", undefined, { activities: [], model: "unknown/model" })).status).toBe(400);
expect((await post("user-a", undefined, { activities: [], unexpected: "field" })).status).toBe(400);
expect(generations).toBe(3);
});
+185
View File
@@ -0,0 +1,185 @@
import { afterEach, beforeEach, expect, it } from "bun:test";
import express from "express";
import { mkdtemp, rm } from "node:fs/promises";
import type { Server } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildChatRouter } from "../../src/routes/chat.js";
import { ChatSessionBridge } from "../../src/chat/sessionBridge.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
import type { ResponseInput } from "../../src/chat/response-inputs.js";
const http = globalThis.fetch;
let root: string;
let server: Server;
let base: string;
let fail = false;
let prompted: string[];
let pendingInputs: ResponseInput[];
let aborts: number;
let gate: Promise<void> | undefined;
let release: () => void = () => {};
let bridge: ChatSessionBridge;
const project = "10000000-0000-4000-8000-000000000001";
beforeEach(async () => {
fail = false; prompted = []; pendingInputs = []; aborts = 0; gate = undefined;
root = await mkdtemp(join(tmpdir(), "tjwater-responses-"));
globalThis.fetch = (async (_input, init) => Response.json({
user_id: new Headers(init?.headers).get("authorization") === "Bearer owner" ? "owner" : "other",
project_id: project, network: "fixture",
})) as typeof fetch;
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new SessionUiStateStore(join(root, "ui"), workspaces);
await sessions.initialize();
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
await sessions.ensure(context);
await states.write(context, { sessionId: "owned", isTitleManuallyEdited: true, messages: [] });
const runtime = {
prompt: async (_id: string, prompt: string) => { prompted.push(prompt); if (fail) throw new Error("fixture execution failed"); },
messages: async () => [], abortSession: async () => { aborts++; }, waitForSessionIdle: async () => {},
listPendingUserInputs: async () => pendingInputs.map((input) => ({ ...input })),
cancelPendingUserInputs: async () => { pendingInputs = []; },
enqueueUserInput: async (_sessionId: string, input: ResponseInput) => { pendingInputs.push({ ...input }); },
updatePendingUserInput: async (_sessionId: string, id: string, delivery: "steer" | "queue") => {
const item = pendingInputs.find((input) => input.id === id);
if (!item) throw Object.assign(new Error("Consumed"), { status: 409 });
item.delivery = delivery;
return { ...item };
},
subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() {
if (gate) await gate;
yield { type: "message.part.updated", properties: { sessionID: "owned", part: { id: "answer", type: "text", text: "", sessionID: "owned", messageID: "native-answer" } } };
yield { type: "message.part.delta", properties: { sessionID: "owned", partID: "answer", field: "text", delta: "压力待核验" } };
for (const item of pendingInputs.splice(0)) {
yield { type: "session.inbox.delivered", properties: { sessionID: "owned", inboxID: item.id } };
}
yield { type: "session.idle", properties: { sessionID: "owned" } };
} }),
} as never;
const app = express(); app.use(express.json());
bridge = new ChatSessionBridge(runtime);
app.use("/api/v1", buildChatRouter({
runtime, sessionBridge: bridge, sessionMetadataStore: sessions, sessionUiStateStore: states,
sessionTranscriptStore: { getRecentTurns: async () => [] } as never,
memoryStore: { buildPromptSnapshot: async () => "" } as never,
learningOrchestrator: { onTurnCompleted: async () => {} } as never,
frontendActionCoordinator: { cancelSession: () => {} } as never,
}));
server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address(); if (!address || typeof address === "string") throw new Error("missing address");
base = `http://127.0.0.1:${address.port}`;
});
afterEach(async () => {
release();
for (let i = 0; i < 100 && bridge.count(); i++) await Bun.sleep(10);
globalThis.fetch = http;
removeRuntimeSessionContext("owned");
server?.closeAllConnections();
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true });
});
const call = (body: unknown, token = "owner", path = "/api/v1/responses") => http(`${base}${path}`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Project-Id": project, "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(5000),
});
it("serves Responses SSE through the real HTTP route and preserves the owned conversation", async () => {
const response = await call({ input: [{ role: "user", content: [{ type: "input_text", text: "核查压力" }] }], conversation: "owned", stream: true });
const text = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.has("x-vercel-ai-ui-message-stream")).toBeFalse();
expect(text).toContain("event: response.created");
expect(text).toContain("event: response.output_text.delta");
expect(text).toContain("event: response.completed");
expect(text).toContain('"conversation":{"id":"owned"}');
expect(prompted).toHaveLength(1);
expect(prompted[0]).toContain("核查压力");
});
it("returns a Response object when streaming is omitted", async () => {
const response = await call({ input: "核查压力", conversation: { id: "owned" } });
expect(await response.json()).toMatchObject({ object: "response", status: "completed", conversation: { id: "owned" }, output: [{ type: "message", content: [{ type: "output_text", text: "压力待核验" }] }] });
});
it("admits steer/queue input without interrupting, edits pending delivery and persists consumed user messages", async () => {
gate = new Promise<void>((resolve) => { release = resolve; });
const initial = await call({ input: "原始问题", conversation: "owned", stream: true });
const first = `msg_${"1".repeat(32)}`;
const second = `msg_${"2".repeat(32)}`;
const baselineAborts = aborts;
for (const [id, text, delivery] of [[first, "补充压力测点", "steer"], [second, "之后检查流量", "queue"]] as const) {
const result = await call({ input: text, conversation: "owned", tjwater: { delivery, input_id: id } });
expect(result.status).toBe(202);
expect(await result.json()).toMatchObject({ id, text, delivery, status: "pending" });
}
expect((await call({ input: "不带方式", conversation: "owned" })).status).toBe(409);
expect(aborts).toBe(baselineAborts);
expect(prompted).toHaveLength(1);
const headers = { Authorization: "Bearer owner", "X-Project-Id": project, "Content-Type": "application/json" };
const changed = await http(`${base}/api/v1/session/owned/inputs/${second}`, { method: "PATCH", headers, body: JSON.stringify({ delivery: "steer" }) });
expect(changed.status).toBe(200);
expect(await changed.json()).toMatchObject({ id: second, delivery: "steer" });
const list = await http(`${base}/api/v1/session/owned/inputs`, { headers });
expect((await list.json()).items).toHaveLength(2);
const foreign = await http(`${base}/api/v1/session/owned/inputs/${first}`, { method: "PATCH", headers: { ...headers, Authorization: "Bearer other" }, body: JSON.stringify({ delivery: "queue" }) });
expect(foreign.status).toBe(404);
expect(aborts).toBe(baselineAborts);
release();
const output = await initial.text();
expect(output).toContain("event: response.completed");
expect(output).toContain("tjwater.user_input");
const history = await http(`${base}/api/v1/session/owned`, { headers });
expect(JSON.stringify((await history.json()).messages)).toContain("补充压力测点");
const consumed = await http(`${base}/api/v1/session/owned/inputs/${second}`, { method: "PATCH", headers, body: JSON.stringify({ delivery: "queue" }) });
expect(consumed.status).toBe(409);
});
it("rejects cross-user conversations, legacy requests and retired URLs before generation", async () => {
expect((await call({ input: "核查压力", conversation: "owned" }, "other")).status).toBe(404);
expect((await call({ message: "legacy", session_id: "owned" })).status).toBe(400);
expect((await call({ input: "核查压力" }, "owner", "/api/v1/agent/chat/stream")).status).toBe(404);
expect(prompted).toEqual([]);
});
it("returns response.failed when the runtime fails", async () => {
fail = true;
const response = await call({ input: "核查压力", conversation: "owned", stream: true });
const text = await response.text();
expect(text).toContain("event: response.failed");
expect(text).not.toContain("event: response.completed");
});
it("keeps generating and saving the owned history after the browser disconnects", async () => {
gate = new Promise<void>((resolve) => { release = resolve; });
const response = await call({ input: "核查压力", conversation: "owned", stream: true });
const reader = response.body!.getReader();
await reader.read();
await reader.cancel();
release();
let loaded: { is_streaming?: boolean; messages?: unknown[] } = {};
for (let i = 0; i < 50; i++) {
await Bun.sleep(10);
const state = await http(`${base}/api/v1/session/owned`, { headers: { Authorization: "Bearer owner", "X-Project-Id": project } });
loaded = await state.json() as typeof loaded;
if (!loaded.is_streaming && JSON.stringify(loaded.messages).includes("压力待核验")) break;
}
expect(loaded.is_streaming).toBeFalse();
expect(JSON.stringify(loaded.messages)).toContain("压力待核验");
});
it("marks an explicitly aborted response incomplete and preserves scoped history", async () => {
gate = new Promise<void>((resolve) => { release = resolve; });
const response = await call({ input: "核查压力", conversation: "owned", stream: true });
const queued = await call({ input: "等待后续处理", conversation: "owned", tjwater: { delivery: "queue", input_id: `msg_${"3".repeat(32)}` } });
expect(queued.status).toBe(202);
const cancelled = await call({ session_id: "owned" }, "owner", "/api/v1/abort");
release();
expect(cancelled.ok).toBeTrue();
expect(pendingInputs).toEqual([]);
const text = await response.text();
expect(text).toContain("event: response.incomplete");
expect(text).not.toContain("event: response.completed");
});
+65 -17
View File
@@ -6,7 +6,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildChatRouter } from "../../src/routes/chat.js";
import { ChatSessionBridge } from "../../src/chat/sessionBridge.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
const originalFetch = globalThis.fetch;
@@ -42,7 +45,9 @@ describe("chat session HTTP isolation", () => {
});
}) as typeof fetch;
const sessionMetadataStore = new SessionMetadataStore(metadataDir);
const workspaces = new SessionWorkspaceStore(join(metadataDir, "workspaces"));
const sessionMetadataStore = new SessionMetadataStore(metadataDir, workspaces);
const sessionUiStateStore = new SessionUiStateStore(join(metadataDir, "old-ui"), workspaces);
await sessionMetadataStore.initialize();
const ownerContext = {
actorKey: toActorKey("product:user-a"),
@@ -54,6 +59,14 @@ describe("chat session HTTP isolation", () => {
...ownerContext,
sessionId: "ordinary-session",
});
await sessionUiStateStore.write({ ...ownerContext, sessionId: "ordinary-session" }, {
sessionId: "ordinary-session", messages: [{ type: "text", text: "owner-only answer" }],
});
const personalContext = { actorKey: ownerContext.actorKey, projectKey: `personal-${ownerContext.actorKey}`, userId: "user-a" };
await sessionMetadataStore.ensure({ ...personalContext, sessionId: "personal-session" });
await sessionUiStateStore.write({ ...personalContext, sessionId: "personal-session" }, {
sessionId: "personal-session", messages: [{ type: "text", text: "personal answer" }],
});
await sessionMetadataStore.ensure({
...ownerContext,
sessionId: "execution-session",
@@ -63,32 +76,64 @@ describe("chat session HTTP isolation", () => {
});
const app = express();
const removed: string[] = [];
let failCleanup = true;
const runtime = {
removeSession: async (sessionId: string) => {
if (failCleanup) throw new Error("native service unavailable");
removed.push(sessionId);
},
} as never;
app.use(express.json());
app.use("/api/v1/agent/chat", buildChatRouter({
frontendActionCoordinator: {} as never,
learningOrchestrator: {} as never,
app.use("/api/v1", buildChatRouter({
frontendActionCoordinator: { cancelSession: () => undefined } as never,
learningOrchestrator: { cancelSession: async () => undefined } as never,
memoryStore: {} as never,
resultReferenceResolver: {} as never,
runtime: {} as never,
sessionBridge: {} as never,
runtime,
sessionBridge: new ChatSessionBridge(runtime),
sessionMetadataStore,
sessionTranscriptStore: {} as never,
sessionUiStateStore: {} as never,
sessionUiStateStore,
}));
server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("expected HTTP server address");
const ownedOrdinary = await getJson(address.port, "/api/v1/agent/chat/sessions", "token-a", ownerContext.projectId);
const ownedExecution = await getJson(address.port, "/api/v1/agent/chat/sessions?purpose=task_execution", "token-a", ownerContext.projectId);
const otherUser = await getJson(address.port, "/api/v1/agent/chat/sessions?purpose=task_execution", "token-b", ownerContext.projectId);
const otherProject = await getJson(address.port, "/api/v1/agent/chat/sessions?purpose=task_execution", "token-a", "10000000-0000-4000-8000-000000000002");
const ownedOrdinary = await getJson(address.port, "/api/v1/sessions", "token-a", ownerContext.projectId);
const ownedExecution = await getJson(address.port, "/api/v1/sessions?purpose=task_execution", "token-a", ownerContext.projectId);
const otherUser = await getJson(address.port, "/api/v1/sessions?purpose=task_execution", "token-b", ownerContext.projectId);
const otherProject = await getJson(address.port, "/api/v1/sessions?purpose=task_execution", "token-a", "10000000-0000-4000-8000-000000000002");
expect(ownedOrdinary.body.sessions.map((item: { id: string }) => item.id)).toEqual(["ordinary-session"]);
expect(ownedExecution.body.sessions.map((item: { id: string }) => item.id)).toEqual(["execution-session"]);
expect(otherUser.body.sessions).toEqual([]);
expect(otherProject.body.sessions).toEqual([]);
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a", ownerContext.projectId)).body.messages).toEqual([{ type: "text", text: "owner-only answer" }]);
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-b", ownerContext.projectId)).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a", "10000000-0000-4000-8000-000000000002")).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a")).status).toBe(404);
const personalHistory = await getJson(address.port, "/api/v1/sessions", "token-a");
expect(personalHistory.body.sessions.map((session: { id: string }) => session.id)).toEqual(["personal-session"]);
expect((await getJson(address.port, "/api/v1/session/personal-session", "token-a")).body.messages).toEqual([{ type: "text", text: "personal answer" }]);
expect((await getJson(address.port, "/api/v1/session/personal-session", "token-b")).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session/personal-session", "token-a", ownerContext.projectId)).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session", "token-a", undefined, { parent_session_id: "ordinary-session" })).status).toBe(404);
expect((await getJson(address.port, "/api/v1/fork", "token-b", ownerContext.projectId, { session_id: "ordinary-session", keep_message_count: 2 })).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session", "token-b", ownerContext.projectId, { session_id: "ordinary-session" })).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session", "token-b", ownerContext.projectId, { session_id: "unknown-session" })).status).toBe(404);
const path = "/api/v1/session/ordinary-session";
expect((await getJson(address.port, path, "token-b", ownerContext.projectId, undefined, "DELETE")).status).toBe(204);
expect((await getJson(address.port, path, "token-a", undefined, undefined, "DELETE")).status).toBe(204);
expect((await sessionUiStateStore.read({ ...ownerContext, sessionId: "ordinary-session" }))?.messages).toHaveLength(1);
expect((await getJson(address.port, path, "token-a", ownerContext.projectId, undefined, "DELETE")).status).toBe(503);
expect((await getJson(address.port, path, "token-a", ownerContext.projectId)).status).toBe(404);
expect((await getJson(address.port, "/api/v1/sessions", "token-a", ownerContext.projectId)).body.sessions).toEqual([]);
failCleanup = false;
expect((await getJson(address.port, path, "token-a", ownerContext.projectId, undefined, "DELETE")).status).toBe(204);
expect(removed).toEqual(["ordinary-session"]);
expect(await sessionUiStateStore.read({ ...ownerContext, sessionId: "ordinary-session" })).toBeNull();
expect((await getJson(address.port, "/api/v1/session/personal-session", "token-a")).status).toBe(200);
});
});
@@ -96,16 +141,19 @@ const getJson = (
port: number,
path: string,
token: string,
projectId: string,
projectId?: string,
body?: Record<string, unknown>,
method?: string,
) => new Promise<{ status: number; body: Record<string, any> }>((resolve, reject) => {
const request = httpRequest({
hostname: "127.0.0.1",
port,
path,
method: "GET",
method: method ?? (body ? "POST" : "GET"),
headers: {
Authorization: `Bearer ${token}`,
"X-Project-Id": projectId,
...(projectId ? { "X-Project-Id": projectId } : {}),
...(body ? { "Content-Type": "application/json" } : {}),
},
}, (response) => {
const chunks: Buffer[] = [];
@@ -113,10 +161,10 @@ const getJson = (
response.on("end", () => {
resolve({
status: response.statusCode ?? 0,
body: JSON.parse(Buffer.concat(chunks).toString("utf8")),
body: JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"),
});
});
});
request.on("error", reject);
request.end();
request.end(body ? JSON.stringify(body) : undefined);
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "bun:test";
import express from "express";
import { registerTaskAgentBridge } from "../../src/routes/task-agent-bridge.js";
import { registerTaskPublicationBridge } from "../../src/routes/task-publication-bridge.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { TASK_AGENT, TASK_READER_AGENT } from "../../src/runtime/task-agent-policy.js";
describe("task reader bridge", () => {
it("uses parent identity only for reads, revokes on run end and forbids child publication", async () => {
const id = "bridge-task-parent";
setRuntimeSessionContext({ sessionId: id, clientSessionId: id, actorKey: "a", projectKey: "p",
projectId: "project-1", taskId: "task-1", traceId: "test", purpose: "task_execution" });
let active = true;
const app = express(); app.use(express.json());
const reader = registerTaskAgentBridge(app, { internalToken: "TEST_ONLY", hasActiveRun: () => active,
getSession: async (sessionId) => ({ id: sessionId, parentID: sessionId === id ? undefined : id,
agent: sessionId === id ? TASK_AGENT : TASK_READER_AGENT, location: { directory: "/workspace" } }),
});
registerTaskPublicationBridge(app, { internalToken: "TEST_ONLY" });
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}`;
const post = (path: string, token = "TEST_ONLY") => fetch(`${base}/internal/operational-task/${path}`, {
method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": token }, body: JSON.stringify({ session_id: "child" }),
});
try {
expect(await (await post("agent-policy")).json()).toEqual({ role: "reader" });
expect((await post("agent-policy", "wrong")).status).toBe(403);
expect((await reader("child"))?.runtimeSessionId).toBe(id);
for (const kind of ["artifacts", "presentation"]) expect((await post(kind)).status).toBe(403);
active = false;
expect(await reader("child")).toBeNull();
expect(await (await post("agent-policy")).json()).toEqual({ role: "denied" });
} finally {
removeRuntimeSessionContext(id);
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
@@ -0,0 +1,71 @@
import { describe, expect, it } from "bun:test";
import express from "express";
import type { OperationalTaskClient } from "../../src/product/operational-task-client.js";
import { registerTaskPublicationBridge } from "../../src/routes/task-publication-bridge.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
describe("task publication bridge", () => {
it("requires a trusted execution context and injects task identity without browser capabilities", async () => {
const taskId = "50000000-0000-4000-8000-000000000001";
const requestId = "60000000-0000-4000-8000-000000000001";
const contexts = ["task-publication-execution", "task-publication-collaboration", "task-publication-ordinary"];
const published: Array<{ kind: string; args: unknown[] }> = [];
contexts.forEach((sessionId, index) => setRuntimeSessionContext({
sessionId, clientSessionId: `client-${index}`, actorKey: "actor-1", projectKey: "project-1", traceId: "test",
taskId, purpose: ["task_execution", "task_collaboration", "ordinary_chat"][index] as "task_execution",
}));
const app = express();
app.use(express.json());
registerTaskPublicationBridge(app, { internalToken: "internal-test", client: {
publishArtifacts: async (...args: unknown[]) => {
published.push({ kind: "artifacts", args });
return { progress_revision: 1 };
},
publishPresentation: async (...args: unknown[]) => {
published.push({ kind: "presentation", args });
return { recorded: true, event_seq: 8, run_id: "55000000-0000-4000-8000-000000000001", execution_generation: 1 };
},
} as unknown as Pick<OperationalTaskClient, "publishArtifacts" | "publishPresentation"> });
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}`;
const post = (kind: string, body: unknown, extraHeaders: Record<string, string> = {}) => fetch(`${base}/internal/operational-task/${kind}`, {
method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "internal-test", ...extraHeaders },
body: JSON.stringify(body),
});
const progress = { summary: "核对实际资料", diagram: null };
const command = { action: "highlight_features", layer: "pipes", feature_ids: ["P-1"], effect: "pulse" };
try {
const body = { session_id: contexts[0], request_id: requestId, command };
expect((await post("presentation", body, { "x-agent-internal-token": "wrong" })).status).toBe(403);
expect((await post("presentation", body, { Origin: "http://browser.test" })).status).toBe(403);
expect((await post("presentation", { ...body, session_id: "unknown" })).status).toBe(403);
for (const sessionId of contexts.slice(1)) {
expect((await post("presentation", { ...body, session_id: sessionId })).status).toBe(403);
expect((await post("artifacts", { session_id: sessionId, request_id: requestId, progress })).status).toBe(403);
}
expect((await post("presentation", { ...body, run_id: "model-supplied-run" })).status).toBe(400);
expect((await post("presentation", { ...body, command: {
action: "set_overlay", overlay_id: "o1", title: "位置", mode: "geometry", evidence_id: "e1", data: { coordinates: [1, 2] },
} })).status).toBe(400);
expect(published).toHaveLength(0);
const result = await post("presentation", body);
expect(result.status).toBe(200);
expect(await result.json()).toMatchObject({ recorded: true, event_seq: 8 });
expect(published[0]).toEqual({ kind: "presentation", args: [contexts[0], taskId, {
requestId, sourceSessionId: "client-0", command,
}] });
expect((await post("artifacts", { session_id: contexts[0], request_id: requestId, progress })).status).toBe(200);
expect(published[1]?.args).toEqual([contexts[0], taskId, {
requestId, sourceSessionId: "client-0", evidence: undefined, executionPlan: undefined, report: undefined, progress,
}]);
const report = { title: "报告", current_conclusion: "核验结束", conclusion_boundary: "授权数据范围",
stage: "final", outcome: "concluded", stop_reason: null, data_gaps: [] };
expect((await post("artifacts", { session_id: contexts[1], request_id: requestId, report })).status).toBe(200);
} finally {
contexts.forEach(removeRuntimeSessionContext);
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
+38
View File
@@ -0,0 +1,38 @@
import { expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { createServer } from "node:net";
const availablePort = () => new Promise<number>((done, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const port = (server.address() as { port: number }).port;
server.close((error) => error ? reject(error) : done(port));
});
});
it("passes the isolated connection path to OpenCode before the Agent writes it", async () => {
const root = await mkdtemp(join(tmpdir(), "query-launcher-test-"));
try {
const ports = await Promise.all([availablePort(), availablePort()]);
// Execute the real launcher up to its first dependency. No OpenCode, Agent,
// provider or business service starts; the dependency records its inputs.
const script = `import { mock } from "bun:test";
mock.module(${JSON.stringify(resolve("src/runtime/opencode2-server.ts"))}, () => ({ startOpencode2: async (options) => {
console.log("LAUNCHER_ENV=" + JSON.stringify({ connection: options.env.TJWATER_AGENT_MCP_CONNECTION_PATH }));
return { password: "TEST_ONLY", exited: Promise.resolve(0), close() {}, client: { server: { async info() { throw new Error("STOP_BEFORE_AGENT"); } } } };
} }));
await import(${JSON.stringify(resolve("scripts/start-isolated-integration.ts"))});`;
const child = Bun.spawn([process.execPath, "-e", script], { cwd: process.cwd(),
env: { ...process.env, INTEGRATION_DATA_DIR: root, INTEGRATION_AGENT_PORT: String(ports[0]), INTEGRATION_OPENCODE_PORT: String(ports[1]),
TJWATER_AGENT_MCP_CONNECTION_PATH: "/unrelated/connection.json", AGENT_INTERNAL_TOKEN: "TEST_ONLY" }, stdout: "pipe", stderr: "pipe" });
const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
expect(code).toBe(1);
expect(err).toContain("STOP_BEFORE_AGENT");
const record = out.split("\n").find((line) => line.startsWith("LAUNCHER_ENV="));
expect(record).toBeDefined();
expect(JSON.parse(record!.slice("LAUNCHER_ENV=".length)).connection).toBe(join(root, "mcp-query", "connection.json"));
} finally { await rm(root, { recursive: true, force: true }); }
});
+5 -4
View File
@@ -26,7 +26,7 @@ describe("MCP configuration and grants", () => {
]) expect(() => normalizeMcpConfig({ servers })).toThrow();
});
it("requires an exact service allow rule and keeps later denials and MCP confirmations effective", () => {
it("requires an exact service allow rule, keeps later denials and applies round mode to MCP requests", () => {
expect(mcpReadonlyTools({ permissions: [{ action: "mcp_*", resource: "*", effect: "allow" }] })).toEqual([]);
const permissions = [
{ action: "mcp_*", resource: "*", effect: "ask" },
@@ -36,9 +36,10 @@ describe("MCP configuration and grants", () => {
];
expect(mcpReadonlyTools({ permissions })).toEqual(["mcp_fixture_read"]);
expect(mcpReadonlyTools({ permissions, agents: { instruction: { permissions: [{ action: "mcp_fixture_read", resource: "*", effect: "deny" }] } } })).toEqual([]);
expect(canAutomaticallyApprove("always", "mcp_fixture_write")).toBe(false);
expect(canAutomaticallyApprove("always", "opencode_read_mcp_resource")).toBe(false);
expect(canAutomaticallyApprove("always", "query_project_data")).toBe(true);
for (const action of ["mcp_fixture_write", "opencode_read_mcp_resource", "mcp_tjwater_query_scada_readings"]) {
expect(canAutomaticallyApprove("request", action)).toBe(false);
expect(canAutomaticallyApprove("always", action)).toBe(true);
}
});
it("shadows inherited credentials and reserved overrides for a trusted stdio process", () => {
+204 -6
View File
@@ -1,9 +1,11 @@
import { LOCAL_QUERY_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
import { describe, expect, it } from "bun:test";
import { type OpenCodeClient as OpencodeClient } from "@opencode/client";
import { createServer } from "node:net";
import { mkdtemp, rm } from "node:fs/promises";
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { basename, join } from "node:path";
import { bindScopedConversationWorkspace, createConversationWorkspace, migrateConversationWorkspace, sessionWorkspaceDirectory } from "../../src/runtime/conversationWorkspace.js";
import { config } from "../../src/config.js";
import { modelCheckTargets } from "../../src/runtime/model-check.js";
@@ -29,6 +31,44 @@ const createRuntimeAdapter = (
},
}) as OpencodeRuntimeAdapter;
describe("OpencodeRuntimeAdapter.removeSession", () => {
it("removes its native session and workspace, rejects a foreign binding, and preserves files on failure", async () => {
const root = await mkdtemp(join(tmpdir(), "tjwater-session-cleanup-"));
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
config.CONVERSATION_WORKSPACE_ROOT = root;
const scope = { actorKey: "actor-a", projectKey: "project-a" };
const id = "cleanup-session";
const directory = await bindScopedConversationWorkspace(await createConversationWorkspace(root), root, { ...scope, sessionId: id });
const foreign = await bindScopedConversationWorkspace(await createConversationWorkspace(root), root, { ...scope, sessionId: "foreign-session" });
const calls: string[] = [];
let binding = directory;
let removed = false;
let failRemoval = true;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => ({ session: {
get: async () => { if (removed) throw { _tag: "SessionNotFoundError" }; return { id, location: { directory: binding } }; },
interrupt: async () => { calls.push("interrupt"); },
active: async () => ({}),
remove: async () => { if (failRemoval) throw new Error("native removal failed"); removed = true; calls.push("remove"); },
} }),
}) as OpencodeRuntimeAdapter;
try {
binding = foreign;
await expect(runtime.removeSession(id)).rejects.toThrow("another session");
expect(calls).toEqual([]);
binding = directory;
await expect(runtime.removeSession(id, scope)).rejects.toThrow("native removal failed");
expect((await lstat(directory)).isDirectory()).toBe(true);
failRemoval = false;
await runtime.removeSession(id, scope);
await expect(lstat(directory)).rejects.toMatchObject({ code: "ENOENT" });
expect((await lstat(foreign)).isDirectory()).toBe(true);
expect(calls).toEqual(["interrupt", "interrupt", "remove"]);
await runtime.removeSession(id, scope);
} finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true, force: true }); }
});
});
describe("OpencodeRuntimeAdapter.revertToUserMessage", () => {
it("skips reverting the first user message when the runtime session is empty", async () => {
const calls = { reverted: [] as string[], removed: [] as string[] };
@@ -95,6 +135,47 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
});
});
describe("OpencodeRuntimeAdapter.generateText", () => {
it("uses scoped one-shot generation and cleans its temporary context on success and failure", async () => {
const root = await mkdtemp(join(tmpdir(), "prompt-suggestion-runtime-"));
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
config.CONVERSATION_WORKSPACE_ROOT = root;
const controller = new AbortController();
const calls: unknown[] = [];
let fail = false;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => ({
rpc: { call: async () => { calls.push("catalog ready"); } },
session: {
create: async (input: unknown) => { calls.push(input); return { id: "temporary-generator" }; },
generate: async (input: unknown, options: { signal?: AbortSignal }) => {
calls.push({ input, signal: options.signal });
if (fail) throw new Error("inference failed");
return { text: "generated suggestions" };
},
remove: async (input: unknown) => { calls.push(input); },
prompt: () => { throw new Error("must not dispatch a chat turn"); },
},
}),
}) as OpencodeRuntimeAdapter;
try {
expect(await runtime.generateText("recommend questions", { providerID: "test", modelID: "fast" }, controller.signal)).toBe("generated suggestions");
expect(calls[0]).toMatchObject({ model: { providerID: "test", id: "fast" } });
expect(calls[1]).toBe("catalog ready");
expect(calls[2]).toEqual({ input: { sessionID: "temporary-generator", prompt: "recommend questions" }, signal: controller.signal });
expect(calls[3]).toEqual({ sessionID: "temporary-generator" });
expect(await readdir(root)).toEqual([]);
fail = true;
await expect(runtime.generateText("recommend questions")).rejects.toThrow("inference failed");
expect(calls.at(-1)).toEqual({ sessionID: "temporary-generator" });
expect(await readdir(root)).toEqual([]);
} finally {
config.CONVERSATION_WORKSPACE_ROOT = previousRoot;
await rm(root, { recursive: true });
}
});
});
describe("OpencodeRuntimeAdapter.resolveAvailableModel", () => {
it("selects the first preferred model actually exposed by OpenCode", async () => {
const client = {
@@ -141,12 +222,14 @@ describe("resolveEmbeddedOpencodePort", () => {
});
describe("OpencodeRuntimeAdapter.warmup", () => {
const toolIds = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "present_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[] = [], invalidModel?: string) {
const toolIds = [...LOCAL_QUERY_TOOL_IDS, "websearch", "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
async function createWarmupRuntime(missing: string[] = [], invalidModel?: string, delayedMcp = false,
search: { providers?: readonly string[]; selection?: false | { provider: string }; unavailable?: boolean; extraTools?: readonly string[] } = {}) {
const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-"));
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
config.CONVERSATION_WORKSPACE_ROOT = root;
const calls: string[] = [];
let catalogReads = 0;
const [providerID, id] = config.OPENCODE_MODEL.split("/");
const client = {
server: { info: async () => { calls.push("health"); return { version: "2.0.24" }; } },
@@ -155,6 +238,16 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
remove: async () => { calls.push("session.remove"); },
},
model: { list: async () => { calls.push("model.list"); return { data: [{ providerID, id, enabled: true, capabilities: { tools: true } }] }; } },
websearch: { providers: async () => {
calls.push("websearch.providers");
if (search.unavailable) throw new Error("PRIVATE_URL_AND_SECRET");
return { data: (search.providers ?? ["fixture-search"]).map((id) => ({ id, name: id })) };
} },
config: { get: async () => {
calls.push("config.get");
return [{ type: "directory", path: root }, { type: "document", info: { websearch: { provider: "unavailable-lower-precedence" } } },
{ type: "document", info: { websearch: search.selection ?? { provider: "fixture-search" } } }];
} },
rpc: { call: async (input: { method: string }) => {
if (input.method === "check_models") {
calls.push("model.check");
@@ -166,7 +259,8 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
}
if (input.method === "mcp") { calls.push("mcp.list"); return { output: { servers: [], configuration_errors: [] } }; }
calls.push("tool.list");
return { output: toolIds.filter((id) => !missing.includes(id)).map((id) => ({ id, direct: true, sandboxed: id === "shell", workspaceRoot: root })) };
catalogReads++;
return { output: [...toolIds, ...(search.extraTools ?? [])].filter((id) => !missing.includes(id) && !(delayedMcp && catalogReads === 1 && id === LOCAL_QUERY_TOOL_IDS[0])).map((id) => ({ id, direct: id !== "websearch", sandboxed: id === "shell", workspaceRoot: root })) };
} },
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
@@ -175,13 +269,46 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
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", "mcp.list", "model.check", "model.list", "session.remove"]); } finally { await cleanup(); }
expect(calls).toEqual(["health", "session.create", "tool.list", "websearch.providers", "config.get", "mcp.list", "model.check", "model.list", "session.remove"]); } finally { await cleanup(); }
});
it("rejects a missing native search tool or unusable provider configuration before readiness and cleans up", async () => {
for (const [missing, search, message] of [
[["websearch"], {}, "websearch tool is missing"],
[[], { providers: [] }, "no registered search provider"],
[[], { selection: false }, "web search is disabled"],
[[], { selection: { provider: "absent" } }, "configured search provider is unavailable"],
[[], { unavailable: true }, "web search configuration unavailable"],
[[], { extraTools: ["browser_click"] }, "retired tools are still registered"],
[[], { extraTools: ["mcp_tjwater_web_search"] }, "retired tools are still registered"],
] as const) {
const { runtime, calls, cleanup } = await createWarmupRuntime([...missing], undefined, false, search);
try {
await expect(runtime.warmup()).rejects.toThrow(message);
expect(calls).not.toContain("model.check");
expect(calls.at(-1)).toBe("session.remove");
} finally { await cleanup(); }
}
});
it("accepts random provider selection without sending a search query during startup", async () => {
const { runtime, calls, cleanup } = await createWarmupRuntime([], undefined, false, { selection: { provider: "random" } });
try {
await runtime.warmup();
expect(calls).toContain("websearch.providers");
} 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(); }
});
it("waits for native MCP catalog publication before reporting ready", async () => {
const { runtime, calls, cleanup } = await createWarmupRuntime([], undefined, true);
try {
await runtime.warmup();
expect(calls.filter((call) => call === "tool.list")).toHaveLength(2);
expect(calls.indexOf("model.check")).toBeGreaterThan(calls.lastIndexOf("tool.list"));
} finally { await cleanup(); }
});
it("rejects an invalid non-default model before readiness and cleans up the session", async () => {
const { runtime, calls, cleanup } = await createWarmupRuntime([], "waternetwork/gpt-6.1-sol");
try {
@@ -193,6 +320,77 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
});
describe("conversation workspace binding", () => {
it("recovers scoped bindings without moving files during MCP status reads", async () => {
const base = await mkdtemp(join(tmpdir(), "scoped-binding-test-"));
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
config.CONVERSATION_WORKSPACE_ROOT = join(base, "data", "session-workspaces");
const original = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
await writeFile(join(original, "files", "result.txt"), "preserved");
const owner = { actorKey: "actor-scope", projectKey: "project-scope", sessionId: "native-scope" };
const expected = join(sessionWorkspaceDirectory(config.CONVERSATION_WORKSPACE_ROOT, owner), "workspace");
let directory = original;
let moves = 0;
let reportedDirectory: string | undefined;
const client = { session: {
get: async () => ({ location: { directory } }),
move: async (input: { directory: string }) => { if (++moves === 1) throw new Error("binding unavailable"); directory = input.directory; },
} };
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => client,
readMcpReport: async (_client: unknown, location: string) => { reportedDirectory = location; return { servers: [], configuration_errors: [] }; },
}) as OpencodeRuntimeAdapter;
try {
await expect(runtime.ensureSessionWorkspace(owner.sessionId, owner)).rejects.toThrow("binding unavailable");
await runtime.mcpStatus(owner.sessionId, owner);
expect(reportedDirectory).toBe(expected);
expect(moves).toBe(1);
expect(await readFile(join(expected, "files", "result.txt"), "utf8")).toBe("preserved");
expect(await runtime.ensureSessionWorkspace(owner.sessionId, owner)).toBe(expected);
expect(moves).toBe(2);
} finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(base, { recursive: true, force: true }); }
});
it("moves legacy files into data and retries a failed native directory binding without losing results", async () => {
const base = await mkdtemp(join(tmpdir(), "workspace-migration-test-"));
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
config.CONVERSATION_WORKSPACE_ROOT = join(base, "data", "session-workspaces");
const original = await createConversationWorkspace(join(base, ".agent-workspaces"));
await writeFile(join(original, "files", "result.json"), '{"value":7}');
let directory = original;
let moves = 0;
const client = { session: {
get: async () => ({ location: { directory } }),
move: async (input: { directory: string }) => { if (++moves === 1) throw new Error("binding unavailable"); directory = input.directory; },
} };
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
try {
await expect(runtime.ensureSessionWorkspace("legacy-session")).rejects.toThrow("binding unavailable");
const [first, second] = await Promise.all([runtime.ensureSessionWorkspace("legacy-session"), runtime.ensureSessionWorkspace("legacy-session")]);
expect(first).toBe(join(config.CONVERSATION_WORKSPACE_ROOT, basename(original)));
expect(second).toBe(first);
expect(moves).toBe(2);
expect(await readFile(join(first, "files", "result.json"), "utf8")).toBe('{"value":7}');
expect(await Bun.file(join(original, "files", "result.json")).exists()).toBe(false);
const restored = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
expect(await restored.ensureSessionWorkspace("legacy-session")).toBe(first);
expect(moves).toBe(2);
} finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(base, { recursive: true, force: true }); }
});
it("refuses a migration collision without overwriting either workspace", async () => {
const base = await mkdtemp(join(tmpdir(), "workspace-collision-test-"));
try {
const root = join(base, "data", "session-workspaces");
const original = await createConversationWorkspace(join(base, ".agent-workspaces"));
const destination = join(root, basename(original));
await mkdir(join(destination, "files"), { recursive: true });
await writeFile(join(original, "files", "result.txt"), "original");
await writeFile(join(destination, "files", "result.txt"), "destination");
await expect(migrateConversationWorkspace(original, root)).rejects.toThrow("already exists");
expect(await readFile(join(original, "files", "result.txt"), "utf8")).toBe("original");
expect(await readFile(join(destination, "files", "result.txt"), "utf8")).toBe("destination");
} finally { await rm(base, { recursive: true, force: true }); }
});
it("reads MCP status without migrating a legacy session or creating a workspace", async () => {
let location: unknown;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
+17 -8
View File
@@ -1,26 +1,31 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { mkdir, 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 { bindScopedConversationWorkspace, 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 base: string;
let workspace: string;
let other: string;
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), "tjwater-sandbox-test-"));
base = await mkdtemp(join(tmpdir(), "tjwater-sandbox-test-"));
root = join(base, "data", "session-workspaces");
config.CONVERSATION_WORKSPACE_ROOT = root;
workspace = await createConversationWorkspace(root);
workspace = await bindScopedConversationWorkspace(await createConversationWorkspace(root), root,
{ actorKey: "actor-sandbox", projectKey: "project-sandbox", sessionId: "sandbox-session" });
await mkdir(join(workspace, "..", "chat"), { mode: 0o700 });
await writeFile(join(workspace, "..", "chat", "messages.json"), "PRIVATE_CHAT_RECORD");
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 }); });
afterAll(async () => { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(base, { recursive: true, force: true }); });
it("supports Landlock and runs local file computation with a sanitized environment", async () => {
expect((await probeLandlockSandbox()).landlockAbi).toBeGreaterThanOrEqual(4);
@@ -38,6 +43,7 @@ describe("conversation OS sandbox", () => {
`cat '${other}/files/private.txt'`,
`cat '${process.cwd()}/src/config.ts'`,
"cat escape",
"cat ../../chat/messages.json",
"printf malicious > ../opencode.json",
"mkdir ../.opencode",
"printf denied > /usr/tjwater-sandbox-test",
@@ -93,14 +99,17 @@ describe("conversation OS sandbox", () => {
await expect(resolveConversationWorkspace(link, root)).rejects.toThrow("symbolic link");
});
it("constrains native file tools and keeps arbitrary execution interactive", () => {
it("constrains native file tools while respecting the selected approval mode", () => {
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();
for (const action of ["shell", "edit", "external_directory", "execute"]) {
expect(canAutomaticallyApprove("request", action)).toBeFalse();
expect(canAutomaticallyApprove("always", action)).toBeTrue();
}
expect(canAutomaticallyApprove("always", "websearch")).toBeTrue();
});
});
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it } from "bun:test";
import { registerTaskAgentPolicy } from "../../.opencode/task-agent-policy.js";
import { TASK_AGENT, TASK_READER_AGENT } from "../../src/runtime/task-agent-policy.js";
const originalFetch = globalThis.fetch;
const originalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN;
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalToken === undefined) delete process.env.TJWATER_AGENT_INTERNAL_TOKEN;
else process.env.TJWATER_AGENT_INTERNAL_TOKEN = originalToken;
});
describe("investigation subagent plugin", () => {
it("bounds parallel calls, rejects background/resume/other agents, and releases slots on failure", async () => {
process.env.TJWATER_AGENT_INTERNAL_TOKEN = "TEST_ONLY";
let role = "coordinator";
globalThis.fetch = (async () => Response.json({ role })) as unknown as typeof fetch;
const pending: Array<() => void> = [];
let entered = 0;
const tool = { execute: async () => {
entered++;
await new Promise<void>((resolve) => pending.push(resolve));
throw new Error("native failure");
} };
const hooks = new Map<string, (event: any) => Promise<void>>();
await registerTaskAgentPolicy({
tool: { transform: async (fn: any) => fn({ update: (_name: string, update: any) => update(tool) }) },
permission: { hook: async (_name: string, fn: any) => { hooks.set("permission", fn); } },
session: { hook: async (_name: string, fn: any) => { hooks.set("context", fn); } },
} as never);
const execute = tool.execute as unknown as (input: unknown, context: unknown) => Promise<unknown>;
const call = (extra = {}) => execute({ agent: TASK_READER_AGENT, prompt: "check", description: "check", ...extra }, { sessionID: "parent", signal: new AbortController().signal });
await expect(call({ background: true })).rejects.toThrow();
await expect(call({ sessionID: "foreign-child" })).rejects.toThrow();
await expect(call({ agent: "general" })).rejects.toThrow();
const first = call().catch((error) => error);
const second = call().catch((error) => error);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(entered).toBe(2);
await expect(call()).rejects.toThrow("at most two");
pending.splice(0).forEach((finish) => finish());
await Promise.all([first, second]);
const third = call().catch((error) => error);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(entered).toBe(3);
pending.splice(0).forEach((finish) => finish());
await third;
role = "denied";
await expect(call()).rejects.toThrow("only an active investigation");
const permission = { sessionID: "ordinary", agent: "instruction", action: "subagent", resources: [TASK_READER_AGENT], effect: "allow" };
await hooks.get("permission")!(permission);
expect(permission.effect).toBe("deny");
role = "coordinator";
Object.assign(permission, { agent: TASK_AGENT, effect: "deny" });
await hooks.get("permission")!(permission);
expect(permission.effect).toBe("deny");
role = "reader";
const event = { sessionID: "child", agent: TASK_READER_AGENT, tools: {
mcp_tjwater_get_task_context: {}, mcp_tjwater_query_scada_readings: {}, publish_task_report: {}, subagent: {}, execute: {}, shell: {},
} };
await hooks.get("context")!(event);
expect(Object.keys(event.tools)).toEqual(["mcp_tjwater_get_task_context", "mcp_tjwater_query_scada_readings"]);
});
});
+79
View File
@@ -0,0 +1,79 @@
import { PRODUCT_MCP_TOOL_IDS, PRODUCT_MCP_READ_TOOL_IDS, PRODUCT_MCP_ANALYSIS_TOOLS, queryMcpToolId } from "../../src/runtime/product-mcp-tools.js";
import { allowedQueryTools } from "../../src/runtime/query-mcp-policy.js";
import { afterEach, describe, expect, it } from "bun:test";
import { resolveTaskAgentScope, TASK_AGENT, TASK_READER_AGENT, type NativeTaskSession } from "../../src/runtime/task-agent-policy.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
const parentId = "task-policy-parent";
afterEach(() => removeRuntimeSessionContext(parentId));
const bind = (purpose: "ordinary_chat" | "task_execution" | "task_collaboration" = "task_execution") => {
setRuntimeSessionContext({ sessionId: parentId, clientSessionId: parentId, actorKey: "a", projectKey: "p",
projectId: "project-1", taskId: "task-1", traceId: "test", purpose });
};
describe("task agent isolation", () => {
it("keeps separate profiles and a finite reader tool allowlist", () => {
const config = serviceOpencodeConfig({});
expect(config.agents.instruction.permissions.at(-1)).toEqual({ action: "subagent", resource: "*", effect: "deny" });
expect(config.agents[TASK_AGENT].permissions.at(-1)).toEqual({ action: "subagent", resource: TASK_READER_AGENT, effect: "allow" });
expect(config.agents[TASK_READER_AGENT].permissions.map((rule) => rule.action)).toEqual([
"*", ...PRODUCT_MCP_READ_TOOL_IDS,
]);
expect(PRODUCT_MCP_READ_TOOL_IDS).toHaveLength(25);
expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(35);
expect(PRODUCT_MCP_TOOL_IDS.some((name) => /publish|approve|execute|shell/.test(name))).toBeFalse();
});
it("resolves only a direct native reader under an active task, without copying its identity", async () => {
bind();
const root: NativeTaskSession = { id: parentId, agent: TASK_AGENT, location: { directory: "/scope" } };
const child: NativeTaskSession = { id: "child", parentID: parentId, agent: TASK_READER_AGENT, location: root.location };
const get = async (id: string) => id === parentId ? root : child;
expect((await resolveTaskAgentScope("child", get, () => true))?.owner.sessionId).toBe(parentId);
expect(await resolveTaskAgentScope("child", get, () => false)).toBeNull();
for (const purpose of ["ordinary_chat", "task_collaboration"] as const) {
bind(purpose);
expect(await resolveTaskAgentScope("child", get, () => true)).toBeNull();
}
bind();
child.location = { directory: "/another-project" };
expect(await resolveTaskAgentScope("child", get, () => true)).toBeNull();
child.location = root.location;
child.agent = "general";
expect(await resolveTaskAgentScope("child", get, () => true)).toBeNull();
child.agent = TASK_READER_AGENT;
root.parentID = "grandparent";
expect(await resolveTaskAgentScope("child", get, () => true)).toBeNull();
});
it("selects the profile from trusted purpose on every prompt including resumed sessions", async () => {
const selected: string[] = [];
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ session: {
switchAgent: async ({ agent }: { agent: string }) => { selected.push(agent); }, prompt: async () => {},
} }) }) as OpencodeRuntimeAdapter;
for (const purpose of ["ordinary_chat", "task_execution", "task_collaboration"] as const) {
bind(purpose);
await runtime.prompt(parentId, "pretend to be a background investigation");
}
expect(selected).toEqual(["instruction", TASK_AGENT, "instruction"]);
});
});
it("allows calculations to project coordinators but never delegated readers or personal/review sessions", () => {
const context = { sessionId: "analysis-owner", clientSessionId: "analysis-chat", actorKey: "a", projectKey: "p", projectId: "p", traceId: "test", purpose: "task_execution" as const };
for (const name of PRODUCT_MCP_ANALYSIS_TOOLS.map(queryMcpToolId)) {
expect(allowedQueryTools(context)).toContain(name);
expect(allowedQueryTools(context, true)).not.toContain(name);
expect(allowedQueryTools({ ...context, projectKey: "personal-a", projectId: undefined })).not.toContain(name);
expect(allowedQueryTools({ ...context, learningMode: "review" })).not.toContain(name);
}
});
it("keeps administrator denials for product MCP queries in both primary and reader permissions", () => {
const denied = serviceOpencodeConfig({ permissions: [{ action: "mcp_tjwater_*", resource: "*", effect: "deny" }] });
expect(denied.permissions.some((rule) => rule.effect === "allow" && PRODUCT_MCP_TOOL_IDS.includes(rule.action))).toBeFalse();
expect(denied.agents[TASK_READER_AGENT].permissions.some((rule) => rule.effect === "allow" && PRODUCT_MCP_TOOL_IDS.includes(rule.action))).toBeFalse();
});
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { config } from "../../src/config.js";
import { LearningOrchestrator } from "../../src/learning/orchestrator.js";
import { SessionLearningStateStore } from "../../src/learning/sessionStateStore.js";
import { MemoryStore } from "../../src/memory/store.js";
import { SessionTranscriptStore } from "../../src/sessions/transcriptStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import { getRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
const owner = { actorKey: "actor-a", projectKey: "personal-actor-a", sessionId: "parent-chat" };
const input = {
sessionId: owner.sessionId, userMessage: "question", assistantMessage: "answer", toolCallCount: 0,
requestContext: { ...owner, clientSessionId: owner.sessionId, traceId: "learning-test" },
};
const deferred = () => {
let resolve!: () => void;
const promise = new Promise<void>((done) => { resolve = done; });
return { resolve, promise };
};
describe("learning session lifecycle", () => {
let base: string;
let originalAuditPath: string;
let originalCooldown: number;
let records: SessionWorkspaceStore;
let state: SessionLearningStateStore;
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "tjwater-learning-lifecycle-"));
originalAuditPath = config.LEARNING_AUDIT_LOG_PATH;
originalCooldown = config.LEARNING_GATE_TURN_COOLDOWN;
config.LEARNING_AUDIT_LOG_PATH = join(base, "audit.log");
config.LEARNING_GATE_TURN_COOLDOWN = 1;
records = new SessionWorkspaceStore(join(base, "workspaces"));
state = new SessionLearningStateStore(join(base, "legacy-learning"), records);
});
afterEach(async () => {
config.LEARNING_AUDIT_LOG_PATH = originalAuditPath;
config.LEARNING_GATE_TURN_COOLDOWN = originalCooldown;
await rm(base, { recursive: true, force: true });
});
const orchestrator = (runtime: object) => new LearningOrchestrator(
runtime as OpencodeRuntimeAdapter, new MemoryStore(join(base, "memory"), join(base, "memory-backup")),
new SessionTranscriptStore(join(base, "legacy-transcripts"), records), state,
);
it("releases both native helper sessions after a completed review and persists progress in chat", async () => {
let nextId = 0;
const removed: string[] = [];
const finished = deferred();
const learning = orchestrator({
createSession: async () => ({ id: `helper-${++nextId}` }), prompt: async () => undefined,
messages: async (id: string) => [{ info: { role: "assistant" }, parts: [{ type: "text", text: JSON.stringify(
id === "helper-1" ? { should_review: true, confidence: 1, focus: "memory", reason: "useful" } : { memories: [], skills: [], summary: "reviewed" },
) }] }],
removeSession: async (id: string) => { removed.push(id); if (removed.length === 2) finished.resolve(); },
});
await learning.onTurnCompleted(input);
await finished.promise;
expect(removed).toEqual(["helper-2", "helper-1"]);
expect(getRuntimeSessionContext("helper-1")).toBeNull();
expect(getRuntimeSessionContext("helper-2")).toBeNull();
expect((await state.read(owner)).lastReviewedTurn).toBe(1);
expect(records.path(owner, "learning.json")).toContain("/personal/");
});
it("cancels a running gate without promoting memory or reviving deleted content", async () => {
const started = deferred();
const interrupted = deferred();
const removed: string[] = [];
const learning = orchestrator({
createSession: async () => ({ id: "cancelled-gate" }),
prompt: async () => { started.resolve(); await interrupted.promise; },
messages: async () => [{ info: { role: "assistant" }, parts: [{ type: "text", text: '{"should_review":true,"confidence":1,"focus":"memory"}' }] }],
abortSession: async () => { interrupted.resolve(); },
removeSession: async (id: string) => { removed.push(id); },
});
await learning.onTurnCompleted(input);
await started.promise;
await records.markDeleted(owner);
await learning.cancelSession(owner.sessionId);
await records.purge(owner);
expect(removed).toEqual(["cancelled-gate"]);
expect(getRuntimeSessionContext("cancelled-gate")).toBeNull();
expect(await records.list(owner, "learning.json")).toEqual([]);
});
it("releases the helper when model inference fails", async () => {
const finished = deferred();
const removed: string[] = [];
const learning = orchestrator({
createSession: async () => ({ id: "failed-gate" }),
prompt: async () => { throw new Error("model unavailable"); },
removeSession: async (id: string) => { removed.push(id); finished.resolve(); },
});
await learning.onTurnCompleted(input);
await finished.promise;
expect(removed).toEqual(["failed-gate"]);
expect(getRuntimeSessionContext("failed-gate")).toBeNull();
});
});
+23 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -157,4 +157,26 @@ describe("SessionMetadataStore", () => {
expect((await store.findByTask(context, "50000000-0000-4000-8000-000000000001", "task_collaboration"))?.sessionId).toBe("collaboration");
expect((await store.findByRequest(context, "60000000-0000-4000-8000-000000000001"))?.sessionId).toBe("execution");
});
it("updates its scoped index after writes and deletion without rereading unrelated records", async () => {
const context = { actorKey: "actor-a", projectKey: "project-a", userId: "user-a" };
const { record } = await store.ensure({ ...context, sessionId: "first" });
expect((await store.list(context)).map((item) => item.sessionId)).toEqual(["first"]);
// A listing already built the index; subsequent calls must not rescan files.
await writeFile(join(tempDir, "unrelated.json"), "not valid JSON");
await store.touch(record, { title: "updated title" });
const listed = await store.list(context);
expect(listed[0]?.title).toBe("updated title");
listed[0]!.actorKey = "other-actor";
expect((await store.list(context))[0]?.actorKey).toBe("actor-a");
await store.ensure({ ...context, sessionId: "second" });
expect((await store.list(context)).map((item) => item.sessionId).sort()).toEqual(["first", "second"]);
await store.remove(record);
expect((await store.list(context)).map((item) => item.sessionId)).toEqual(["second"]);
expect(await store.get(context, "first")).toBeNull();
expect((await store.get(context, "first", { includeDeleted: true }))?.deletedAt).toBeDefined();
expect(await store.get({ ...context, userId: "user-b" }, "first", { includeDeleted: true })).toBeNull();
await expect(store.touch(record, { title: "revived" })).rejects.toThrow("session not found");
await expect(store.ensure({ ...context, sessionId: "first" })).rejects.toThrow("session not found");
});
});
+172
View File
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { SessionTranscriptStore } from "../../src/sessions/transcriptStore.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionLearningStateStore } from "../../src/learning/sessionStateStore.js";
import { bindScopedConversationWorkspace, createConversationWorkspace, sessionWorkspaceDirectory } from "../../src/runtime/conversationWorkspace.js";
describe("owned session workspaces", () => {
let base: string;
let records: SessionWorkspaceStore;
const owner = { actorKey: "actor-a", projectKey: "project-a", sessionId: "chat-1" };
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "tjwater-owned-chat-"));
records = new SessionWorkspaceStore(join(base, "data", "session-workspaces"));
});
afterEach(async () => { await rm(base, { recursive: true, force: true }); });
it("keeps messages and searchable transcripts private to their user and scope", async () => {
const ui = new SessionUiStateStore(join(base, "old-ui"), records);
const transcript = new SessionTranscriptStore(join(base, "old-transcripts"), records);
await ui.write(owner, { sessionId: owner.sessionId, messages: [{ text: "private answer" }] });
await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 });
expect((await new SessionUiStateStore(join(base, "old-ui"), records).read(owner))?.messages).toHaveLength(1);
expect(await ui.read({ ...owner, actorKey: "actor-b" })).toBeNull();
expect(await ui.read({ ...owner, projectKey: "project-b" })).toBeNull();
expect(await transcript.search(owner, "private")).toHaveLength(2);
expect(await transcript.search({ ...owner, actorKey: "actor-b" }, "private")).toEqual([]);
expect(await transcript.search({ ...owner, projectKey: "project-b" }, "private")).toEqual([]);
expect((await lstat(records.path(owner, "messages.json"))).mode & 0o777).toBe(0o600);
expect((await lstat(join(sessionWorkspaceDirectory(records.root, owner), "chat"))).mode & 0o777).toBe(0o700);
await expect(transcript.cloneThread(owner, { ...owner, actorKey: "actor-b" }, 2)).rejects.toThrow("across users or scopes");
});
it("has a separate personal namespace for each user and separates it from projects", async () => {
const ui = new SessionUiStateStore(join(base, "old-ui"), records);
const personal = { ...owner, projectKey: "personal-actor-a" };
await ui.write(personal, { sessionId: owner.sessionId, messages: [{ text: "personal answer" }] });
expect(records.path(personal, "messages.json")).toContain("/actor-a/personal/");
expect(await ui.read(owner)).toBeNull();
expect(await ui.read({ ...personal, actorKey: "actor-b", projectKey: "personal-actor-b" })).toBeNull();
expect(() => records.path({ ...personal, projectKey: "personal-actor-b" }, "messages.json")).toThrow("owner mismatch");
});
it("preserves legacy bytes and keeps backups without overwriting later chat updates", async () => {
const metadataDir = join(base, "metadata");
const uiStateDir = join(base, "ui");
const transcriptDir = join(base, "transcripts");
for (const path of [metadataDir, uiStateDir, transcriptDir]) await mkdir(path);
await writeFile(join(metadataDir, "chat.json"), JSON.stringify(owner));
const messages = JSON.stringify({ sessionId: owner.sessionId, messages: [{ text: "legacy" }] });
const turns = JSON.stringify({ ...owner, turns: [{ userMessage: "legacy question", assistantMessage: "legacy answer" }] });
await writeFile(join(uiStateDir, "chat.json"), messages);
await writeFile(join(uiStateDir, "orphan.json"), JSON.stringify({ sessionId: "unknown", messages: [] }));
await writeFile(join(transcriptDir, "chat.json"), turns);
const options = { metadataDir, uiStateDir, transcriptDir };
expect(await records.migrateLegacyRecords(options)).toEqual({ uiStates: 1, transcripts: 1, learningStates: 0, retainedUnownedUiStates: 1 });
expect(await readFile(records.path(owner, "messages.json"), "utf8")).toBe(messages);
expect(await readFile(records.path(owner, "transcript.json"), "utf8")).toBe(turns);
await new SessionUiStateStore(uiStateDir, records).write(owner, { sessionId: owner.sessionId, messages: [{ text: "new answer" }] });
expect((await records.migrateLegacyRecords(options)).uiStates).toBe(0);
const archive = join(sessionWorkspaceDirectory(records.backupRoot, owner), "chat");
const backup = (await readdir(archive)).find((name) => name.startsWith("messages-"))!;
expect(await readFile(join(archive, backup), "utf8")).toBe(messages);
expect(await readdir(uiStateDir)).toEqual(["orphan.json"]);
expect((await new SessionUiStateStore(uiStateDir, records).read(owner))?.messages).toEqual([{ text: "new answer" }]);
});
it("rejects traversal, linked records and mismatched ownership", async () => {
expect(() => records.path({ ...owner, actorKey: "../other-user" }, "messages.json")).toThrow("invalid workspace scope");
await records.ensure(owner);
const foreign = join(base, "foreign.json");
await writeFile(foreign, "private");
await symlink(foreign, records.path(owner, "messages.json"));
await expect(new SessionUiStateStore(join(base, "old-ui"), records).read(owner)).rejects.toThrow("symbolic link");
await writeFile(join(sessionWorkspaceDirectory(records.root, owner), "chat", "owner.json"), JSON.stringify({ ...owner, actorKey: "actor-b" }));
await expect(records.ensure(owner)).rejects.toThrow("ownership mismatch");
});
it("moves computation files beside chat records and refuses another user's binding", async () => {
await records.ensure(owner);
const old = await createConversationWorkspace(records.root);
await writeFile(join(old, "files", "result.txt"), "kept result");
const workspace = await bindScopedConversationWorkspace(old, records.root, owner);
expect(await readFile(join(workspace, "files", "result.txt"), "utf8")).toBe("kept result");
expect(await bindScopedConversationWorkspace(old, records.root, owner)).toBe(workspace);
await expect(bindScopedConversationWorkspace(workspace, records.root, { ...owner, actorKey: "actor-b" })).rejects.toThrow("another user");
expect(await readFile(join(workspace, "files", "result.txt"), "utf8")).toBe("kept result");
});
it("checks full identity on history lists and rejects session filename aliases", async () => {
const metadata = new SessionMetadataStore(join(base, "metadata"));
await metadata.initialize();
await metadata.ensure({ ...owner, userId: "user-a", projectId: "project-a" });
expect(await metadata.list({ ...owner, userId: "user-b", projectId: "project-a" })).toEqual([]);
expect(await metadata.get(owner, "CHAT-1")).toBeNull();
await expect(metadata.ensure({ ...owner, sessionId: "CHAT-1" })).rejects.toThrow("session not found");
const attempts = await Promise.allSettled([
metadata.ensure({ ...owner, sessionId: "concurrent", userId: "user-a" }),
metadata.ensure({ ...owner, sessionId: "concurrent", userId: "user-b" }),
]);
expect(attempts.filter((result) => result.status === "fulfilled")).toHaveLength(1);
expect(attempts.filter((result) => result.status === "rejected")).toHaveLength(1);
});
it("purges owned content and prevents late writes or a legacy import from reviving a deleted chat", async () => {
const metadataDir = join(base, "metadata");
const uiStateDir = join(base, "ui");
const transcriptDir = join(base, "transcripts");
const learningStateDir = join(base, "learning");
for (const path of [metadataDir, uiStateDir, transcriptDir, learningStateDir]) await mkdir(path);
const metadata = new SessionMetadataStore(metadataDir, records);
const { record } = await metadata.ensure(owner);
const ui = new SessionUiStateStore(uiStateDir, records);
const transcript = new SessionTranscriptStore(transcriptDir, records);
const learning = new SessionLearningStateStore(learningStateDir, records);
const legacyUi = JSON.stringify({ sessionId: owner.sessionId, messages: [{ text: "private content" }] });
await writeFile(join(uiStateDir, "chat.json"), legacyUi);
await writeFile(join(learningStateDir, `${owner.sessionId}.json`), JSON.stringify({ sessionId: owner.sessionId, lastGatedTurn: 2, lastReviewedTurn: 0, updatedAt: "2026-10-10" }));
const sources = { metadataDir, uiStateDir, transcriptDir, learningStateDir };
expect((await records.migrateLegacyRecords(sources)).learningStates).toBe(1);
expect((await learning.read(owner)).lastGatedTurn).toBe(2);
await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 });
const workspace = await bindScopedConversationWorkspace(await createConversationWorkspace(records.root), records.root, owner);
await writeFile(join(workspace, "files", "result.txt"), "private result");
const foreign = { ...owner, actorKey: "actor-b", sessionId: "chat-2" };
await ui.write(foreign, { sessionId: foreign.sessionId, messages: [{ text: "keep this" }] });
await metadata.remove(record);
expect(await metadata.get(owner, owner.sessionId)).toBeNull();
expect(await metadata.list(owner)).toEqual([]);
expect(await transcript.search(owner, "private")).toEqual([]);
await expect(ui.write(owner, { sessionId: owner.sessionId, messages: [] })).rejects.toThrow("session not found");
await expect(metadata.touch(record, { title: "late title" })).rejects.toThrow("session not found");
await expect(learning.completeGate(owner, 4)).rejects.toThrow("session not found");
await metadata.purge(record);
expect((await readdir(join(sessionWorkspaceDirectory(records.root, owner), "chat"))).sort()).toEqual(["deleted.json", "owner.json"]);
await expect(lstat(workspace)).rejects.toMatchObject({ code: "ENOENT" });
await expect(lstat(sessionWorkspaceDirectory(records.backupRoot, owner))).rejects.toMatchObject({ code: "ENOENT" });
expect((await ui.read(foreign))?.messages).toEqual([{ text: "keep this" }]);
await writeFile(join(uiStateDir, "chat.json"), legacyUi);
await writeFile(join(transcriptDir, "chat.json"), JSON.stringify({ ...owner, turns: [] }));
expect((await records.migrateLegacyRecords(sources)).uiStates).toBe(0);
expect(await ui.read(owner)).toBeNull();
expect(await readdir(uiStateDir)).toEqual([]);
await expect(metadata.ensure(owner)).rejects.toThrow("session not found");
await metadata.purge(record);
});
it("honors the durable index deletion when writing the workspace marker fails", async () => {
const metadataDir = join(base, "metadata");
const metadata = new SessionMetadataStore(metadataDir, records);
const { record } = await metadata.ensure(owner);
const transcript = new SessionTranscriptStore(join(base, "legacy"), records);
await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 });
const markDeleted = records.markDeleted.bind(records);
records.markDeleted = async () => { throw new Error("disk write interrupted"); };
await expect(metadata.remove(record)).rejects.toThrow("disk write interrupted");
expect(await metadata.get(owner, owner.sessionId)).toBeNull();
expect(await transcript.search(owner, "private")).toEqual([]);
await expect(records.writeRecord(owner, "messages.json", { sessionId: owner.sessionId, messages: [] })).rejects.toThrow("session not found");
records = new SessionWorkspaceStore(records.root, records.backupRoot);
const restarted = new SessionMetadataStore(metadataDir, records);
expect(await new SessionTranscriptStore(join(base, "legacy"), records).search(owner, "private")).toEqual([]);
await expect(restarted.ensure(owner)).rejects.toThrow("session not found");
await markDeleted(owner);
await restarted.remove(record);
await restarted.purge(record);
});
});