Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions apps/desktop/electron/main/plugin-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4014,11 +4014,17 @@ export class PluginRuntime {
// Remote code the desktop cannot inspect; never silently auto-approved.
risk: "medium",
schema: tool.inputSchema,
execute: async (toolArgs, ctx) => this.mcpCalls.run(
ctx?.sessionId,
(signal) => client.callTool(tool.name, toolArgs, signal),
ctx?.signal,
),
execute: async (toolArgs, ctx) => {
// MCP contributions cannot declare an enforced plan-safe action list.
if (ctx?.mode === "plan" || ctx?.mode === "goal") {
throw apiError("PERMISSION_DENIED", `plugin tool ${name} is not available in ${ctx.mode} mode`);
}
return this.mcpCalls.run(
ctx?.sessionId,
(signal) => client.callTool(tool.name, toolArgs, signal),
ctx?.signal,
);
},
});
}
}
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/test/context-compaction.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,14 @@ test("the hard boundary is enforced by the host, with a model-side escape hatch"
runtime,
/messages: \[\s*\.\.\.context\.messages,\s*\{\s*role: "system",\s*content: "",\s*sections: \{ \[CONTEXT_BUDGET_SECTION\]: reminder \}/,
);
assert.match(hostPermissions, /"new_context"/);
// ADR 0332: the contract modes keep a deny list instead of a read-only
// allowlist, so `new_context` is admitted by falling through it. Denying it
// there would take compaction away from Plan and Goal.
const contractDenyList = hostPermissions.match(
/CONTRACT_MODE_STATE_CHANGING[^=]*=\s*\[([\s\S]*?)\];/,
);
assert.ok(contractDenyList, "host-core contract-mode deny list not found");
assert.doesNotMatch(contractDenyList[1], /"new_context"/);
});

test("a delegate gets the session's turn-boundary budget protection (ADR 0299)", () => {
Expand Down
37 changes: 35 additions & 2 deletions apps/desktop/test/plugin-tool-invocations.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { fork } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { register } from "node:module";
Expand Down Expand Up @@ -74,12 +75,13 @@ const PLUGIN = `
};
`;

async function fixture(t, onInvoke) {
async function fixture(t, onInvoke, mcpServers = []) {
const dir = mkdtempSync(join(tmpdir(), "pi-tool-invocations-"));
const id = "demo.invocations";
const permissions = ["agent.tool.register", "browser.cdp", "desktop.control", "session.read"];
const permissions = ["agent.tool.register", "browser.cdp", "desktop.control", "session.read", ...(mcpServers.length ? ["mcp.server.remote"] : [])];
writeFileSync(join(dir, "manifest.json"), JSON.stringify({
schemaVersion: 1, id, name: id, version: "0.0.1", main: "main.js", permissions,
net: { domains: ["127.0.0.1"] }, contributes: { mcpServers },
}));
writeFileSync(join(dir, "main.js"), PLUGIN);
const calls = [];
Expand Down Expand Up @@ -200,3 +202,34 @@ test("the tool deadline aborts child work and revokes its invocation", { timeout
t.mock.timers.reset();
assert.equal(await runtime.invokePanelBridge(id, "probe.aborted", { sessionId: "a" }), true);
});

test("plugin-contributed MCP tools reject contract modes before calling the server", { timeout: 10000 }, async (t) => {
const calls = [];
const server = createServer(async (req, res) => {
if (req.method !== "POST") { res.writeHead(405).end(); return; }
let body = "";
for await (const chunk of req) body += chunk;
const message = JSON.parse(body);
if (message.id === undefined) { res.writeHead(202).end(); return; }
const result = message.method === "initialize"
? { protocolVersion: message.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } }
: message.method === "tools/list"
? { tools: [{ name: "mutate", inputSchema: { type: "object" } }] }
: (calls.push(message.params), { content: [{ type: "text", text: "called" }] });
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }));
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise((resolve) => { server.closeAllConnections(); server.close(resolve); }));
const { runtime } = await fixture(t, async () => ({}), [{
id: "fixture", transport: "http", url: `http://127.0.0.1:${server.address().port}/mcp`,
}]);
const tool = runtime.getTools().find((entry) => entry.name.endsWith("mutate"));
assert.ok(tool);
for (const mode of ["plan", "goal"]) {
await assert.rejects(tool.execute({}, { sessionId: "contract", mode }), { code: "PERMISSION_DENIED" });
}
assert.equal(calls.length, 0);
await tool.execute({}, { sessionId: "agent", mode: "agent" });
assert.equal(calls.length, 1);
});
Loading
Loading