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:
@@ -10,7 +10,7 @@ export type AgentModelOption = {
|
||||
|
||||
export const defaultAgentModelOptions: AgentModelOption[] = [
|
||||
{
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
id: "deepseek/deepseek-flash",
|
||||
label: "快速",
|
||||
description: "快速回答和任务执行",
|
||||
icon: "bolt",
|
||||
|
||||
@@ -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
@@ -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 数组。
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -573,7 +573,7 @@ const toRuntimeModel = (model?: SupportedModel) => {
|
||||
};
|
||||
|
||||
const GATE_MODEL = {
|
||||
modelID: "deepseek-v4-flash",
|
||||
modelID: "deepseek-flash",
|
||||
providerID: "deepseek",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 }); }
|
||||
}
|
||||
}
|
||||
@@ -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) })) };
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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); }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
chown,
|
||||
lstat,
|
||||
mkdir,
|
||||
realpath,
|
||||
stat,
|
||||
} from "node:fs/promises";
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
export const SANDBOX_UID = 10_001;
|
||||
export const SANDBOX_GID = 10_001;
|
||||
|
||||
const isOutsideRoot = (root: string, candidate: string) => {
|
||||
const relativePath = relative(root, candidate);
|
||||
return (
|
||||
relativePath === ".." ||
|
||||
relativePath.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relativePath)
|
||||
);
|
||||
};
|
||||
|
||||
export const setSandboxOwnership = async (path: string) => {
|
||||
if (typeof process.getuid !== "function" || process.getuid() !== 0) {
|
||||
return;
|
||||
}
|
||||
await chown(path, SANDBOX_UID, SANDBOX_GID);
|
||||
};
|
||||
|
||||
export const resolveConversationWorkspace = async (
|
||||
workspaceDirectory: string,
|
||||
importRoot: string,
|
||||
) => {
|
||||
const workspaceLinkStat = await lstat(workspaceDirectory);
|
||||
if (workspaceLinkStat.isSymbolicLink()) {
|
||||
throw new Error("conversation workspace must not be a symbolic link");
|
||||
}
|
||||
const [resolvedImportRoot, resolvedWorkspaceDirectory] = await Promise.all([
|
||||
realpath(importRoot),
|
||||
realpath(workspaceDirectory),
|
||||
]);
|
||||
if (isOutsideRoot(resolvedImportRoot, resolvedWorkspaceDirectory)) {
|
||||
throw new Error("conversation workspace must be inside CONVERSATION_WORKSPACE_ROOT");
|
||||
}
|
||||
const relativeWorkspace = relative(
|
||||
resolvedImportRoot,
|
||||
resolvedWorkspaceDirectory,
|
||||
);
|
||||
if (
|
||||
!relativeWorkspace ||
|
||||
relativeWorkspace.includes("/") ||
|
||||
relativeWorkspace.includes("\\")
|
||||
) {
|
||||
throw new Error(
|
||||
"conversation workspace must be a direct child of CONVERSATION_WORKSPACE_ROOT",
|
||||
);
|
||||
}
|
||||
const workspaceStat = await stat(resolvedWorkspaceDirectory);
|
||||
if (!workspaceStat.isDirectory()) {
|
||||
throw new Error("conversation workspace must point to a directory");
|
||||
}
|
||||
return resolvedWorkspaceDirectory;
|
||||
};
|
||||
|
||||
export const createConversationWorkspace = async (root: string) => {
|
||||
await mkdir(root, { recursive: true, mode: 0o711 });
|
||||
if ((await lstat(root)).isSymbolicLink()) throw new Error("workspace root must not be a symbolic link");
|
||||
const directory = resolve(await realpath(root), `conversation-${randomUUID()}`);
|
||||
// Keep the location root read-only to Shell. OpenCode must never load plugins
|
||||
// or configuration written by the model; writable files live one level below.
|
||||
await mkdir(directory, { mode: 0o755 });
|
||||
for (const name of ["files", "tmp", ".cache"]) {
|
||||
const path = resolve(directory, name);
|
||||
await mkdir(path, { mode: 0o700 });
|
||||
await setSandboxOwnership(path);
|
||||
}
|
||||
return directory;
|
||||
};
|
||||
+171
-248
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
createOpencode,
|
||||
createOpencodeClient,
|
||||
type OpencodeClient,
|
||||
} from "@opencode-ai/sdk/v2";
|
||||
import type { OpenCodeClient as OpencodeClient } from "@opencode/client";
|
||||
import { createRuntimeClient, startOpencode2 } from "./opencode2-server.js";
|
||||
import { formAnswers, normalizeRuntimeMessage, Opencode2EventNormalizer } from "./opencode2-events.js";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { resolve } from "node:path";
|
||||
import { realpath, rm } from "node:fs/promises";
|
||||
import { createConversationWorkspace, resolveConversationWorkspace } from "./conversationWorkspace.js";
|
||||
import { serviceOpencodeConfig } from "./opencode2-config.js";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
@@ -76,6 +77,7 @@ export const resolveEmbeddedOpencodePort = async (
|
||||
export class OpencodeRuntimeAdapter {
|
||||
private clientPromise: Promise<OpencodeClient> | null = null;
|
||||
private closeServer: (() => void) | null = null;
|
||||
private workspaceBindings = new Map<string, Promise<string>>();
|
||||
|
||||
async ensureClient(): Promise<OpencodeClient> {
|
||||
if (!this.clientPromise) {
|
||||
@@ -89,90 +91,85 @@ export class OpencodeRuntimeAdapter {
|
||||
|
||||
async health(): Promise<RuntimeHealth> {
|
||||
const client = await this.ensureClient();
|
||||
const response = await client.global.health();
|
||||
return requireData(response.data, "global.health");
|
||||
const info = await client.server.info();
|
||||
return { healthy: true, version: info.version };
|
||||
}
|
||||
|
||||
async resolveAvailableModel(preferredModelIds: string[]): Promise<string> {
|
||||
const client = await this.ensureClient();
|
||||
const response = await client.config.providers({});
|
||||
const configured = requireData(response.data, "config.providers");
|
||||
const available = new Set(
|
||||
configured.providers.flatMap((provider) =>
|
||||
Object.keys(provider.models).map((modelId) => `${provider.id}/${modelId}`),
|
||||
),
|
||||
);
|
||||
// V2 2.0.24 model.list alone does not await a cold Location's plugin setup.
|
||||
await client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory: process.cwd() } });
|
||||
const models = await client.model.list({ location: { directory: process.cwd() } });
|
||||
const available = new Set(models.data.filter((model) => model.enabled && model.capabilities.tools)
|
||||
.map((model) => `${model.providerID}/${model.id}`));
|
||||
const selected = preferredModelIds.find((modelId) => available.has(modelId));
|
||||
if (!selected) {
|
||||
throw new Error("no configured operational task model is available from OpenCode");
|
||||
}
|
||||
if (!selected) throw new Error("no configured operational task model is available from OpenCode");
|
||||
return selected;
|
||||
}
|
||||
|
||||
async warmup(): Promise<void> {
|
||||
const client = await this.ensureClient();
|
||||
const healthStartedAt = Date.now();
|
||||
const healthResponse = await client.global.health();
|
||||
const health = requireData(healthResponse.data, "global.health");
|
||||
logDevelopmentDebug("opencode warmup health check completed", {
|
||||
elapsedMs: Math.max(0, Date.now() - healthStartedAt),
|
||||
healthy: health.healthy,
|
||||
version: health.version,
|
||||
});
|
||||
|
||||
const sessionStartedAt = Date.now();
|
||||
const sessionResponse = await client.session.create({
|
||||
title: "tjwater-agent-warmup",
|
||||
});
|
||||
const session = requireData(sessionResponse.data, "session.create");
|
||||
logDevelopmentDebug("opencode warmup session created", {
|
||||
elapsedMs: Math.max(0, Date.now() - sessionStartedAt),
|
||||
sessionId: session.id,
|
||||
});
|
||||
|
||||
const health = await this.health();
|
||||
if (!health.healthy || !health.version.startsWith("2.")) {
|
||||
throw new Error(`OpenCode 2 is required; received ${health.version}`);
|
||||
}
|
||||
const session = await this.createSession("tjwater-agent-warmup");
|
||||
try {
|
||||
const [provider, model] = config.OPENCODE_MODEL.split("/");
|
||||
if (!provider || !model) {
|
||||
throw new Error(
|
||||
`invalid OPENCODE_MODEL; expected provider/model, received ${config.OPENCODE_MODEL}`,
|
||||
);
|
||||
const response = await client.rpc.call({
|
||||
rpcID: "tjwater.tools", method: "list", input: {},
|
||||
location: { directory: session.location.directory },
|
||||
});
|
||||
const tools = response.output as { id: string; direct: boolean; sandboxed: boolean; workspaceRoot?: string }[] | undefined;
|
||||
const required = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"];
|
||||
const missing = required.filter((name) => !tools?.some((tool) => tool.id === name && tool.direct));
|
||||
if (missing.length) throw new Error(`OpenCode 2 business tools missing: ${missing.join(", ")}`);
|
||||
if (!tools?.some((tool) => tool.id === "shell" && tool.sandboxed)) throw new Error("OpenCode shell sandbox replacement is missing");
|
||||
const actualRoot = tools.find((tool) => tool.id === "shell")?.workspaceRoot;
|
||||
if (!actualRoot || await realpath(actualRoot) !== await realpath(config.CONVERSATION_WORKSPACE_ROOT)) throw new Error("OpenCode and Agent conversation workspace roots do not match");
|
||||
const models = await client.model.list({ location: { directory: session.location.directory } });
|
||||
const model = models.data.find((model) => `${model.providerID}/${model.id}` === config.OPENCODE_MODEL);
|
||||
if (!model?.enabled || !model.capabilities.tools) {
|
||||
throw new Error(`OpenCode 2 model unavailable or does not support tools: ${config.OPENCODE_MODEL}`);
|
||||
}
|
||||
const toolsStartedAt = Date.now();
|
||||
const toolsResponse = await client.tool.list({ provider, model });
|
||||
const tools = requireData(toolsResponse.data, "tool.list");
|
||||
logDevelopmentDebug("opencode warmup tools loaded", {
|
||||
elapsedMs: Math.max(0, Date.now() - toolsStartedAt),
|
||||
model: config.OPENCODE_MODEL,
|
||||
sessionId: session.id,
|
||||
toolCount: tools.length,
|
||||
});
|
||||
logger.info({ version: health.version, model: config.OPENCODE_MODEL, toolCount: tools?.length }, "opencode2 warmup tools verified");
|
||||
} finally {
|
||||
const cleanupStartedAt = Date.now();
|
||||
let cleanupSucceeded = true;
|
||||
await client.session.delete(
|
||||
{ sessionID: session.id },
|
||||
{ throwOnError: true },
|
||||
).catch((error) => {
|
||||
cleanupSucceeded = false;
|
||||
logger.warn(
|
||||
{ err: error, sessionId: session.id },
|
||||
"failed to remove opencode warmup session",
|
||||
);
|
||||
});
|
||||
logDevelopmentDebug("opencode warmup session cleanup completed", {
|
||||
elapsedMs: Math.max(0, Date.now() - cleanupStartedAt),
|
||||
sessionId: session.id,
|
||||
succeeded: cleanupSucceeded,
|
||||
});
|
||||
await client.session.remove({ sessionID: session.id });
|
||||
await rm(await resolveConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async createSession(title?: string) {
|
||||
const client = await this.ensureClient();
|
||||
const response = await client.session.create({
|
||||
title,
|
||||
});
|
||||
return requireData(response.data, "session.create");
|
||||
const [providerID, id] = config.OPENCODE_MODEL.split("/");
|
||||
const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
|
||||
try {
|
||||
return await client.session.create({ title, agent: "instruction", model: { providerID, id }, location: { directory } });
|
||||
} catch (error) {
|
||||
await rm(directory, { recursive: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureSessionWorkspace(sessionId: string): Promise<string> {
|
||||
this.workspaceBindings ??= new Map();
|
||||
const pending = this.workspaceBindings.get(sessionId);
|
||||
if (pending) return pending;
|
||||
const operation = (async () => {
|
||||
const client = await this.ensureClient();
|
||||
const session = await client.session.get({ sessionID: sessionId });
|
||||
if (resolve(session.location.directory) !== process.cwd()) {
|
||||
return resolveConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT);
|
||||
}
|
||||
// Old sessions used the service checkout. Moving an idle session binds a
|
||||
// fresh directory without copying application data, credentials or logs.
|
||||
const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
|
||||
try { await client.session.move({ sessionID: sessionId, directory }); }
|
||||
catch (error) { await rm(directory, { recursive: true }); throw error; }
|
||||
return directory;
|
||||
})();
|
||||
this.workspaceBindings.set(sessionId, operation);
|
||||
try { return await operation; }
|
||||
finally { this.workspaceBindings.delete(sessionId); }
|
||||
}
|
||||
|
||||
async sendPrompt(sessionId: string, text: string) {
|
||||
@@ -182,6 +179,43 @@ export class OpencodeRuntimeAdapter {
|
||||
return this.messages(sessionId);
|
||||
}
|
||||
|
||||
async approveSandboxShell(input: {
|
||||
sessionId: string; command: string; description?: string;
|
||||
callId: string; messageId: string;
|
||||
}, signal: AbortSignal) {
|
||||
const client = await this.ensureClient();
|
||||
const controller = new AbortController();
|
||||
const combined = AbortSignal.any([signal, controller.signal, AbortSignal.timeout(5 * 60_000)]);
|
||||
const iterator = client.event.subscribe({ signal: combined })[Symbol.asyncIterator]();
|
||||
let requestId: string | undefined;
|
||||
let replied = false;
|
||||
try {
|
||||
// Subscribe first: an immediate client reply must never be lost.
|
||||
await iterator.next();
|
||||
const permission = await client.permission.create({
|
||||
sessionID: input.sessionId, action: "shell", resources: [input.command], save: [],
|
||||
metadata: { command: input.command, description: input.description ?? "" },
|
||||
source: { type: "tool", messageID: input.messageId, id: input.callId },
|
||||
}, { signal: combined });
|
||||
requestId = permission.id;
|
||||
if (permission.effect === "deny") throw Object.assign(new Error("shell permission denied"), { status: 403 });
|
||||
if (permission.effect === "allow") throw Object.assign(new Error("sandbox shell requires interactive approval"), { status: 403 });
|
||||
while (true) {
|
||||
const next = await iterator.next();
|
||||
if (next.done) throw new Error("permission event stream ended before approval");
|
||||
const event = next.value;
|
||||
if (event.type !== "permission.replied" || event.data.sessionID !== input.sessionId || event.data.requestID !== requestId) continue;
|
||||
replied = true;
|
||||
if (event.data.reply === "reject") throw Object.assign(new Error("shell permission rejected"), { status: 403 });
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
controller.abort();
|
||||
await iterator.return?.();
|
||||
if (requestId && !replied) await client.permission.reply({ sessionID: input.sessionId, requestID: requestId, decision: "reject" }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
|
||||
const client = await this.ensureClient();
|
||||
const startedAt = Date.now();
|
||||
@@ -193,11 +227,8 @@ export class OpencodeRuntimeAdapter {
|
||||
textChars: text.length,
|
||||
},
|
||||
);
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model,
|
||||
parts: [{ type: "text", text }],
|
||||
});
|
||||
if (model) await client.session.switchModel({ sessionID: sessionId, model: { providerID: model.providerID, id: model.modelID } });
|
||||
await client.session.prompt({ sessionID: sessionId, text });
|
||||
logDevelopmentDebug(
|
||||
"opencode session.prompt returned",
|
||||
{
|
||||
@@ -209,29 +240,19 @@ export class OpencodeRuntimeAdapter {
|
||||
|
||||
async messages(sessionId: string, limit = 20) {
|
||||
const client = await this.ensureClient();
|
||||
const messages = await client.session.messages({
|
||||
sessionID: sessionId,
|
||||
limit,
|
||||
});
|
||||
return requireData(messages.data, "session.messages");
|
||||
const messages = await client.message.list({ sessionID: sessionId, limit, order: "desc" });
|
||||
return messages.data.reverse().map((message) => normalizeRuntimeMessage(message, sessionId));
|
||||
}
|
||||
|
||||
async revertMessage(sessionId: string, messageId: string) {
|
||||
const client = await this.ensureClient();
|
||||
const response = await client.session.revert({
|
||||
sessionID: sessionId,
|
||||
messageID: messageId,
|
||||
});
|
||||
return response.data;
|
||||
return client.session.revert.stage({ sessionID: sessionId, messageID: messageId, files: false });
|
||||
}
|
||||
|
||||
async removeMessage(sessionId: string, messageId: string) {
|
||||
const client = await this.ensureClient();
|
||||
const response = await client.session.deleteMessage({
|
||||
sessionID: sessionId,
|
||||
messageID: messageId,
|
||||
});
|
||||
return response.data;
|
||||
// V2 commits the staged message boundary atomically; individual deletion was removed.
|
||||
await client.session.revert.commit({ sessionID: sessionId });
|
||||
}
|
||||
|
||||
async revertToUserMessage(sessionId: string, options: { userOrdinal: number }) {
|
||||
@@ -253,32 +274,13 @@ export class OpencodeRuntimeAdapter {
|
||||
}
|
||||
|
||||
const targetMessageId = getRuntimeMessageId(targetUserMessage);
|
||||
const targetIndex = messages.findIndex(
|
||||
(message) => getRuntimeMessageId(message) === targetMessageId,
|
||||
);
|
||||
const messagesToRemove = targetIndex >= 0 ? messages.slice(targetIndex) : [targetUserMessage];
|
||||
|
||||
await this.revertMessage(sessionId, targetMessageId);
|
||||
|
||||
for (const message of messagesToRemove.reverse()) {
|
||||
const messageId = getRuntimeMessageId(message);
|
||||
try {
|
||||
await this.removeMessage(sessionId, messageId);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ err: error, sessionId, messageId },
|
||||
"failed to remove reverted opencode message",
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.removeMessage(sessionId, targetMessageId);
|
||||
}
|
||||
|
||||
async abortSession(sessionId: string) {
|
||||
const client = await this.ensureClient();
|
||||
const response = await client.session.abort({
|
||||
sessionID: sessionId,
|
||||
});
|
||||
return requireData(response.data, "session.abort");
|
||||
return client.session.interrupt({ sessionID: sessionId });
|
||||
}
|
||||
|
||||
async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) {
|
||||
@@ -286,10 +288,8 @@ export class OpencodeRuntimeAdapter {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const response = await client.session.status({});
|
||||
const statuses = requireData(response.data, "session.status");
|
||||
const status = statuses[sessionId];
|
||||
if (!status || status.type === "idle") {
|
||||
const active = await client.session.active();
|
||||
if (!active[sessionId]) {
|
||||
return;
|
||||
}
|
||||
await delay(100);
|
||||
@@ -303,126 +303,56 @@ export class OpencodeRuntimeAdapter {
|
||||
|
||||
async subscribeEvents() {
|
||||
const client = await this.ensureClient();
|
||||
const response = await client.event.subscribe();
|
||||
return response.stream;
|
||||
}
|
||||
|
||||
async replyPermission(options: {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
reply: PermissionReply;
|
||||
message?: string;
|
||||
}) {
|
||||
const client = await this.ensureClient();
|
||||
if ("permission" in client && client.permission?.reply) {
|
||||
const response = await client.permission.reply({
|
||||
requestID: options.requestId,
|
||||
reply: options.reply,
|
||||
message: options.message,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
if ("permission" in client && client.permission?.respond && options.sessionId) {
|
||||
const response = await client.permission.respond({
|
||||
sessionID: options.sessionId,
|
||||
permissionID: options.requestId,
|
||||
response: options.reply,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw new Error("opencode permission reply API is unavailable");
|
||||
}
|
||||
|
||||
async replyQuestion(options: {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
answers: QuestionAnswers;
|
||||
}) {
|
||||
const client = await this.ensureClient();
|
||||
if ("question" in client && client.question?.reply) {
|
||||
try {
|
||||
const response = await client.question.reply({
|
||||
requestID: options.requestId,
|
||||
answers: options.answers,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (!options.sessionId) {
|
||||
throw error;
|
||||
const controller = new AbortController();
|
||||
const iterator = client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]();
|
||||
// Await the connected event before dispatching a prompt so its first events cannot be lost.
|
||||
const first = await iterator.next();
|
||||
const normalizer = new Opencode2EventNormalizer();
|
||||
const stream = (async function* () {
|
||||
try {
|
||||
let next = first;
|
||||
while (!next.done) {
|
||||
for (const event of normalizer.normalize(next.value)) yield event;
|
||||
next = await iterator.next();
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) throw error;
|
||||
} finally {
|
||||
controller.abort();
|
||||
await iterator.return?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const v2Question = (client as unknown as {
|
||||
v2?: {
|
||||
session?: {
|
||||
question?: {
|
||||
reply?: (parameters: {
|
||||
sessionID: string;
|
||||
requestID: string;
|
||||
questionV2Reply: { answers: QuestionAnswers };
|
||||
}) => Promise<{ data: unknown }>;
|
||||
};
|
||||
})();
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
next: () => stream.next(),
|
||||
return: async () => {
|
||||
// Cancel pending next() before returning; async generator return alone cannot interrupt it.
|
||||
controller.abort();
|
||||
return stream.return(undefined);
|
||||
},
|
||||
};
|
||||
};
|
||||
}).v2?.session?.question;
|
||||
|
||||
if (v2Question?.reply && options.sessionId) {
|
||||
const response = await v2Question.reply({
|
||||
sessionID: options.sessionId,
|
||||
requestID: options.requestId,
|
||||
questionV2Reply: {
|
||||
answers: options.answers,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw new Error("opencode question reply API is unavailable");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async rejectQuestion(options: {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
}) {
|
||||
async replyPermission(options: { requestId: string; sessionId?: string; reply: PermissionReply; message?: string }) {
|
||||
if (!options.sessionId) throw new Error("OpenCode 2 permission replies require sessionId");
|
||||
const client = await this.ensureClient();
|
||||
if ("question" in client && client.question?.reject) {
|
||||
try {
|
||||
const response = await client.question.reject({
|
||||
requestID: options.requestId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (!options.sessionId) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return client.permission.reply({ sessionID: options.sessionId, requestID: options.requestId, decision: options.reply, message: options.message });
|
||||
}
|
||||
|
||||
const v2Question = (client as unknown as {
|
||||
v2?: {
|
||||
session?: {
|
||||
question?: {
|
||||
reject?: (parameters: {
|
||||
sessionID: string;
|
||||
requestID: string;
|
||||
}) => Promise<{ data: unknown }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}).v2?.session?.question;
|
||||
async replyQuestion(options: { requestId: string; sessionId?: string; answers: QuestionAnswers }) {
|
||||
if (!options.sessionId) throw new Error("OpenCode 2 form replies require sessionId");
|
||||
const client = await this.ensureClient();
|
||||
const form = await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId });
|
||||
return client.session.form.reply({ sessionID: options.sessionId, formID: options.requestId, answer: formAnswers(form, options.answers) });
|
||||
}
|
||||
|
||||
if (v2Question?.reject && options.sessionId) {
|
||||
const response = await v2Question.reject({
|
||||
sessionID: options.sessionId,
|
||||
requestID: options.requestId,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw new Error("opencode question reject API is unavailable");
|
||||
async rejectQuestion(options: { requestId: string; sessionId?: string }) {
|
||||
if (!options.sessionId) throw new Error("OpenCode 2 form cancellation requires sessionId");
|
||||
const client = await this.ensureClient();
|
||||
return client.session.form.cancel({ sessionID: options.sessionId, formID: options.requestId });
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
@@ -438,15 +368,14 @@ export class OpencodeRuntimeAdapter {
|
||||
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
|
||||
mode: config.OPENCODE_MODE,
|
||||
},
|
||||
"connecting to opencode server in client mode",
|
||||
"connecting to opencode2 server in client mode",
|
||||
);
|
||||
return createOpencodeClient({
|
||||
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
|
||||
});
|
||||
return createRuntimeClient(config.OPENCODE_CLIENT_BASE_URL!, config.OPENCODE_CLIENT_PASSWORD);
|
||||
}
|
||||
|
||||
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
|
||||
// 这样 .opencode/tools 下的自定义工具可以回调本服务。
|
||||
process.env.TJWATER_AGENT_WORKSPACE_ROOT = resolve(config.CONVERSATION_WORKSPACE_ROOT);
|
||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`;
|
||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN =
|
||||
config.AGENT_INTERNAL_TOKEN ??
|
||||
@@ -474,13 +403,14 @@ export class OpencodeRuntimeAdapter {
|
||||
model: config.OPENCODE_MODEL,
|
||||
mode: config.OPENCODE_MODE,
|
||||
},
|
||||
"starting opencode server in embedded mode",
|
||||
"starting opencode2 server in embedded mode",
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let runtime;
|
||||
try {
|
||||
runtime = await createOpencode({
|
||||
runtime = await startOpencode2({
|
||||
command: config.OPENCODE_COMMAND,
|
||||
hostname: config.OPENCODE_HOSTNAME,
|
||||
port,
|
||||
timeout: config.OPENCODE_TIMEOUT_MS,
|
||||
@@ -489,7 +419,7 @@ export class OpencodeRuntimeAdapter {
|
||||
} catch (error) {
|
||||
if (isMissingOpencodeCli(error)) {
|
||||
throw new Error(
|
||||
"embedded mode requires the opencode CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL",
|
||||
"embedded mode requires the opencode2 CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
@@ -499,14 +429,14 @@ export class OpencodeRuntimeAdapter {
|
||||
{
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
hostname: config.OPENCODE_HOSTNAME,
|
||||
baseUrl: runtime.server.url,
|
||||
baseUrl: runtime.url,
|
||||
mode: config.OPENCODE_MODE,
|
||||
},
|
||||
"opencode server started in embedded mode",
|
||||
"opencode2 server started in embedded mode",
|
||||
);
|
||||
|
||||
this.closeServer = () => {
|
||||
runtime.server.close();
|
||||
runtime.close();
|
||||
};
|
||||
|
||||
return runtime.client;
|
||||
@@ -516,12 +446,12 @@ export class OpencodeRuntimeAdapter {
|
||||
export const opencodeRuntime = new OpencodeRuntimeAdapter();
|
||||
|
||||
function buildOpencodeConfig(): Record<string, unknown> {
|
||||
return deepMerge(
|
||||
return serviceOpencodeConfig(deepMerge(
|
||||
deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
|
||||
{
|
||||
model: config.OPENCODE_MODEL,
|
||||
},
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
function readProjectOpencodeConfig(): Record<string, unknown> {
|
||||
@@ -577,13 +507,6 @@ function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException {
|
||||
);
|
||||
}
|
||||
|
||||
function requireData<T>(data: T | undefined, operation: string): T {
|
||||
if (data === undefined) {
|
||||
throw new Error(`${operation} returned no data`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// A conversation Location can be outside the checkout. Load only the trusted
|
||||
// service assets explicitly, rather than relying on its current directory.
|
||||
export function serviceOpencodeConfig(input: Record<string, unknown>) {
|
||||
const agents = (input.agents ?? {}) as Record<string, unknown>;
|
||||
const instruction = (agents.instruction ?? {}) as Record<string, unknown>;
|
||||
const system = readFileSync(resolve(".opencode/agents/instruction.md"), "utf8")
|
||||
.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
|
||||
return {
|
||||
...input,
|
||||
snapshots: false,
|
||||
plugins: ["*", ...((input.plugins ?? []) as unknown[]), resolve(".opencode/tjwater-plugin")],
|
||||
skills: [resolve(process.env.OPENCODE_SKILLS_ROOT_DIR ?? ".opencode/skills")],
|
||||
agents: { ...agents, instruction: {
|
||||
description: "TJWater structured data and UI agent", mode: "primary", ...instruction, system,
|
||||
} },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Event, Part } from "@opencode-ai/sdk/v2";
|
||||
import type { FormInfo, SessionMessageInfo, V2Event } from "@opencode/client";
|
||||
|
||||
// Preserve the product's existing SSE contract while consuming the native V2 API.
|
||||
const legacyEvent = (type: string, properties: unknown) => ({ type, properties }) as Event;
|
||||
const legacyPart = (part: unknown) => part as Part;
|
||||
const textContent = (content: readonly { type: string; text?: string }[]) =>
|
||||
content.filter((item) => item.type === "text").map((item) => item.text ?? "").join("\n");
|
||||
|
||||
export function normalizeRuntimeMessage(message: SessionMessageInfo, sessionID: string) {
|
||||
const parts: Part[] = [];
|
||||
if (message.type === "assistant") {
|
||||
message.content.forEach((content, ordinal) => {
|
||||
const id = `${message.id}:${content.type}:${ordinal}`;
|
||||
if (content.type === "tool") {
|
||||
const state = content.state;
|
||||
parts.push(legacyPart({
|
||||
id: content.id, sessionID, messageID: message.id, type: "tool",
|
||||
callID: content.id, tool: content.name,
|
||||
state: {
|
||||
...state,
|
||||
input: "input" in state ? state.input : {},
|
||||
output: "content" in state && state.content ? textContent(state.content) : undefined,
|
||||
error: "error" in state ? state.error.message : undefined,
|
||||
time: { start: content.time.created, end: content.time.completed },
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
parts.push(legacyPart({
|
||||
...content, id, sessionID, messageID: message.id,
|
||||
time: { start: message.time.created, end: message.time.completed },
|
||||
}));
|
||||
}
|
||||
});
|
||||
} else if ("text" in message) {
|
||||
parts.push(legacyPart({ id: `${message.id}:text:0`, sessionID, messageID: message.id, type: "text", text: message.text }));
|
||||
}
|
||||
return { info: { id: message.id, role: message.type }, parts };
|
||||
}
|
||||
|
||||
export class Opencode2EventNormalizer {
|
||||
private tools = new Map<string, { name: string; input: Record<string, unknown>; start: number }>();
|
||||
|
||||
normalize(event: V2Event): Event[] {
|
||||
if (!("data" in event)) return [];
|
||||
const data = event.data;
|
||||
if (event.type === "permission.asked") {
|
||||
return [legacyEvent("permission.v2.asked", {
|
||||
...event.data,
|
||||
tool: event.data.source?.type === "tool" ? {
|
||||
messageID: event.data.source.messageID, callID: event.data.source.id,
|
||||
} : undefined,
|
||||
})];
|
||||
}
|
||||
if (event.type === "permission.replied") return [legacyEvent("permission.v2.replied", data)];
|
||||
if (event.type === "form.created") {
|
||||
const form = event.data.form;
|
||||
return [legacyEvent("question.v2.asked", {
|
||||
id: form.id, sessionID: form.sessionID,
|
||||
questions: form.fields.filter((field) => !("hidden" in field && field.hidden)).map((field) => ({
|
||||
header: field.title ?? form.title,
|
||||
question: field.description ?? field.title ?? form.title,
|
||||
options: "options" in field ? (field.options ?? []).map((option) => ({
|
||||
label: option.label, description: option.description ?? "",
|
||||
})) : [],
|
||||
multiple: field.type === "multiselect",
|
||||
custom: "custom" in field ? field.custom : true,
|
||||
})),
|
||||
})];
|
||||
}
|
||||
if (event.type === "form.replied") return [legacyEvent("question.v2.replied", {
|
||||
sessionID: event.data.sessionID, requestID: event.data.id,
|
||||
answers: Object.values(event.data.answer).map((value) => Array.isArray(value) ? value : [String(value)]),
|
||||
})];
|
||||
if (event.type === "form.cancelled") return [legacyEvent("question.v2.rejected", { sessionID: event.data.sessionID, requestID: event.data.id })];
|
||||
if (event.type === "session.status" || event.type === "session.idle") return [legacyEvent(event.type, data)];
|
||||
if (event.type === "session.retry.scheduled") return [legacyEvent("session.status", {
|
||||
sessionID: event.data.sessionID,
|
||||
status: { type: "retry", attempt: event.data.attempt, next: event.data.at, message: event.data.error.message },
|
||||
})];
|
||||
if (event.type === "session.execution.started") return [legacyEvent("session.status", { sessionID: event.data.sessionID, status: { type: "busy" } })];
|
||||
if (event.type === "session.execution.succeeded" || event.type === "session.execution.interrupted") return [
|
||||
legacyEvent("session.status", { sessionID: event.data.sessionID, status: { type: "idle" } }),
|
||||
legacyEvent("session.idle", { sessionID: event.data.sessionID }),
|
||||
];
|
||||
if (event.type === "session.execution.failed") {
|
||||
const error = event.data.error;
|
||||
return [legacyEvent("session.error", { sessionID: event.data.sessionID, error: { name: error.type, data: { message: error.message } } })];
|
||||
}
|
||||
if (event.type.startsWith("session.text.") || event.type.startsWith("session.reasoning.")) {
|
||||
const item = data as { sessionID: string; assistantMessageID: string; ordinal: number; text?: string; delta?: string };
|
||||
const type = event.type.startsWith("session.text.") ? "text" : "reasoning";
|
||||
const id = `${item.assistantMessageID}:${type}:${item.ordinal}`;
|
||||
const properties = { sessionID: item.sessionID, messageID: item.assistantMessageID };
|
||||
if (event.type.endsWith(".delta")) return [legacyEvent("message.part.delta", { ...properties, partID: id, field: "text", delta: item.delta ?? "" })];
|
||||
return [legacyEvent("message.part.updated", { ...properties, part: {
|
||||
...properties, id, type, text: item.text ?? "",
|
||||
time: { start: "created" in event ? event.created : Date.now(), ...(event.type.endsWith(".ended") ? { end: "created" in event ? event.created : Date.now() } : {}) },
|
||||
} })];
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
this.tools.set(event.data.id, { name: event.data.name, input: {}, start: event.created });
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
const tool = this.tools.get(event.data.id);
|
||||
if (tool) tool.input = event.data.input;
|
||||
}
|
||||
if (event.type === "session.tool.input.started" || event.type === "session.tool.called" || event.type === "session.tool.success" || event.type === "session.tool.failed") {
|
||||
const item = event.data;
|
||||
const tool = this.tools.get(item.id);
|
||||
if (!tool) return [];
|
||||
const completed = event.type === "session.tool.success";
|
||||
const failed = event.type === "session.tool.failed";
|
||||
const part = {
|
||||
id: item.id, callID: item.id, sessionID: item.sessionID, messageID: item.assistantMessageID,
|
||||
type: "tool", tool: tool.name,
|
||||
state: {
|
||||
status: completed ? "completed" : failed ? "error" : "running",
|
||||
input: tool.input,
|
||||
metadata: { native_form: tool.name === "question" },
|
||||
output: event.type === "session.tool.success" ? textContent(event.data.content) : undefined,
|
||||
error: event.type === "session.tool.failed" ? event.data.error.message : undefined,
|
||||
time: { start: tool.start, ...(completed || failed ? { end: event.created } : {}) },
|
||||
},
|
||||
};
|
||||
if (completed || failed) this.tools.delete(item.id);
|
||||
const events = [legacyEvent("message.part.updated", { sessionID: item.sessionID, part })];
|
||||
if (completed && tool.name === "todowrite") events.push(legacyEvent("todo.updated", { sessionID: item.sessionID, todos: tool.input.todos ?? [] }));
|
||||
return events;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function formAnswers(form: FormInfo, answers: string[][]) {
|
||||
return Object.fromEntries(form.fields.filter((field) => !("hidden" in field && field.hidden)).map((field, index) => {
|
||||
const values = (answers[index] ?? []).map((value) => "options" in field
|
||||
? field.options?.find((option) => option.label === value)?.value ?? value : value);
|
||||
return [field.key, field.type === "multiselect" ? values : values[0] ?? ""];
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode/client";
|
||||
|
||||
export const createRuntimeClient = (baseUrl: string, password?: string) =>
|
||||
OpenCode.make({
|
||||
baseUrl,
|
||||
headers: password ? {
|
||||
authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`,
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
export async function startOpencode2(options: {
|
||||
command: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
timeout: number;
|
||||
config: Record<string, unknown>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ client: OpenCodeClient; url: string; password: string; close: () => void; exited: Promise<number | null> }> {
|
||||
const child = spawn(options.command, [
|
||||
"serve", `--hostname=${options.hostname}`, `--port=${options.port}`,
|
||||
], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...options.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config) },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const stopOnExit = () => { child.kill("SIGTERM"); };
|
||||
process.once("exit", stopOnExit);
|
||||
child.once("exit", () => process.removeListener("exit", stopOnExit));
|
||||
try {
|
||||
const endpoint = await new Promise<{ url: string; password: string }>((resolve, reject) => {
|
||||
let stdout = "";
|
||||
const timer = setTimeout(() => reject(new Error("opencode2 startup timed out")), options.timeout);
|
||||
const fail = (error: Error) => { clearTimeout(timer); reject(error); };
|
||||
child.once("error", fail);
|
||||
child.once("exit", (code) => fail(new Error(`opencode2 exited during startup (code ${code})`)));
|
||||
// The CLI prints a generated password. Capture it privately; never forward it to logs.
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
const url = /server listening on (https?:\/\/\S+)/.exec(stdout)?.[1];
|
||||
const password = /server password (\S+)/.exec(stdout)?.[1];
|
||||
if (url && password) { clearTimeout(timer); resolve({ url, password }); }
|
||||
});
|
||||
child.stderr.resume();
|
||||
});
|
||||
const client = createRuntimeClient(endpoint.url, endpoint.password);
|
||||
await client.server.info({ signal: AbortSignal.timeout(options.timeout) });
|
||||
return { client, url: endpoint.url, password: endpoint.password, close: () => { child.kill("SIGTERM"); }, exited: new Promise((resolve) => child.once("exit", resolve)) };
|
||||
} catch (error) {
|
||||
child.kill("SIGTERM");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export type RuntimeSessionContext = {
|
||||
capabilities?: string[];
|
||||
purpose?: "ordinary_chat" | "task_execution" | "task_collaboration";
|
||||
taskId?: string;
|
||||
workspaceDirectory?: string;
|
||||
};
|
||||
|
||||
const contexts = new Map<string, RuntimeSessionContext>();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user