fix(daytona): set per-sandbox user/org defaults (#3175)

* feat(daytona): re-add Daytona cloud provider

* fix(daytona): tighten live provider behavior

* fix(daytona): harden reconnect and dashboard flows

* fix(daytona): use platform sandbox defaults

* fix(daytona): add user and org defaults

* fix(ux): stop echoing shell script on startup

---------

Signed-off-by: Ahmed Abushagur <ahmed@abushagur.com>
Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com>
This commit is contained in:
Muhammad Hashmi 2026-04-04 18:08:40 -07:00 committed by GitHub
parent 564b5001a4
commit a60d238dfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 136 additions and 33 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.30.13",
"version": "0.30.14",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -91,25 +91,18 @@ const DAYTONA_ALLOWED_METADATA_KEYS = new Set([
export const SANDBOX_SIZES: SandboxSize[] = [
{
id: "small",
cpu: 2,
memory: 4,
disk: 30,
label: "2 vCPU · 4 GiB RAM · 30 GiB disk",
id: "user-default",
cpu: 1,
memory: 1,
disk: 3,
label: "User default (1 vCPU · 1 GiB RAM · 3 GiB disk)",
},
{
id: "medium",
id: "org-default",
cpu: 4,
memory: 8,
disk: 50,
label: "4 vCPU · 8 GiB RAM · 50 GiB disk",
},
{
id: "large",
cpu: 8,
memory: 16,
disk: 100,
label: "8 vCPU · 16 GiB RAM · 100 GiB disk",
disk: 10,
label: "Org default (4 vCPU · 8 GiB RAM · 10 GiB disk)",
},
];
@ -317,6 +310,27 @@ function resolveSandboxSizeFromEnv(): SandboxSize | null {
return matched;
}
/**
* Let Daytona apply its own documented defaults unless the user picked an explicit size.
*/
function getCreateResources(size: SandboxSize):
| {
cpu: number;
memory: number;
disk: number;
}
| undefined {
if (size.id === DEFAULT_SANDBOX_SIZE.id) {
return undefined;
}
return {
cpu: size.cpu,
memory: size.memory,
disk: size.disk,
};
}
/**
* Prompt for a sandbox size or resolve one from environment variables.
*/
@ -406,16 +420,17 @@ export async function createServer(name: string): Promise<VMConnection> {
const client = await getRequiredClient();
const size = _state.sandboxSize;
const image = getRequestedImage();
const resources = getCreateResources(size);
logStep(`Creating Daytona sandbox '${name}' (${size.label})...`);
const sandbox = await client.create({
name,
image,
resources: {
cpu: size.cpu,
memory: size.memory,
disk: size.disk,
},
...(resources
? {
resources,
}
: {}),
labels: buildCreateLabels(),
autoStopInterval: 0,
autoArchiveInterval: 0,
@ -723,10 +738,65 @@ function getPtySize(): {
};
}
/**
* Upload a small bootstrap script so the PTY only has to exec a file path.
*
* The script clears the shell's echoed command line before launching the agent.
*/
async function prepareInteractiveBootstrapScript(sandboxId: string, cmd: string): Promise<string> {
const sandbox = await ensureSandboxStarted(sandboxId);
const homeDir = await getSandboxHomeDir(sandboxId);
const remotePath = `${homeDir}/.spawn-interactive-session.sh`;
const script = `#!/usr/bin/env bash
set -e
# Clear the shell's echoed bootstrap command before the agent UI takes over.
printf '\\033[1A\\r\\033[2K\\r'
${cmd}
`;
await sandbox.fs.uploadFile(Buffer.from(script), remotePath);
await sandbox.process.executeCommand(`chmod 700 ${shellQuote(remotePath)}`);
return remotePath;
}
function consumeTerminalLine(buffer: string): {
line: string;
rest: string;
} | null {
const newlineIndex = buffer.search(/[\r\n]/);
if (newlineIndex === -1) {
return null;
}
let lineEnd = newlineIndex + 1;
if (buffer[newlineIndex] === "\r" && buffer[newlineIndex + 1] === "\n") {
lineEnd += 1;
}
return {
line: buffer.slice(0, lineEnd),
rest: buffer.slice(lineEnd),
};
}
function shouldSuppressBootstrapEcho(line: string, bootstrapScript: string): boolean {
const trimmed = line.trim();
return trimmed === `exec ${shellQuote(bootstrapScript)}`;
}
async function runInteractivePty(sandboxId: string, cmd: string): Promise<number> {
if (!cmd || /\0/.test(cmd)) {
throw new Error("Invalid command: must be non-empty and must not contain null bytes");
}
const sandbox = await ensureSandboxStarted(sandboxId);
const decoder = new TextDecoder();
const { cols, rows } = getPtySize();
const bootstrapScript = await prepareInteractiveBootstrapScript(sandboxId, cmd);
let startupBuffer = "";
let filteringStartupEcho = true;
const pty = await sandbox.process.createPty({
id: `spawn-${randomUUID()}`,
cols,
@ -737,11 +807,33 @@ async function runInteractivePty(sandboxId: string, cmd: string): Promise<number
LANG: process.env.LANG || "en_US.UTF-8",
},
onData: (data) => {
process.stdout.write(
decoder.decode(data, {
stream: true,
}),
);
const text = decoder.decode(data, {
stream: true,
});
if (!filteringStartupEcho) {
process.stdout.write(text);
return;
}
startupBuffer += text;
for (;;) {
const consumed = consumeTerminalLine(startupBuffer);
if (!consumed) {
break;
}
startupBuffer = consumed.rest;
if (shouldSuppressBootstrapEcho(consumed.line, bootstrapScript)) {
continue;
}
filteringStartupEcho = false;
process.stdout.write(consumed.line + startupBuffer);
startupBuffer = "";
break;
}
},
});
@ -759,7 +851,8 @@ async function runInteractivePty(sandboxId: string, cmd: string): Promise<number
process.stdin.resume();
process.stdin.setRawMode?.(true);
const result = await asyncTryCatch(async () => {
await pty.sendInput(`exec bash -lc ${shellQuote(cmd)}\n`);
await pty.waitForConnection();
await pty.sendInput(`exec ${shellQuote(bootstrapScript)}\n`);
return pty.wait();
});
@ -768,7 +861,15 @@ async function runInteractivePty(sandboxId: string, cmd: string): Promise<number
process.stdin.setRawMode?.(false);
process.stdin.pause();
await asyncTryCatch(() => pty.disconnect());
process.stdout.write(decoder.decode());
const tail = decoder.decode();
if (filteringStartupEcho) {
startupBuffer += tail;
if (!shouldSuppressBootstrapEcho(startupBuffer, bootstrapScript)) {
process.stdout.write(startupBuffer);
}
} else {
process.stdout.write(tail);
}
if (!result.ok) {
throw result.error;
@ -789,7 +890,7 @@ export async function interactiveSession(cmd: string): Promise<number> {
throw new Error("No Daytona sandbox is active");
}
const exitCode = await runInteractivePty(_state.sandboxId, cmd);
const exitCode = await runInteractiveDaytonaCommand(_state.sandboxId, cmd);
process.stderr.write("\n");
logWarn(`Session ended. Your sandbox '${_state.sandboxId}' may still be running.`);
logWarn(`Manage or delete it in the Daytona dashboard: ${DAYTONA_DASHBOARD_URL}`);

View file

@ -76,10 +76,12 @@ OPENROUTER_API_KEY=sk-or-v1-xxxxx \
| `DAYTONA_API_KEY` | Daytona API key | _(prompted)_ |
| `DAYTONA_SANDBOX_NAME` | Sandbox name | _(prompted)_ |
| `DAYTONA_IMAGE` | Base sandbox image | `daytonaio/sandbox:latest` |
| `DAYTONA_SANDBOX_SIZE` | Spawn preset (`small`, `medium`, `large`) | `small` |
| `DAYTONA_CPU` | vCPU override | _(preset)_ |
| `DAYTONA_MEMORY` | Memory override in GiB | _(preset)_ |
| `DAYTONA_DISK` | Disk override in GiB | _(preset)_ |
| `DAYTONA_SANDBOX_SIZE` | Spawn preset (`user-default`, `org-default`) | `user-default` |
| `DAYTONA_CPU` | vCPU override | `1` when partially overridden |
| `DAYTONA_MEMORY` | Memory override in GiB | `1` when partially overridden |
| `DAYTONA_DISK` | Disk override in GiB | `3` when partially overridden |
| `OPENROUTER_API_KEY` | OpenRouter API key | _(OAuth or prompted)_ |
If you leave all sandbox sizing variables unset, Spawn defers to Daytona's platform defaults: 1 vCPU, 1 GiB RAM, and 3 GiB disk. Set `DAYTONA_SANDBOX_SIZE=org-default` to request Daytona's documented organization per-sandbox limit: 4 vCPU, 8 GiB RAM, and 10 GiB disk.
Signed preview URLs are generated on demand for web dashboards. SSH access tokens are minted only when you connect and are never stored in Spawn history.