diff --git a/packages/cli/package.json b/packages/cli/package.json index a548c99d..948945bb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.30.13", + "version": "0.30.14", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/daytona/daytona.ts b/packages/cli/src/daytona/daytona.ts index 832620d2..6e160962 100644 --- a/packages/cli/src/daytona/daytona.ts +++ b/packages/cli/src/daytona/daytona.ts @@ -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 { 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 { + 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 { + 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 { - 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 { - 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 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 { 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}`); diff --git a/sh/daytona/README.md b/sh/daytona/README.md index 52c2dd8b..59496345 100644 --- a/sh/daytona/README.md +++ b/sh/daytona/README.md @@ -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.