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,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");
}
});
+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 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);
});