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.
97 lines
6.6 KiB
TypeScript
97 lines
6.6 KiB
TypeScript
import { PRODUCT_MCP_TOOL_IDS, QUERY_MCP_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"];
|
|
|
|
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 = readFileSync(resolve(".opencode/agents/instruction.md"), "utf8")
|
|
.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
|
|
return {
|
|
...input,
|
|
...compileOpencodeModels(models),
|
|
permissions: sessionWorkspacePermissions([...(input.permissions ?? []) as PermissionRule[], ...QUERY_MCP_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "allow" }))], 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: [
|
|
...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" },
|
|
],
|
|
},
|
|
[TASK_AGENT]: {
|
|
description: "后台工况调查,协调只读子任务并统一发布进展与报告", mode: "primary",
|
|
model: models.defaults.task,
|
|
system: `${system}\n\n${readFileSync(resolve(".opencode/agents/investigation.md"), "utf8")}`,
|
|
permissions: [
|
|
...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",
|
|
system: readFileSync(resolve(".opencode/agents/investigation-reader.md"), "utf8"),
|
|
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" })),
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|