diff --git a/README.md b/README.md index 463885d..ca5a021 100644 --- a/README.md +++ b/README.md @@ -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/`,工具返回预览和路径;取消、超时、超限和命令结束均清理该进程组,不保留后台进程。 diff --git a/src/routes/chatPermissionPolicy.ts b/src/routes/chatPermissionPolicy.ts index 5de70c8..67b3079 100644 --- a/src/routes/chatPermissionPolicy.ts +++ b/src/routes/chatPermissionPolicy.ts @@ -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, backgroundSandboxShell = false) { + return mode === "always" || (backgroundSandboxShell && action === "shell"); } diff --git a/src/routes/chatSessionRoutes.ts b/src/routes/chatSessionRoutes.ts index 983e108..ae7f492 100644 --- a/src/routes/chatSessionRoutes.ts +++ b/src/routes/chatSessionRoutes.ts @@ -116,6 +116,47 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({ const toSse = (event: string, data: Record) => `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" }); }); }; diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index 5aec04a..5a92da1 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -84,6 +84,7 @@ type StreamPromptOptions = { message: string; model?: SupportedModel; approvalMode?: ApprovalMode; + backgroundSandboxShell?: boolean; traceId?: string; projectId?: string; signal?: AbortSignal; @@ -148,6 +149,7 @@ export const streamPromptResponse = async ({ message, model, approvalMode = "request", + backgroundSandboxShell = 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, backgroundSandboxShell); 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, backgroundSandboxShell); 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) { diff --git a/src/routes/chatStreamRoute.ts b/src/routes/chatStreamRoute.ts index 9afcb09..be59ca7 100644 --- a/src/routes/chatStreamRoute.ts +++ b/src/routes/chatStreamRoute.ts @@ -997,6 +997,7 @@ export const registerChatStreamRoute = ( message: preparedMessage, model: parsed.data.model, approvalMode: parsed.data.approval_mode, + backgroundSandboxShell: sessionPurpose === "task_execution", traceId: requestContext.traceId, projectId: requestContext.projectId, signal: abortController.signal, diff --git a/src/routes/sandboxToolBridge.ts b/src/routes/sandboxToolBridge.ts index efff838..62910ec 100644 --- a/src/routes/sandboxToolBridge.ts +++ b/src/routes/sandboxToolBridge.ts @@ -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; approveSandboxShell(input: { sessionId: string; command: string; description?: string; callId: string; messageId: string }, signal: AbortSignal): Promise; }; @@ -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> = {}; for (const stream of ["stdout", "stderr"] as const) { diff --git a/src/sandbox/tool-output.ts b/src/sandbox/tool-output.ts new file mode 100644 index 0000000..8b5b7a8 --- /dev/null +++ b/src/sandbox/tool-output.ts @@ -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>, +) { + const sources = new Set(); + 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; +} diff --git a/tests/product/operationalTaskClient.test.ts b/tests/product/operationalTaskClient.test.ts index 182df99..6f3519e 100644 --- a/tests/product/operationalTaskClient.test.ts +++ b/tests/product/operationalTaskClient.test.ts @@ -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[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"); + } +}); diff --git a/tests/routes/chatStream.test.ts b/tests/routes/chatStream.test.ts index b11e89d..e8bb95d 100644 --- a/tests/routes/chatStream.test.ts +++ b/tests/routes/chatStream.test.ts @@ -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> = []; + 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) => { replies.push(input); }, + } as unknown as OpencodeRuntimeAdapter; + const events: Array<{ event: string; data: Record }> = []; + 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"]); + }); +}); diff --git a/tests/routes/sandboxToolBridge.test.ts b/tests/routes/sandboxToolBridge.test.ts index d194b15..1fdd9a2 100644 --- a/tests/routes/sandboxToolBridge.test.ts +++ b/tests/routes/sandboxToolBridge.test.ts @@ -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((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((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((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 }; + }; + 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); +});