feat(agent): migrate OpenCode 2 runtime and sandbox tools
Upgrade the runtime and native plugin API to OpenCode 2.0.24. Restore session-scoped learning, credential renewal, typed project queries and controlled frontend actions from the legacy Agent. Replace host Shell execution with an approved Landlock/seccomp executor and persistent per-session workspaces. Add typed Bocha search and Tianditu geocoding bridges using the owning session credential. Require a working kernel sandbox before startup and persist workspace files in deployments. Validation completed before this commit: TypeScript checks, 150 Bun tests, Docker build and UID 10001 sandbox execution, native Shell approval and session recovery. Tianditu succeeded; Bocha currently reports exhausted account quota. Unrelated investigation-report contract work is excluded.
This commit is contained in:
@@ -0,0 +1,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); }
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user