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
+1 -1
View File
@@ -10,7 +10,7 @@ export type AgentModelOption = {
export const defaultAgentModelOptions: AgentModelOption[] = [
{
id: "deepseek/deepseek-v4-flash",
id: "deepseek/deepseek-flash",
label: "快速",
description: "快速回答和任务执行",
icon: "bolt",
+2
View File
@@ -67,7 +67,9 @@ export class ChatSessionBridge {
sessionId,
startedAt: Date.now(),
};
const workspaceDirectory = await this.runtime.ensureSessionWorkspace?.(sessionId);
setRuntimeSessionContext({
workspaceDirectory,
actorKey: requestContext.actorKey,
allowLearningWrite: true,
clientSessionId: requestContext.clientSessionId,
+10 -3
View File
@@ -58,16 +58,23 @@ const envSchema = z
.default("./logs/llm-request-audit.log"),
// 内部工具桥鉴权 token;embedded 可自动生成,client 模式必须与外部 OpenCode 显式共享。
AGENT_INTERNAL_TOKEN: optionalString(),
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
// 每个 OpenCode 会话独占的 Shell 工作目录;与受保护的业务 data/logs 分开。
CONVERSATION_WORKSPACE_ROOT: z.string().default("./.agent-workspaces"),
SANDBOX_MAX_OUTPUT_BYTES: z.coerce.number().int().positive().max(16 * 1024 * 1024).default(1024 * 1024),
// OpenCode 2 CLI 命令或绝对路径。
OPENCODE_COMMAND: z.string().default("opencode2"),
// 外部 OpenCode 2 Server 的密码;embedded 模式自动管理。
OPENCODE_CLIENT_PASSWORD: optionalString(),
// embedded 启动私有 CLI 子进程;client 连接现有 V2 server。
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
// embedded opencode server 的监听地址。
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
// embedded opencode server 的监听端口;0 表示由系统动态分配。
OPENCODE_PORT: z.coerce.number().int().nonnegative().default(0),
// opencode SDK 启动或连接运行时时的超时时间(毫秒)。
// OpenCode 2 启动超时时间(毫秒)。
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// 默认使用的 opencode 模型标识。
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-flash"),
OPENCODE_MODEL: z.string().default("deepseek/deepseek-flash"),
// 后台工况任务首选模型;缺省沿用 OPENCODE_MODEL,运行时会从实际可用模型中选择回退。
OPERATIONAL_TASK_MODEL: optionalString(),
// 聊天 UI 和 /stream 允许选择的 opencode 模型完整配置,JSON 数组。
+1
View File
@@ -82,6 +82,7 @@ export const attachTrustedAgentContext = async (req: Request) => {
trustedProductCredentials.set(req, {
authorization,
projectId: verified.project_id,
expiresAt: verified.token_expires_at,
});
} catch (error) {
const status = error && typeof error === "object" && "status" in error &&
+17
View File
@@ -25,7 +25,24 @@ const renderScadaAnalysisInput = z.object({
seen.add(item.device_id);
});
});
const viewManifest = (id: string, map = false): FrontendActionManifest => ({ id, version: "frontend-action@1", risk: "view", timeoutMs: 15_000, supportedSurfaces: map ? ["map_overlay"] : ["side_panel", "canvas"] });
export const safeChartInput = z.object({
title: z.string().max(200), chart_type: z.enum(["line", "bar"]),
x_data: z.array(z.string().max(128)).min(1).max(100),
series: z.array(z.object({ name: z.string().max(128), data: z.array(z.number().finite()).min(1).max(100) }).strict()).min(1).max(8),
}).strict().refine((value) => value.series.every((series) => series.data.length === value.x_data.length), "series must align with x_data");
const definitions: Definition[] = [
{ manifest: viewManifest("locate_features", true),
inputSchema: z.object({ layer: z.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]), feature_ids: z.array(deviceId).min(1).max(100) }).strict(),
outputSchema: z.object({ located_ids: z.array(deviceId).min(1), missing_ids: z.array(deviceId), fitted: z.literal(true) }).strict() },
{ manifest: viewManifest("zoom_to_map", true),
inputSchema: z.object({ center: z.tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)]), zoom: z.number().min(0).max(24) }).strict(),
outputSchema: z.object({ fitted: z.literal(true) }).strict() },
{ manifest: viewManifest("show_chart"), inputSchema: safeChartInput,
outputSchema: z.object({ displayed: z.literal(true), series_count: z.number().int().positive() }).strict() },
{ manifest: viewManifest("view_history"),
inputSchema: z.object({ element_type: z.enum(["junction", "pipe"]), element_id: deviceId, start: z.string().datetime({ offset: true }), end: z.string().datetime({ offset: true }), chart: safeChartInput.optional() }).strict(),
outputSchema: z.object({ displayed: z.literal(true), series_count: z.number().int().positive() }).strict() },
{
manifest: {
id: "render_scada_analysis",
+1 -1
View File
@@ -573,7 +573,7 @@ const toRuntimeModel = (model?: SupportedModel) => {
};
const GATE_MODEL = {
modelID: "deepseek-v4-flash",
modelID: "deepseek-flash",
providerID: "deepseek",
} as const;
+195
View File
@@ -0,0 +1,195 @@
import { type MemoryScope, MemoryStore } from "../memory/store.js";
import {
setRuntimeSessionContext,
type RuntimeSessionContext,
} from "../runtime/sessionContext.js";
import { SkillStore } from "../skills/store.js";
export type MemoryManagerInput = {
action: "add" | "list" | "replace" | "remove";
content?: string;
scope: string;
target_id?: string;
};
export type SkillManagerInput = {
action:
| "list"
| "write_skill"
| "remove_skill"
| "append_pattern"
| "remove_pattern"
| "write_reference"
| "remove_reference"
| "write_script"
| "remove_script";
content?: string;
file_path?: string;
pattern?: string;
skill_path: string;
target_id?: string;
};
export const executeMemoryManager = async (
memoryStore: MemoryStore,
sessionContext: RuntimeSessionContext,
input: MemoryManagerInput,
) => {
const scope: MemoryScope | null =
input.scope === "user"
? "user"
: input.scope === "workspace"
? "workspace"
: null;
if (!scope) {
return rejected(
"memory",
`unsupported scope: ${input.scope}; use exact keyword 'user' or 'workspace'`,
);
}
if (sessionContext.allowLearningWrite === false && input.action !== "list") {
return rejected("memory", "memory writes are disabled for this session");
}
const scopeKey =
scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
if (input.action === "list") {
const items = await memoryStore.list(scope, scopeKey);
setRuntimeSessionContext({
...sessionContext,
memoryListReadScopes: {
...(sessionContext.memoryListReadScopes ?? {}),
[scope]: true,
},
});
return {
ok: true,
kind: "memory",
decision: "accepted",
detail: "memory listed",
items,
target: scope,
};
}
if (input.action === "add") {
if (sessionContext.memoryListReadScopes?.[scope] !== true) {
return {
...rejected(
"memory",
`must list ${scope} memory and review existing entries before add`,
),
target: scope,
};
}
const result = await memoryStore.upsert(scope, scopeKey, {
content: input.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
});
if (!result.entry) {
return rejected("memory", "content rejected by persistence policy");
}
return {
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "deduped",
detail: result.detail,
entry: result.entry,
target: scope,
};
}
const result =
input.action === "replace"
? await memoryStore.replace(scope, scopeKey, input.target_id ?? "", {
content: input.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
})
: await memoryStore.remove(scope, scopeKey, input.target_id ?? "");
return {
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: scope,
};
};
export const executeSkillManager = async (
skillStore: SkillStore,
sessionContext: RuntimeSessionContext,
input: SkillManagerInput,
) => {
if (sessionContext.allowLearningWrite === false && input.action !== "list") {
return rejected("skill", "skill writes are disabled for this session");
}
if (input.action === "list") {
const result = await skillStore.list(input.skill_path);
if (!result) {
return rejected(
"skill",
"invalid skill_path; expected a relative path under .opencode/skills",
);
}
return {
ok: true,
kind: "skill",
decision: "accepted",
detail: "skill listed",
references: result.references,
scripts: result.scripts,
skill_path: result.skillPath,
target: result.target,
patterns: result.patterns,
};
}
const result =
input.action === "write_skill"
? await skillStore.writeSkill(input.skill_path, input.content ?? "")
: input.action === "remove_skill"
? await skillStore.removeSkill(input.skill_path)
: input.action === "append_pattern"
? await skillStore.appendPattern(input.skill_path, input.pattern ?? "")
: input.action === "remove_pattern"
? await skillStore.removePattern(input.skill_path, input.target_id ?? "")
: input.action === "write_reference"
? await skillStore.writeReference(
input.skill_path,
input.file_path ?? "",
input.content ?? "",
)
: input.action === "remove_reference"
? await skillStore.removeReference(
input.skill_path,
input.file_path ?? "",
)
: input.action === "write_script"
? await skillStore.writeScript(
input.skill_path,
input.file_path ?? "",
input.content ?? "",
)
: await skillStore.removeScript(
input.skill_path,
input.file_path ?? "",
);
return {
ok: true,
kind: "skill",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: result.target,
};
};
const rejected = (kind: "memory" | "skill", detail: string) => ({
ok: true,
kind,
decision: "rejected",
detail,
});
+283
View File
@@ -0,0 +1,283 @@
import { randomUUID } from "node:crypto";
import { type ProductCredential } from "./server-auth-client.js";
export type CredentialRefreshReason =
| "access_token_expired"
| "access_token_rejected";
export type CredentialRefreshEvent =
| {
type: "credential_refresh_required";
requestId: string;
reason: CredentialRefreshReason;
timeoutMs: number;
}
| {
type: "credential_refreshed";
requestId: string;
}
| {
type: "credential_refresh_failed";
requestId: string;
message: string;
};
type PendingRefresh = {
deadlineAt: number;
promise: Promise<ProductCredential>;
reason: CredentialRefreshReason;
reject: (error: Error) => void;
requestId: string;
resolve: (context: ProductCredential) => void;
timer: ReturnType<typeof setTimeout>;
};
type CredentialRefreshListener = (event: CredentialRefreshEvent) => void;
export class CredentialRefreshError extends Error {
override readonly name = "CredentialRefreshError";
constructor(
message: string,
readonly code: "cancelled" | "failed" | "timeout" | "unavailable" = "failed",
) {
super(message);
}
}
const AUTH_EXPIRY_SKEW_MS = 30_000;
export const isRuntimeCredentialExpired = (
context: ProductCredential,
now = Date.now(),
) => {
if (!context.expiresAt) {
return false;
}
const expiresAt = Date.parse(context.expiresAt);
return Number.isFinite(expiresAt) && now >= expiresAt - AUTH_EXPIRY_SKEW_MS;
};
export class CredentialRefreshCoordinator {
private readonly listeners = new Map<
string,
Set<CredentialRefreshListener>
>();
private readonly background = new Set<string>();
enableBackground(sessionId: string) { this.background.add(sessionId); }
disableBackground(sessionId: string) { this.background.delete(sessionId); }
notifyFailure(sessionId: string, message: string) {
this.emit(sessionId, { type: "credential_refresh_failed", requestId: `credential-${randomUUID()}`, message });
}
private readonly pending = new Map<string, PendingRefresh>();
constructor(private readonly timeoutMs = 30_000) {}
subscribe(sessionId: string, listener: CredentialRefreshListener) {
const listeners =
this.listeners.get(sessionId) ?? new Set<CredentialRefreshListener>();
listeners.add(listener);
this.listeners.set(sessionId, listeners);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
this.listeners.delete(sessionId);
}
};
}
request(sessionId: string, reason: CredentialRefreshReason) {
const existing = this.pending.get(sessionId);
if (existing) {
return existing.promise;
}
if (!this.listeners.get(sessionId)?.size && !this.background.has(sessionId)) {
return Promise.reject(
new CredentialRefreshError(
"credential refresh channel is unavailable",
"unavailable",
),
);
}
const requestId = `credential-${randomUUID()}`;
let resolvePromise!: (context: ProductCredential) => void;
let rejectPromise!: (error: Error) => void;
const promise = new Promise<ProductCredential>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
const timer = setTimeout(() => {
this.fail(sessionId, requestId, "credential refresh timed out", "timeout");
}, this.timeoutMs);
this.pending.set(sessionId, {
deadlineAt: Date.now() + this.timeoutMs,
promise,
reason,
reject: rejectPromise,
requestId,
resolve: resolvePromise,
timer,
});
this.emit(sessionId, {
type: "credential_refresh_required",
requestId,
reason,
timeoutMs: this.timeoutMs,
});
return promise;
}
resolve(
sessionId: string,
requestId: string,
context: ProductCredential,
) {
const pending = this.pending.get(sessionId);
if (!pending || pending.requestId !== requestId) {
return false;
}
clearTimeout(pending.timer);
this.pending.delete(sessionId);
pending.resolve(context);
this.emit(sessionId, {
type: "credential_refreshed",
requestId,
});
return true;
}
fail(
sessionId: string,
requestId: string,
message: string,
code: CredentialRefreshError["code"] = "failed",
emitFailureEvent = true,
) {
const pending = this.pending.get(sessionId);
if (!pending || pending.requestId !== requestId) {
return false;
}
clearTimeout(pending.timer);
this.pending.delete(sessionId);
pending.reject(new CredentialRefreshError(message, code));
if (emitFailureEvent) {
this.emit(sessionId, {
type: "credential_refresh_failed",
requestId,
message,
});
}
return true;
}
cancelSession(sessionId: string, message = "credential refresh cancelled") {
const pending = this.pending.get(sessionId);
if (!pending) {
return false;
}
return this.fail(
sessionId,
pending.requestId,
message,
"cancelled",
false,
);
}
getPendingRequestId(sessionId: string) {
return this.pending.get(sessionId)?.requestId;
}
getPendingEvent(
sessionId: string,
): Extract<CredentialRefreshEvent, { type: "credential_refresh_required" }> | null {
const pending = this.pending.get(sessionId);
if (!pending) return null;
return {
type: "credential_refresh_required",
requestId: pending.requestId,
reason: pending.reason,
timeoutMs: Math.max(0, pending.deadlineAt - Date.now()),
};
}
private emit(sessionId: string, event: CredentialRefreshEvent) {
for (const listener of this.listeners.get(sessionId) ?? []) {
listener(event);
}
}
}
export const runWithCredentialRefresh = async <T extends { status: number }>(
coordinator: CredentialRefreshCoordinator,
sessionId: string,
context: ProductCredential,
execute: (context: ProductCredential) => Promise<T>,
getCurrent?: () => ProductCredential | undefined,
) => {
let activeContext = getCurrent?.() ?? context;
let refreshed = false;
if (isRuntimeCredentialExpired(activeContext)) {
activeContext = await coordinator.request(
sessionId,
"access_token_expired",
);
refreshed = true;
}
let result = await execute(activeContext);
if (result.status !== 401 || refreshed) {
return result;
}
if (result instanceof Response) await result.body?.cancel();
const latest = getCurrent?.();
activeContext = latest && latest.authorization !== activeContext.authorization
? latest : await coordinator.request(sessionId, "access_token_rejected");
result = await execute(activeContext);
return result;
};
const credentialVault = new Map<string, ProductCredential>();
const credentialFailures = new Set<string>();
export const credentialRefreshCoordinator = new CredentialRefreshCoordinator(45_000);
export const bindSessionCredential = (sessionId: string, credential: ProductCredential | null) => {
if (credential) { credentialVault.set(sessionId, { ...credential }); credentialFailures.delete(sessionId); }
};
export const releaseSessionCredential = (sessionId: string) => {
credentialRefreshCoordinator.cancelSession(sessionId);
credentialRefreshCoordinator.disableBackground(sessionId);
credentialVault.delete(sessionId);
credentialFailures.delete(sessionId);
};
export const refreshSessionCredential = (sessionId: string, credential: ProductCredential, requestId?: string) => {
const existing = credentialVault.get(sessionId);
if (!existing || existing.projectId !== credential.projectId) return false;
const pendingId = credentialRefreshCoordinator.getPendingRequestId(sessionId);
if (requestId && requestId !== pendingId) return false;
bindSessionCredential(sessionId, credential);
if (pendingId) credentialRefreshCoordinator.resolve(sessionId, pendingId, credential);
return true;
};
export const requestWithSessionCredential = async (
sessionId: string, execute: (credential: ProductCredential) => Promise<Response>,
) => {
const credential = credentialVault.get(sessionId);
if (!credential) throw Object.assign(new Error("task access requires a current product credential"), { status: 401 });
if (credentialFailures.has(sessionId)) throw Object.assign(new CredentialRefreshError("credential renewal failed"), { status: 401 });
try {
const response = await runWithCredentialRefresh(credentialRefreshCoordinator, sessionId, credential, execute, () => credentialVault.get(sessionId));
if (response.status === 401) {
credentialFailures.add(sessionId);
credentialRefreshCoordinator.notifyFailure(sessionId, "renewed credential was rejected");
}
return response;
} catch (error) {
if (error instanceof CredentialRefreshError && error.code !== "cancelled") {
credentialFailures.add(sessionId);
if (error.code === "unavailable") credentialRefreshCoordinator.notifyFailure(sessionId, error.message);
}
throw error;
}
};
@@ -0,0 +1,64 @@
import { z } from "zod";
import { config } from "../config.js";
import { requestWithSessionCredential } from "./credential-refresh.js";
import type { components } from "../generated/server-api.js";
const domain = z.string().trim().min(1).max(253).regex(/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/);
export const webSearchInput = z.object({
query: z.string().trim().min(1).max(2000),
freshness: z.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"]).default("no_limit"),
summary: z.boolean().default(true),
count: z.number().int().min(1).max(50).default(10),
include: z.array(domain).max(20).optional(),
exclude: z.array(domain).max(20).optional(),
}).strict();
export const geocodeInput = z.object({ keyword: z.string().trim().min(1).max(500) }).strict();
const freshness = { no_limit: "noLimit", one_day: "oneDay", one_week: "oneWeek", one_month: "oneMonth", one_year: "oneYear" } as const;
export class ExternalInformationClient {
constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {}
async webSearch(sessionId: string, input: z.infer<typeof webSearchInput>, signal?: AbortSignal) {
const parsed = webSearchInput.parse(input);
const body = { ...parsed, freshness: freshness[parsed.freshness] } satisfies components["schemas"]["WebSearchRequest"];
return this.request(sessionId, "/api/v1/web-searches", body, signal);
}
async geocode(sessionId: string, input: z.infer<typeof geocodeInput>, signal?: AbortSignal) {
const body = geocodeInput.parse(input) satisfies components["schemas"]["TiandituGeocodeRequest"];
const response = await this.request(sessionId, "/api/v1/geocoding-requests", body, signal);
if (!response || typeof response !== "object") throw Object.assign(new Error("geocoding returned no valid coordinate"), { status: 502 });
const data = response as Record<string, unknown>;
const location = data.location as { lon?: unknown; lat?: unknown } | undefined;
const coordinate = (value: unknown) => typeof value === "number" || (typeof value === "string" && value.trim()) ? Number(value) : NaN;
const lon = coordinate(location?.lon);
const lat = coordinate(location?.lat);
if (String(data.status) !== "0" || !Number.isFinite(lon) || !Number.isFinite(lat) || Math.abs(lon) > 180 || Math.abs(lat) > 90) {
throw Object.assign(new Error("geocoding returned no valid coordinate"), { status: 502 });
}
return { ...data, location: { ...location, lon, lat }, source_crs: "EPSG:4326", center: [lon, lat] };
}
private async request(sessionId: string, path: string, body: unknown, signal?: AbortSignal): Promise<unknown> {
const response = await requestWithSessionCredential(sessionId, (credential) => fetch(new URL(path, this.baseUrl), {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: credential.authorization, "X-Project-Id": credential.projectId },
body: JSON.stringify(body),
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
}));
if (!response.ok) {
let detail = "";
try {
const data = await response.json() as { detail?: unknown };
if (typeof data.detail === "string") detail = data.detail;
else if (data.detail && typeof data.detail === "object") {
const message = (data.detail as { message?: unknown; msg?: unknown }).message ?? (data.detail as { msg?: unknown }).msg;
if (typeof message === "string") detail = message;
}
} catch { /* Preserve the HTTP status if the upstream error has no JSON. */ }
throw Object.assign(new Error(`external information query failed (${response.status})${detail ? `: ${detail.slice(0, 500)}` : ""}`), { status: response.status });
}
try { return await response.json(); }
catch { throw Object.assign(new Error("external information returned invalid JSON"), { status: 502 }); }
}
}
+18
View File
@@ -0,0 +1,18 @@
import { z } from "zod";
const historyResponse = z.object({ series: z.array(z.object({
element_id: z.string(), metric: z.string(), source: z.string(), source_unit: z.string(),
device_id: z.string().nullable().optional(),
points: z.array(z.object({ time: z.string(), value: z.number().finite().nullable() })),
})) });
export const historyChart = (value: unknown, elementId: string) => {
const series = historyResponse.parse(value).series.filter((item) => item.element_id === elementId && item.points.some((point) => point.value !== null));
const first = series[0];
if (!first) throw new Error("NO_HISTORY_DATA");
const compatible = series.filter((item) => item.metric === first.metric && item.source_unit === first.source_unit).slice(0, 8);
const points = first.points.filter((point) => point.value !== null).slice(0, 100);
const aligned = compatible.map((item) => ({ item, values: new Map(item.points.map((point) => [point.time, point.value])) }))
.filter(({ values }) => points.every((point) => typeof values.get(point.time) === "number"));
return { title: `${elementId} ${first.metric} (${first.source_unit}) · 最多100点`, chart_type: "line" as const,
x_data: points.map((point) => point.time), series: aligned.map(({ item, values }) => ({ name: `${item.device_id ?? elementId} ${item.source}`, data: points.map((point) => values.get(point.time) as number) })) };
};
+19 -22
View File
@@ -1,3 +1,4 @@
import { bindSessionCredential, releaseSessionCredential, requestWithSessionCredential } from "./credential-refresh.js";
import { z } from "zod";
import { config } from "../config.js";
@@ -179,7 +180,6 @@ export const taskArtifactBridgeSchema = z.object({
{ message: "at least one task artifact is required" },
);
const credentialVault = new Map<string, ProductCredential>();
const taskRunStates = new Map<string, { runId: string; revision: number }>();
const taskRunMutationQueues = new Map<string, Promise<void>>();
@@ -187,11 +187,11 @@ export const bindOperationalTaskCredential = (
runtimeSessionId: string,
credential: ProductCredential | null,
) => {
if (credential) credentialVault.set(runtimeSessionId, { ...credential });
bindSessionCredential(runtimeSessionId, credential);
};
export const releaseOperationalTaskCredential = (runtimeSessionId: string) => {
credentialVault.delete(runtimeSessionId);
releaseSessionCredential(runtimeSessionId);
taskRunStates.delete(runtimeSessionId);
taskRunMutationQueues.delete(runtimeSessionId);
};
@@ -393,25 +393,22 @@ export class OperationalTaskClient {
}
private async request(runtimeSessionId: string, path: string, init: RequestInit) {
const credential = credentialVault.get(runtimeSessionId);
if (!credential) {
throw new OperationalTaskClientError(401, "task access requires a current product credential");
}
let response: Response;
try {
response = await fetch(new URL(path, this.baseUrl), {
...init,
signal: init.signal ?? AbortSignal.timeout(config.TJWATER_SERVER_TIMEOUT_MS),
headers: {
Accept: "application/json",
Authorization: credential.authorization,
"Content-Type": "application/json",
"X-Project-Id": credential.projectId,
},
});
} catch {
throw new OperationalTaskClientError(503, "TJWater Server task API unavailable");
}
const response = await requestWithSessionCredential(runtimeSessionId, async (credential) => {
try {
return await fetch(new URL(path, this.baseUrl), {
...init,
signal: init.signal ?? AbortSignal.timeout(config.TJWATER_SERVER_TIMEOUT_MS),
headers: {
Accept: "application/json",
Authorization: credential.authorization,
"Content-Type": "application/json",
"X-Project-Id": credential.projectId,
},
});
} catch {
throw new OperationalTaskClientError(503, "TJWater Server task API unavailable");
}
});
const text = await response.text();
const value = parseJson(text);
if (!response.ok) {
+56
View File
@@ -0,0 +1,56 @@
import { z } from "zod";
import { config } from "../config.js";
import { requestWithSessionCredential } from "./credential-refresh.js";
import type { components } from "../generated/server-api.js";
const id = z.string().trim().min(1).max(128);
const windowFields = { start: z.string().datetime({ offset: true }), end: z.string().datetime({ offset: true }) };
export const projectDataQuerySchema = z.discriminatedUnion("source", [
z.object({ source: z.literal("element_properties"), element_type: z.enum(["junction", "pipe", "valve", "pump", "tank", "reservoir"]), element_id: id }).strict(),
z.object({ source: z.literal("scada_devices") }).strict(),
z.object({ source: z.literal("scada_history"), device_ids: z.array(id).min(1).max(20), field: z.enum(["monitored_value", "cleaned_value"]), ...windowFields }).strict(),
z.object({ source: z.literal("element_history"), element_type: z.enum(["junction", "pipe"]), element_id: id, ...windowFields }).strict(),
z.object({ source: z.literal("analysis_runs"), limit: z.number().int().min(1).max(100).default(20), offset: z.number().int().nonnegative().default(0) }).strict(),
z.object({ source: z.literal("analysis_run"), run_id: z.string().uuid() }).strict(),
z.object({ source: z.literal("analysis_results"), run_id: z.string().uuid(), limit: z.number().int().min(1).max(100).default(20), offset: z.number().int().nonnegative().default(0) }).strict(),
]).superRefine((query, context) => {
if (!("start" in query)) return;
const duration = Date.parse(query.end) - Date.parse(query.start);
if (duration <= 0 || duration > 24 * 60 * 60 * 1000) context.addIssue({ code: z.ZodIssueCode.custom, message: "time window must be positive and at most 24 hours" });
});
export type ProjectDataQuery = z.infer<typeof projectDataQuerySchema>;
export class ProjectDataClient {
constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {}
async query(sessionId: string, input: ProjectDataQuery) {
const query = projectDataQuerySchema.parse(input);
let path: string;
let init: RequestInit = { method: "GET" };
switch (query.source) {
case "element_properties":
path = `/api/v1/${query.element_type}s/properties?${new URLSearchParams({ [query.element_type]: query.element_id })}`;
break;
case "scada_devices": path = "/api/v1/scada-devices"; break;
case "scada_history":
path = `/api/v1/timeseries/scada-readings/fields?${new URLSearchParams({ device_ids: query.device_ids.join(","), field: query.field, start_time: query.start, end_time: query.end })}`;
break;
case "element_history": {
const body = { start_time: query.start, end_time: query.end, mode: "observed", elements: [{ element_type: query.element_type, element_id: query.element_id }] } satisfies components["schemas"]["ElementHistoryQuery"];
path = "/api/v1/timeseries/views/element-history/query";
init = { method: "POST", body: JSON.stringify(body) };
break;
}
case "analysis_runs": path = `/api/v1/analysis/runs?limit=${query.limit}&offset=${query.offset}`; break;
case "analysis_run": path = `/api/v1/analysis/runs/${query.run_id}`; break;
case "analysis_results": path = `/api/v1/analysis/runs/${query.run_id}/results?limit=${query.limit}&offset=${query.offset}`; break;
}
const response = await requestWithSessionCredential(sessionId, (credential) => fetch(new URL(path, this.baseUrl), {
...init,
signal: AbortSignal.timeout(config.TJWATER_SERVER_TIMEOUT_MS),
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: credential.authorization, "X-Project-Id": credential.projectId },
}));
if (!response.ok) throw Object.assign(new Error(`project data query rejected (${response.status})`), { status: response.status });
return response.json() as Promise<unknown>;
}
}
+1
View File
@@ -4,6 +4,7 @@ import type { components } from "../generated/server-api.js";
export type ProductCredential = {
authorization: string;
projectId: string;
expiresAt?: string | null;
};
export type AgentAuthContext = components["schemas"]["AgentAuthContextResponse"];
+28
View File
@@ -0,0 +1,28 @@
import type { Part } from "@opencode-ai/sdk/v2";
const labels = { reference: "查找所需资料", data: "获取业务数据", analysis: "分析与核查", visualization: "更新地图与图表", other: "执行操作" };
const classify = (tool: string): keyof typeof labels => {
if (["skill", "read", "glob", "grep", "session_search", "memory_manager", "skill_manager"].includes(tool)) return "reference";
if (["query_project_data", "query_operational_task_data", "get_operational_task_context"].includes(tool)) return "data";
if (["render_scada_analysis", "clear_scada_analysis", "locate_features", "zoom_to_map", "show_chart", "view_history"].includes(tool)) return "visualization";
if (["publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence"].includes(tool)) return "analysis";
return "other";
};
export const createBusinessStageTracker = (write: (event: string, data: Record<string, unknown>) => void) => {
const stages = new Map<string, { startedAt: number; actions: Map<string, string> }>();
return (part: Extract<Part, { type: "tool" }>) => {
if (["final_answer", "todowrite", "todoread", "question"].includes(part.tool)) return;
const kind = classify(part.tool);
const stage = stages.get(kind) ?? { startedAt: Date.now(), actions: new Map() };
stage.actions.set(part.id, part.state.status);
stages.set(kind, stage);
const statuses = [...stage.actions.values()];
const running = statuses.some((status) => status === "pending" || status === "running");
const status = running ? "running" : statuses.includes("error") ? "error" : "completed";
write("progress", {
id: `business-${kind}`, phase: kind, status, title: labels[kind],
detail: `${statuses.filter((s) => s === "completed").length}/${statuses.length} 项操作已完成`,
started_at: stage.startedAt, ...(running ? { elapsed_ms: Date.now() - stage.startedAt } : { ended_at: Date.now(), duration_ms: Date.now() - stage.startedAt }),
});
};
};
+17 -1
View File
@@ -1,7 +1,8 @@
import { refreshSessionCredential } from "../product/credential-refresh.js";
import { type Router } from "express";
import { z } from "zod";
import { getLocalAgentContext } from "../context/localContext.js";
import { getLocalAgentContext, getTrustedProductCredential } from "../context/localContext.js";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
@@ -72,6 +73,21 @@ export const registerChatInteractionRoutes = (
sessionUiStateStore,
}: RegisterInteractionRoutesOptions,
) => {
chatRouter.post("/session/:session_id/credentials", async (req, res) => {
const record = await sessionMetadataStore.get(getLocalAgentContext(req), req.params.session_id);
const credential = getTrustedProductCredential(req);
if (!record || !credential) { res.status(404).json({ message: "session not found" }); return; }
const run = activeRuns.get(record.sessionId);
const requestId = typeof req.body?.request_id === "string" ? req.body.request_id : undefined;
if (!run || run.status !== "running" || (!requestId && record.purpose !== "task_execution")) {
res.status(409).json({ message: "credential refresh is not pending" }); return;
}
if (!refreshSessionCredential(record.sessionId, credential, requestId)) {
res.status(409).json({ message: "credential refresh request does not match" }); return;
}
res.status(202).json({ accepted: true });
});
chatRouter.post("/permission/:request_id/reply", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
+5
View File
@@ -0,0 +1,5 @@
// Shell and file mutation remain interactive even when ordinary read-only
// business tools are automatically approved. Static denials stay in OpenCode.
export function canAutomaticallyApprove(mode: string, action: string) {
return mode === "always" && !["shell", "bash", "edit", "write", "patch", "read", "external_directory", "execute", "subagent"].includes(action);
}
+5
View File
@@ -1,3 +1,4 @@
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
import { type Router } from "express";
import { z } from "zod";
@@ -576,6 +577,10 @@ export const registerChatSessionRoutes = (
},
};
run.subscribers.add(subscriber);
const pendingRefresh = credentialRefreshCoordinator.getPendingEvent(sessionRecord.sessionId);
if (pendingRefresh) subscriber.write(pendingRefresh.type, {
...pendingRefresh, session_id: sessionRecord.sessionId, request_id: pendingRefresh.requestId,
});
const cleanup = () => {
run.subscribers.delete(subscriber);
+33 -12
View File
@@ -1,4 +1,6 @@
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { canAutomaticallyApprove } from "./chatPermissionPolicy.js";
import { createBusinessStageTracker } from "./chatBusinessStages.js";
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
import { type SupportedModel } from "../chat/models.js";
@@ -187,6 +189,8 @@ export const streamPromptResponse = async ({
let promptSettled = false;
let aborted = signal?.aborted ?? false;
let failed = false;
let finalAnswer: string | undefined;
const trackBusinessStage = createBusinessStageTracker((event, data) => write(event, { ...data, session_id: clientSessionId }));
const debugContext = {
sessionId,
clientSessionId,
@@ -291,6 +295,7 @@ export const streamPromptResponse = async ({
...debugContext,
});
let pendingEvent: Promise<{ type: "event"; result: IteratorResult<OpencodeEvent> }> | null = null;
try {
while (!done) {
if (signal?.aborted) {
@@ -302,7 +307,9 @@ export const streamPromptResponse = async ({
break;
}
const nextEvent = iterator
// V2 prompt() acknowledges inbox admission before generation. Keep the same
// pending read if that acknowledgement wins the race, so no event is consumed unseen.
const nextEvent = pendingEvent ??= iterator
.next()
.then((result) => ({ type: "event" as const, result }));
const nextPrompt = promptSettled
@@ -329,6 +336,7 @@ export const streamPromptResponse = async ({
if (next.type === "prompt") {
continue;
}
pendingEvent = null;
if (next.result.done) {
break;
}
@@ -402,17 +410,18 @@ export const streamPromptResponse = async ({
patterns: event.properties.patterns,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission);
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
status: autoApprove ? "completed" : "running",
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
autoApprove
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionDetail(event),
});
if (approvalMode === "always") {
if (autoApprove) {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
@@ -447,17 +456,18 @@ export const streamPromptResponse = async ({
resources: event.properties.resources,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action);
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
status: autoApprove ? "completed" : "running",
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
autoApprove
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionV2Detail(event),
});
if (approvalMode === "always") {
if (autoApprove) {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
@@ -725,6 +735,16 @@ export const streamPromptResponse = async ({
});
}
if (part.type === "tool") {
if (part.tool === "final_answer") {
if (part.state.status === "completed") {
const output = parseToolOutput(part.state.output);
if (output && typeof output === "object" && typeof (output as Record<string, unknown>).answer === "string") {
finalAnswer = (output as { answer: string }).answer;
}
}
continue;
}
trackBusinessStage(part);
if (part.tool === "publish_execution_plan" && part.state.status === "completed" && !emittedToolParts.has(part.id)) {
const plan = executionPlanSchema.safeParse(part.state.input);
const output = parseToolOutput(part.state.output);
@@ -970,7 +990,7 @@ export const streamPromptResponse = async ({
}
await promptPromise;
if (!emittedText) {
if (!emittedText && !finalAnswer) {
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
@@ -978,6 +998,7 @@ export const streamPromptResponse = async ({
await emitFallbackMessage(runtime, sessionId, clientSessionId, tokenSmoother.writeToken);
}
await tokenSmoother.flush();
if (finalAnswer) write("final_answer", { session_id: clientSessionId, content: finalAnswer });
emitProgress({
id: "request-received",
phase: "start",
@@ -990,7 +1011,7 @@ export const streamPromptResponse = async ({
phase: "complete",
status: "completed",
title: "分析完成",
detail: emittedText
detail: (emittedText || finalAnswer)
? "最终回答已生成并推送到前端。"
: "已完成分析,并通过兜底消息补发最终回答内容。",
});
@@ -1007,7 +1028,7 @@ export const streamPromptResponse = async ({
return { aborted: false, failed: false, toolCallCount };
} finally {
await tokenSmoother.flush();
await iterator.return?.(undefined);
await iterator.return?.();
if (!promptSettled && !aborted) {
await promptPromise.catch(() => undefined);
} else if (!promptSettled) {
+4
View File
@@ -299,6 +299,10 @@ export const normalizeQuestionToolPayload = (
params: Record<string, unknown>,
clientSessionId: string,
): QuestionRequestPayload | undefined => {
// Native V2 question tools create a separate frm_ request. A call ID is not a form ID.
if ("metadata" in part.state && part.state.metadata?.native_form === true) {
return undefined;
}
if (!questionToolNames.has(part.tool)) {
return undefined;
}
+31 -10
View File
@@ -1,3 +1,4 @@
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
import { type Router } from "express";
import { randomUUID } from "node:crypto";
import {
@@ -112,8 +113,10 @@ const isUiMessage = (value: unknown): value is UIMessage =>
(value.role === "system" || value.role === "user" || value.role === "assistant") &&
Array.isArray(value.parts);
const readUiMessageText = (message: UIMessage | undefined) =>
message?.parts
const readUiMessageText = (message: UIMessage | undefined) => {
const finalPart = message?.parts.slice().reverse().find((part) => isObjectRecord(part) && part.type === "data-final_answer" && isObjectRecord(part.data) && typeof part.data.content === "string");
if (finalPart && "data" in finalPart && isObjectRecord(finalPart.data)) return String(finalPart.data.content).trim();
return message?.parts
.flatMap((part) =>
isObjectRecord(part) && part.type === "text" && typeof part.text === "string"
? [part.text]
@@ -121,6 +124,7 @@ const readUiMessageText = (message: UIMessage | undefined) =>
)
.join("")
.trim() ?? "";
};
const readLatestUserText = (messages: UIMessage[]) => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
@@ -142,10 +146,8 @@ const toUiMessages = (messages: unknown[] | undefined) =>
export const toCollaborationMessages = (messages: UIMessage[]) =>
messages.flatMap((message) => {
if (message.role !== "user" && message.role !== "assistant") return [];
const textParts = message.parts.filter(
(part) => isObjectRecord(part) && part.type === "text" && typeof part.text === "string",
);
return textParts.length > 0 ? [{ ...message, parts: textParts } as UIMessage] : [];
const content = readUiMessageText(message);
return content ? [{ ...message, parts: [{ type: "text" as const, text: content }] } as UIMessage] : [];
});
const toLegacyMessages = (messages: UIMessage[]) =>
@@ -651,6 +653,10 @@ export const registerChatStreamRoute = (
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false,
}));
} else if (event === "final_answer") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, content: typeof data.content === "string" ? data.content : message.content,
}));
} else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
@@ -826,6 +832,17 @@ export const registerChatStreamRoute = (
const unregisterFrontendActionSink = parsed.data.capabilities.includes("frontend-action@1")
? frontendActionCoordinator.registerSink(clientSessionId, (request) => publish("frontend_action", request as unknown as Record<string, unknown>))
: undefined;
let credentialFailure: Error | undefined;
const unsubscribeCredentialRefresh = credentialRefreshCoordinator.subscribe(binding.sessionId, (event) => {
publish(event.type, { ...event, session_id: clientSessionId, request_id: event.requestId });
if (event.type === "credential_refresh_failed") {
credentialFailure = new Error(event.message);
abortController.abort();
}
});
if (sessionPurpose === "task_execution") credentialRefreshCoordinator.enableBackground(binding.sessionId);
const cancelCredentialRefresh = () => credentialRefreshCoordinator.cancelSession(binding.sessionId);
abortController.signal.addEventListener("abort", cancelCredentialRefresh, { once: true });
let taskHeartbeat: ReturnType<typeof setInterval> | undefined;
let taskHeartbeatActive = true;
let taskHeartbeatPromise: Promise<void> | undefined;
@@ -897,7 +914,9 @@ export const registerChatStreamRoute = (
publish(event, data);
},
});
if (taskLeaseFailure) {
if (credentialFailure) {
publish("error", { session_id: clientSessionId, message: "会话凭据续期失败,本次执行已停止。" });
} else if (taskLeaseFailure) {
publish("error", {
session_id: clientSessionId,
message: "工况任务租约已失效,本次执行已停止,等待任务重新领取。",
@@ -920,7 +939,7 @@ export const registerChatStreamRoute = (
activeSessionRecord.taskId,
activeSessionRecord.runId,
clientSessionId,
streamResult.aborted ? "AGENT_ABORTED" : "AGENT_STREAM_FAILED",
credentialFailure ? "CREDENTIAL_REFRESH_FAILED" : streamResult.aborted ? "AGENT_ABORTED" : "AGENT_STREAM_FAILED",
streamResult.aborted ? "Agent execution was aborted" : "Agent stream failed",
);
taskRunFinalized = true;
@@ -963,7 +982,7 @@ export const registerChatStreamRoute = (
const assistantMessage = [...messages]
.reverse()
.find((message) => message.info.role === "assistant");
const assistantText = collectTextContent(assistantMessage?.parts ?? []);
const assistantText = readUiMessageText(latestUiMessages.at(-1)) || collectTextContent(assistantMessage?.parts ?? []);
const latestSessionRecord =
(await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
@@ -1046,6 +1065,8 @@ export const registerChatStreamRoute = (
}
throw error;
} finally {
unsubscribeCredentialRefresh();
abortController.signal.removeEventListener("abort", cancelCredentialRefresh);
await stopTaskHeartbeat();
await taskArtifactPublishQueue.catch(() => undefined);
unregisterFrontendActionSink?.();
@@ -1079,7 +1100,7 @@ export const registerChatStreamRoute = (
});
sessionBridge.finalizeRequest(clientSessionId);
releaseOperationalTaskCredential(binding.sessionId);
activeRun.status = taskLeaseFailure
activeRun.status = (taskLeaseFailure || credentialFailure)
? "error"
: abortController.signal.aborted
? "aborted"
+33
View File
@@ -0,0 +1,33 @@
import { type Express } from "express";
import { z } from "zod";
import { ExternalInformationClient, geocodeInput, webSearchInput } from "../product/external-information-client.js";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
export function registerExternalInformationBridge(app: Express, options: {
internalToken: string;
client?: ExternalInformationClient;
}) {
const client = options.client ?? new ExternalInformationClient();
for (const name of ["web-search", "geocode"] as const) {
app.post(`/internal/tools/${name}`, async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) { res.status(403).json({ message: "forbidden" }); return; }
const parsed = z.object({ session_id: z.string().min(1).max(128), input: name === "web-search" ? webSearchInput : geocodeInput }).strict().safeParse(req.body);
if (!parsed.success) { res.status(400).json({ message: "invalid external information input" }); return; }
const context = getRuntimeSessionContext(parsed.data.session_id);
if (!context || context.learningMode === "review") { res.status(403).json({ message: "active session context required" }); return; }
const controller = new AbortController();
const cancel = () => { if (!res.writableEnded) controller.abort(); };
res.once("close", cancel);
try {
const result = name === "web-search"
? await client.webSearch(context.sessionId, webSearchInput.parse(parsed.data.input), controller.signal)
: await client.geocode(context.sessionId, geocodeInput.parse(parsed.data.input), controller.signal);
if (!controller.signal.aborted) res.json(result);
} catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502;
if (!controller.signal.aborted) res.status(status).json({ message: error instanceof Error ? error.message : "external information unavailable" });
} finally { res.removeListener("close", cancel); }
});
}
}
+54
View File
@@ -0,0 +1,54 @@
import { type Express } from "express";
import { z } from "zod";
import { executeMemoryManager, executeSkillManager } from "../learning/toolManagers.js";
import { type MemoryStore } from "../memory/store.js";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { type SkillStore } from "../skills/store.js";
const memoryInput = z.object({
action: z.enum(["add", "list", "replace", "remove"]),
scope: z.string().max(32),
reason: z.string().max(2000).optional(),
content: z.string().max(20000).optional(),
target_id: z.string().max(128).optional(),
}).strict();
const skillInput = z.object({
action: z.enum(["list", "write_skill", "remove_skill", "append_pattern", "remove_pattern", "write_reference", "remove_reference", "write_script", "remove_script"]),
skill_path: z.string().max(512),
reason: z.string().max(2000).optional(),
content: z.string().max(200000).optional(),
file_path: z.string().max(512).optional(),
pattern: z.string().max(20000).optional(),
target_id: z.string().max(128).optional(),
}).strict();
export const registerLearningToolBridge = (app: Express, options: {
internalToken: string;
memoryStore: MemoryStore;
skillStore: SkillStore;
}) => {
for (const name of ["memory-manager", "skill-manager"] as const) {
app.post(`/internal/tools/${name}`, async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) {
res.status(403).json({ message: "forbidden" });
return;
}
const context = typeof req.body?.session_id === "string"
? getRuntimeSessionContext(req.body.session_id) : null;
if (!context) {
res.status(404).json({ message: "session context not found" });
return;
}
if (name === "memory-manager") {
const input = memoryInput.safeParse(req.body.input);
if (!input.success) { res.status(400).json({ message: "invalid memory input" }); return; }
res.json(await executeMemoryManager(options.memoryStore, context, input.data));
} else {
const input = skillInput.safeParse(req.body.input);
if (!input.success) { res.status(400).json({ message: "invalid skill input" }); return; }
res.json(await executeSkillManager(options.skillStore, context, input.data));
}
});
}
};
+80
View File
@@ -0,0 +1,80 @@
import { readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { type Express } from "express";
import { z } from "zod";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { executeSandboxCommand } from "../sandbox/landlockSandbox.js";
import { setSandboxOwnership } from "../runtime/conversationWorkspace.js";
export const sandboxInput = z.object({
session_id: z.string().min(1).max(128),
call_id: z.string().min(1).max(128),
message_id: z.string().min(1).max(128),
command: z.string().min(1).max(20000),
description: z.string().max(500).optional(),
timeout: z.number().int().min(1).max(1800).default(120),
}).strict();
// The replacement tool must retain the service's static command denials as
// well as the native approval gate. Command parsing is not the OS boundary.
export function isShellCommandDenied(command: string): boolean {
const rules = (JSON.parse(readFileSync("opencode.json", "utf8")) as {
permissions: { action: string; resource: string; effect: string }[];
}).permissions;
let effect = "ask";
for (const rule of rules) {
if (rule.action !== "shell" && rule.action !== "*") continue;
const pattern = rule.resource.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replaceAll("*", ".*").replaceAll("?", ".");
if (new RegExp(`^${pattern}$`, "s").test(command)) effect = rule.effect;
}
return effect === "deny";
}
export function registerSandboxToolBridge(app: Express, options: {
internalToken: string;
runtime: {
ensureSessionWorkspace(sessionId: string): Promise<string>;
approveSandboxShell(input: { sessionId: string; command: string; description?: string; callId: string; messageId: string }, signal: AbortSignal): Promise<void>;
};
}) {
app.post("/internal/tools/sandbox-shell", async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) {
res.status(403).json({ message: "forbidden" }); return;
}
const parsed = sandboxInput.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ message: "invalid sandbox input" }); return; }
const context = getRuntimeSessionContext(parsed.data.session_id);
if (!context) { res.status(404).json({ message: "session context not found" }); return; }
if (context.learningMode === "review" || isShellCommandDenied(parsed.data.command)) {
res.status(403).json({ message: "shell command denied" }); return;
}
const controller = new AbortController();
const cancel = () => { if (!res.writableEnded) controller.abort(); };
res.once("close", cancel);
try {
const workspace = await options.runtime.ensureSessionWorkspace(context.sessionId);
await options.runtime.approveSandboxShell({
sessionId: context.sessionId, command: parsed.data.command, description: parsed.data.description,
callId: parsed.data.call_id, messageId: parsed.data.message_id,
}, controller.signal);
const result = await executeSandboxCommand(workspace, parsed.data.command, parsed.data.timeout, controller.signal);
const files: Partial<Record<"stdout_file" | "stderr_file", string>> = {};
for (const stream of ["stdout", "stderr"] as const) {
if (Buffer.byteLength(result[stream]) <= 3000) continue;
const path = resolve(workspace, "files", `shell-${randomUUID()}-${stream}.txt`);
await writeFile(path, result[stream], { flag: "wx", mode: 0o600 });
await setSandboxOwnership(path);
files[`${stream}_file`] = path;
result[stream] = Buffer.from(result[stream]).subarray(0, 3000).toString("utf8");
}
if (!controller.signal.aborted) res.json({ ...result, ...files, workspace_directory: workspace, cwd: `${workspace}/files` });
} catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 503;
if (!controller.signal.aborted) res.status(status).json({ message: error instanceof Error ? error.message : "sandbox unavailable" });
} finally { res.removeListener("close", cancel); }
});
}
+78
View File
@@ -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
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);
+20
View File
@@ -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,
} },
};
}
+141
View File
@@ -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] ?? ""];
}));
}
+53
View File
@@ -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;
}
}
+1
View File
@@ -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>();
+242
View File
@@ -0,0 +1,242 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { lstat, mkdir, realpath, rm } from "node:fs/promises";
import { resolve } from "node:path";
import { config } from "../config.js";
import {
resolveConversationWorkspace,
createConversationWorkspace,
SANDBOX_GID,
SANDBOX_UID,
setSandboxOwnership,
} from "../runtime/conversationWorkspace.js";
export type SandboxProbe = {
landlockAbi: number;
seccomp: boolean;
};
export type SandboxExecutionResult = {
exitCode: number | null;
outcome: "completed" | "output_limit" | "timeout" | "cancelled";
signal: NodeJS.Signals | null;
stderr: string;
stderrTruncated: boolean;
stdout: string;
};
const sandboxRunnerPath = resolve("scripts/landlock_sandbox.py");
const maxSandboxTimeoutSeconds = 30 * 60;
const resolvePythonPath = () => {
const virtualEnvironment = process.env.VIRTUAL_ENV?.trim();
const candidates = [
virtualEnvironment ? resolve(virtualEnvironment, "bin/python") : "",
"/opt/venv/bin/python",
"/usr/bin/python3",
];
return candidates.find((candidate) => candidate && existsSync(candidate)) ?? "python3";
};
const collectProcess = async (
args: string[],
options: {
cwd?: string;
env?: NodeJS.ProcessEnv;
maxStderrBytes: number;
maxStdoutBytes: number;
timeoutMs: number;
signal?: AbortSignal;
},
): Promise<SandboxExecutionResult> => {
const child = spawn(resolvePythonPath(), [sandboxRunnerPath, ...args], {
cwd: options.cwd,
detached: true,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let stderrTruncated = false;
let outcome: SandboxExecutionResult["outcome"] = "completed";
let terminationStarted = false;
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
const killGroup = (signal: NodeJS.Signals) => {
if (child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Fall back to the direct child when process groups are unavailable.
}
}
child.kill(signal);
};
// The foreground shell can exit before descendants close inherited pipes.
// Reap its group on exit, rather than waiting for pipe close or the timeout.
child.once("exit", () => killGroup("SIGKILL"));
const terminate = (nextOutcome: "output_limit" | "timeout" | "cancelled") => {
if (terminationStarted) return;
terminationStarted = true;
outcome = nextOutcome;
killGroup("SIGTERM");
forceKillTimer = setTimeout(() => killGroup("SIGKILL"), 1500);
};
const cancel = () => terminate("cancelled");
options.signal?.addEventListener("abort", cancel, { once: true });
if (options.signal?.aborted) cancel();
const timeoutTimer = setTimeout(() => terminate("timeout"), options.timeoutMs);
child.stdout.on("data", (chunk: Buffer) => {
if (terminationStarted) return;
if (stdoutBytes + chunk.length > options.maxStdoutBytes) {
terminate("output_limit");
return;
}
stdoutChunks.push(chunk);
stdoutBytes += chunk.length;
});
child.stderr.on("data", (chunk: Buffer) => {
if (stderrTruncated) return;
const remaining = options.maxStderrBytes - stderrBytes;
if (chunk.length > remaining) {
if (remaining > 0) {
stderrChunks.push(chunk.subarray(0, remaining));
stderrBytes += remaining;
}
stderrTruncated = true;
return;
}
stderrChunks.push(chunk);
stderrBytes += chunk.length;
});
return await new Promise((resolveResult, reject) => {
child.once("error", (error) => {
options.signal?.removeEventListener("abort", cancel);
clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
reject(error);
});
child.once("close", (exitCode, signal) => {
options.signal?.removeEventListener("abort", cancel);
clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
resolveResult({
exitCode,
outcome,
signal,
stderr: Buffer.concat(stderrChunks, stderrBytes).toString("utf8"),
stderrTruncated,
stdout:
outcome === "output_limit"
? ""
: Buffer.concat(stdoutChunks, stdoutBytes).toString("utf8"),
});
});
});
};
export const probeLandlockSandbox = async (): Promise<SandboxProbe> => {
const result = await collectProcess(["--probe"], {
maxStderrBytes: 16 * 1024,
maxStdoutBytes: 16 * 1024,
timeoutMs: 5000,
});
if (result.exitCode !== 0) {
throw new Error(result.stderr || "Landlock sandbox probe failed");
}
const payload = JSON.parse(result.stdout) as {
landlock_abi?: unknown;
seccomp?: unknown;
};
if (
typeof payload.landlock_abi !== "number" ||
payload.landlock_abi < 4 ||
payload.seccomp !== true
) {
throw new Error("Landlock ABI 4 and seccomp are required");
}
const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
try {
const execution = await executeSandboxCommand(directory, "python3 -c 'import socket;\ntry: socket.socket()\nexcept PermissionError: print(\"sandbox-probe-ok\")\nelse: raise RuntimeError(\"network filter not installed\")'", 5);
if (execution.exitCode !== 0 || !execution.stdout.includes("sandbox-probe-ok")) {
throw new Error(execution.stderr || "sandbox execution probe failed");
}
} finally { await rm(directory, { recursive: true }); }
return { landlockAbi: payload.landlock_abi, seccomp: true };
};
const buildSandboxEnvironment = (workspace: string): NodeJS.ProcessEnv => ({
HOME: workspace,
LANG: process.env.LANG ?? "C.UTF-8",
LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
MPLCONFIGDIR: resolve(workspace, ".cache/matplotlib"),
PATH: "/opt/venv/bin:/usr/local/bin:/usr/bin:/bin",
PYTHONDONTWRITEBYTECODE: "1",
TMPDIR: resolve(workspace, "tmp"),
TZ: process.env.TZ ?? "Asia/Shanghai",
VIRTUAL_ENV: "/opt/venv",
XDG_CACHE_HOME: resolve(workspace, ".cache"),
});
export const executeSandboxCommand = async (
workspaceDirectory: string,
command: string,
timeoutSeconds: number,
signal?: AbortSignal,
): Promise<SandboxExecutionResult> => {
const workspace = await resolveConversationWorkspace(
workspaceDirectory,
config.CONVERSATION_WORKSPACE_ROOT,
);
const normalizedTimeout = Math.min(
maxSandboxTimeoutSeconds,
Math.max(1, Math.trunc(timeoutSeconds)),
);
for (const path of [resolve(workspace, "files"), resolve(workspace, "tmp"), resolve(workspace, ".cache")]) {
await mkdir(path, { mode: 0o700, recursive: true });
if ((await lstat(path)).isSymbolicLink() || await realpath(path) !== path) {
throw new Error("sandbox writable directory must not be a symbolic link");
}
await setSandboxOwnership(path);
}
const readOnlyPaths = [
"/usr",
"/bin",
"/lib",
"/lib64",
"/opt/venv",
"/etc/ld.so.cache",
"/etc/localtime",
"/etc/group",
"/etc/nsswitch.conf",
"/etc/passwd",
resolve(config.OPENCODE_SKILLS_ROOT_DIR),
].filter(existsSync);
const args = [
"--workspace",
workspace,
"--uid",
String(SANDBOX_UID),
"--gid",
String(SANDBOX_GID),
...readOnlyPaths.flatMap((path) => ["--read-only", path]),
...["files", "tmp", ".cache"].flatMap((name) => ["--writable", resolve(workspace, name)]),
"--command",
command,
];
return collectProcess(args, {
cwd: resolve(workspace, "files"),
env: buildSandboxEnvironment(workspace),
maxStderrBytes: 256 * 1024,
maxStdoutBytes: config.SANDBOX_MAX_OUTPUT_BYTES,
timeoutMs: normalizedTimeout * 1000,
signal,
});
};
+37 -1
View File
@@ -10,6 +10,11 @@ import { SessionMetadataStore } from "./sessions/metadataStore.js";
import { logger } from "./logger.js";
import { LearningOrchestrator } from "./learning/orchestrator.js";
import { MemoryStore } from "./memory/store.js";
import { SkillStore } from "./skills/store.js";
import { registerSandboxToolBridge } from "./routes/sandboxToolBridge.js";
import { registerExternalInformationBridge } from "./routes/externalInformationBridge.js";
import { probeLandlockSandbox } from "./sandbox/landlockSandbox.js";
import { registerLearningToolBridge } from "./routes/learningToolBridge.js";
import { ResultReferenceResolver } from "./results/resolver.js";
import { ResultReferenceStore } from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js";
@@ -25,6 +30,9 @@ import {
taskArtifactBridgeSchema,
} from "./product/operational-task-client.js";
import { isTrustedInternalBridgeRequest } from "./product/internal-bridge-auth.js";
import { ProjectDataClient, projectDataQuerySchema } from "./product/project-data-client.js";
import { historyChart } from "./product/history-chart.js";
import { getFrontendActionDefinition } from "./frontendAction/registry.js";
const app = express();
@@ -48,6 +56,22 @@ process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
app.use(cors());
app.use(express.json({ limit: "1mb" }));
registerSandboxToolBridge(app, { internalToken, runtime: opencodeRuntime });
registerExternalInformationBridge(app, { internalToken });
registerLearningToolBridge(app, { internalToken, memoryStore, skillStore: new SkillStore() });
const projectDataClient = new ProjectDataClient();
app.post("/internal/tools/project-data", async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, internalToken)) { res.status(403).json({ message: "forbidden" }); return; }
const context = typeof req.body?.session_id === "string" ? getRuntimeSessionContext(req.body.session_id) : null;
if (!context || context.purpose !== "ordinary_chat") { res.status(403).json({ message: "ordinary chat context required" }); return; }
const query = projectDataQuerySchema.safeParse(req.body.query);
if (!query.success) { res.status(400).json({ message: "invalid project data query", detail: query.error.flatten() }); return; }
try { res.json(await projectDataClient.query(context.sessionId, query.data)); }
catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502;
res.status(status).json({ message: error instanceof Error ? error.message : "project data query failed" });
}
});
app.post("/internal/frontend-actions/request", async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, internalToken)) {
@@ -65,11 +89,20 @@ app.post("/internal/frontend-actions/request", async (req, res) => {
return;
}
try {
const name = typeof req.body?.name === "string" ? req.body.name : "";
let params = req.body?.params;
if (name === "view_history") {
if (context.purpose !== "ordinary_chat") { res.status(403).json({ message: "ordinary chat context required" }); return; }
const input = getFrontendActionDefinition(name)!.inputSchema.parse(params) as { element_type: "junction" | "pipe"; element_id: string; start: string; end: string };
const query = projectDataQuerySchema.parse({ source: "element_history", element_type: input.element_type, element_id: input.element_id, start: input.start, end: input.end });
const data = await projectDataClient.query(context.sessionId, query);
params = { ...input, chart: historyChart(data, input.element_id) };
}
const result = await frontendActionCoordinator.request({
sessionId: context.clientSessionId,
toolCallId: typeof req.body?.call_id === "string" ? req.body.call_id : "",
name: typeof req.body?.name === "string" ? req.body.name : "",
params: req.body?.params,
params,
fallbackText: typeof req.body?.fallback_text === "string" ? req.body.fallback_text : undefined,
});
res.json(result);
@@ -226,6 +259,7 @@ app.get("/health", async (_req, res) => {
ready: true,
warmed_up: true,
runtime,
sandbox: sandboxProbe,
sessions: sessionBridge.count(),
});
} catch (error) {
@@ -322,10 +356,12 @@ const warmupOpencodeRuntime = async () => {
},
"failed to warm up opencode runtime",
);
await opencodeRuntime.dispose();
throw error;
}
};
const sandboxProbe = await probeLandlockSandbox();
await bootstrap();
await warmupOpencodeRuntime();
resultReferenceStore.startCleanupLoop();