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,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 });
|
||||
}
|
||||
Reference in New Issue
Block a user