This commit is contained in:
@@ -49,6 +49,8 @@ This relative path assumes the configuration file is in the selected global conf
|
|||||||
|
|
||||||
The plugin supplies session-local `todoread` and `todowrite` tools when no other plugin provides them. In this pinned V2 beta, custom tools cannot open permission prompts: an `ask` or `deny` rule blocks these todo tools rather than bypassing the rule. Explicitly allow them if you want to use them.
|
The plugin supplies session-local `todoread` and `todowrite` tools when no other plugin provides them. In this pinned V2 beta, custom tools cannot open permission prompts: an `ask` or `deny` rule blocks these todo tools rather than bypassing the rule. Explicitly allow them if you want to use them.
|
||||||
|
|
||||||
|
When an MCP server named `codegraph` is already configured, its tools are exposed directly to `coordinate` and retain the built-in `build` agent's matching allow, ask, and deny rules. The plugin does not configure or start a missing server, and no other MCP server is exposed this way.
|
||||||
|
|
||||||
## Uninstall
|
## Uninstall
|
||||||
|
|
||||||
Remove `plugins/stanp-opencode` from the selected config directory and remove any explicit configuration entry for it, then restart OpenCode.
|
Remove `plugins/stanp-opencode` from the selected config directory and remove any explicit configuration entry for it, then restart OpenCode.
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export default Plugin.define({
|
|||||||
Agent.ID.make("build"),
|
Agent.ID.make("build"),
|
||||||
).permissions;
|
).permissions;
|
||||||
let permissionKey = JSON.stringify(inherited);
|
let permissionKey = JSON.stringify(inherited);
|
||||||
|
let hasCodegraph = false;
|
||||||
|
let codegraphTools: string[] = [];
|
||||||
|
let agentTransformReady = false;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
// Subscribe before the first read: configuration can publish its catalog while
|
// Subscribe before the first read: configuration can publish its catalog while
|
||||||
// asynchronous setup is running.
|
// asynchronous setup is running.
|
||||||
@@ -80,6 +83,31 @@ export default Plugin.define({
|
|||||||
let workers: (typeof workerNames)[number][] = [];
|
let workers: (typeof workerNames)[number][] = [];
|
||||||
const active = new Set<string>();
|
const active = new Set<string>();
|
||||||
|
|
||||||
|
await context.mcp.transform((editor) => {
|
||||||
|
hasCodegraph = editor.get("codegraph") !== undefined;
|
||||||
|
if (hasCodegraph)
|
||||||
|
editor.update("codegraph", (server) => {
|
||||||
|
server.codemode = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await context.tool.transform((editor) => {
|
||||||
|
const next = editor
|
||||||
|
.list()
|
||||||
|
.map((tool) => tool.id)
|
||||||
|
.filter((id) => id.startsWith("codegraph_"))
|
||||||
|
.sort();
|
||||||
|
if (JSON.stringify(next) === JSON.stringify(codegraphTools)) return;
|
||||||
|
codegraphTools = next;
|
||||||
|
if (agentTransformReady)
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void context.agent
|
||||||
|
.reload()
|
||||||
|
.catch((error) =>
|
||||||
|
console.error("stanp-opencode agent reload failed", error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await context.agent.transform((editor) => {
|
await context.agent.transform((editor) => {
|
||||||
const deep = selected.mode === "custom" || models.has("gpt-6-astra");
|
const deep = selected.mode === "custom" || models.has("gpt-6-astra");
|
||||||
const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-"));
|
const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-"));
|
||||||
@@ -146,6 +174,13 @@ export default Plugin.define({
|
|||||||
.filter((rule) => matches(action, rule.action))
|
.filter((rule) => matches(action, rule.action))
|
||||||
.map((rule) => ({ ...rule, action })),
|
.map((rule) => ({ ...rule, action })),
|
||||||
),
|
),
|
||||||
|
...(hasCodegraph
|
||||||
|
? codegraphTools.flatMap((action) =>
|
||||||
|
inherited
|
||||||
|
.filter((rule) => matches(action, rule.action))
|
||||||
|
.map((rule) => ({ ...rule, action })),
|
||||||
|
)
|
||||||
|
: []),
|
||||||
...workers.map((resource) => ({
|
...workers.map((resource) => ({
|
||||||
action: "subagent",
|
action: "subagent",
|
||||||
resource,
|
resource,
|
||||||
@@ -154,6 +189,7 @@ export default Plugin.define({
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
agentTransformReady = true;
|
||||||
async function assertTodo(agentID: Agent.ID, action: string) {
|
async function assertTodo(agentID: Agent.ID, action: string) {
|
||||||
const { data: agent } = await context.agent.get({ agentID });
|
const { data: agent } = await context.agent.get({ agentID });
|
||||||
const effect =
|
const effect =
|
||||||
|
|||||||
+140
-18
@@ -5,6 +5,7 @@ import type { DeepMutable } from "@opencode/plugin/promise/types";
|
|||||||
import type { Info, ToolContext, ToolEditor } from "@opencode/plugin/promise/tool";
|
import type { Info, ToolContext, ToolEditor } from "@opencode/plugin/promise/tool";
|
||||||
import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client";
|
import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client";
|
||||||
import type { SessionContext } from "@opencode/plugin/promise/session";
|
import type { SessionContext } from "@opencode/plugin/promise/session";
|
||||||
|
import { z } from "zod";
|
||||||
import { gptAgents } from "../src/config.js";
|
import { gptAgents } from "../src/config.js";
|
||||||
import plugin from "../src/index.js";
|
import plugin from "../src/index.js";
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ type FixtureSetup = (
|
|||||||
async function host(
|
async function host(
|
||||||
models: ModelInfo[] = allModels,
|
models: ModelInfo[] = allModels,
|
||||||
options: Plugin.Context["options"] = { provider: "test" },
|
options: Plugin.Context["options"] = { provider: "test" },
|
||||||
|
configuredMcp: Record<
|
||||||
|
string,
|
||||||
|
{ type: "local"; command: string[]; codemode?: boolean; disabled?: boolean }
|
||||||
|
> = {},
|
||||||
|
configuredMcpTools: string[] = [],
|
||||||
) {
|
) {
|
||||||
const agents = new Map<string, DeepMutable<Agent.Info>>();
|
const agents = new Map<string, DeepMutable<Agent.Info>>();
|
||||||
const build: DeepMutable<Agent.Info> = Agent.Info.default(Agent.ID.make("build"));
|
const build: DeepMutable<Agent.Info> = Agent.Info.default(Agent.ID.make("build"));
|
||||||
@@ -48,6 +54,50 @@ async function host(
|
|||||||
});
|
});
|
||||||
const storage = new Map<string, JsonValue>();
|
const storage = new Map<string, JsonValue>();
|
||||||
const tools = new Map<string, Info>();
|
const tools = new Map<string, Info>();
|
||||||
|
const mcpTools = new Set(configuredMcpTools);
|
||||||
|
const mcpServers = new Map(Object.entries(structuredClone(configuredMcp)));
|
||||||
|
let mcpTransform!: (editor: {
|
||||||
|
get(name: string): (typeof configuredMcp)[string] | undefined;
|
||||||
|
update(name: string, update: (server: (typeof configuredMcp)[string]) => void): void;
|
||||||
|
}) => void;
|
||||||
|
const toolTransforms: ((editor: ToolEditor) => void)[] = [];
|
||||||
|
const editMcp = () => ({
|
||||||
|
get: (name: string) => mcpServers.get(name),
|
||||||
|
update(name: string, update: (server: (typeof configuredMcp)[string]) => void) {
|
||||||
|
const server = mcpServers.get(name);
|
||||||
|
if (server) update(server);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const editTools = (): ToolEditor => ({
|
||||||
|
get(id) {
|
||||||
|
const tool = tools.get(id);
|
||||||
|
return tool ? { ...tool, id } : undefined;
|
||||||
|
},
|
||||||
|
list: () => [
|
||||||
|
...[...tools].map(([id, tool]) => ({ ...tool, id })),
|
||||||
|
...[...mcpTools].map((id) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
description: id,
|
||||||
|
input: z.object({}),
|
||||||
|
options: { codemode: false },
|
||||||
|
async execute() {
|
||||||
|
return { content: "" };
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
namespace() {},
|
||||||
|
add(tool) {
|
||||||
|
tools.set(tool.name, tool);
|
||||||
|
},
|
||||||
|
update(id, update) {
|
||||||
|
const tool = tools.get(id);
|
||||||
|
if (tool) update(tool);
|
||||||
|
},
|
||||||
|
remove(id) {
|
||||||
|
tools.delete(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
// SAFETY: This test host implements the native APIs used by setup; unrelated context
|
// SAFETY: This test host implements the native APIs used by setup; unrelated context
|
||||||
// services are deliberately absent so unexpected new dependencies fail the test.
|
// services are deliberately absent so unexpected new dependencies fail the test.
|
||||||
const context = {
|
const context = {
|
||||||
@@ -73,6 +123,13 @@ async function host(
|
|||||||
storage.set(key, value);
|
storage.set(key, value);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mcp: {
|
||||||
|
async transform(callback: typeof mcpTransform) {
|
||||||
|
mcpTransform = callback;
|
||||||
|
callback(editMcp());
|
||||||
|
return registration;
|
||||||
|
},
|
||||||
|
},
|
||||||
agent: {
|
agent: {
|
||||||
async list() {
|
async list() {
|
||||||
return { data: [...agents.values()] };
|
return { data: [...agents.values()] };
|
||||||
@@ -119,24 +176,8 @@ async function host(
|
|||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
async transform(callback: (editor: ToolEditor) => void) {
|
async transform(callback: (editor: ToolEditor) => void) {
|
||||||
callback({
|
toolTransforms.push(callback);
|
||||||
get(id) {
|
callback(editTools());
|
||||||
const tool = tools.get(id);
|
|
||||||
return tool ? { ...tool, id } : undefined;
|
|
||||||
},
|
|
||||||
list: () => [...tools].map(([id, tool]) => ({ ...tool, id })),
|
|
||||||
namespace() {},
|
|
||||||
add(tool) {
|
|
||||||
tools.set(tool.name, tool);
|
|
||||||
},
|
|
||||||
update(id, update) {
|
|
||||||
const tool = tools.get(id);
|
|
||||||
if (tool) update(tool);
|
|
||||||
},
|
|
||||||
remove(id) {
|
|
||||||
tools.delete(id);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return registration;
|
return registration;
|
||||||
},
|
},
|
||||||
async hook(_name: string, callback: typeof before) {
|
async hook(_name: string, callback: typeof before) {
|
||||||
@@ -153,6 +194,7 @@ async function host(
|
|||||||
return {
|
return {
|
||||||
agents,
|
agents,
|
||||||
build,
|
build,
|
||||||
|
mcpServers,
|
||||||
defaultAgent,
|
defaultAgent,
|
||||||
originalBuild,
|
originalBuild,
|
||||||
async refresh(next: ModelInfo[]) {
|
async refresh(next: ModelInfo[]) {
|
||||||
@@ -167,6 +209,21 @@ async function host(
|
|||||||
events.enqueue({ type: "agent.updated" });
|
events.enqueue({ type: "agent.updated" });
|
||||||
await reloaded.promise;
|
await reloaded.promise;
|
||||||
},
|
},
|
||||||
|
async changeCodegraph(
|
||||||
|
server:
|
||||||
|
| { type: "local"; command: string[]; codemode?: boolean; disabled?: boolean }
|
||||||
|
| undefined,
|
||||||
|
toolNames: string[],
|
||||||
|
) {
|
||||||
|
if (server) mcpServers.set("codegraph", server);
|
||||||
|
else mcpServers.delete("codegraph");
|
||||||
|
mcpTools.clear();
|
||||||
|
for (const name of toolNames) mcpTools.add(name);
|
||||||
|
reloaded = Promise.withResolvers<void>();
|
||||||
|
mcpTransform(editMcp());
|
||||||
|
for (const callback of toolTransforms) callback(editTools());
|
||||||
|
await reloaded.promise;
|
||||||
|
},
|
||||||
async todo(name: string, sessionID: string, input: JsonValue = {}) {
|
async todo(name: string, sessionID: string, input: JsonValue = {}) {
|
||||||
// SAFETY: Todo executors use only sessionID and agent from the native context.
|
// SAFETY: Todo executors use only sessionID and agent from the native context.
|
||||||
return tools
|
return tools
|
||||||
@@ -257,6 +314,71 @@ test("worker inheritance and coordinator isolation preserve build and default",
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coordinator inherits only codegraph MCP permissions when that server exists", async () => {
|
||||||
|
const absent = await host();
|
||||||
|
expect(
|
||||||
|
absent.agents
|
||||||
|
.get("coordinate")
|
||||||
|
?.permissions.some((rule) => rule.action.startsWith("codegraph_")),
|
||||||
|
).toBe(false);
|
||||||
|
expect(absent.mcpServers.has("codegraph")).toBe(false);
|
||||||
|
|
||||||
|
const result = await host(
|
||||||
|
allModels,
|
||||||
|
{ provider: "test" },
|
||||||
|
{
|
||||||
|
codegraph: { type: "local", command: ["codegraph"], codemode: true },
|
||||||
|
docs: { type: "local", command: ["docs"], codemode: true },
|
||||||
|
},
|
||||||
|
["codegraph_codegraph_search", "codegraph_future_query", "docs_search"],
|
||||||
|
);
|
||||||
|
expect(result.mcpServers.get("codegraph")?.codemode).toBe(false);
|
||||||
|
expect(result.mcpServers.get("docs")?.codemode).toBe(true);
|
||||||
|
let coordinate = result.agents.get("coordinate")!;
|
||||||
|
expect(coordinate.permissions).toContainEqual({
|
||||||
|
action: "codegraph_codegraph_search",
|
||||||
|
resource: "*",
|
||||||
|
effect: "allow",
|
||||||
|
});
|
||||||
|
expect(coordinate.permissions.some((rule) => rule.action.startsWith("docs_"))).toBe(false);
|
||||||
|
expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false);
|
||||||
|
expect(coordinate.permissions.some((rule) => rule.action === "execute")).toBe(false);
|
||||||
|
|
||||||
|
await result.changeBuildPermissions([
|
||||||
|
{ action: "*", resource: "*", effect: "allow" },
|
||||||
|
{ action: "code*", resource: "*", effect: "ask" },
|
||||||
|
{ action: "*search", resource: "*", effect: "deny" },
|
||||||
|
{ action: "docs_*", resource: "*", effect: "allow" },
|
||||||
|
{ action: "shell", resource: "*", effect: "allow" },
|
||||||
|
]);
|
||||||
|
coordinate = result.agents.get("coordinate")!;
|
||||||
|
expect(coordinate.permissions).toContainEqual({
|
||||||
|
action: "codegraph_future_query",
|
||||||
|
resource: "*",
|
||||||
|
effect: "ask",
|
||||||
|
});
|
||||||
|
expect(coordinate.permissions).toContainEqual({
|
||||||
|
action: "codegraph_codegraph_search",
|
||||||
|
resource: "*",
|
||||||
|
effect: "deny",
|
||||||
|
});
|
||||||
|
expect(coordinate.permissions.some((rule) => rule.action === "docs_*")).toBe(false);
|
||||||
|
expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false);
|
||||||
|
|
||||||
|
await result.changeCodegraph(undefined, []);
|
||||||
|
coordinate = result.agents.get("coordinate")!;
|
||||||
|
expect(coordinate.permissions.some((rule) => rule.action.startsWith("codegraph_"))).toBe(false);
|
||||||
|
await result.changeCodegraph({ type: "local", command: ["codegraph"], codemode: true }, [
|
||||||
|
"codegraph_added_later",
|
||||||
|
]);
|
||||||
|
expect(result.mcpServers.get("codegraph")?.codemode).toBe(false);
|
||||||
|
expect(result.agents.get("coordinate")?.permissions).toContainEqual({
|
||||||
|
action: "codegraph_added_later",
|
||||||
|
resource: "*",
|
||||||
|
effect: "ask",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("manual calls and command-created child sessions cannot bypass delegation restrictions", async () => {
|
test("manual calls and command-created child sessions cannot bypass delegation restrictions", async () => {
|
||||||
const result = await host(allModels.slice(0, 2));
|
const result = await host(allModels.slice(0, 2));
|
||||||
for (const invoke of [result.delegate, result.command]) {
|
for (const invoke of [result.delegate, result.command]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user