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
+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;
}