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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -116,6 +116,47 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||||
const toSse = (event: string, data: Record<string, unknown>) =>
|
||||
`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" });
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>;
|
||||
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,
|
||||
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<Record<"stdout_file" | "stderr_file", string>> = {};
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user