fix(agent): keep background sandbox tasks running
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user