evOo-local/packages/adapters/src/computer-support.ts

274 lines
9.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;
}
/** Docker computers also take streamed writes, so files copied onto them have no transfer cap. */
export function computerFileWriteMaxBytes(computer: Pick<ComputerRef, "kind">): number {
return computer.kind === "docker"
? Number.POSITIVE_INFINITY
: computerFileTransferMaxBytes(computer);
}
/** 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,
command?: string,
): string | undefined {
if (scope !== "team") return requestedCwd;
if (!requestedCwd || requestedCwd === ".") {
if (command && referencesTeamRootFromDefaultCwd(command)) return ".";
return teamBotWorkspaceDirectory(botId);
}
if (requestedCwd.startsWith("/")) {
const stripped = stripVirtualWorkspaceRoot(requestedCwd);
const normalized = stripped !== null ? normalizeWorkspacePath(stripped) : "";
if (!normalized || isTeamRootPath(normalized)) return normalized || ".";
return requestedCwd;
}
return resolveBotWorkspacePath(scope, botId, requestedCwd);
}
function referencesTeamRootFromDefaultCwd(command: string): boolean {
return (
/(?:^|[\s"'`=:(;|&])(?:\.\/)?(?:project|shared|bots)\//.test(command) ||
/\b(?:cd|ls|find|du|stat|realpath|git\s+-C)\s+(?:-[A-Za-z0-9]+\s+)*(?:["'])?(?:\.\/)?(?:project|shared|bots)(?:["'\s/;|$]|$)/.test(
command,
)
);
}
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";