274 lines
9.0 KiB
TypeScript
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";
|