Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e476569ebc | ||
|
|
a7ddff7b64 |
@@ -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 和文件修改仍经过原生权限审批。聊天选择“请求批准”(`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/`,工具返回预览和路径;取消、超时、超限和命令结束均清理该进程组,不保留后台进程。
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Called only for native "asked" events. Explicit denials and host isolation
|
||||
// still run before this point; the selected round mode answers pending requests.
|
||||
export function canAutomaticallyApprove(mode: string, _action: string) {
|
||||
return mode === "always";
|
||||
export function canAutomaticallyApprove(mode: string, action: string, backgroundSandboxPermissions = false) {
|
||||
return mode === "always" || (backgroundSandboxPermissions && (action === "shell" || action === "edit"));
|
||||
}
|
||||
|
||||
@@ -116,6 +116,47 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||||
const toSse = (event: string, data: Record<string, unknown>) =>
|
||||
`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 = (
|
||||
chatRouter: Router,
|
||||
options: RegisterChatSessionRoutesOptions,
|
||||
@@ -183,35 +224,8 @@ export const registerChatSessionRoutes = (
|
||||
workflow?: string,
|
||||
) => {
|
||||
queueMicrotask(() => {
|
||||
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`;
|
||||
void 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: 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");
|
||||
});
|
||||
void runBackgroundTaskExecution({ sessionId: record.sessionId, credential, model, workflow,
|
||||
isRunning: () => activeRuns.get(record.sessionId)?.status === "running" });
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ type StreamPromptOptions = {
|
||||
message: string;
|
||||
model?: SupportedModel;
|
||||
approvalMode?: ApprovalMode;
|
||||
backgroundSandboxPermissions?: boolean;
|
||||
traceId?: string;
|
||||
projectId?: string;
|
||||
signal?: AbortSignal;
|
||||
@@ -148,6 +149,7 @@ export const streamPromptResponse = async ({
|
||||
message,
|
||||
model,
|
||||
approvalMode = "request",
|
||||
backgroundSandboxPermissions = false,
|
||||
traceId,
|
||||
projectId,
|
||||
signal,
|
||||
@@ -481,7 +483,7 @@ export const streamPromptResponse = async ({
|
||||
patterns: event.properties.patterns,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission);
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission, backgroundSandboxPermissions);
|
||||
emitProgress({
|
||||
id: `permission-${event.properties.id}`,
|
||||
phase: "permission",
|
||||
@@ -489,7 +491,9 @@ export const streamPromptResponse = async ({
|
||||
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
|
||||
detail:
|
||||
autoApprove
|
||||
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
|
||||
? approvalMode === "always"
|
||||
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
|
||||
: "后台任务的会话内 Shell / 文件修改已自动批准;目录边界与明确拒绝规则仍生效。"
|
||||
: buildPermissionDetail(event),
|
||||
});
|
||||
if (autoApprove) {
|
||||
@@ -529,7 +533,7 @@ export const streamPromptResponse = async ({
|
||||
resources: event.properties.resources,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action);
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action, backgroundSandboxPermissions);
|
||||
emitProgress({
|
||||
id: `permission-${event.properties.id}`,
|
||||
phase: "permission",
|
||||
@@ -537,7 +541,9 @@ export const streamPromptResponse = async ({
|
||||
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
|
||||
detail:
|
||||
autoApprove
|
||||
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
|
||||
? approvalMode === "always"
|
||||
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
|
||||
: "后台任务的会话内 Shell / 文件修改已自动批准;目录边界与明确拒绝规则仍生效。"
|
||||
: buildPermissionV2Detail(event),
|
||||
});
|
||||
if (autoApprove) {
|
||||
|
||||
@@ -997,6 +997,7 @@ export const registerChatStreamRoute = (
|
||||
message: preparedMessage,
|
||||
model: parsed.data.model,
|
||||
approvalMode: parsed.data.approval_mode,
|
||||
backgroundSandboxPermissions: sessionPurpose === "task_execution",
|
||||
traceId: requestContext.traceId,
|
||||
projectId: requestContext.projectId,
|
||||
signal: abortController.signal,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.
|
||||
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),
|
||||
@@ -37,6 +39,7 @@ export function isShellCommandDenied(command: string): boolean {
|
||||
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>;
|
||||
};
|
||||
@@ -61,6 +64,15 @@ export function registerSandboxToolBridge(app: Express, options: {
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
bindOperationalTaskCredential,
|
||||
humanTaskEvidenceCommandSchema,
|
||||
hasOperationalTaskRun,
|
||||
OperationalTaskClient,
|
||||
proposedTaskActionSchema,
|
||||
releaseOperationalTaskCredential,
|
||||
} from "../../src/product/operational-task-client.js";
|
||||
|
||||
import { runBackgroundTaskExecution } from "../../src/routes/chatSessionRoutes.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const taskId = "50000000-0000-4000-8000-000000000001";
|
||||
const runId = "55000000-0000-4000-8000-000000000001";
|
||||
@@ -364,3 +367,28 @@ describe("OperationalTaskClient", () => {
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -714,3 +714,40 @@ describe("streamPromptResponse", () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe("background sandbox permissions", () => {
|
||||
it.each([["permission.asked", true], ["permission.v2.asked", true],
|
||||
["permission.asked", false], ["permission.v2.asked", false]] as const)("limits session Shell/edit approval for %s background=%s", async (type, background) => {
|
||||
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", backgroundSandboxPermissions: background,
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
expect(replies).toEqual(background ? [
|
||||
{ requestId: "shell", sessionId: "runtime-session-1", reply: "once" },
|
||||
{ requestId: "edit", sessionId: "runtime-session-1", reply: "once" },
|
||||
] : []);
|
||||
expect(events.filter(({ event }) => event === "permission_request")
|
||||
.map(({ data }) => data.permission)).toEqual(background ? ["mcp_fixture_write"] : ["shell", "edit", "mcp_fixture_write"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import express from "express";
|
||||
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 { join } from "node:path";
|
||||
import { config } from "../../src/config.js";
|
||||
@@ -11,6 +11,7 @@ import { registerSandboxToolBridge } from "../../src/routes/sandboxToolBridge.js
|
||||
|
||||
let server: Server | undefined;
|
||||
let root: string | undefined;
|
||||
const previousDataHome = process.env.XDG_DATA_HOME;
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
const session = "sandbox-bridge-test";
|
||||
afterEach(async () => {
|
||||
@@ -18,6 +19,8 @@ afterEach(async () => {
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
config.CONVERSATION_WORKSPACE_ROOT = previousRoot;
|
||||
removeRuntimeSessionContext(session);
|
||||
if (previousDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||
else process.env.XDG_DATA_HOME = previousDataHome;
|
||||
});
|
||||
describe("sandbox execution bridge", () => {
|
||||
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 app = express(); app.use(express.json());
|
||||
registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: {
|
||||
messages: async () => [],
|
||||
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; },
|
||||
} });
|
||||
@@ -55,3 +59,50 @@ describe("sandbox execution bridge", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user