Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 默认在 `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/`,工具返回预览和路径;取消、超时、超限和命令结束均清理该进程组,不保留后台进程。
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user