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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Vendored
-27
@@ -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) }; }
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"] }] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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("未覆盖完整时间窗");
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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("权限拒绝和接口错误不能证明数据缺失");
|
||||
|
||||
@@ -96,6 +96,7 @@ describe("generateSessionTitle", () => {
|
||||
},
|
||||
],
|
||||
abortSession: async () => undefined,
|
||||
removeSession: async () => undefined,
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
|
||||
const title = await generateSessionTitle(runtime, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user