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

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

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

Validation completed before this commit: TypeScript checks, 150 Bun tests,
Docker build and UID 10001 sandbox execution, native Shell approval and
session recovery. Tianditu succeeded; Bocha currently reports exhausted
account quota. Unrelated investigation-report contract work is excluded.
This commit is contained in:
2026-10-08 15:46:37 +08:00
parent 1a9b0cafc1
commit 7383ed6d13
93 changed files with 8065 additions and 1098 deletions
+33 -12
View File
@@ -1,4 +1,6 @@
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { canAutomaticallyApprove } from "./chatPermissionPolicy.js";
import { createBusinessStageTracker } from "./chatBusinessStages.js";
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
import { type SupportedModel } from "../chat/models.js";
@@ -187,6 +189,8 @@ export const streamPromptResponse = async ({
let promptSettled = false;
let aborted = signal?.aborted ?? false;
let failed = false;
let finalAnswer: string | undefined;
const trackBusinessStage = createBusinessStageTracker((event, data) => write(event, { ...data, session_id: clientSessionId }));
const debugContext = {
sessionId,
clientSessionId,
@@ -291,6 +295,7 @@ export const streamPromptResponse = async ({
...debugContext,
});
let pendingEvent: Promise<{ type: "event"; result: IteratorResult<OpencodeEvent> }> | null = null;
try {
while (!done) {
if (signal?.aborted) {
@@ -302,7 +307,9 @@ export const streamPromptResponse = async ({
break;
}
const nextEvent = iterator
// V2 prompt() acknowledges inbox admission before generation. Keep the same
// pending read if that acknowledgement wins the race, so no event is consumed unseen.
const nextEvent = pendingEvent ??= iterator
.next()
.then((result) => ({ type: "event" as const, result }));
const nextPrompt = promptSettled
@@ -329,6 +336,7 @@ export const streamPromptResponse = async ({
if (next.type === "prompt") {
continue;
}
pendingEvent = null;
if (next.result.done) {
break;
}
@@ -402,17 +410,18 @@ export const streamPromptResponse = async ({
patterns: event.properties.patterns,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission);
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
status: autoApprove ? "completed" : "running",
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
autoApprove
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionDetail(event),
});
if (approvalMode === "always") {
if (autoApprove) {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
@@ -447,17 +456,18 @@ export const streamPromptResponse = async ({
resources: event.properties.resources,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action);
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
status: autoApprove ? "completed" : "running",
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
autoApprove
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionV2Detail(event),
});
if (approvalMode === "always") {
if (autoApprove) {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
@@ -725,6 +735,16 @@ export const streamPromptResponse = async ({
});
}
if (part.type === "tool") {
if (part.tool === "final_answer") {
if (part.state.status === "completed") {
const output = parseToolOutput(part.state.output);
if (output && typeof output === "object" && typeof (output as Record<string, unknown>).answer === "string") {
finalAnswer = (output as { answer: string }).answer;
}
}
continue;
}
trackBusinessStage(part);
if (part.tool === "publish_execution_plan" && part.state.status === "completed" && !emittedToolParts.has(part.id)) {
const plan = executionPlanSchema.safeParse(part.state.input);
const output = parseToolOutput(part.state.output);
@@ -970,7 +990,7 @@ export const streamPromptResponse = async ({
}
await promptPromise;
if (!emittedText) {
if (!emittedText && !finalAnswer) {
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
@@ -978,6 +998,7 @@ export const streamPromptResponse = async ({
await emitFallbackMessage(runtime, sessionId, clientSessionId, tokenSmoother.writeToken);
}
await tokenSmoother.flush();
if (finalAnswer) write("final_answer", { session_id: clientSessionId, content: finalAnswer });
emitProgress({
id: "request-received",
phase: "start",
@@ -990,7 +1011,7 @@ export const streamPromptResponse = async ({
phase: "complete",
status: "completed",
title: "分析完成",
detail: emittedText
detail: (emittedText || finalAnswer)
? "最终回答已生成并推送到前端。"
: "已完成分析,并通过兜底消息补发最终回答内容。",
});
@@ -1007,7 +1028,7 @@ export const streamPromptResponse = async ({
return { aborted: false, failed: false, toolCallCount };
} finally {
await tokenSmoother.flush();
await iterator.return?.(undefined);
await iterator.return?.();
if (!promptSettled && !aborted) {
await promptPromise.catch(() => undefined);
} else if (!promptSettled) {