fix(agent): keep background sandbox tasks running
Generic Container CI/CD / test-build-publish (push) Successful in 1m47s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m48s

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.
This commit is contained in:
2026-11-08 01:54:48 +08:00
parent d8eb29466a
commit a7ddff7b64
10 changed files with 242 additions and 37 deletions
+3 -1
View File
@@ -269,7 +269,9 @@ OpenCode 的 `location.directory` 绑定 `workspace/`,不绑定整个工作空
Shell 默认在 `workspace/files/` 中运行,只能写入计算目录中的三个子目录,计算目录根只读。`chat/` 只由 Agent 服务读写,目录权限为 `0700`、记录文件为 `0600`,模型文件工具及 Shell 不能访问。OpenCode 仅为受管理的计算目录增加文件权限,插件还验证目录属于当前原生会话;其他业务记录、聊天记录和 `logs/` 继续禁止访问。目录隔离配合 Landlock 和 seccomp 限制文件访问、网络与进程行为;目录本身不等于 OS 沙箱。限制覆盖 Shell 及其子进程,OpenCode 服务和类型化业务工具仍由可信宿主进程运行。本地 stdio MCP 也不在这个沙箱中。 Shell 默认在 `workspace/files/` 中运行,只能写入计算目录中的三个子目录,计算目录根只读。`chat/` 只由 Agent 服务读写,目录权限为 `0700`、记录文件为 `0600`,模型文件工具及 Shell 不能访问。OpenCode 仅为受管理的计算目录增加文件权限,插件还验证目录属于当前原生会话;其他业务记录、聊天记录和 `logs/` 继续禁止访问。目录隔离配合 Landlock 和 seccomp 限制文件访问、网络与进程行为;目录本身不等于 OS 沙箱。限制覆盖 Shell 及其子进程,OpenCode 服务和类型化业务工具仍由可信宿主进程运行。本地 stdio MCP 也不在这个沙箱中。
Shell 和文件修改仍经过原生权限审批。聊天选择“请求批准”(`approval_mode=request`)时逐次询问;选择“始终允许”(`approval_mode=always`)后,Agent 自动批准本轮进入审批流程的请求,包括 Shell、文件修改和 MCP 请求。每次仅回复原生 `once`,不把本轮选择保存为后续轮次授权;明确拒绝的操作不会进入自动批准,业务权限、MCP 会话策略和沙箱限制继续执行。只读白名单工具可直接执行,不因选择“请求批准”而增加询问。 Shell 和文件修改仍经过原生权限审批。宿主确认的后台 `task_execution` 会话对沙箱 Shell 的待审批请求自动回复 `once`,避免无人值守任务等待交互确认;不自动批准原生文件修改或业务写工具,也不覆盖明确拒绝与沙箱限制。聊天选择“请求批准”(`approval_mode=request`)时逐次询问;选择“始终允许”(`approval_mode=always`)后,Agent 自动批准本轮进入审批流程的请求,包括 Shell、文件修改和 MCP 请求。每次仅回复原生 `once`,不把本轮选择保存为后续轮次授权;明确拒绝的操作不会进入自动批准,业务权限、MCP 会话策略和沙箱限制继续执行。只读白名单工具可直接执行,不因选择“请求批准”而增加询问。
OpenCode 原生大结果仍保存在其全局工具输出目录。Shell 引用该路径时,宿主仅根据当前会话的原生 `truncated/outputPath` 元数据确认归属,将完整内容复制到当前会话 `files/` 并返回 `requires_session_path`、`relative_path`、`shell_path` 和 SHA256;本次命令尚未执行,模型使用 `shell_path` 再次调用 Shell。全局目录、其他会话结果和符号链接不会因此开放。
Shell 不能联网或访问源码、密钥、其他会话;业务查询和地址解析使用 MCP,网页搜索使用原生 `websearch`。超时单位为秒,默认 120 秒、最大 1800 秒;stdout 默认限制 1 MiB、stderr 最多 256 KiB、单文件最多 128 MiB。较长输出以文件保存在 `files/`,工具返回预览和路径;取消、超时、超限和命令结束均清理该进程组,不保留后台进程。 Shell 不能联网或访问源码、密钥、其他会话;业务查询和地址解析使用 MCP,网页搜索使用原生 `websearch`。超时单位为秒,默认 120 秒、最大 1800 秒;stdout 默认限制 1 MiB、stderr 最多 256 KiB、单文件最多 128 MiB。较长输出以文件保存在 `files/`,工具返回预览和路径;取消、超时、超限和命令结束均清理该进程组,不保留后台进程。
+2 -2
View File
@@ -1,5 +1,5 @@
// Called only for native "asked" events. Explicit denials and host isolation // Called only for native "asked" events. Explicit denials and host isolation
// still run before this point; the selected round mode answers pending requests. // still run before this point; the selected round mode answers pending requests.
export function canAutomaticallyApprove(mode: string, _action: string) { export function canAutomaticallyApprove(mode: string, action: string, backgroundSandboxShell = false) {
return mode === "always"; return mode === "always" || (backgroundSandboxShell && action === "shell");
} }
+43 -29
View File
@@ -116,6 +116,47 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
const toSse = (event: string, data: Record<string, unknown>) => const toSse = (event: string, data: Record<string, unknown>) =>
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
export async function runBackgroundTaskExecution(input: {
sessionId: string;
credential: ProductCredential;
model: string;
workflow?: string;
isRunning: () => boolean;
}) {
const { sessionId, credential, model, workflow } = input;
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`;
await fetch(new URL("/api/v1/responses", baseUrl), {
method: "POST",
headers: {
Authorization: credential.authorization,
"Content-Type": "application/json",
"X-Project-Id": credential.projectId,
},
body: JSON.stringify({
conversation: sessionId,
model,
stream: true,
input: buildTaskExecutionPrompt(workflow),
}),
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
while (!(await reader.read()).done) {
// Drain the stream without retaining the model response in memory.
}
}
if (!response.ok) {
if (!input.isRunning()) clearOperationalTaskLease(sessionId);
logger.error({ sessionId, status: response.status, executionContinues: input.isRunning() }, "background task execution failed");
}
}).catch((error) => {
// A lost response stream does not end the host-owned execution. Its route
// still renews/finalizes the lease and owns cleanup of the cached revision.
if (!input.isRunning()) clearOperationalTaskLease(sessionId);
logger.warn({ err: error, sessionId, executionContinues: input.isRunning() }, "background task response stream failed");
});
}
export const registerChatSessionRoutes = ( export const registerChatSessionRoutes = (
chatRouter: Router, chatRouter: Router,
options: RegisterChatSessionRoutesOptions, options: RegisterChatSessionRoutesOptions,
@@ -183,35 +224,8 @@ export const registerChatSessionRoutes = (
workflow?: string, workflow?: string,
) => { ) => {
queueMicrotask(() => { queueMicrotask(() => {
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`; void runBackgroundTaskExecution({ sessionId: record.sessionId, credential, model, workflow,
void fetch(new URL("/api/v1/responses", baseUrl), { isRunning: () => activeRuns.get(record.sessionId)?.status === "running" });
method: "POST",
headers: {
Authorization: credential.authorization,
"Content-Type": "application/json",
"X-Project-Id": credential.projectId,
},
body: JSON.stringify({
conversation: record.sessionId,
model,
stream: true,
input: buildTaskExecutionPrompt(workflow),
}),
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
while (!(await reader.read()).done) {
// Drain the stream without retaining the model response in memory.
}
}
if (!response.ok) {
clearOperationalTaskLease(record.sessionId);
logger.error({ sessionId: record.sessionId, status: response.status }, "background task execution failed");
}
}).catch((error) => {
clearOperationalTaskLease(record.sessionId);
logger.error({ err: error, sessionId: record.sessionId }, "background task execution request failed");
});
}); });
}; };
+8 -2
View File
@@ -84,6 +84,7 @@ type StreamPromptOptions = {
message: string; message: string;
model?: SupportedModel; model?: SupportedModel;
approvalMode?: ApprovalMode; approvalMode?: ApprovalMode;
backgroundSandboxShell?: boolean;
traceId?: string; traceId?: string;
projectId?: string; projectId?: string;
signal?: AbortSignal; signal?: AbortSignal;
@@ -148,6 +149,7 @@ export const streamPromptResponse = async ({
message, message,
model, model,
approvalMode = "request", approvalMode = "request",
backgroundSandboxShell = false,
traceId, traceId,
projectId, projectId,
signal, signal,
@@ -481,7 +483,7 @@ export const streamPromptResponse = async ({
patterns: event.properties.patterns, patterns: event.properties.patterns,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission); const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission, backgroundSandboxShell);
emitProgress({ emitProgress({
id: `permission-${event.properties.id}`, id: `permission-${event.properties.id}`,
phase: "permission", phase: "permission",
@@ -489,7 +491,9 @@ export const streamPromptResponse = async ({
title: autoApprove ? "已自动允许权限请求" : "等待权限确认", title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
detail: detail:
autoApprove autoApprove
? approvalMode === "always"
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。" ? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
: "后台任务的沙箱 Shell 已自动批准;沙箱与明确拒绝规则仍生效。"
: buildPermissionDetail(event), : buildPermissionDetail(event),
}); });
if (autoApprove) { if (autoApprove) {
@@ -529,7 +533,7 @@ export const streamPromptResponse = async ({
resources: event.properties.resources, resources: event.properties.resources,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action); const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action, backgroundSandboxShell);
emitProgress({ emitProgress({
id: `permission-${event.properties.id}`, id: `permission-${event.properties.id}`,
phase: "permission", phase: "permission",
@@ -537,7 +541,9 @@ export const streamPromptResponse = async ({
title: autoApprove ? "已自动允许权限请求" : "等待权限确认", title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
detail: detail:
autoApprove autoApprove
? approvalMode === "always"
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。" ? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
: "后台任务的沙箱 Shell 已自动批准;沙箱与明确拒绝规则仍生效。"
: buildPermissionV2Detail(event), : buildPermissionV2Detail(event),
}); });
if (autoApprove) { if (autoApprove) {
+1
View File
@@ -997,6 +997,7 @@ export const registerChatStreamRoute = (
message: preparedMessage, message: preparedMessage,
model: parsed.data.model, model: parsed.data.model,
approvalMode: parsed.data.approval_mode, approvalMode: parsed.data.approval_mode,
backgroundSandboxShell: sessionPurpose === "task_execution",
traceId: requestContext.traceId, traceId: requestContext.traceId,
projectId: requestContext.projectId, projectId: requestContext.projectId,
signal: abortController.signal, signal: abortController.signal,
+12
View File
@@ -8,6 +8,8 @@ import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.
import { getRuntimeSessionContext } from "../runtime/sessionContext.js"; import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { executeSandboxCommand } from "../sandbox/landlockSandbox.js"; import { executeSandboxCommand } from "../sandbox/landlockSandbox.js";
import { setSandboxOwnership } from "../runtime/conversationWorkspace.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({ export const sandboxInput = z.object({
session_id: z.string().min(1).max(128), session_id: z.string().min(1).max(128),
@@ -37,6 +39,7 @@ export function isShellCommandDenied(command: string): boolean {
export function registerSandboxToolBridge(app: Express, options: { export function registerSandboxToolBridge(app: Express, options: {
internalToken: string; internalToken: string;
runtime: { runtime: {
messages: OpencodeRuntimeAdapter["messages"];
ensureSessionWorkspace(sessionId: string): Promise<string>; ensureSessionWorkspace(sessionId: string): Promise<string>;
approveSandboxShell(input: { sessionId: string; command: string; description?: string; callId: string; messageId: string }, signal: AbortSignal): Promise<void>; approveSandboxShell(input: { sessionId: string; command: string; description?: string; callId: string; messageId: string }, signal: AbortSignal): Promise<void>;
}; };
@@ -61,6 +64,15 @@ export function registerSandboxToolBridge(app: Express, options: {
sessionId: context.sessionId, command: parsed.data.command, description: parsed.data.description, sessionId: context.sessionId, command: parsed.data.command, description: parsed.data.description,
callId: parsed.data.call_id, messageId: parsed.data.message_id, callId: parsed.data.call_id, messageId: parsed.data.message_id,
}, controller.signal); }, 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 result = await executeSandboxCommand(workspace, parsed.data.command, parsed.data.timeout, controller.signal);
const files: Partial<Record<"stdout_file" | "stderr_file", string>> = {}; const files: Partial<Record<"stdout_file" | "stderr_file", string>> = {};
for (const stream of ["stdout", "stderr"] as const) { for (const stream of ["stdout", "stderr"] as const) {
+58
View File
@@ -0,0 +1,58 @@
import { constants } from "node:fs";
import { open, realpath } from "node:fs/promises";
import { createHash, randomUUID } from "node:crypto";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { SANDBOX_GID, SANDBOX_UID } from "../runtime/conversationWorkspace.js";
import { openArtifactDirectory } from "../mcp/artifact-files.js";
export const nativeToolOutputDirectory = () => resolve(
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "tool-output",
);
// Only native truncation metadata from this session authorizes a source file.
// Text supplied by the model or a tool response is never an ownership proof.
export async function stageSessionToolOutputs(
command: string, workspace: string, sessionId: string,
messages: Awaited<ReturnType<OpencodeRuntimeAdapter["messages"]>>,
) {
const sources = new Set<string>();
const root = nativeToolOutputDirectory();
for (const message of messages) for (const part of message.parts) {
if (part.type !== "tool" || part.state.status !== "completed") continue;
const metadata = part.state.metadata;
const path = metadata?.outputPath;
if (metadata?.truncated === true && typeof path === "string" && command.includes(path)
&& dirname(path) === root && /^tool_[a-zA-Z0-9]+$/.test(basename(path))) sources.add(path);
}
if (!sources.size) return [];
const canonicalRoot = await realpath(root);
const directory = await openArtifactDirectory(workspace, sessionId);
const files: Array<{ source_path: string; relative_path: string; shell_path: string; size: number; sha256: string }> = [];
let remaining = 128 * 1024 * 1024;
try {
for (const path of sources) {
if (await realpath(path) !== join(canonicalRoot, basename(path))) throw new Error("Unsafe tool output path");
const source = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
let bytes: Buffer;
try {
const stat = await source.stat();
if (!stat.isFile() || stat.size > remaining) throw new Error("Tool output staging budget exceeded");
bytes = await source.readFile();
if (bytes.length > remaining) throw new Error("Tool output staging budget exceeded");
remaining -= bytes.length;
} finally { await source.close(); }
const filename = `tool-output-${randomUUID()}.txt`;
const destination = await open(`/proc/self/fd/${directory.fd}/${filename}`,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try {
await destination.writeFile(bytes);
if (typeof process.getuid === "function" && process.getuid() === 0) await destination.chown(SANDBOX_UID, SANDBOX_GID);
} finally { await destination.close(); }
files.push({ source_path: path, relative_path: `files/${filename}`, shell_path: filename, size: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex") });
}
} finally { await directory.close(); }
return files;
}
@@ -3,11 +3,14 @@ import { afterEach, describe, expect, it } from "bun:test";
import { import {
bindOperationalTaskCredential, bindOperationalTaskCredential,
humanTaskEvidenceCommandSchema, humanTaskEvidenceCommandSchema,
hasOperationalTaskRun,
OperationalTaskClient, OperationalTaskClient,
proposedTaskActionSchema, proposedTaskActionSchema,
releaseOperationalTaskCredential, releaseOperationalTaskCredential,
} from "../../src/product/operational-task-client.js"; } from "../../src/product/operational-task-client.js";
import { runBackgroundTaskExecution } from "../../src/routes/chatSessionRoutes.js";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const taskId = "50000000-0000-4000-8000-000000000001"; const taskId = "50000000-0000-4000-8000-000000000001";
const runId = "55000000-0000-4000-8000-000000000001"; const runId = "55000000-0000-4000-8000-000000000001";
@@ -364,3 +367,28 @@ describe("OperationalTaskClient", () => {
expect(requestBodies[2]).toMatchObject({ expected_revision: 3 }); expect(requestBodies[2]).toMatchObject({ expected_revision: 3 });
}); });
}); });
it.each([true, false])("retains lease state on background stream loss only while running=%s", async (running) => {
const credential = { authorization: "Bearer fixture", projectId: "project-1" };
bindOperationalTaskCredential("runtime-1", credential);
globalThis.fetch = (async () => Response.json(run)) as unknown as typeof fetch;
const client = new OperationalTaskClient("http://server.test");
await client.claimTask("runtime-1", taskId, runId, {
requestId: executionRequestId, expectedRevision: 1, sourceSessionId: "session-1",
});
expect(hasOperationalTaskRun("runtime-1")).toBe(true);
globalThis.fetch = (async () => new Response(new ReadableStream({
start(controller) { controller.error(new Error("fixture stream disconnected")); },
}))) as unknown as typeof fetch;
await runBackgroundTaskExecution({ sessionId: "runtime-1", credential, model: "fixture/model",
isRunning: () => running });
expect(hasOperationalTaskRun("runtime-1")).toBe(running);
if (running) {
globalThis.fetch = (async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
expect(JSON.parse(String(init?.body)).expected_revision).toBe(2);
return Response.json({ ...run, revision: 3 });
}) as unknown as typeof fetch;
await client.heartbeatTask("runtime-1", taskId, runId, "session-1");
}
});
+33
View File
@@ -714,3 +714,36 @@ describe("streamPromptResponse", () => {
}); });
}); });
describe("background sandbox Shell approval", () => {
it.each(["permission.asked", "permission.v2.asked"])("only auto-approves sandbox Shell for %s", async (type) => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () => createEventStream([
...["shell", "edit", "mcp_fixture_write"].map((action) => ({
type,
properties: {
id: action, sessionID: "runtime-session-1",
...(type === "permission.asked"
? { permission: action, patterns: ["fixture"], always: [] }
: { action, resources: ["fixture"], save: [] }),
},
})),
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (input: Record<string, unknown>) => { replies.push(input); },
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1",
message: "后台调查", approvalMode: "request", backgroundSandboxShell: true,
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([{ requestId: "shell", sessionId: "runtime-session-1", reply: "once" }]);
expect(events.filter(({ event }) => event === "permission_request")
.map(({ data }) => data.permission)).toEqual(["edit", "mcp_fixture_write"]);
});
});
+52 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from "bun:test"; import { afterEach, describe, expect, it } from "bun:test";
import express from "express"; import express from "express";
import { type Server } from "node:http"; import { type Server } from "node:http";
import { mkdtemp, readFile, rm } from "node:fs/promises"; import { mkdtemp, mkdir, readFile, rm, writeFile, symlink } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { config } from "../../src/config.js"; import { config } from "../../src/config.js";
@@ -11,6 +11,7 @@ import { registerSandboxToolBridge } from "../../src/routes/sandboxToolBridge.js
let server: Server | undefined; let server: Server | undefined;
let root: string | undefined; let root: string | undefined;
const previousDataHome = process.env.XDG_DATA_HOME;
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT; const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
const session = "sandbox-bridge-test"; const session = "sandbox-bridge-test";
afterEach(async () => { afterEach(async () => {
@@ -18,6 +19,8 @@ afterEach(async () => {
if (root) await rm(root, { recursive: true, force: true }); if (root) await rm(root, { recursive: true, force: true });
config.CONVERSATION_WORKSPACE_ROOT = previousRoot; config.CONVERSATION_WORKSPACE_ROOT = previousRoot;
removeRuntimeSessionContext(session); removeRuntimeSessionContext(session);
if (previousDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousDataHome;
}); });
describe("sandbox execution bridge", () => { describe("sandbox execution bridge", () => {
it("executes only after approval and cannot override the owning workspace", async () => { it("executes only after approval and cannot override the owning workspace", async () => {
@@ -31,6 +34,7 @@ describe("sandbox execution bridge", () => {
const requested = new Promise<void>((resolve) => { ready = resolve; }); const requested = new Promise<void>((resolve) => { ready = resolve; });
const app = express(); app.use(express.json()); const app = express(); app.use(express.json());
registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: { registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: {
messages: async () => [],
ensureSessionWorkspace: async (id) => { expect(id).toBe(session); return directory; }, ensureSessionWorkspace: async (id) => { expect(id).toBe(session); return directory; },
approveSandboxShell: async (input) => { if (approved) return; expect(input.command).toContain("approved.txt"); ready(); await new Promise<void>((resolve) => { approve = resolve; }); approved = true; }, approveSandboxShell: async (input) => { if (approved) return; expect(input.command).toContain("approved.txt"); ready(); await new Promise<void>((resolve) => { approve = resolve; }); approved = true; },
} }); } });
@@ -55,3 +59,50 @@ describe("sandbox execution bridge", () => {
expect(await readFile(large.stdout_file, "utf8")).toBe(`${"x".repeat(10000)}\n`); expect(await readFile(large.stdout_file, "utf8")).toBe(`${"x".repeat(10000)}\n`);
}); });
}); });
it("stages only this session's native output and keeps the global directory sandboxed", async () => {
root = await mkdtemp(join(tmpdir(), "sandbox-output-test-"));
config.CONVERSATION_WORKSPACE_ROOT = root;
process.env.XDG_DATA_HOME = join(root, "native");
const output = join(process.env.XDG_DATA_HOME, "opencode", "tool-output");
await mkdir(output, { recursive: true });
const own = join(output, "tool_own"), other = join(output, "tool_other");
const content = JSON.stringify({ evidence_id: "fixture", readings: "完整结果".repeat(1000) });
await writeFile(own, content); await writeFile(other, "other session");
const directory = await createConversationWorkspace(root);
setRuntimeSessionContext({ sessionId: session, clientSessionId: session, actorKey: "owner", projectKey: "project", traceId: "test" });
const metadata = { truncated: true, outputPath: own };
const app = express(); app.use(express.json());
registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: {
ensureSessionWorkspace: async () => directory,
approveSandboxShell: async () => {},
messages: async () => [{ info: { id: "native", role: "assistant" }, parts: [{
type: "tool", state: { status: "completed", metadata },
}] }] as never,
} });
server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server!.once("listening", resolve));
const post = async (command: string) => {
const response = await fetch(`http://127.0.0.1:${(server!.address() as { port: number }).port}/internal/tools/sandbox-shell`, {
method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "TEST_ONLY" },
body: JSON.stringify({ session_id: session, call_id: "fixture", message_id: "fixture", command }),
});
return { status: response.status, data: await response.json() as Record<string, any> };
};
const staged = await post(`cat '${own}'`);
expect(staged.status).toBe(200);
expect(staged.data.outcome).toBe("requires_session_path");
const file = staged.data.files[0];
expect(await readFile(join(directory, file.relative_path), "utf8")).toBe(content);
const result = await post(`python3 -c 'import hashlib; print(hashlib.sha256(open("${file.shell_path}","rb").read()).hexdigest())'`);
expect(result.data.exitCode).toBe(0);
expect(result.data.stdout.trim()).toBe(file.sha256);
const forbidden = await post(`cat '${other}'`);
expect(forbidden.data.exitCode).not.toBe(0);
expect(forbidden.data.stderr).toContain("Permission denied");
const link = join(output, "tool_link");
await symlink(other, link);
metadata.outputPath = link;
expect((await post(`cat '${link}'`)).status).toBe(503);
});