Attachments copied into a local computer's workspace were read whole from the artifact store first, so a 500 MiB video sat in memory before it was written to disk. attach_file did the same in the other direction: a large file on a local computer was read whole before it was stored. SandboxProvider gains optional readFileStream/writeFileStream. The desktop (host workspace) provider implements them with the same path confinement as its byte methods, one chunk in memory at a time, and removes a file whose write fails or is aborted, including one that replaced an earlier copy. The host-aware wrapper routes them, and providers without them (remote computers, still capped at 12 MiB) receive the collected bytes. Current-turn staging and attach_file now go through these helpers, and unread streams are cancelled so the stored file is released. Media job inputs stop copying when the job is cancelled. The stream helpers move from the local artifact store into a shared module so both use one implementation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
249 lines
8.0 KiB
TypeScript
249 lines
8.0 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import path from "node:path";
|
|
import type {
|
|
AdapterContext,
|
|
ComputerAction,
|
|
ComputerObservation,
|
|
ComputerRef,
|
|
PortableFileStream,
|
|
SandboxProvider,
|
|
} from "@rakazo/adapter-kit";
|
|
import type { ComputerMode } from "@rakazo/contracts";
|
|
import { collectBytes, discardByteStream } from "./byte-streams.js";
|
|
|
|
export function toComputerRef(computer: {
|
|
homeKey: string;
|
|
kind: string;
|
|
providerRef: string | null;
|
|
}): ComputerRef {
|
|
if (!computer.providerRef) throw new Error("computer provider reference is missing");
|
|
return {
|
|
id: computer.providerRef,
|
|
botId: computer.homeKey,
|
|
kind: computer.kind as ComputerRef["kind"],
|
|
providerRef: computer.providerRef,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remote computers move files as base64 JSON (the Docker supervisor accepts 16 MiB requests), so
|
|
* files copied to or read from them stay below this size. Local computers have no transfer cap.
|
|
*/
|
|
export const REMOTE_COMPUTER_FILE_TRANSFER_MAX_BYTES = 12 * 1024 * 1024;
|
|
|
|
export function computerFileTransferMaxBytes(computer: Pick<ComputerRef, "kind">): number {
|
|
return computer.kind === "desktop" || computer.kind === "fake"
|
|
? Number.POSITIVE_INFINITY
|
|
: REMOTE_COMPUTER_FILE_TRANSFER_MAX_BYTES;
|
|
}
|
|
|
|
/** Streams a file into the workspace; providers without streaming writes receive its bytes. */
|
|
export async function writeComputerFileStream(
|
|
sandbox: SandboxProvider,
|
|
computer: ComputerRef,
|
|
file: PortableFileStream,
|
|
context: AdapterContext,
|
|
): Promise<void> {
|
|
try {
|
|
if (sandbox.writeFileStream) {
|
|
await sandbox.writeFileStream(computer, file, context);
|
|
return;
|
|
}
|
|
const content = await collectBytes(file.stream);
|
|
await sandbox.writeFile(
|
|
computer,
|
|
{ path: file.path, content, ...(file.executable ? { executable: true } : {}) },
|
|
context,
|
|
);
|
|
} catch (error) {
|
|
discardByteStream(file.stream);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Opens a workspace file as a stream; providers without streaming reads return it in one chunk. */
|
|
export async function readComputerFileStream(
|
|
sandbox: SandboxProvider,
|
|
computer: ComputerRef,
|
|
filePath: string,
|
|
context: AdapterContext,
|
|
options?: { maxBytes?: number },
|
|
): Promise<ReadableStream<Uint8Array>> {
|
|
if (sandbox.readFileStream) return sandbox.readFileStream(computer, filePath, context, options);
|
|
const bytes = await sandbox.readFile(computer, filePath, context, options);
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(bytes);
|
|
controller.close();
|
|
},
|
|
});
|
|
}
|
|
|
|
const EMPTY_PNG = Uint8Array.from(
|
|
Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
"base64",
|
|
),
|
|
);
|
|
|
|
export function computerObservation(
|
|
image: Uint8Array,
|
|
input: Omit<ComputerObservation, "frameId" | "capturedAt" | "image">,
|
|
): ComputerObservation {
|
|
return {
|
|
...input,
|
|
image,
|
|
frameId: createHash("sha256").update(image).digest("hex"),
|
|
capturedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
export function placeholderObservation(label = "ready"): ComputerObservation {
|
|
return computerObservation(EMPTY_PNG, {
|
|
mimeType: "image/png",
|
|
width: 1,
|
|
height: 1,
|
|
activeWindow: { id: "placeholder", title: label },
|
|
});
|
|
}
|
|
|
|
export function applyPlaceholderAction(box: { screen: string }, action: ComputerAction): void {
|
|
if (action.kind === "clipboard") box.screen = action.text;
|
|
else if (action.kind === "open") box.screen = `opened ${action.path}`;
|
|
else if (action.kind === "launch") box.screen = `launched ${action.application}`;
|
|
else box.screen = action.kind;
|
|
}
|
|
|
|
export function boundedComputerActions(actions: readonly ComputerAction[]): ComputerAction[] {
|
|
if (actions.length > 24) throw new Error("computer action batch exceeds 24 actions");
|
|
return [...actions];
|
|
}
|
|
|
|
export function clampRounded(value: number, min: number, max: number): number {
|
|
return Math.min(Math.max(Math.round(value), min), max);
|
|
}
|
|
|
|
export function normalizeWorkspacePath(value: string): string {
|
|
const normalized = value.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
const rawSegments = normalized.split("/").filter(Boolean);
|
|
if (rawSegments.some((segment) => segment === "..")) {
|
|
throw new Error("Path escapes the computer workspace");
|
|
}
|
|
const segments = rawSegments.filter((segment) => segment !== ".");
|
|
return segments.join("/");
|
|
}
|
|
|
|
export function workspacePath(root: string, relative: string): string {
|
|
const normalized = normalizeWorkspacePath(relative);
|
|
return normalized ? path.posix.join(root, normalized) : root;
|
|
}
|
|
|
|
export function teamBotWorkspaceDirectory(botId: string): string {
|
|
return path.posix.join("bots", normalizeWorkspacePath(botId));
|
|
}
|
|
|
|
const TEAM_WORKSPACE_ESCAPE = "Path escapes the bot computer workspace";
|
|
|
|
/** Bot home segment under `bots/<botId>/…`, or null for shared/root paths. */
|
|
export function teamWorkspaceTargetBotId(storedPath: string): string | null {
|
|
const normalized = normalizeWorkspacePath(storedPath);
|
|
if (normalized === "bots" || !normalized.startsWith("bots/")) return null;
|
|
const remainder = normalized.slice("bots/".length);
|
|
if (!remainder) return null;
|
|
const [botId] = remainder.split("/");
|
|
return botId || null;
|
|
}
|
|
|
|
export function assertTeamWorkspacePathAllowed(
|
|
storedPath: string,
|
|
allowedBotIds: ReadonlySet<string>,
|
|
): void {
|
|
const targetBotId = teamWorkspaceTargetBotId(storedPath);
|
|
if (!targetBotId || allowedBotIds.has(targetBotId)) return;
|
|
throw new Error(TEAM_WORKSPACE_ESCAPE);
|
|
}
|
|
|
|
export function assertTeamShellCommandAllowed(
|
|
command: string,
|
|
allowedBotIds: ReadonlySet<string>,
|
|
): void {
|
|
for (const match of command.matchAll(/\bbots\/([A-Za-z0-9_-]+)/g)) {
|
|
const botId = match[1];
|
|
if (botId && !allowedBotIds.has(botId)) {
|
|
throw new Error(TEAM_WORKSPACE_ESCAPE);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function filterTeamWorkspaceEntries<T extends { path: string }>(
|
|
entries: readonly T[],
|
|
allowedBotIds: ReadonlySet<string>,
|
|
): T[] {
|
|
return entries.filter((entry) => {
|
|
const targetBotId = teamWorkspaceTargetBotId(entry.path);
|
|
return !targetBotId || allowedBotIds.has(targetBotId);
|
|
});
|
|
}
|
|
|
|
export function resolveBotWorkspacePath(
|
|
scope: ComputerMode,
|
|
botId: string,
|
|
requestedPath: string,
|
|
): string {
|
|
if (scope !== "team") return requestedPath;
|
|
const explicitRootPath = stripVirtualWorkspaceRoot(requestedPath);
|
|
const normalized = normalizeWorkspacePath(explicitRootPath ?? requestedPath);
|
|
if (explicitRootPath !== null || isTeamRootPath(normalized)) return normalized;
|
|
return path.posix.join(teamBotWorkspaceDirectory(botId), normalized);
|
|
}
|
|
|
|
export function resolveBotWorkspaceCwd(
|
|
scope: ComputerMode,
|
|
botId: string,
|
|
requestedCwd: string | undefined,
|
|
): string | undefined {
|
|
if (scope !== "team") return requestedCwd;
|
|
if (!requestedCwd || requestedCwd === ".") return teamBotWorkspaceDirectory(botId);
|
|
if (requestedCwd.startsWith("/")) return requestedCwd;
|
|
return resolveBotWorkspacePath(scope, botId, requestedCwd);
|
|
}
|
|
|
|
export function displayBotWorkspacePath(
|
|
scope: ComputerMode,
|
|
botId: string,
|
|
requestedPath: string,
|
|
storedPath: string,
|
|
): string {
|
|
if (scope !== "team" || !isBotRelativeRequest(requestedPath)) return storedPath;
|
|
const normalized = normalizeWorkspacePath(storedPath);
|
|
const botDirectory = teamBotWorkspaceDirectory(botId);
|
|
if (normalized === botDirectory) return "";
|
|
return normalized.startsWith(`${botDirectory}/`)
|
|
? normalized.slice(botDirectory.length + 1)
|
|
: normalized;
|
|
}
|
|
|
|
function isBotRelativeRequest(value: string): boolean {
|
|
if (stripVirtualWorkspaceRoot(value) !== null) return false;
|
|
return !isTeamRootPath(normalizeWorkspacePath(value));
|
|
}
|
|
|
|
function isTeamRootPath(value: string): boolean {
|
|
return (
|
|
value === "shared" ||
|
|
value.startsWith("shared/") ||
|
|
value === "project" ||
|
|
value.startsWith("project/") ||
|
|
value === "bots" ||
|
|
value.startsWith("bots/")
|
|
);
|
|
}
|
|
|
|
function stripVirtualWorkspaceRoot(value: string): string | null {
|
|
const portable = value.replace(/\\/g, "/");
|
|
if (portable === "/") return "";
|
|
return portable.startsWith("/") ? portable.slice(1) : null;
|
|
}
|
|
|
|
export { shellQuote } from "@rakazo/core/node/desktop-runtime";
|