evOo-local/packages/adapters/src/box-sandbox.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

868 lines
28 KiB
TypeScript

import { randomUUID } from "node:crypto";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import {
type Box,
BoxApi,
type Command200Response,
type CommandResponse,
Configuration,
ResponseError,
} from "@asciidev/box-sdk";
import type {
AdapterContext,
CommandRequest,
ComputerActionRequest,
ComputerFileEntry,
ComputerInput,
ComputerObservation,
ComputerRef,
ControlLeaseRef,
PortableFile,
ProcessEvent,
SandboxProvider,
ScreenRequest,
ScreenSession,
} from "@rakazo/adapter-kit";
import { boundedSandboxCommandTimeoutMs } from "@rakazo/core";
import { boxResponseError, wrapBoxCall } from "./box-errors.js";
import { normalizeWorkspacePath, shellQuote, workspacePath } from "./computer-support.js";
import {
PORTABLE_TRANSFER_BATCH_BYTES,
shouldSkipPortableWorkspaceFile,
} from "./computer-workspace.js";
import { LinuxDesktop, PREPARE_LINUX_DESKTOP } from "./linux-desktop.js";
import { withAbort } from "./web-ssrf.js";
const BOX_API_BASE = "https://ascii.dev/api/box/v1";
const BOX_WORKSPACE = "/home/user/rakazo-home";
const BOX_BROWSER_PROFILES = `${BOX_WORKSPACE}/.browser-profiles`;
const BOX_READY_TIMEOUT_MS = 5 * 60_000;
const BOX_API_COMMAND_TIMEOUT_SECONDS = 600;
const BOX_TTL_SECONDS = 2 * 60 * 60;
const BOX_DELETE_TIMEOUT_MS = 60_000;
const BOX_EXPORT_CONCURRENCY = 16;
export type BoxSandboxSdk = Pick<
BoxApi,
| "artifactRaw"
| "command"
| "commandStatus"
| "create"
| "desktop"
| "get"
| "resume"
| "stop"
| "update"
| "writeFile"
> & {
deleteBox(boxId: string): Promise<void>;
};
interface BoxCommandResult {
stdout: string;
stderr: string;
exitCode: number;
timedOut: boolean;
}
export class BoxSandboxProvider implements SandboxProvider {
private readonly client: BoxSandboxSdk;
private readonly desktops = new LinuxDesktop({
environment: async () => ({
homeDir: "/home/user",
workspaceDir: BOX_WORKSPACE,
browserProfilesDir: BOX_BROWSER_PROFILES,
displayStart: 20,
portStart: 6100,
}),
run: async (computer, command, context) => {
const result = await this.runCommand(
this.id(computer),
`bash -c ${shellQuote(command)}`,
undefined,
120_000,
context.signal,
);
return {
code: result.timedOut ? 124 : result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
};
},
screenUrl: async (computer, port, context) => {
const result = await this.runCommand(
this.id(computer),
`host ${port} --private`,
undefined,
60_000,
context.signal,
);
if (result.exitCode !== 0 || result.timedOut)
throw new Error("could not expose protected desktop port");
const match = result.stdout.match(/https:\/\/[^\s]+/);
if (!match) throw new Error("Box did not return a desktop URL");
const url = new URL(match[0]);
if (!url.hostname.endsWith(".on.ascii.dev") || !url.searchParams.get("_token"))
throw new Error("Box desktop URL is not protected");
url.pathname = "/vnc.html";
return url.toString();
},
});
private readonly pendingProvisions = new Map<string, Promise<ComputerRef>>();
private readonly prepared = new Set<string>();
private readonly preparations = new Map<string, Promise<void>>();
constructor(
config: { apiKey: string; apiUrl?: string },
client: BoxSandboxSdk = createBoxSdk(config),
) {
this.client = client;
}
describe() {
return {
id: "box",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: {
graphical: true,
pty: false,
snapshots: true,
takeover: true,
persistentHome: true,
multiScreen: true,
},
};
}
async provision(
request: {
botId: string;
homePath: string;
providerRef?: string;
providerKind?: ComputerRef["kind"];
},
context: AdapterContext,
): Promise<ComputerRef> {
const reconnecting = request.providerRef && request.providerKind === "box";
const key = reconnecting ? request.providerRef! : `new:${request.botId}`;
const pending = this.pendingProvisions.get(key);
if (pending) return pending;
let provision!: Promise<ComputerRef>;
provision = this.provisionOnce(request, context).finally(() => {
if (this.pendingProvisions.get(key) === provision) this.pendingProvisions.delete(key);
});
this.pendingProvisions.set(key, provision);
return provision;
}
private async provisionOnce(
request: {
botId: string;
providerRef?: string;
providerKind?: ComputerRef["kind"];
},
context: AdapterContext,
): Promise<ComputerRef> {
if (request.providerRef && request.providerKind === "box") {
try {
const existing = await this.client.get(
{ boxId: request.providerRef },
{ signal: context.signal },
);
if (existing.box.state === "archived") {
await this.client.resume(
{
boxId: request.providerRef,
resumeRequest: { noEnv: true, ttlSeconds: BOX_TTL_SECONDS },
},
{ signal: context.signal },
);
}
return this.ref(request.providerRef, request.botId, false);
} catch (error) {
if (!isUnrecoverableBoxError(error)) throw error;
this.forget(request.providerRef);
}
}
const created = await this.client.create(
{
createBoxRequest: {
ttlSeconds: BOX_TTL_SECONDS,
noEnv: true,
env: {
RAKAZO_BOT_ID: request.botId,
RAKAZO_SANDBOX: "computer",
},
},
},
{ signal: context.signal },
);
return this.ref(created.box.id, request.botId, true);
}
async prepare(computer: ComputerRef, context: AdapterContext): Promise<void> {
const id = this.id(computer);
if (this.prepared.has(id)) return;
const pending = this.preparations.get(id);
if (pending) return pending;
let preparation!: Promise<void>;
preparation = (async () => {
await this.ensureRunnable(id, context);
const result = await this.runCommand(
id,
configureBoxWorkspaceCommand(),
undefined,
60_000,
context.signal,
);
if (result.exitCode !== 0) {
throw new Error(result.stderr || result.stdout || "could not prepare Box workspace");
}
const runtime = await this.runCommand(
id,
PREPARE_LINUX_DESKTOP,
undefined,
180_000,
context.signal,
);
if (runtime.exitCode !== 0 || runtime.timedOut)
throw new Error("could not prepare computer desktop tools");
if (this.preparations.get(id) !== preparation) {
throw new Error("Box workspace preparation was invalidated during teardown");
}
this.prepared.add(id);
})().finally(() => {
if (this.preparations.get(id) === preparation) this.preparations.delete(id);
});
this.preparations.set(id, preparation);
return preparation;
}
async *execute(
computer: ComputerRef,
request: CommandRequest,
context: AdapterContext,
): AsyncIterable<ProcessEvent> {
if (context.signal.aborted) {
yield { type: "stderr", data: "command aborted\n" };
yield { type: "exit", code: 130 };
return;
}
const timeoutMs = boundedSandboxCommandTimeoutMs(request.timeoutMs);
const argv = request.argv.length ? request.argv : ["true"];
const environment = Object.entries(request.env ?? {}).map(([key, value]) => `${key}=${value}`);
const command = [...(environment.length ? ["env", ...environment] : []), ...argv]
.map(shellQuote)
.join(" ");
try {
const result = await this.runCommand(
this.id(computer),
command,
boxCwd(request.cwd),
timeoutMs,
context.signal,
);
if (context.signal.aborted) {
yield { type: "stderr", data: "command aborted\n" };
yield { type: "exit", code: 130 };
return;
}
if (result.stdout) yield { type: "stdout", data: result.stdout };
if (result.timedOut) {
yield { type: "stderr", data: `command timed out after ${timeoutMs} ms\n` };
yield { type: "exit", code: 124 };
return;
}
if (result.stderr) yield { type: "stderr", data: result.stderr };
yield { type: "exit", code: result.exitCode };
} catch (error) {
if (context.signal.aborted || isAbortError(error)) {
yield { type: "stderr", data: "command aborted\n" };
yield { type: "exit", code: 130 };
return;
}
throw error;
}
}
async connectScreen(
computer: ComputerRef,
request: ScreenRequest,
context: AdapterContext,
): Promise<ScreenSession> {
return this.desktops.connectScreen(computer, request, context);
}
async setScreenControl(
computer: ComputerRef,
interactive: boolean,
context: AdapterContext,
controlToken?: string,
): Promise<void> {
return this.desktops.setScreenControl(computer, interactive, context, controlToken);
}
async sendInput(
computer: ComputerRef,
input: ComputerInput,
_lease: ControlLeaseRef,
context: AdapterContext,
): Promise<void> {
return this.desktops.sendInput(computer, input, context);
}
async observe(computer: ComputerRef, context: AdapterContext): Promise<ComputerObservation> {
return this.desktops.observe(computer, context);
}
async act(computer: ComputerRef, request: ComputerActionRequest, context: AdapterContext) {
return this.desktops.act(computer, request, context);
}
async listFiles(
computer: ComputerRef,
directory: string,
context: AdapterContext,
): Promise<ComputerFileEntry[]> {
const relative = normalizeWorkspacePath(directory);
const result = await this.runCommand(
this.id(computer),
listBoxFilesCommand(workspacePath(BOX_WORKSPACE, relative)),
undefined,
60_000,
context.signal,
);
if (result.exitCode !== 0) {
throw new Error(result.stderr || result.stdout || "could not list Box workspace");
}
return parseBoxFileEntries(result.stdout);
}
async readFile(
computer: ComputerRef,
filePath: string,
context: AdapterContext,
options?: { maxBytes?: number },
): Promise<Uint8Array> {
return this.readRemoteFile(
this.id(computer),
workspacePath(BOX_WORKSPACE, filePath),
context.signal,
options?.maxBytes,
);
}
async writeFile(computer: ComputerRef, file: PortableFile, context: AdapterContext) {
await this.writeFiles(this.id(computer), [file], context);
}
async *exportWorkspace(
computer: ComputerRef,
context: AdapterContext,
): AsyncIterable<PortableFile> {
const id = this.id(computer);
await this.desktops.stopBrowsers(computer, context);
const entries = await this.listWorkspaceFiles(id, context);
for (let index = 0; index < entries.length; index += BOX_EXPORT_CONCURRENCY) {
const files = await Promise.all(
entries.slice(index, index + BOX_EXPORT_CONCURRENCY).map(async (entry) => ({
path: entry.path,
content: await this.readFile(computer, entry.path, context),
executable: entry.executable,
})),
);
yield* files;
}
}
async importWorkspace(
computer: ComputerRef,
files: AsyncIterable<PortableFile>,
context: AdapterContext,
): Promise<void> {
const id = this.id(computer);
await this.desktops.stopBrowsers(computer, context);
let batch: PortableFile[] = [];
let batchBytes = 0;
const flush = async () => {
if (!batch.length) return;
await this.writeFiles(id, batch, context);
batch = [];
batchBytes = 0;
};
for await (const file of files) {
if (context.signal.aborted) throw context.signal.reason ?? new Error("import aborted");
if (
batch.length >= 8 ||
batchBytes + file.content.byteLength > PORTABLE_TRANSFER_BATCH_BYTES
) {
await flush();
}
batch.push(file);
batchBytes += file.content.byteLength;
}
await flush();
// Browser profiles are imported without opening a shared browser.
}
async snapshot(computer: ComputerRef, context: AdapterContext) {
const observation = await this.observe(computer, context);
return { id: observation.frameId, createdAt: observation.capturedAt };
}
async keepAlive(computer: ComputerRef): Promise<void> {
await this.client.update({
boxId: this.id(computer),
updateBoxRequest: { ttlSeconds: BOX_TTL_SECONDS },
});
}
async releaseScreen(computer: ComputerRef, context: AdapterContext): Promise<void> {
return this.desktops.releaseScreen(computer, context);
}
async stop(computer: ComputerRef, context: AdapterContext): Promise<void> {
const id = this.id(computer);
try {
const current = await this.client.get({ boxId: id }, { signal: context.signal });
if (current.box.state !== "archived" && current.box.state !== "archiving") {
await this.client.stop({ boxId: id }, { signal: context.signal });
}
await this.waitForState(id, new Set(["archived"]), context);
} catch (error) {
if (!isUnrecoverableBoxError(error)) throw error;
} finally {
this.forget(id);
}
}
async destroy(computer: ComputerRef, _context: AdapterContext): Promise<void> {
const id = this.id(computer);
try {
await this.client.deleteBox(id);
} catch (error) {
if (!isUnrecoverableBoxError(error)) throw error;
} finally {
this.forget(id);
}
}
private async ensureRunnable(id: string, context: AdapterContext): Promise<Box> {
const deadline = Date.now() + BOX_READY_TIMEOUT_MS;
let resumed = false;
while (true) {
if (context.signal.aborted) {
throw context.signal.reason ?? new Error("Box preparation aborted");
}
const response = await this.client.get({ boxId: id }, { signal: context.signal });
const box = response.box;
if (box.state === "ready" || box.state === "idle" || box.state === "running") return box;
if (box.state === "archived" && !resumed) {
await this.client.resume(
{ boxId: id, resumeRequest: { noEnv: true, ttlSeconds: BOX_TTL_SECONDS } },
{ signal: context.signal },
);
resumed = true;
} else if (box.state === "error") {
throw new Error(`Box ${id} entered the error state`);
}
if (Date.now() >= deadline) throw new Error(`Box ${id} did not become ready`);
await delay(1_000, undefined, { signal: context.signal });
}
}
private async waitForState(
id: string,
states: Set<Box["state"]>,
context: AdapterContext,
): Promise<Box> {
const deadline = Date.now() + BOX_READY_TIMEOUT_MS;
while (true) {
if (context.signal.aborted) throw context.signal.reason ?? new Error("Box wait aborted");
const response = await this.client.get({ boxId: id }, { signal: context.signal });
if (states.has(response.box.state)) return response.box;
if (response.box.state === "error") throw new Error(`Box ${id} entered the error state`);
if (Date.now() >= deadline)
throw new Error(`Box ${id} did not reach ${[...states].join("/")}`);
await delay(1_000, undefined, { signal: context.signal });
}
}
private async writeFiles(
id: string,
files: readonly PortableFile[],
context: AdapterContext,
): Promise<void> {
if (!files.length) return;
await Promise.all(
files.map((file) =>
this.client.writeFile(
{
boxId: id,
fileWriteRequest: {
path: workspacePath(BOX_WORKSPACE, file.path),
content: Buffer.from(file.content).toString("base64"),
encoding: "base64",
},
},
{ signal: context.signal },
),
),
);
const executable = files.map(
(file) =>
`${file.executable ? "700" : "600"} ${shellQuote(workspacePath(BOX_WORKSPACE, file.path))}`,
);
const result = await this.runCommand(
id,
executable.map((entry) => `chmod ${entry}`).join(" && "),
undefined,
60_000,
context.signal,
);
if (result.exitCode !== 0) {
throw new Error(result.stderr || result.stdout || "could not set Box file permissions");
}
}
private async listWorkspaceFiles(
id: string,
context: AdapterContext,
): Promise<ComputerFileEntry[]> {
const result = await this.runCommand(
id,
listBoxWorkspaceFilesCommand(),
undefined,
60_000,
context.signal,
);
if (result.exitCode !== 0) {
throw new Error(result.stderr || result.stdout || "could not enumerate Box workspace");
}
return parseBoxFileEntries(result.stdout).filter(
(entry) => entry.kind === "file" && !shouldSkipPortableWorkspaceFile(entry.path),
);
}
private async readRemoteFile(
id: string,
filePath: string,
signal?: AbortSignal,
maxBytes?: number,
): Promise<Uint8Array> {
// The JSON/base64 file endpoint rejects files over 5 MiB, including browser profiles.
const { raw: response } = await this.client.artifactRaw(
{ boxId: id, path: filePath },
signal ? { signal } : undefined,
);
const reader = response.body?.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
if (maxBytes !== undefined && Number(response.headers.get("content-length")) > maxBytes) {
throw new Error(`computer file exceeds ${maxBytes} bytes`);
}
if (!reader) return new Uint8Array();
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (maxBytes !== undefined && size > maxBytes) {
throw new Error(`computer file exceeds ${maxBytes} bytes`);
}
chunks.push(value);
}
const content = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
content.set(chunk, offset);
offset += chunk.byteLength;
}
return content;
} catch (error) {
await reader?.cancel().catch(() => undefined);
throw error;
} finally {
reader?.releaseLock();
}
}
private async runCommand(
id: string,
command: string,
cwd: string | undefined,
timeoutMs: number,
signal?: AbortSignal,
): Promise<BoxCommandResult> {
const marker = `/tmp/rakazo-command-${randomUUID()}.completed-124`;
const wrapped = timeoutCommand(command, timeoutMs, marker);
const timeoutSeconds = Math.ceil(timeoutMs / 1_000);
const detached = timeoutSeconds + 5 > BOX_API_COMMAND_TIMEOUT_SECONDS;
try {
const response = await this.client.command(
{
boxId: id,
commandRequest: {
command: wrapped,
cwd,
...(detached
? { detached: true }
: { timeoutSeconds: Math.min(timeoutSeconds + 5, BOX_API_COMMAND_TIMEOUT_SECONDS) }),
},
},
signal ? { signal } : undefined,
);
const result = isFinishedCommand(response)
? commandResponse(response)
: await this.pollCommand(id, response.processId, timeoutMs + 10_000, signal);
if (result.exitCode !== 124 || result.timedOut) return result;
const completed = await this.rawCommand(id, `test -f ${shellQuote(marker)}`, 10);
if (completed.exitCode === 0) {
await this.rawCommand(id, `rm -f -- ${shellQuote(marker)}`, 10).catch(() => undefined);
return result;
}
return { ...result, timedOut: true };
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
await this.terminateCommand(id, marker).catch(() => undefined);
}
throw error;
}
}
private async pollCommand(
id: string,
processId: number,
timeoutMs: number,
signal?: AbortSignal,
): Promise<BoxCommandResult> {
const deadline = Date.now() + timeoutMs;
while (true) {
if (signal?.aborted) throw signal.reason ?? new Error("command aborted");
const status = await this.client.commandStatus(
{ boxId: id, processId, tailBytes: 1_000_000 },
signal ? { signal } : undefined,
);
if (!status.running) {
return {
stdout: status.stdout,
stderr: status.stderr,
exitCode: status.exitCode ?? 1,
timedOut: false,
};
}
if (Date.now() >= deadline) {
const pid = status.pid ?? processId;
await this.rawCommand(
id,
`kill -TERM -- -${pid} 2>/dev/null || kill -TERM ${pid} 2>/dev/null || true`,
10,
).catch(() => undefined);
return { stdout: status.stdout, stderr: status.stderr, exitCode: 124, timedOut: true };
}
await delay(500, undefined, signal ? { signal } : undefined);
}
}
private async terminateCommand(id: string, marker: string): Promise<void> {
const pattern = marker.replace("rakazo", "[r]akazo");
await this.rawCommand(
id,
`pkill -TERM -f ${shellQuote(pattern)} 2>/dev/null || true; sleep 0.2; pkill -KILL -f ${shellQuote(pattern)} 2>/dev/null || true`,
10,
);
}
private async rawCommand(id: string, command: string, timeoutSeconds: number) {
const response = await this.client.command({
boxId: id,
commandRequest: { command, timeoutSeconds },
});
if (!isFinishedCommand(response)) {
return this.pollCommand(id, response.processId, timeoutSeconds * 1_000 + 5_000);
}
return commandResponse(response);
}
private ref(id: string, botId: string, fresh: boolean): ComputerRef {
return { id, botId, kind: "box", providerRef: id, fresh };
}
private id(computer: ComputerRef): string {
return computer.providerRef || computer.id;
}
private forget(id: string): void {
this.pendingProvisions.delete(id);
this.prepared.delete(id);
this.preparations.delete(id);
}
}
export function isUnrecoverableBoxError(error: unknown): boolean {
if (error instanceof ResponseError) return error.response.status === 404;
if (!error || typeof error !== "object") return false;
const value = error as { status?: unknown; statusCode?: unknown; code?: unknown };
return value.status === 404 || value.statusCode === 404 || value.code === 404;
}
function createBoxSdk(config: { apiKey: string; apiUrl?: string }): BoxSandboxSdk {
const apiUrl = (config.apiUrl ?? BOX_API_BASE).replace(/\/$/, "");
const api = new BoxApi(
new Configuration({
basePath: apiUrl,
accessToken: config.apiKey,
}),
);
return {
artifactRaw: wrapBoxCall(api.artifactRaw.bind(api), config.apiKey),
command: wrapBoxCall(api.command.bind(api), config.apiKey),
commandStatus: wrapBoxCall(api.commandStatus.bind(api), config.apiKey),
create: wrapBoxCall(api.create.bind(api), config.apiKey),
desktop: wrapBoxCall(api.desktop.bind(api), config.apiKey),
get: wrapBoxCall(api.get.bind(api), config.apiKey),
resume: wrapBoxCall(api.resume.bind(api), config.apiKey),
stop: wrapBoxCall(api.stop.bind(api), config.apiKey),
update: wrapBoxCall(api.update.bind(api), config.apiKey),
writeFile: wrapBoxCall(api.writeFile.bind(api), config.apiKey),
deleteBox: async (boxId: string) => {
const url = `${apiUrl}/boxes/${encodeURIComponent(boxId)}`;
const headers = { Authorization: `Bearer ${config.apiKey}` };
const signal = AbortSignal.timeout(BOX_DELETE_TIMEOUT_MS);
try {
const response = await fetch(url, {
method: "DELETE",
headers: {
...headers,
"X-Ascii-Confirm-Delete": boxId,
},
signal,
});
if (response.status === 404) return;
if (!response.ok) {
throw await boxResponseError(response, config.apiKey, signal);
}
while (!signal.aborted) {
const status = await fetch(url, { headers, signal });
if (status.status === 404) return;
if (!status.ok) {
throw await boxResponseError(status, config.apiKey, signal);
}
await withAbort(delay(500), signal);
}
} catch (error) {
if (!signal.aborted || error !== signal.reason) throw error;
}
throw new Error(`Box ${boxId} was not deleted within 60 seconds`);
},
};
}
function timeoutCommand(command: string, timeoutMs: number, marker: string): string {
const seconds = Math.max(timeoutMs / 1_000, 0.001);
const completionScript =
'"$@"; status=$?; if [ "$status" -eq 124 ]; then : > "$0"; fi; exit "$status"';
return [
"timeout",
"--kill-after=1s",
`${seconds}s`,
"sh",
"-c",
completionScript,
marker,
"bash",
"-lc",
command,
]
.map(shellQuote)
.join(" ");
}
function isFinishedCommand(response: Command200Response): response is CommandResponse {
return response.type === "command.finished";
}
function commandResponse(response: CommandResponse): BoxCommandResult {
return {
stdout: response.stdout,
stderr: response.stderr,
exitCode: response.exitCode ?? 1,
timedOut: response.timedOut,
};
}
function boxCwd(cwd: string | undefined): string {
if (
!cwd ||
cwd === "." ||
cwd === "/" ||
cwd === "/home/rakazo" ||
cwd === "/home/user" ||
cwd === BOX_WORKSPACE
) {
return "rakazo-home";
}
const relative = cwd.startsWith(`${BOX_WORKSPACE}/`)
? cwd.slice(BOX_WORKSPACE.length + 1)
: cwd.startsWith("/home/rakazo/")
? cwd.slice("/home/rakazo/".length)
: cwd;
return path.posix.join("rakazo-home", normalizeWorkspacePath(relative));
}
function configureBoxWorkspaceCommand(): string {
return `mkdir -p ${shellQuote(BOX_BROWSER_PROFILES)}`;
}
function listBoxFilesCommand(directory: string): string {
return [
"set -o pipefail",
`directory=${shellQuote(directory)}`,
'test -d "$directory"',
'find "$directory" -mindepth 1 -maxdepth 1 -print0 | sort -z | while IFS= read -r -d "" entry; do',
' if [ -d "$entry" ]; then kind=dir; elif [ -f "$entry" ]; then kind=file; else continue; fi',
' encoded=$(printf %s "$entry" | base64 -w0)',
' size=$(stat -c %s "$entry")',
' if [ "$kind" = file ] && [ -x "$entry" ]; then executable=1; else executable=0; fi',
' printf \'%s\\t%s\\t%s\\t%s\\n\' "$encoded" "$kind" "$size" "$executable"',
"done",
].join("\n");
}
function listBoxWorkspaceFilesCommand(): string {
return [
"set -o pipefail",
`find ${shellQuote(BOX_WORKSPACE)} -type f -print0 | sort -z | while IFS= read -r -d "" entry; do`,
' encoded=$(printf %s "$entry" | base64 -w0)',
' size=$(stat -c %s "$entry")',
' if [ -x "$entry" ]; then executable=1; else executable=0; fi',
' printf \'%s\\tfile\\t%s\\t%s\\n\' "$encoded" "$size" "$executable"',
"done",
].join("\n");
}
function parseBoxFileEntries(output: string): ComputerFileEntry[] {
return output
.split("\n")
.filter(Boolean)
.flatMap((line): ComputerFileEntry[] => {
const [encoded, kind, size, executable] = line.split("\t");
if (!encoded || (kind !== "file" && kind !== "dir")) return [];
const absolute = Buffer.from(encoded, "base64").toString("utf8");
if (!absolute.startsWith(`${BOX_WORKSPACE}/`)) return [];
const child = normalizeWorkspacePath(absolute.slice(BOX_WORKSPACE.length + 1));
return [
{
path: child,
kind,
size: Number(size) || 0,
...(kind === "file" && executable === "1" ? { executable: true } : {}),
},
];
})
.sort((a, b) => a.path.localeCompare(b.path));
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && (error.name === "AbortError" || /aborted/i.test(error.message));
}