feat(agent): migrate OpenCode 2 runtime and sandbox tools

Upgrade the runtime and native plugin API to OpenCode 2.0.24. Restore
session-scoped learning, credential renewal, typed project queries and
controlled frontend actions from the legacy Agent.

Replace host Shell execution with an approved Landlock/seccomp executor
and persistent per-session workspaces. Add typed Bocha search and Tianditu
geocoding bridges using the owning session credential. Require a working
kernel sandbox before startup and persist workspace files in deployments.

Validation completed before this commit: TypeScript checks, 150 Bun tests,
Docker build and UID 10001 sandbox execution, native Shell approval and
session recovery. Tianditu succeeded; Bocha currently reports exhausted
account quota. Unrelated investigation-report contract work is excluded.
This commit is contained in:
2026-10-08 15:46:37 +08:00
parent 1a9b0cafc1
commit 7383ed6d13
93 changed files with 8065 additions and 1098 deletions
+171 -248
View File
@@ -1,11 +1,12 @@
import {
createOpencode,
createOpencodeClient,
type OpencodeClient,
} from "@opencode-ai/sdk/v2";
import type { OpenCodeClient as OpencodeClient } from "@opencode/client";
import { createRuntimeClient, startOpencode2 } from "./opencode2-server.js";
import { formAnswers, normalizeRuntimeMessage, Opencode2EventNormalizer } from "./opencode2-events.js";
import { existsSync, readFileSync } from "node:fs";
import { createServer } from "node:net";
import { resolve } from "node:path";
import { realpath, rm } from "node:fs/promises";
import { createConversationWorkspace, resolveConversationWorkspace } from "./conversationWorkspace.js";
import { serviceOpencodeConfig } from "./opencode2-config.js";
import { config } from "../config.js";
import { logger } from "../logger.js";
@@ -76,6 +77,7 @@ export const resolveEmbeddedOpencodePort = async (
export class OpencodeRuntimeAdapter {
private clientPromise: Promise<OpencodeClient> | null = null;
private closeServer: (() => void) | null = null;
private workspaceBindings = new Map<string, Promise<string>>();
async ensureClient(): Promise<OpencodeClient> {
if (!this.clientPromise) {
@@ -89,90 +91,85 @@ export class OpencodeRuntimeAdapter {
async health(): Promise<RuntimeHealth> {
const client = await this.ensureClient();
const response = await client.global.health();
return requireData(response.data, "global.health");
const info = await client.server.info();
return { healthy: true, version: info.version };
}
async resolveAvailableModel(preferredModelIds: string[]): Promise<string> {
const client = await this.ensureClient();
const response = await client.config.providers({});
const configured = requireData(response.data, "config.providers");
const available = new Set(
configured.providers.flatMap((provider) =>
Object.keys(provider.models).map((modelId) => `${provider.id}/${modelId}`),
),
);
// V2 2.0.24 model.list alone does not await a cold Location's plugin setup.
await client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory: process.cwd() } });
const models = await client.model.list({ location: { directory: process.cwd() } });
const available = new Set(models.data.filter((model) => model.enabled && model.capabilities.tools)
.map((model) => `${model.providerID}/${model.id}`));
const selected = preferredModelIds.find((modelId) => available.has(modelId));
if (!selected) {
throw new Error("no configured operational task model is available from OpenCode");
}
if (!selected) throw new Error("no configured operational task model is available from OpenCode");
return selected;
}
async warmup(): Promise<void> {
const client = await this.ensureClient();
const healthStartedAt = Date.now();
const healthResponse = await client.global.health();
const health = requireData(healthResponse.data, "global.health");
logDevelopmentDebug("opencode warmup health check completed", {
elapsedMs: Math.max(0, Date.now() - healthStartedAt),
healthy: health.healthy,
version: health.version,
});
const sessionStartedAt = Date.now();
const sessionResponse = await client.session.create({
title: "tjwater-agent-warmup",
});
const session = requireData(sessionResponse.data, "session.create");
logDevelopmentDebug("opencode warmup session created", {
elapsedMs: Math.max(0, Date.now() - sessionStartedAt),
sessionId: session.id,
});
const health = await this.health();
if (!health.healthy || !health.version.startsWith("2.")) {
throw new Error(`OpenCode 2 is required; received ${health.version}`);
}
const session = await this.createSession("tjwater-agent-warmup");
try {
const [provider, model] = config.OPENCODE_MODEL.split("/");
if (!provider || !model) {
throw new Error(
`invalid OPENCODE_MODEL; expected provider/model, received ${config.OPENCODE_MODEL}`,
);
const response = await client.rpc.call({
rpcID: "tjwater.tools", method: "list", input: {},
location: { directory: session.location.directory },
});
const tools = response.output as { id: string; direct: boolean; sandboxed: boolean; workspaceRoot?: string }[] | undefined;
const required = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"];
const missing = required.filter((name) => !tools?.some((tool) => tool.id === name && tool.direct));
if (missing.length) throw new Error(`OpenCode 2 business tools missing: ${missing.join(", ")}`);
if (!tools?.some((tool) => tool.id === "shell" && tool.sandboxed)) throw new Error("OpenCode shell sandbox replacement is missing");
const actualRoot = tools.find((tool) => tool.id === "shell")?.workspaceRoot;
if (!actualRoot || await realpath(actualRoot) !== await realpath(config.CONVERSATION_WORKSPACE_ROOT)) throw new Error("OpenCode and Agent conversation workspace roots do not match");
const models = await client.model.list({ location: { directory: session.location.directory } });
const model = models.data.find((model) => `${model.providerID}/${model.id}` === config.OPENCODE_MODEL);
if (!model?.enabled || !model.capabilities.tools) {
throw new Error(`OpenCode 2 model unavailable or does not support tools: ${config.OPENCODE_MODEL}`);
}
const toolsStartedAt = Date.now();
const toolsResponse = await client.tool.list({ provider, model });
const tools = requireData(toolsResponse.data, "tool.list");
logDevelopmentDebug("opencode warmup tools loaded", {
elapsedMs: Math.max(0, Date.now() - toolsStartedAt),
model: config.OPENCODE_MODEL,
sessionId: session.id,
toolCount: tools.length,
});
logger.info({ version: health.version, model: config.OPENCODE_MODEL, toolCount: tools?.length }, "opencode2 warmup tools verified");
} finally {
const cleanupStartedAt = Date.now();
let cleanupSucceeded = true;
await client.session.delete(
{ sessionID: session.id },
{ throwOnError: true },
).catch((error) => {
cleanupSucceeded = false;
logger.warn(
{ err: error, sessionId: session.id },
"failed to remove opencode warmup session",
);
});
logDevelopmentDebug("opencode warmup session cleanup completed", {
elapsedMs: Math.max(0, Date.now() - cleanupStartedAt),
sessionId: session.id,
succeeded: cleanupSucceeded,
});
await client.session.remove({ sessionID: session.id });
await rm(await resolveConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT), { recursive: true });
}
}
async createSession(title?: string) {
const client = await this.ensureClient();
const response = await client.session.create({
title,
});
return requireData(response.data, "session.create");
const [providerID, id] = config.OPENCODE_MODEL.split("/");
const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
try {
return await client.session.create({ title, agent: "instruction", model: { providerID, id }, location: { directory } });
} catch (error) {
await rm(directory, { recursive: true });
throw error;
}
}
async ensureSessionWorkspace(sessionId: string): Promise<string> {
this.workspaceBindings ??= new Map();
const pending = this.workspaceBindings.get(sessionId);
if (pending) return pending;
const operation = (async () => {
const client = await this.ensureClient();
const session = await client.session.get({ sessionID: sessionId });
if (resolve(session.location.directory) !== process.cwd()) {
return resolveConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT);
}
// Old sessions used the service checkout. Moving an idle session binds a
// fresh directory without copying application data, credentials or logs.
const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
try { await client.session.move({ sessionID: sessionId, directory }); }
catch (error) { await rm(directory, { recursive: true }); throw error; }
return directory;
})();
this.workspaceBindings.set(sessionId, operation);
try { return await operation; }
finally { this.workspaceBindings.delete(sessionId); }
}
async sendPrompt(sessionId: string, text: string) {
@@ -182,6 +179,43 @@ export class OpencodeRuntimeAdapter {
return this.messages(sessionId);
}
async approveSandboxShell(input: {
sessionId: string; command: string; description?: string;
callId: string; messageId: string;
}, signal: AbortSignal) {
const client = await this.ensureClient();
const controller = new AbortController();
const combined = AbortSignal.any([signal, controller.signal, AbortSignal.timeout(5 * 60_000)]);
const iterator = client.event.subscribe({ signal: combined })[Symbol.asyncIterator]();
let requestId: string | undefined;
let replied = false;
try {
// Subscribe first: an immediate client reply must never be lost.
await iterator.next();
const permission = await client.permission.create({
sessionID: input.sessionId, action: "shell", resources: [input.command], save: [],
metadata: { command: input.command, description: input.description ?? "" },
source: { type: "tool", messageID: input.messageId, id: input.callId },
}, { signal: combined });
requestId = permission.id;
if (permission.effect === "deny") throw Object.assign(new Error("shell permission denied"), { status: 403 });
if (permission.effect === "allow") throw Object.assign(new Error("sandbox shell requires interactive approval"), { status: 403 });
while (true) {
const next = await iterator.next();
if (next.done) throw new Error("permission event stream ended before approval");
const event = next.value;
if (event.type !== "permission.replied" || event.data.sessionID !== input.sessionId || event.data.requestID !== requestId) continue;
replied = true;
if (event.data.reply === "reject") throw Object.assign(new Error("shell permission rejected"), { status: 403 });
return;
}
} finally {
controller.abort();
await iterator.return?.();
if (requestId && !replied) await client.permission.reply({ sessionID: input.sessionId, requestID: requestId, decision: "reject" }).catch(() => {});
}
}
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
const client = await this.ensureClient();
const startedAt = Date.now();
@@ -193,11 +227,8 @@ export class OpencodeRuntimeAdapter {
textChars: text.length,
},
);
await client.session.prompt({
sessionID: sessionId,
model,
parts: [{ type: "text", text }],
});
if (model) await client.session.switchModel({ sessionID: sessionId, model: { providerID: model.providerID, id: model.modelID } });
await client.session.prompt({ sessionID: sessionId, text });
logDevelopmentDebug(
"opencode session.prompt returned",
{
@@ -209,29 +240,19 @@ export class OpencodeRuntimeAdapter {
async messages(sessionId: string, limit = 20) {
const client = await this.ensureClient();
const messages = await client.session.messages({
sessionID: sessionId,
limit,
});
return requireData(messages.data, "session.messages");
const messages = await client.message.list({ sessionID: sessionId, limit, order: "desc" });
return messages.data.reverse().map((message) => normalizeRuntimeMessage(message, sessionId));
}
async revertMessage(sessionId: string, messageId: string) {
const client = await this.ensureClient();
const response = await client.session.revert({
sessionID: sessionId,
messageID: messageId,
});
return response.data;
return client.session.revert.stage({ sessionID: sessionId, messageID: messageId, files: false });
}
async removeMessage(sessionId: string, messageId: string) {
const client = await this.ensureClient();
const response = await client.session.deleteMessage({
sessionID: sessionId,
messageID: messageId,
});
return response.data;
// V2 commits the staged message boundary atomically; individual deletion was removed.
await client.session.revert.commit({ sessionID: sessionId });
}
async revertToUserMessage(sessionId: string, options: { userOrdinal: number }) {
@@ -253,32 +274,13 @@ export class OpencodeRuntimeAdapter {
}
const targetMessageId = getRuntimeMessageId(targetUserMessage);
const targetIndex = messages.findIndex(
(message) => getRuntimeMessageId(message) === targetMessageId,
);
const messagesToRemove = targetIndex >= 0 ? messages.slice(targetIndex) : [targetUserMessage];
await this.revertMessage(sessionId, targetMessageId);
for (const message of messagesToRemove.reverse()) {
const messageId = getRuntimeMessageId(message);
try {
await this.removeMessage(sessionId, messageId);
} catch (error) {
logger.warn(
{ err: error, sessionId, messageId },
"failed to remove reverted opencode message",
);
}
}
await this.removeMessage(sessionId, targetMessageId);
}
async abortSession(sessionId: string) {
const client = await this.ensureClient();
const response = await client.session.abort({
sessionID: sessionId,
});
return requireData(response.data, "session.abort");
return client.session.interrupt({ sessionID: sessionId });
}
async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) {
@@ -286,10 +288,8 @@ export class OpencodeRuntimeAdapter {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const response = await client.session.status({});
const statuses = requireData(response.data, "session.status");
const status = statuses[sessionId];
if (!status || status.type === "idle") {
const active = await client.session.active();
if (!active[sessionId]) {
return;
}
await delay(100);
@@ -303,126 +303,56 @@ export class OpencodeRuntimeAdapter {
async subscribeEvents() {
const client = await this.ensureClient();
const response = await client.event.subscribe();
return response.stream;
}
async replyPermission(options: {
requestId: string;
sessionId?: string;
reply: PermissionReply;
message?: string;
}) {
const client = await this.ensureClient();
if ("permission" in client && client.permission?.reply) {
const response = await client.permission.reply({
requestID: options.requestId,
reply: options.reply,
message: options.message,
});
return response.data;
}
if ("permission" in client && client.permission?.respond && options.sessionId) {
const response = await client.permission.respond({
sessionID: options.sessionId,
permissionID: options.requestId,
response: options.reply,
});
return response.data;
}
throw new Error("opencode permission reply API is unavailable");
}
async replyQuestion(options: {
requestId: string;
sessionId?: string;
answers: QuestionAnswers;
}) {
const client = await this.ensureClient();
if ("question" in client && client.question?.reply) {
try {
const response = await client.question.reply({
requestID: options.requestId,
answers: options.answers,
});
return response.data;
} catch (error) {
if (!options.sessionId) {
throw error;
const controller = new AbortController();
const iterator = client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]();
// Await the connected event before dispatching a prompt so its first events cannot be lost.
const first = await iterator.next();
const normalizer = new Opencode2EventNormalizer();
const stream = (async function* () {
try {
let next = first;
while (!next.done) {
for (const event of normalizer.normalize(next.value)) yield event;
next = await iterator.next();
}
} catch (error) {
if (!controller.signal.aborted) throw error;
} finally {
controller.abort();
await iterator.return?.();
}
}
}
const v2Question = (client as unknown as {
v2?: {
session?: {
question?: {
reply?: (parameters: {
sessionID: string;
requestID: string;
questionV2Reply: { answers: QuestionAnswers };
}) => Promise<{ data: unknown }>;
};
})();
return {
[Symbol.asyncIterator]() {
return {
next: () => stream.next(),
return: async () => {
// Cancel pending next() before returning; async generator return alone cannot interrupt it.
controller.abort();
return stream.return(undefined);
},
};
};
}).v2?.session?.question;
if (v2Question?.reply && options.sessionId) {
const response = await v2Question.reply({
sessionID: options.sessionId,
requestID: options.requestId,
questionV2Reply: {
answers: options.answers,
},
});
return response.data;
}
throw new Error("opencode question reply API is unavailable");
},
};
}
async rejectQuestion(options: {
requestId: string;
sessionId?: string;
}) {
async replyPermission(options: { requestId: string; sessionId?: string; reply: PermissionReply; message?: string }) {
if (!options.sessionId) throw new Error("OpenCode 2 permission replies require sessionId");
const client = await this.ensureClient();
if ("question" in client && client.question?.reject) {
try {
const response = await client.question.reject({
requestID: options.requestId,
});
return response.data;
} catch (error) {
if (!options.sessionId) {
throw error;
}
}
}
return client.permission.reply({ sessionID: options.sessionId, requestID: options.requestId, decision: options.reply, message: options.message });
}
const v2Question = (client as unknown as {
v2?: {
session?: {
question?: {
reject?: (parameters: {
sessionID: string;
requestID: string;
}) => Promise<{ data: unknown }>;
};
};
};
}).v2?.session?.question;
async replyQuestion(options: { requestId: string; sessionId?: string; answers: QuestionAnswers }) {
if (!options.sessionId) throw new Error("OpenCode 2 form replies require sessionId");
const client = await this.ensureClient();
const form = await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId });
return client.session.form.reply({ sessionID: options.sessionId, formID: options.requestId, answer: formAnswers(form, options.answers) });
}
if (v2Question?.reject && options.sessionId) {
const response = await v2Question.reject({
sessionID: options.sessionId,
requestID: options.requestId,
});
return response.data;
}
throw new Error("opencode question reject API is unavailable");
async rejectQuestion(options: { requestId: string; sessionId?: string }) {
if (!options.sessionId) throw new Error("OpenCode 2 form cancellation requires sessionId");
const client = await this.ensureClient();
return client.session.form.cancel({ sessionID: options.sessionId, formID: options.requestId });
}
async dispose(): Promise<void> {
@@ -438,15 +368,14 @@ export class OpencodeRuntimeAdapter {
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
mode: config.OPENCODE_MODE,
},
"connecting to opencode server in client mode",
"connecting to opencode2 server in client mode",
);
return createOpencodeClient({
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
});
return createRuntimeClient(config.OPENCODE_CLIENT_BASE_URL!, config.OPENCODE_CLIENT_PASSWORD);
}
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
// 这样 .opencode/tools 下的自定义工具可以回调本服务。
process.env.TJWATER_AGENT_WORKSPACE_ROOT = resolve(config.CONVERSATION_WORKSPACE_ROOT);
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`;
process.env.TJWATER_AGENT_INTERNAL_TOKEN =
config.AGENT_INTERNAL_TOKEN ??
@@ -474,13 +403,14 @@ export class OpencodeRuntimeAdapter {
model: config.OPENCODE_MODEL,
mode: config.OPENCODE_MODE,
},
"starting opencode server in embedded mode",
"starting opencode2 server in embedded mode",
);
const startedAt = Date.now();
let runtime;
try {
runtime = await createOpencode({
runtime = await startOpencode2({
command: config.OPENCODE_COMMAND,
hostname: config.OPENCODE_HOSTNAME,
port,
timeout: config.OPENCODE_TIMEOUT_MS,
@@ -489,7 +419,7 @@ export class OpencodeRuntimeAdapter {
} catch (error) {
if (isMissingOpencodeCli(error)) {
throw new Error(
"embedded mode requires the opencode CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL",
"embedded mode requires the opencode2 CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL",
);
}
throw error;
@@ -499,14 +429,14 @@ export class OpencodeRuntimeAdapter {
{
elapsedMs: Math.max(0, Date.now() - startedAt),
hostname: config.OPENCODE_HOSTNAME,
baseUrl: runtime.server.url,
baseUrl: runtime.url,
mode: config.OPENCODE_MODE,
},
"opencode server started in embedded mode",
"opencode2 server started in embedded mode",
);
this.closeServer = () => {
runtime.server.close();
runtime.close();
};
return runtime.client;
@@ -516,12 +446,12 @@ export class OpencodeRuntimeAdapter {
export const opencodeRuntime = new OpencodeRuntimeAdapter();
function buildOpencodeConfig(): Record<string, unknown> {
return deepMerge(
return serviceOpencodeConfig(deepMerge(
deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
{
model: config.OPENCODE_MODEL,
},
);
));
}
function readProjectOpencodeConfig(): Record<string, unknown> {
@@ -577,13 +507,6 @@ function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException {
);
}
function requireData<T>(data: T | undefined, operation: string): T {
if (data === undefined) {
throw new Error(`${operation} returned no data`);
}
return data;
}
function delay(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);