Files
next-tjwater-agent/src/routes/sandboxToolBridge.ts
T
jiang a7ddff7b64
Generic Container CI/CD / test-build-publish (push) Successful in 1m47s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m48s
fix(agent): keep background sandbox tasks running
Auto-approve sandbox Shell only for host-bound task execution, stage native results within their owning session, and preserve active lease state on response stream loss. The repeated failures came from interactive approval in unattended runs and transport cleanup invalidating execution state; focused regressions cover both boundaries.
2026-11-08 01:54:48 +08:00

93 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { nativeToolOutputDirectory, stageSessionToolOutputs } from "../sandbox/tool-output.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: {
messages: OpencodeRuntimeAdapter["messages"];
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);
if (parsed.data.command.includes(`${nativeToolOutputDirectory()}/`)) {
const files = await stageSessionToolOutputs(parsed.data.command, workspace, context.sessionId,
await options.runtime.messages(context.sessionId, 100));
if (files.length) {
res.json({ outcome: "requires_session_path", files,
detail: "命令尚未执行。当前会话的完整工具结果已复制到会话目录。请用返回的 shell_path 重新调用 Shell;relative_path 相对于会话 workspace,shell_path 相对于 Shell 的 files/ 工作目录。" });
return;
}
}
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); }
});
}