109 lines
7.3 KiB
TypeScript
109 lines
7.3 KiB
TypeScript
import { PRODUCT_MCP_TOOL_IDS, QUERY_MCP_AUTOALLOW_TOOL_IDS, PRODUCT_MCP_WRITE_TOOL_IDS, queryMcpToolId } from "./product-mcp-tools.js";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { normalizeMcpConfig } from "./mcp-config.js";
|
|
import { compileOpencodeModels, loadAgentModelConfig, type AgentModelConfig } from "../chat/modelConfig.js";
|
|
import { DEFAULT_SESSION_WORKSPACE_ROOT } from "./conversationWorkspace.js";
|
|
import { TASK_AGENT, TASK_READER_AGENT, TASK_READER_TOOLS } from "./task-agent-policy.js";
|
|
|
|
const agentMessageTools = [queryMcpToolId("list_investigation_tasks"), queryMcpToolId("get_investigation_task"), "send_task_message", queryMcpToolId("read_agent_messages"), "reply_agent_message"];
|
|
|
|
const readAgentInstructions = (name: "instruction" | "investigation" | "prompt-suggestions") =>
|
|
readFileSync(resolve(`.opencode/agents/${name}.md`), "utf8")
|
|
.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
|
|
|
|
type PermissionRule = { action: string; resource: string; effect: "allow" | "ask" | "deny" };
|
|
const deniedProductQuery = (name: string, rules: PermissionRule[]) => rules.some((rule) => {
|
|
if (rule.effect !== "deny") return false;
|
|
const backslash = String.fromCharCode(92);
|
|
const special = `${backslash}.^$+()[]{}|`;
|
|
const pattern = Array.from(rule.action).map((char) => char === "*" ? ".*" : char === "?" ? "." : special.includes(char) ? backslash + char : char).join("");
|
|
return new RegExp(`^${pattern}$`).test(name);
|
|
});
|
|
const dataPatterns = new Set(["data/**", "**/data/**"]);
|
|
|
|
// Exempt only managed session locations from ancestor data/ denials. Earlier
|
|
// explicit read/edit denials are reapplied; later administrator rules still win.
|
|
export const sessionWorkspacePermissions = (rules: PermissionRule[], workspaceRoot: string) => {
|
|
const root = resolve(workspaceRoot).replaceAll("\\", "/");
|
|
const result: PermissionRule[] = [];
|
|
for (const [index, rule] of rules.entries()) {
|
|
result.push(rule);
|
|
if (!["read", "edit"].includes(rule.action) || rule.effect !== "deny" || !dataPatterns.has(rule.resource)) continue;
|
|
if (rules.slice(index + 1).some((next) => next.action === rule.action && next.effect === "deny" && dataPatterns.has(next.resource))) continue;
|
|
if (rule.action === "read") {
|
|
result.push({ action: "read", resource: `${root}/conversation-*`, effect: "allow" }, { action: "read", resource: `${root}/conversation-*/**`, effect: "allow" });
|
|
for (const scope of ["project-*", "personal"]) {
|
|
const workspace = `${root}/actor-*/${scope}/session-*/workspace`;
|
|
result.push({ action: "read", resource: workspace, effect: "allow" }, { action: "read", resource: `${workspace}/**`, effect: "allow" });
|
|
}
|
|
} else {
|
|
for (const child of ["files", "tmp", ".cache"]) result.push({ action: "edit", resource: `${root}/conversation-*/${child}/**`, effect: "ask" });
|
|
for (const scope of ["project-*", "personal"]) for (const child of ["files", "tmp", ".cache"]) {
|
|
result.push({ action: "edit", resource: `${root}/actor-*/${scope}/session-*/workspace/${child}/**`, effect: "ask" });
|
|
}
|
|
}
|
|
result.push(...rules.slice(0, index).filter((previous) => previous.action === rule.action && previous.effect === "deny" && !dataPatterns.has(previous.resource)));
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// A conversation Location can be outside the checkout. Load only the trusted
|
|
// service assets explicitly, rather than relying on its current directory.
|
|
export function serviceOpencodeConfig(input: Record<string, unknown>, models: AgentModelConfig = loadAgentModelConfig(), workspaceRoot = process.env.TJWATER_AGENT_WORKSPACE_ROOT ?? process.env.CONVERSATION_WORKSPACE_ROOT ?? DEFAULT_SESSION_WORKSPACE_ROOT) {
|
|
const agents = (input.agents ?? {}) as Record<string, unknown>;
|
|
const instruction = (agents.instruction ?? {}) as Record<string, unknown>;
|
|
const system = readAgentInstructions("instruction");
|
|
return {
|
|
...input,
|
|
...compileOpencodeModels(models),
|
|
permissions: sessionWorkspacePermissions([...(input.permissions ?? []) as PermissionRule[], ...QUERY_MCP_AUTOALLOW_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "allow" })), ...PRODUCT_MCP_WRITE_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "ask" }))], workspaceRoot),
|
|
snapshots: false,
|
|
mcp: normalizeMcpConfig(input.mcp),
|
|
plugins: ["*", ...((input.plugins ?? []) as unknown[]), resolve(".opencode/tjwater-plugin"),
|
|
"-opencode.browser", "-opencode.browser.*",
|
|
// The wildcard also loads local-model discovery. vLLM probes port 8000,
|
|
// mistaking TJWater Server's /health for a model server. Models, including
|
|
// local providers, must come from agent-models.json instead of discovery.
|
|
"-opencode.provider.vllm", "-opencode.provider.lmstudio", "-opencode.provider.ollama"],
|
|
skills: [resolve(process.env.OPENCODE_SKILLS_ROOT_DIR ?? ".opencode/skills")],
|
|
agents: { ...agents,
|
|
instruction: {
|
|
description: "TJWater structured data and UI agent", mode: "primary", ...instruction, model: models.defaults.chat, system,
|
|
permissions: [
|
|
{ action: "ask_task_user", resource: "*", effect: "deny" },
|
|
...agentMessageTools.map((action): PermissionRule => ({ action, resource: "*", effect: action.startsWith("mcp_") && deniedProductQuery(action, (input.permissions ?? []) as PermissionRule[]) ? "deny" : "allow" })),
|
|
...((instruction.permissions ?? []) as PermissionRule[]),
|
|
{ action: "subagent", resource: "*", effect: "deny" },
|
|
],
|
|
},
|
|
"prompt-suggestions": {
|
|
description: "仅根据已加载活动生成推荐问题 JSON", mode: "primary",
|
|
model: models.defaults.chat,
|
|
system: readAgentInstructions("prompt-suggestions"),
|
|
permissions: [{ action: "*", resource: "*", effect: "deny" }],
|
|
},
|
|
[TASK_AGENT]: {
|
|
description: "后台工况调查,协调只读子任务并统一发布进展与报告", mode: "primary",
|
|
model: models.defaults.task,
|
|
system: `${system}\n\n${readAgentInstructions("investigation")}`,
|
|
permissions: [
|
|
{ action: "ask_task_user", resource: "*", effect: "allow" },
|
|
{ action: "question", resource: "*", effect: "deny" },
|
|
{ action: "ask_user", resource: "*", effect: "deny" },
|
|
...agentMessageTools.map((action): PermissionRule => ({ action, resource: "*", effect: [queryMcpToolId("read_agent_messages"), "reply_agent_message"].includes(action) && !(action.startsWith("mcp_") && deniedProductQuery(action, (input.permissions ?? []) as PermissionRule[])) ? "allow" : "deny" })),
|
|
{ action: "subagent", resource: "*", effect: "deny" },
|
|
{ action: "subagent", resource: TASK_READER_AGENT, effect: "allow" },
|
|
],
|
|
},
|
|
[TASK_READER_AGENT]: {
|
|
description: "运行时只读调查子代理,具体核查提示由父调查在每次调用中提供", mode: "subagent",
|
|
permissions: [
|
|
{ action: "*", resource: "*", effect: "deny" },
|
|
...TASK_READER_TOOLS.filter((name) => !PRODUCT_MCP_TOOL_IDS.includes(name) || !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action) => ({ action, resource: "*", effect: "allow" })),
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|