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:
@@ -0,0 +1,78 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
chown,
|
||||
lstat,
|
||||
mkdir,
|
||||
realpath,
|
||||
stat,
|
||||
} from "node:fs/promises";
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
export const SANDBOX_UID = 10_001;
|
||||
export const SANDBOX_GID = 10_001;
|
||||
|
||||
const isOutsideRoot = (root: string, candidate: string) => {
|
||||
const relativePath = relative(root, candidate);
|
||||
return (
|
||||
relativePath === ".." ||
|
||||
relativePath.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relativePath)
|
||||
);
|
||||
};
|
||||
|
||||
export const setSandboxOwnership = async (path: string) => {
|
||||
if (typeof process.getuid !== "function" || process.getuid() !== 0) {
|
||||
return;
|
||||
}
|
||||
await chown(path, SANDBOX_UID, SANDBOX_GID);
|
||||
};
|
||||
|
||||
export const resolveConversationWorkspace = async (
|
||||
workspaceDirectory: string,
|
||||
importRoot: string,
|
||||
) => {
|
||||
const workspaceLinkStat = await lstat(workspaceDirectory);
|
||||
if (workspaceLinkStat.isSymbolicLink()) {
|
||||
throw new Error("conversation workspace must not be a symbolic link");
|
||||
}
|
||||
const [resolvedImportRoot, resolvedWorkspaceDirectory] = await Promise.all([
|
||||
realpath(importRoot),
|
||||
realpath(workspaceDirectory),
|
||||
]);
|
||||
if (isOutsideRoot(resolvedImportRoot, resolvedWorkspaceDirectory)) {
|
||||
throw new Error("conversation workspace must be inside CONVERSATION_WORKSPACE_ROOT");
|
||||
}
|
||||
const relativeWorkspace = relative(
|
||||
resolvedImportRoot,
|
||||
resolvedWorkspaceDirectory,
|
||||
);
|
||||
if (
|
||||
!relativeWorkspace ||
|
||||
relativeWorkspace.includes("/") ||
|
||||
relativeWorkspace.includes("\\")
|
||||
) {
|
||||
throw new Error(
|
||||
"conversation workspace must be a direct child of CONVERSATION_WORKSPACE_ROOT",
|
||||
);
|
||||
}
|
||||
const workspaceStat = await stat(resolvedWorkspaceDirectory);
|
||||
if (!workspaceStat.isDirectory()) {
|
||||
throw new Error("conversation workspace must point to a directory");
|
||||
}
|
||||
return resolvedWorkspaceDirectory;
|
||||
};
|
||||
|
||||
export const createConversationWorkspace = async (root: string) => {
|
||||
await mkdir(root, { recursive: true, mode: 0o711 });
|
||||
if ((await lstat(root)).isSymbolicLink()) throw new Error("workspace root must not be a symbolic link");
|
||||
const directory = resolve(await realpath(root), `conversation-${randomUUID()}`);
|
||||
// Keep the location root read-only to Shell. OpenCode must never load plugins
|
||||
// or configuration written by the model; writable files live one level below.
|
||||
await mkdir(directory, { mode: 0o755 });
|
||||
for (const name of ["files", "tmp", ".cache"]) {
|
||||
const path = resolve(directory, name);
|
||||
await mkdir(path, { mode: 0o700 });
|
||||
await setSandboxOwnership(path);
|
||||
}
|
||||
return directory;
|
||||
};
|
||||
+171
-248
@@ -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);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// 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>) {
|
||||
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,
|
||||
snapshots: false,
|
||||
plugins: ["*", ...((input.plugins ?? []) as unknown[]), resolve(".opencode/tjwater-plugin")],
|
||||
skills: [resolve(process.env.OPENCODE_SKILLS_ROOT_DIR ?? ".opencode/skills")],
|
||||
agents: { ...agents, instruction: {
|
||||
description: "TJWater structured data and UI agent", mode: "primary", ...instruction, system,
|
||||
} },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Event, Part } from "@opencode-ai/sdk/v2";
|
||||
import type { FormInfo, SessionMessageInfo, V2Event } from "@opencode/client";
|
||||
|
||||
// Preserve the product's existing SSE contract while consuming the native V2 API.
|
||||
const legacyEvent = (type: string, properties: unknown) => ({ type, properties }) as Event;
|
||||
const legacyPart = (part: unknown) => part as Part;
|
||||
const textContent = (content: readonly { type: string; text?: string }[]) =>
|
||||
content.filter((item) => item.type === "text").map((item) => item.text ?? "").join("\n");
|
||||
|
||||
export function normalizeRuntimeMessage(message: SessionMessageInfo, sessionID: string) {
|
||||
const parts: Part[] = [];
|
||||
if (message.type === "assistant") {
|
||||
message.content.forEach((content, ordinal) => {
|
||||
const id = `${message.id}:${content.type}:${ordinal}`;
|
||||
if (content.type === "tool") {
|
||||
const state = content.state;
|
||||
parts.push(legacyPart({
|
||||
id: content.id, sessionID, messageID: message.id, type: "tool",
|
||||
callID: content.id, tool: content.name,
|
||||
state: {
|
||||
...state,
|
||||
input: "input" in state ? state.input : {},
|
||||
output: "content" in state && state.content ? textContent(state.content) : undefined,
|
||||
error: "error" in state ? state.error.message : undefined,
|
||||
time: { start: content.time.created, end: content.time.completed },
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
parts.push(legacyPart({
|
||||
...content, id, sessionID, messageID: message.id,
|
||||
time: { start: message.time.created, end: message.time.completed },
|
||||
}));
|
||||
}
|
||||
});
|
||||
} else if ("text" in message) {
|
||||
parts.push(legacyPart({ id: `${message.id}:text:0`, sessionID, messageID: message.id, type: "text", text: message.text }));
|
||||
}
|
||||
return { info: { id: message.id, role: message.type }, parts };
|
||||
}
|
||||
|
||||
export class Opencode2EventNormalizer {
|
||||
private tools = new Map<string, { name: string; input: Record<string, unknown>; start: number }>();
|
||||
|
||||
normalize(event: V2Event): Event[] {
|
||||
if (!("data" in event)) return [];
|
||||
const data = event.data;
|
||||
if (event.type === "permission.asked") {
|
||||
return [legacyEvent("permission.v2.asked", {
|
||||
...event.data,
|
||||
tool: event.data.source?.type === "tool" ? {
|
||||
messageID: event.data.source.messageID, callID: event.data.source.id,
|
||||
} : undefined,
|
||||
})];
|
||||
}
|
||||
if (event.type === "permission.replied") return [legacyEvent("permission.v2.replied", data)];
|
||||
if (event.type === "form.created") {
|
||||
const form = event.data.form;
|
||||
return [legacyEvent("question.v2.asked", {
|
||||
id: form.id, sessionID: form.sessionID,
|
||||
questions: form.fields.filter((field) => !("hidden" in field && field.hidden)).map((field) => ({
|
||||
header: field.title ?? form.title,
|
||||
question: field.description ?? field.title ?? form.title,
|
||||
options: "options" in field ? (field.options ?? []).map((option) => ({
|
||||
label: option.label, description: option.description ?? "",
|
||||
})) : [],
|
||||
multiple: field.type === "multiselect",
|
||||
custom: "custom" in field ? field.custom : true,
|
||||
})),
|
||||
})];
|
||||
}
|
||||
if (event.type === "form.replied") return [legacyEvent("question.v2.replied", {
|
||||
sessionID: event.data.sessionID, requestID: event.data.id,
|
||||
answers: Object.values(event.data.answer).map((value) => Array.isArray(value) ? value : [String(value)]),
|
||||
})];
|
||||
if (event.type === "form.cancelled") return [legacyEvent("question.v2.rejected", { sessionID: event.data.sessionID, requestID: event.data.id })];
|
||||
if (event.type === "session.status" || event.type === "session.idle") return [legacyEvent(event.type, data)];
|
||||
if (event.type === "session.retry.scheduled") return [legacyEvent("session.status", {
|
||||
sessionID: event.data.sessionID,
|
||||
status: { type: "retry", attempt: event.data.attempt, next: event.data.at, message: event.data.error.message },
|
||||
})];
|
||||
if (event.type === "session.execution.started") return [legacyEvent("session.status", { sessionID: event.data.sessionID, status: { type: "busy" } })];
|
||||
if (event.type === "session.execution.succeeded" || event.type === "session.execution.interrupted") return [
|
||||
legacyEvent("session.status", { sessionID: event.data.sessionID, status: { type: "idle" } }),
|
||||
legacyEvent("session.idle", { sessionID: event.data.sessionID }),
|
||||
];
|
||||
if (event.type === "session.execution.failed") {
|
||||
const error = event.data.error;
|
||||
return [legacyEvent("session.error", { sessionID: event.data.sessionID, error: { name: error.type, data: { message: error.message } } })];
|
||||
}
|
||||
if (event.type.startsWith("session.text.") || event.type.startsWith("session.reasoning.")) {
|
||||
const item = data as { sessionID: string; assistantMessageID: string; ordinal: number; text?: string; delta?: string };
|
||||
const type = event.type.startsWith("session.text.") ? "text" : "reasoning";
|
||||
const id = `${item.assistantMessageID}:${type}:${item.ordinal}`;
|
||||
const properties = { sessionID: item.sessionID, messageID: item.assistantMessageID };
|
||||
if (event.type.endsWith(".delta")) return [legacyEvent("message.part.delta", { ...properties, partID: id, field: "text", delta: item.delta ?? "" })];
|
||||
return [legacyEvent("message.part.updated", { ...properties, part: {
|
||||
...properties, id, type, text: item.text ?? "",
|
||||
time: { start: "created" in event ? event.created : Date.now(), ...(event.type.endsWith(".ended") ? { end: "created" in event ? event.created : Date.now() } : {}) },
|
||||
} })];
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
this.tools.set(event.data.id, { name: event.data.name, input: {}, start: event.created });
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
const tool = this.tools.get(event.data.id);
|
||||
if (tool) tool.input = event.data.input;
|
||||
}
|
||||
if (event.type === "session.tool.input.started" || event.type === "session.tool.called" || event.type === "session.tool.success" || event.type === "session.tool.failed") {
|
||||
const item = event.data;
|
||||
const tool = this.tools.get(item.id);
|
||||
if (!tool) return [];
|
||||
const completed = event.type === "session.tool.success";
|
||||
const failed = event.type === "session.tool.failed";
|
||||
const part = {
|
||||
id: item.id, callID: item.id, sessionID: item.sessionID, messageID: item.assistantMessageID,
|
||||
type: "tool", tool: tool.name,
|
||||
state: {
|
||||
status: completed ? "completed" : failed ? "error" : "running",
|
||||
input: tool.input,
|
||||
metadata: { native_form: tool.name === "question" },
|
||||
output: event.type === "session.tool.success" ? textContent(event.data.content) : undefined,
|
||||
error: event.type === "session.tool.failed" ? event.data.error.message : undefined,
|
||||
time: { start: tool.start, ...(completed || failed ? { end: event.created } : {}) },
|
||||
},
|
||||
};
|
||||
if (completed || failed) this.tools.delete(item.id);
|
||||
const events = [legacyEvent("message.part.updated", { sessionID: item.sessionID, part })];
|
||||
if (completed && tool.name === "todowrite") events.push(legacyEvent("todo.updated", { sessionID: item.sessionID, todos: tool.input.todos ?? [] }));
|
||||
return events;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function formAnswers(form: FormInfo, answers: string[][]) {
|
||||
return Object.fromEntries(form.fields.filter((field) => !("hidden" in field && field.hidden)).map((field, index) => {
|
||||
const values = (answers[index] ?? []).map((value) => "options" in field
|
||||
? field.options?.find((option) => option.label === value)?.value ?? value : value);
|
||||
return [field.key, field.type === "multiselect" ? values : values[0] ?? ""];
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode/client";
|
||||
|
||||
export const createRuntimeClient = (baseUrl: string, password?: string) =>
|
||||
OpenCode.make({
|
||||
baseUrl,
|
||||
headers: password ? {
|
||||
authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`,
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
export async function startOpencode2(options: {
|
||||
command: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
timeout: number;
|
||||
config: Record<string, unknown>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ client: OpenCodeClient; url: string; password: string; close: () => void; exited: Promise<number | null> }> {
|
||||
const child = spawn(options.command, [
|
||||
"serve", `--hostname=${options.hostname}`, `--port=${options.port}`,
|
||||
], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...options.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config) },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const stopOnExit = () => { child.kill("SIGTERM"); };
|
||||
process.once("exit", stopOnExit);
|
||||
child.once("exit", () => process.removeListener("exit", stopOnExit));
|
||||
try {
|
||||
const endpoint = await new Promise<{ url: string; password: string }>((resolve, reject) => {
|
||||
let stdout = "";
|
||||
const timer = setTimeout(() => reject(new Error("opencode2 startup timed out")), options.timeout);
|
||||
const fail = (error: Error) => { clearTimeout(timer); reject(error); };
|
||||
child.once("error", fail);
|
||||
child.once("exit", (code) => fail(new Error(`opencode2 exited during startup (code ${code})`)));
|
||||
// The CLI prints a generated password. Capture it privately; never forward it to logs.
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
const url = /server listening on (https?:\/\/\S+)/.exec(stdout)?.[1];
|
||||
const password = /server password (\S+)/.exec(stdout)?.[1];
|
||||
if (url && password) { clearTimeout(timer); resolve({ url, password }); }
|
||||
});
|
||||
child.stderr.resume();
|
||||
});
|
||||
const client = createRuntimeClient(endpoint.url, endpoint.password);
|
||||
await client.server.info({ signal: AbortSignal.timeout(options.timeout) });
|
||||
return { client, url: endpoint.url, password: endpoint.password, close: () => { child.kill("SIGTERM"); }, exited: new Promise((resolve) => child.once("exit", resolve)) };
|
||||
} catch (error) {
|
||||
child.kill("SIGTERM");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export type RuntimeSessionContext = {
|
||||
capabilities?: string[];
|
||||
purpose?: "ordinary_chat" | "task_execution" | "task_collaboration";
|
||||
taskId?: string;
|
||||
workspaceDirectory?: string;
|
||||
};
|
||||
|
||||
const contexts = new Map<string, RuntimeSessionContext>();
|
||||
|
||||
Reference in New Issue
Block a user