Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 253 additions & 19 deletions packages/core/src/sandbox/daytona-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@
* Caches the sandbox handle to avoid redundant API calls per operation.
*/

import { randomUUID } from "node:crypto";
import { Daytona } from "@daytonaio/sdk";
import type { ExecResult, Sandbox } from "../../../../src/sandbox/types.js";
import type {
ExecResult,
ExecWithArgsOptions,
Sandbox,
} from "../../../../src/sandbox/types.js";

export interface DaytonaSandboxConfig {
apiKey: string;
Expand All @@ -22,9 +27,216 @@ type SandboxHandle = Awaited<
ReturnType<InstanceType<typeof Daytona>["create"]>
>;

type DaytonaSessionCommand = {
cmdId?: string;
exitCode?: number;
};

type DaytonaSessionLogs = {
output?: string;
stdout?: string;
stderr?: string;
};

type DaytonaProcessApi = SandboxHandle["process"] & {
createSession?: (sessionId: string) => Promise<void>;
deleteSession?: (sessionId: string) => Promise<void>;
executeSessionCommand?: (
sessionId: string,
req: {
command: string;
runAsync?: boolean;
suppressInputEcho?: boolean;
},
timeout?: number,
) => Promise<DaytonaSessionCommand>;
getSessionCommand?: (
sessionId: string,
commandId: string,
) => Promise<DaytonaSessionCommand>;
getSessionCommandLogs?: (
sessionId: string,
commandId: string,
) => Promise<DaytonaSessionLogs>;
};

const SESSION_POLL_MS = 100;
const SESSION_COMMAND_TIMEOUT_MS = 90_000;
const EXEC_OUTPUT_MAX_BUFFER = 40 * 1024;

function cancelledExecResult(): ExecResult {
return { stdout: "", stderr: "", exitCode: 1 };
}

function quoteShellArg(value: string): string {
if (/^[A-Za-z0-9_./:=@%+,-]+$/u.test(value)) {
return value;
}
return `'${value.replace(/'/g, `'\\''`)}'`;
}

function truncateOutput(value: string, maxBuffer?: number): string {
if (maxBuffer === undefined) {
return value;
}
const bytes = Buffer.from(value);
if (bytes.length <= maxBuffer) {
return value;
}
return bytes.subarray(0, maxBuffer).toString("utf-8");
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export class DaytonaSandbox implements Sandbox {
private constructor(private handle: SandboxHandle) {}

private hasSessionApi(processApi: DaytonaProcessApi): boolean {
return !!(
processApi.createSession &&
processApi.deleteSession &&
processApi.executeSessionCommand &&
processApi.getSessionCommand &&
processApi.getSessionCommandLogs
);
}

private buildShellCommand(
command: string,
cwd?: string,
env?: Record<string, string>,
): string {
let fullCommand = command;
if (env && Object.keys(env).length > 0) {
const envPrefix = Object.entries(env)
.map(([k, v]) => {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) {
throw new Error(`Invalid environment variable name: ${k}`);
}
const escaped = v.replace(/'/g, "'\\''");
return `${k}='${escaped}'`;
})
.join(" ");
fullCommand = `${envPrefix} ${fullCommand}`;
}
if (cwd) {
const escapedCwd = cwd.replace(/'/g, "'\\''");
fullCommand = `cd '${escapedCwd}' && ${fullCommand}`;
}
return fullCommand;
}

private async execWithSession(
command: string,
options: ExecWithArgsOptions = {},
): Promise<ExecResult> {
const processApi = this.handle.process as DaytonaProcessApi;
if (!this.hasSessionApi(processApi)) {
if (options.signal?.aborted) {
return cancelledExecResult();
}
if (options.signal) {
throw new Error(
"Daytona abortable execution requires session API support",
);
}
const result = await processApi.executeCommand(command);
return {
stdout: truncateOutput(result.result, options.maxBuffer),
stderr: "",
exitCode: result.exitCode,
};
}

const sessionId = `maestro-exec-${randomUUID()}`;
let sessionDeleted = false;
let sessionDeletePromise: Promise<void> | undefined;
const deleteSession = async (): Promise<void> => {
if (sessionDeleted) {
return;
}
if (sessionDeletePromise) {
await sessionDeletePromise;
if (sessionDeleted) {
return;
}
}
sessionDeletePromise = (async () => {
try {
await processApi.deleteSession!(sessionId);
sessionDeleted = true;
} catch {
// The session may not exist yet during setup cancellation.
} finally {
sessionDeletePromise = undefined;
}
})();
await sessionDeletePromise;
};
const abortSession = (): void => {
void deleteSession();
};
options.signal?.addEventListener("abort", abortSession, { once: true });

try {
if (options.signal?.aborted) {
return cancelledExecResult();
}
await processApi.createSession(sessionId);
if (options.signal?.aborted) {
return cancelledExecResult();
}

const response = await processApi.executeSessionCommand(sessionId, {
command,
runAsync: true,
suppressInputEcho: true,
});
if (!response.cmdId) {
throw new Error("Daytona session command did not return a command id");
}

const startedAt = Date.now();
while (!options.signal?.aborted) {
if (Date.now() - startedAt >= SESSION_COMMAND_TIMEOUT_MS) {
throw new Error("Daytona session command timed out");
}
const commandState = await processApi.getSessionCommand(
sessionId,
response.cmdId,
);
if (options.signal?.aborted) {
return cancelledExecResult();
}
if (typeof commandState.exitCode === "number") {
const logs = await processApi.getSessionCommandLogs(
sessionId,
response.cmdId,
);
if (options.signal?.aborted) {
return cancelledExecResult();
}
return {
stdout: truncateOutput(
logs.stdout ?? logs.output ?? "",
options.maxBuffer,
),
stderr: truncateOutput(logs.stderr ?? "", options.maxBuffer),
exitCode: commandState.exitCode,
};
}
await sleep(SESSION_POLL_MS);
}

return cancelledExecResult();
} finally {
options.signal?.removeEventListener("abort", abortSession);
await deleteSession();
}
}

/**
* Create a new Daytona sandbox. This is async because it provisions
* a remote sandbox environment.
Expand All @@ -49,28 +261,21 @@ export class DaytonaSandbox implements Sandbox {
command: string,
cwd?: string,
env?: Record<string, string>,
signal?: AbortSignal,
): Promise<ExecResult> {
try {
// Build command with env vars and cwd if provided
let fullCommand = command;
if (env && Object.keys(env).length > 0) {
const envPrefix = Object.entries(env)
.map(([k, v]) => {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) {
throw new Error(`Invalid environment variable name: ${k}`);
}
// Use single quotes to prevent shell interpretation
const escaped = v.replace(/'/g, "'\\''");
return `${k}='${escaped}'`;
})
.join(" ");
fullCommand = `${envPrefix} ${fullCommand}`;
const fullCommand = this.buildShellCommand(command, cwd, env);
const processApi = this.handle.process as DaytonaProcessApi;
if (signal?.aborted) {
return cancelledExecResult();
}
if (cwd) {
const escapedCwd = cwd.replace(/'/g, "'\\''");
fullCommand = `cd '${escapedCwd}' && ${fullCommand}`;
if (signal && this.hasSessionApi(processApi)) {
return await this.execWithSession(fullCommand, {
signal,
maxBuffer: EXEC_OUTPUT_MAX_BUFFER,
});
}
const result = await this.handle.process.executeCommand(fullCommand);
const result = await processApi.executeCommand(fullCommand);
return {
stdout: result.result,
stderr: "",
Expand All @@ -85,6 +290,35 @@ export class DaytonaSandbox implements Sandbox {
}
}

async execWithArgs(
command: string,
args: string[] = [],
options: ExecWithArgsOptions = {},
): Promise<ExecResult> {
try {
const fullCommand = this.buildShellCommand(
[command, ...args].map(quoteShellArg).join(" "),
options.cwd,
options.env,
);
if (options.signal) {
return await this.execWithSession(fullCommand, options);
}
const result = await this.handle.process.executeCommand(fullCommand);
return {
stdout: truncateOutput(result.result, options.maxBuffer),
stderr: "",
exitCode: result.exitCode,
};
} catch (err) {
return {
stdout: "",
stderr: err instanceof Error ? err.message : String(err),
exitCode: 1,
};
}
}

async readFile(path: string): Promise<string> {
const content = await this.handle.fs.downloadFile(path);
return typeof content === "string" ? content : content.toString("utf-8");
Expand Down
Loading
Loading