Files
jiang 6a49d2209e 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.
2026-10-19 18:46:58 +08:00

115 lines
6.4 KiB
TypeScript

import { lstatSync, realpathSync } from "node:fs";
import { lstat, readdir } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
import { type Plugin } from "@opencode/plugin";
import { z } from "zod";
import { getInternalBridgeConfig } from "./internalBridge.js";
import { DEFAULT_SESSION_WORKSPACE_ROOT, isManagedConversationWorkspace } from "../src/runtime/conversationWorkspace.js";
import { TASK_READER_AGENT } from "../src/runtime/task-agent-policy.js";
type Context = Parameters<Plugin.Plugin["setup"]>[0];
const inside = (root: string, path: string) => {
const value = relative(root, path);
return value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value);
};
const canonical = (path: string): string => {
let existing = path;
const exists = (candidate: string) => { try { lstatSync(candidate); return true; } catch { return false; } };
while (!exists(existing)) {
const parent = dirname(existing);
if (parent === existing) throw new Error("invalid path");
existing = parent;
}
return resolve(realpathSync(existing), relative(existing, path));
};
export function canAccessConversationPath(workspace: string, path: string, edit = false, skills?: string) {
try {
const target = canonical(resolve(workspace, path));
const scopedPath = inside(workspace, target) ? relative(workspace, target) : path;
if (scopedPath.split(/[\\/]/).some((name) => name.includes(".env") || name === "data" || name === "logs")) return false;
if (!edit && skills && inside(canonical(skills), target)) return true;
if (!inside(workspace, target)) return false;
return !edit || ["files", "tmp", ".cache"].some((name) => inside(resolve(workspace, name), target));
} catch { return false; }
}
export async function registerSessionSandbox(ctx: Context) {
const root = resolve(process.env.TJWATER_AGENT_WORKSPACE_ROOT ?? DEFAULT_SESSION_WORKSPACE_ROOT);
const skills = resolve(process.env.OPENCODE_SKILLS_ROOT_DIR ?? ".opencode/skills");
const workspace = async (sessionID: string) => {
const session = await ctx.session.get({ sessionID });
const directory = canonical(session.location.directory);
if (!isManagedConversationWorkspace(directory, canonical(root), sessionID)) {
throw new Error("session has no managed conversation workspace");
}
return directory;
};
await ctx.tool.transform((editor) => {
// A later add replaces the native executor for both direct and Code Mode calls.
editor.remove("shell");
editor.add({
name: "shell",
description: "在当前会话的 Landlock + seccomp 沙箱运行 Shell。工作目录为会话的 files/;不能联网、读取密钥、源码或其他会话。后台进程不保留。业务查询使用类型化工具。",
input: z.object({
command: z.string().min(1).max(20000),
description: z.string().max(500).optional(),
timeout: z.number().int().min(1).max(1800).optional().describe("Timeout in seconds, default 120."),
}).strict(),
options: { codemode: false, permission: "shell" },
async execute(input, context) {
await workspace(context.sessionID);
const { baseUrl, internalToken } = getInternalBridgeConfig();
const response = await fetch(`${baseUrl}/internal/tools/sandbox-shell`, {
method: "POST", signal: context.signal,
headers: { "Content-Type": "application/json", "x-agent-internal-token": internalToken },
body: JSON.stringify({ ...input, session_id: context.sessionID, call_id: context.id, message_id: context.messageID }),
});
const text = await response.text();
if (!response.ok) throw new Error(`sandbox shell failed (${response.status}): ${text}`);
return { content: text, metadata: { sandbox: "landlock-seccomp" } };
},
});
for (const name of ["glob", "grep"]) {
editor.update(name, (tool) => {
const execute = tool.execute;
tool.execute = async (input, context) => {
const directory = await workspace(context.sessionID);
const path = (input as { path?: string }).path ?? directory;
if (!canAccessConversationPath(directory, path, false, skills)) throw new Error("search outside conversation workspace denied");
// Native recursive search does not ask 'read' permission per match.
// Do not allow a recursive search tree containing links or secrets.
const pending = [canonical(resolve(directory, path))];
while (pending.length) {
const current = pending.pop()!;
if (!canAccessConversationPath(directory, current, false, skills) || (await lstat(current)).isSymbolicLink()) throw new Error("unsafe search tree denied");
if ((await lstat(current)).isDirectory()) for (const entry of await readdir(current)) pending.push(resolve(current, entry));
}
return execute(input, context);
};
});
}
});
// Close alternate native Shell APIs as well. Only the authenticated bridge
// above may spawn commands, with kernel restrictions already installed.
await ctx.shell.hook("create.before", () => { throw new Error("host shell disabled; use the sandbox shell tool"); });
await ctx.permission.hook("evaluate", async (event) => {
if (event.effect === "deny") return;
if (event.action === "shell") { event.effect = "ask"; return; }
if (event.action === "read" || event.action === "edit" || event.action === "external_directory") {
try {
const directory = await workspace(event.sessionID);
if (event.resources.some((path) => !canAccessConversationPath(directory, path.replace(/\/\*$/, ""), event.action === "edit", skills))) {
event.effect = "deny";
} else if (event.action === "edit") event.effect = "ask";
} catch { event.effect = "deny"; }
}
});
await ctx.session.hook("context", async (event) => {
// Native child sessions share the parent's Location, not its file identity.
// Readers have no filesystem tools; task-agent-policy verifies their parent.
if (event.agent === TASK_READER_AGENT) return;
const directory = await workspace(event.sessionID);
event.system.push({ type: "text", text: `当前会话目录:${directory}。Shell 的工作目录是 ${directory}/files。写入与计算文件放在 files/;目录根只读。shell 禁止网络与跨会话访问;业务数据和地理编码使用 MCP 查询,公开网页搜索使用原生 websearch。` });
});
}