From fda5e6a0fb01d427335c14fbae14d44625d51412 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Wed, 29 Jul 2026 15:41:01 +0200 Subject: [PATCH 1/3] feat: OpenClaw via SDK update Signed-off-by: Mislav Ivanda --- typescript/openclaw/README.md | 32 ++-- typescript/openclaw/openclaw.json | 2 +- typescript/openclaw/src/index.ts | 171 ++++++++++++++---- .../openclaw/src/local-pairing-proxy.cjs | 129 +++++++++++++ 4 files changed, 286 insertions(+), 48 deletions(-) create mode 100644 typescript/openclaw/src/local-pairing-proxy.cjs diff --git a/typescript/openclaw/README.md b/typescript/openclaw/README.md index 88f6c75..6c7a3d8 100644 --- a/typescript/openclaw/README.md +++ b/typescript/openclaw/README.md @@ -14,6 +14,7 @@ This example runs [OpenClaw](https://openclaw.ai/), a general purpose AI assista ## Prerequisites - **Node.js:** Version 18 or higher is required +- **Daytona account:** the sandbox is created private, so opening the preview link requires being signed in to Daytona ## Environment Variables @@ -50,11 +51,13 @@ Create these files in the project directory (copy from `.env.example` and `.env. When this example is run, the agent follows the following workflow: -1. A new Daytona sandbox is created (using the `daytona-medium` snapshot with OpenClaw preinstalled). -2. OpenClaw is configured with your `openclaw.json` and `.env.sandbox` secrets. -3. The OpenClaw gateway starts inside the sandbox. -4. A Daytona preview link is shown pointing to the OpenClaw Control UI. -5. When the script is terminated (Ctrl+C), the sandbox is deletedโ€”unless `PERSIST_SANDBOX` is set to `true`, in which case the sandbox is left running. +1. A new Daytona sandbox is created (using the `daytona-medium` snapshot with OpenClaw preinstalled). It stays private unless you set `MAKE_PUBLIC`. +2. The preview link for `LOCAL_PROXY_PORT` is resolved first, because its origin must be allowlisted in the gateway config (`gateway.controlUi.allowedOrigins`) before the gateway starts. +3. OpenClaw is configured with your `openclaw.json`, `.env.sandbox` secrets, a generated gateway token, and that origin allowlist. The gateway binds loopback only. +4. The OpenClaw gateway starts inside the sandbox, and the script waits until it responds. +5. `src/local-pairing-proxy.cjs` is uploaded and started on `LOCAL_PROXY_PORT`. It strips Daytona's forwarding headers, so the gateway treats your browser as a clean local client and silently auto-approves its device pairing (token auth still applies first). The Control UI connects on the first attempt - no approval prompt. +6. A Daytona preview link is shown pointing to the Control UI, with the gateway token appended as a `#token=` URL fragment (fragments are never sent to servers, so the token stays out of access logs). The Control UI consumes the token and strips it from the address bar. Because the sandbox is private, opening the link also requires Daytona authentication. +7. When the script is terminated (Ctrl+C), the sandbox is deletedโ€”unless `PERSIST_SANDBOX` is set to `true` (the default), in which case the sandbox is left running. ## Alternative: inject the key as a Daytona Secret @@ -108,23 +111,23 @@ Inside the sandbox, `env` now shows `ANTHROPIC_API_KEY=dtn_secret_...`, yet Open Creating Daytona sandbox... Configuring OpenClaw... Starting OpenClaw... -(Ctrl+C to shut down and delete the sandbox) +(Ctrl+C to stop the script; the sandbox keeps running) -๐Ÿ”— Secret link to Control UI: https://18789-898f722f-76fc-4ec6-85ca-a82bb30f3d72.proxy.daytona.works?token=7e38c7347437c5642c57bc769f630e53fe118e001d7b6c6c +๐Ÿ”— Secret link to Control UI: https://18790-898f722f-76fc-4ec6-85ca-a82bb30f3d72.proxy.daytona.works#token=7e38c7347437c5642c57bc769f630e53fe118e001d7b6c6c -OpenClaw logs: +The sandbox is private - open the link while signed in to Daytona. + +OpenClaw is ready. -------------------------------- -(node:131) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead. -(Use `node --trace-deprecation ...` to show where the warning was created) โ”‚ โ—‡ Doctor changes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ โ”‚ -โ”‚ WhatsApp configured, not enabled yet. โ”‚ +โ”‚ WhatsApp configured, enabled automatically. โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ ``` -Open the provided URL in your browser to interact with the OpenClaw agent via the Control UI. +Open the provided URL in your browser (while signed in to Daytona) to interact with the OpenClaw agent via the Control UI. ## Configuration @@ -136,8 +139,9 @@ You will find several constants in `src/index.ts` which control the behavior of |----------|---------|-------------| | `OPENCLAW_PORT` | 18789 | OpenClaw Gateway and Control UI port | | `SHOW_LOGS` | true | Stream OpenClaw stdout/stderr to the terminal. | -| `MAKE_PUBLIC` | true | Expose the sandbox for public internet access. | +| `MAKE_PUBLIC` | false | When false the sandbox stays private and the preview URL requires Daytona authentication. | | `PERSIST_SANDBOX` | true | When true, the sandbox is not deleted when the script exits. | +| `LOCAL_PROXY_PORT` | 18790 | In-sandbox pairing proxy port; the preview link targets it. | | `DAYTONA_SNAPSHOT` | daytona-medium | Sandbox image with OpenClaw preinstalled. | ### OpenClaw Configuration @@ -150,7 +154,7 @@ The default configuration is: { "agents": { "defaults": { - "model": { "primary": "anthropic/claude-sonnet-4-5" } + "model": { "primary": "anthropic/claude-sonnet-4-6" } } }, "auth": { diff --git a/typescript/openclaw/openclaw.json b/typescript/openclaw/openclaw.json index 144fc2f..9a32895 100644 --- a/typescript/openclaw/openclaw.json +++ b/typescript/openclaw/openclaw.json @@ -1,6 +1,6 @@ { "agents": { - "defaults": { "model": { "primary": "anthropic/claude-sonnet-4-5" } } + "defaults": { "model": { "primary": "anthropic/claude-sonnet-4-6" } } }, "auth": { "profiles": { diff --git a/typescript/openclaw/src/index.ts b/typescript/openclaw/src/index.ts index cdcc077..dce1af2 100644 --- a/typescript/openclaw/src/index.ts +++ b/typescript/openclaw/src/index.ts @@ -9,18 +9,25 @@ import type { Sandbox } from '@daytona/sdk' import { randomBytes } from 'crypto' import { readFileSync } from 'fs' import { join } from 'path' +import { fileURLToPath } from 'url' import { deepMerge, readEnvFile } from './utils.js' // Constants -const OPENCLAW_PORT = 18789 // OpenClaw Gateway and Control UI port +const OPENCLAW_PORT = 18789 // OpenClaw Gateway and Control UI port (loopback only) +const LOCAL_PROXY_PORT = 18790 // In-sandbox pairing proxy; the preview URL targets this port const SHOW_LOGS = true // Stream OpenClaw stdout/stderr to the terminal -const MAKE_PUBLIC = true // Expose the sandbox for public internet access +// Keep the sandbox private: the preview URL then requires Daytona +// authentication, so the gateway token is not the only thing standing between +// the internet and your assistant. +const MAKE_PUBLIC = false const PERSIST_SANDBOX = true // Keep the sandbox running after the script exits const DAYTONA_SNAPSHOT = 'daytona-medium' // This snapshot has openclaw installed // Paths const USER_CONFIG_PATH = join(process.cwd(), 'openclaw.json') const ENV_SANDBOX_PATH = join(process.cwd(), '.env.sandbox') +// Resolved relative to this module so it works from any working directory. +const LOCAL_PROXY_SCRIPT_PATH = fileURLToPath(new URL('./local-pairing-proxy.cjs', import.meta.url)) // Global variables let currentSandbox: Sandbox | null = null @@ -43,14 +50,14 @@ async function shutdown() { process.exit(0) } -// OpenClaw config to run in a Daytona sandbox +// OpenClaw config to run in a Daytona sandbox. The gateway binds loopback +// only: the sole exposed entrance is the local pairing proxy in front of it. const OPENCLAW_CONFIG = { gateway: { mode: 'local' as const, port: OPENCLAW_PORT, - bind: 'lan' as const, + bind: 'loopback' as const, auth: { mode: 'token' as const, token: '' }, - controlUi: { allowInsecureAuth: true }, // This bypasses the pairing step in the Control UI }, agents: { defaults: { @@ -59,6 +66,85 @@ const OPENCLAW_CONFIG = { }, } +const GATEWAY_READY_TIMEOUT_MS = 90_000 // Per-attempt readiness budget +const GATEWAY_START_ATTEMPTS = 3 + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +// Start the gateway and wait until it answers on OPENCLAW_PORT. The first +// command in a freshly created sandbox can occasionally exit before the +// runtime is fully ready, so retry instead of failing the whole run. +async function startGatewayUntilReady(sandbox: Sandbox, sessionId: string): Promise { + for (let attempt = 1; attempt <= GATEWAY_START_ATTEMPTS; attempt++) { + const { cmdId } = await sandbox.process.executeSessionCommand(sessionId, { + command: 'openclaw gateway run', + runAsync: true, + }) + const deadline = Date.now() + GATEWAY_READY_TIMEOUT_MS + while (Date.now() < deadline) { + await sleep(3000) + const probe = await sandbox.process.executeCommand( + `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:${OPENCLAW_PORT}`, + ) + if (probe.result?.trim() === '200') { + return cmdId! + } + const session = await sandbox.process.getSession(sessionId) + const command = session.commands?.find((c) => c.id === cmdId) + if (command?.exitCode != null) { + break // The command died; start a fresh attempt. + } + } + console.log(`Gateway start attempt ${attempt} did not become ready, retrying...`) + } + throw new Error('OpenClaw gateway failed to start') +} + +// OpenClaw pairs every new browser/device before it may use the Control UI - +// but it silently auto-approves pairing for clean LOCAL connections (loopback +// peer, no forwarded identity headers) once gateway token auth has succeeded. +// Daytona's preview proxy adds X-Forwarded-For, which makes browsers look +// remote and forces a manual approval step. The proxy script below strips those +// headers inside the sandbox, so preview visitors are treated as local and pair +// silently on their first attempt. Token auth still applies before pairing: the +// tokenized preview URL remains the secret that gates access. +// See src/local-pairing-proxy.cjs for the implementation and its trade-offs. +async function startLocalPairingProxy(sandbox: Sandbox, home: string): Promise { + const remotePath = `${home}/.openclaw-local-proxy.cjs` + await sandbox.fs.uploadFile(LOCAL_PROXY_SCRIPT_PATH, remotePath) + const sessionId = 'openclaw-local-proxy' + await sandbox.process.createSession(sessionId) + await sandbox.process.executeSessionCommand(sessionId, { + command: `node ${remotePath} ${LOCAL_PROXY_PORT} ${OPENCLAW_PORT}`, + runAsync: true, + }) + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + await sleep(1000) + const probe = await sandbox.process.executeCommand( + `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:${LOCAL_PROXY_PORT}`, + ) + if (probe.result?.trim() === '200') return + } + throw new Error('Local pairing proxy failed to start') +} + +// Resolve when the gateway command actually exits. Polls the session command +// status instead of relying on the log stream, which can close early while the +// gateway keeps running. +async function waitForGatewayExit(sandbox: Sandbox, sessionId: string, cmdId: string): Promise { + for (;;) { + await sleep(5000) + try { + const session = await sandbox.process.getSession(sessionId) + const command = session.commands?.find((c) => c.id === cmdId) + if (command?.exitCode != null) return + } catch { + // Transient control-plane error; keep watching. + } + } +} + // Main function async function main() { // Create a new Daytona instance @@ -79,8 +165,15 @@ async function main() { // Get the user home directory const home = await sandbox.getUserHomeDir() + if (!home) throw new Error('Could not resolve the sandbox home directory') const openclawDir = `${home}/.openclaw` + // Resolve the preview URL up front: its origin must be allowlisted so the + // Gateway accepts browser connections coming through the Daytona proxy. + // The preview targets the local pairing proxy port, not the gateway itself. + const signed = await sandbox.getPreviewLink(LOCAL_PROXY_PORT) + const previewOrigin = new URL(signed.url).origin + // Read the user config and merge it with the base config const userConfig = JSON.parse(readFileSync(USER_CONFIG_PATH, 'utf8')) const baseConfig = deepMerge(OPENCLAW_CONFIG, userConfig) @@ -90,6 +183,8 @@ async function main() { const config = deepMerge(baseConfig, { gateway: { auth: { mode: 'token' as const, token: gatewayToken }, + // Browser origin check: allow the preview URL origin explicitly. + controlUi: { allowedOrigins: [previewOrigin] }, }, }) @@ -98,41 +193,51 @@ async function main() { await sandbox.fs.createFolder(openclawDir, '755') await sandbox.fs.uploadFile(Buffer.from(JSON.stringify(config, null, 2), 'utf8'), `${openclawDir}/openclaw.json`) - // Start the gateway + // Start the gateway and wait until it is ready const sessionId = 'openclaw-gateway' console.log('Starting OpenClaw...') await sandbox.process.createSession(sessionId) - const { cmdId } = await sandbox.process.executeSessionCommand(sessionId, { - command: 'openclaw gateway run', - runAsync: true, - }) - console.log('(Ctrl+C to shut down and delete the sandbox)') - - // Stream OpenClaw output to the terminal and delete the sandbox when the process ends - sandbox.process - .getSessionCommandLogs( - sessionId, - cmdId, - SHOW_LOGS - ? (chunk) => process.stdout.write(chunk) - : () => { - return - }, - SHOW_LOGS - ? (chunk) => process.stderr.write(chunk) - : () => { - return - }, - ) - .then(shutdown) - .catch(shutdown) + const cmdId = await startGatewayUntilReady(sandbox, sessionId) + console.log( + PERSIST_SANDBOX + ? '(Ctrl+C to stop the script; the sandbox keeps running)' + : '(Ctrl+C to shut down and delete the sandbox)', + ) + + // Start the local pairing proxy (see LOCAL_PROXY_SOURCE for why): browsers + // arriving through the preview URL pair silently as local clients. + await startLocalPairingProxy(sandbox, home) + + // Stream OpenClaw output for visibility. The stream can end on its own + // (idle timeout, proxy reset) while the gateway keeps running, so it must + // NOT drive shutdown - waitForGatewayExit owns lifecycle. + if (SHOW_LOGS) { + void sandbox.process + .getSessionCommandLogs( + sessionId, + cmdId, + (chunk) => process.stdout.write(chunk), + (chunk) => process.stderr.write(chunk), + ) + .catch(() => { + // Ignore stream errors; waitForGatewayExit owns lifecycle. + }) + } - const signed = await sandbox.getPreviewLink(OPENCLAW_PORT) - const dashboardUrl = `${signed.url}?token=${gatewayToken}` + // Pass the token in the URL fragment: fragments are never sent to servers, + // so the token stays out of proxy/access logs and Referer headers. The + // Control UI reads it, applies it, and strips it from the address bar. + const dashboardUrl = `${signed.url}#token=${gatewayToken}` console.log(`\n\x1b[1m๐Ÿ”— Secret link to Control UI: ${dashboardUrl}\x1b[0m`) - console.log(`\nOpenClaw is starting...`) + console.log('\nThe sandbox is private - open the link while signed in to Daytona.') + console.log(`\nOpenClaw is ready.`) console.log('--------------------------------') + + // Keep the process (and the auto-approval loop) alive until the gateway + // process exits or the user presses Ctrl+C - not tied to the log stream. + await waitForGatewayExit(sandbox, sessionId, cmdId) + await shutdown() } main().catch((err) => { diff --git a/typescript/openclaw/src/local-pairing-proxy.cjs b/typescript/openclaw/src/local-pairing-proxy.cjs new file mode 100644 index 0000000..c5c1bbf --- /dev/null +++ b/typescript/openclaw/src/local-pairing-proxy.cjs @@ -0,0 +1,129 @@ +/* + * Copyright 2025 Daytona Platforms Inc. + * SPDX-License-Identifier: Apache-2.0 + * + * Local pairing proxy (runs inside the Daytona sandbox). + * + * Why this exists: OpenClaw silently auto-approves Control UI device pairing + * for clean LOCAL connections (loopback peer, no forwarded identity headers) + * once gateway token auth has succeeded. Daytona's preview proxy adds + * X-Forwarded-For, which makes browsers look remote and forces a manual + * device-approval step. This proxy sits between them and removes the + * forwarding/identity headers, so preview visitors are treated as local and + * pair silently on their first attempt. + * + * Security notes: + * - Token auth still runs before pairing, so this is not an auth bypass; the + * tokenized preview URL remains the credential that gates access. + * - Identity headers are stripped (not rewritten) so nothing downstream can be + * spoofed through this hop. The trade-off is that the gateway sees 127.0.0.1 + * as the client IP for every request, so its logs and per-IP rate limiting + * can no longer distinguish callers. + * - Both listeners bind loopback only: nothing here is directly reachable from + * outside the sandbox. + * + * Usage: node local-pairing-proxy.cjs + */ + +const http = require('http') +const net = require('net') + +const LISTEN_PORT = Number(process.argv[2]) +const GATEWAY_PORT = Number(process.argv[3]) + +if (!Number.isInteger(LISTEN_PORT) || !Number.isInteger(GATEWAY_PORT)) { + console.error('usage: node local-pairing-proxy.cjs ') + process.exit(1) +} + +// Headers that assert a client identity on behalf of an upstream proxy. The +// gateway treats their absence as proof of a direct local caller, so they must +// never survive this hop. +const STRIPPED_HEADERS = new Set([ + 'forwarded', + 'x-real-ip', + 'tailscale-user-login', + 'tailscale-user-name', + 'tailscale-user-profile-pic', +]) + +function isStripped(name) { + const lower = name.toLowerCase() + return STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forwarded-') +} + +function sanitizeHeaders(headers) { + const out = {} + for (const name of Object.keys(headers)) { + if (isStripped(name)) continue + out[name] = headers[name] + } + return out +} + +// Plain HTTP: static Control UI assets and JSON endpoints. +const server = http.createServer((req, res) => { + const upstream = http.request( + { + host: '127.0.0.1', + port: GATEWAY_PORT, + method: req.method, + path: req.url, + headers: sanitizeHeaders(req.headers), + }, + (upstreamRes) => { + res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers) + upstreamRes.pipe(res) + }, + ) + upstream.on('error', () => { + res.writeHead(502) + res.end('gateway unavailable') + }) + req.pipe(upstream) +}) + +// WebSocket upgrades: the Control UI's live gateway connection. +// +// A WebSocket starts as an ordinary HTTP/1.1 GET carrying `Connection: Upgrade`. +// Once the server answers `101 Switching Protocols` the socket stops being HTTP +// and becomes a raw two-way byte pipe, so Node does not route these through the +// normal request handler: it hands us the raw socket here instead. That means we +// cannot reuse http.request() and must replay the handshake ourselves, then just +// relay bytes. `head` holds any bytes that arrived in the same packet after the +// headers (the first protocol data) and must not be dropped. +server.on('upgrade', (req, socket, head) => { + const headers = sanitizeHeaders(req.headers) + const lines = [`${req.method} ${req.url} HTTP/1.1`] + for (const name of Object.keys(headers)) { + const value = headers[name] + for (const item of Array.isArray(value) ? value : [value]) { + // Header blocks are CR/LF-delimited text and we are writing them by hand, + // so a value containing CR/LF could close its own line and inject extra + // headers - re-adding an identity header sanitizeHeaders just removed - or + // end the block and smuggle a second request past this hop. Node's parser + // normally rejects such values already; fail closed rather than rely on it. + if (typeof item !== 'string' || /[\r\n]/.test(item)) { + socket.destroy() + return + } + lines.push(`${name}: ${item}`) + } + } + + const upstream = net.connect(GATEWAY_PORT, '127.0.0.1', () => { + upstream.write(`${lines.join('\r\n')}\r\n\r\n`) // blank line terminates the header block + if (head && head.length) upstream.write(head) + // From here this is a dumb relay: the 101 response and every WebSocket + // frame flow through untouched. Frames are never parsed. + socket.pipe(upstream) + upstream.pipe(socket) + }) + // Tear down the peer so a half-open connection cannot leak. + upstream.on('error', () => socket.destroy()) + socket.on('error', () => upstream.destroy()) +}) + +server.listen(LISTEN_PORT, '127.0.0.1', () => { + console.log(`local pairing proxy listening on 127.0.0.1:${LISTEN_PORT} -> 127.0.0.1:${GATEWAY_PORT}`) +}) From 1528512acddfc15d293b95a0a3b12f59adb38765 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Wed, 29 Jul 2026 18:31:44 +0200 Subject: [PATCH 2/3] fix: gateway start prevent duplicate session commands, port range validation Signed-off-by: Mislav Ivanda --- typescript/openclaw/src/index.ts | 12 ++++++++++-- typescript/openclaw/src/local-pairing-proxy.cjs | 6 ++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/typescript/openclaw/src/index.ts b/typescript/openclaw/src/index.ts index dce1af2..4d2928c 100644 --- a/typescript/openclaw/src/index.ts +++ b/typescript/openclaw/src/index.ts @@ -81,6 +81,7 @@ async function startGatewayUntilReady(sandbox: Sandbox, sessionId: string): Prom runAsync: true, }) const deadline = Date.now() + GATEWAY_READY_TIMEOUT_MS + let exited = false while (Date.now() < deadline) { await sleep(3000) const probe = await sandbox.process.executeCommand( @@ -92,10 +93,17 @@ async function startGatewayUntilReady(sandbox: Sandbox, sessionId: string): Prom const session = await sandbox.process.getSession(sessionId) const command = session.commands?.find((c) => c.id === cmdId) if (command?.exitCode != null) { - break // The command died; start a fresh attempt. + exited = true + break } } - console.log(`Gateway start attempt ${attempt} did not become ready, retrying...`) + // Only retry a command that actually exited. A gateway that is still + // running owns OPENCLAW_PORT, so starting a second one would fail with + // EADDRINUSE and leave us tracking the wrong command id. + if (!exited) { + throw new Error('OpenClaw gateway started but never became ready') + } + console.log(`Gateway start attempt ${attempt} exited before becoming ready, retrying...`) } throw new Error('OpenClaw gateway failed to start') } diff --git a/typescript/openclaw/src/local-pairing-proxy.cjs b/typescript/openclaw/src/local-pairing-proxy.cjs index c5c1bbf..b2f48aa 100644 --- a/typescript/openclaw/src/local-pairing-proxy.cjs +++ b/typescript/openclaw/src/local-pairing-proxy.cjs @@ -31,8 +31,10 @@ const net = require('net') const LISTEN_PORT = Number(process.argv[2]) const GATEWAY_PORT = Number(process.argv[3]) -if (!Number.isInteger(LISTEN_PORT) || !Number.isInteger(GATEWAY_PORT)) { - console.error('usage: node local-pairing-proxy.cjs ') +const isValidPort = (value) => Number.isInteger(value) && value >= 1 && value <= 65535 + +if (!isValidPort(LISTEN_PORT) || !isValidPort(GATEWAY_PORT)) { + console.error('usage: node local-pairing-proxy.cjs (1-65535)') process.exit(1) } From b447108d91cb235e1610d9c9c69a60a1a496604a Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Wed, 29 Jul 2026 18:50:20 +0200 Subject: [PATCH 3/3] fix: sandbox clenaup on failure Signed-off-by: Mislav Ivanda --- typescript/openclaw/src/index.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/typescript/openclaw/src/index.ts b/typescript/openclaw/src/index.ts index 4d2928c..3fae2ec 100644 --- a/typescript/openclaw/src/index.ts +++ b/typescript/openclaw/src/index.ts @@ -34,10 +34,13 @@ let currentSandbox: Sandbox | null = null let sandboxDeleted = false // Shutdown the sandbox -async function shutdown() { +// `forceDelete` is used when startup failed: PERSIST_SANDBOX means "keep my +// working assistant running", but a sandbox that never produced a link is +// unusable, and auto-stop is disabled, so it would run until deleted by hand. +async function shutdown(exitCode = 0, forceDelete = false) { if (sandboxDeleted) return sandboxDeleted = true - if (!PERSIST_SANDBOX) { + if (!PERSIST_SANDBOX || forceDelete) { console.log('\nShutting down sandbox...') try { await currentSandbox?.delete(30) @@ -45,9 +48,11 @@ async function shutdown() { console.error(e) } } else { - console.log('\nSandbox left running.') + // Sandboxes are created with auto-stop disabled, so surface the id: a + // sandbox left behind here keeps running until it is deleted. + console.log(`\nSandbox left running${currentSandbox ? ` (${currentSandbox.id})` : ''}.`) } - process.exit(0) + process.exit(exitCode) } // OpenClaw config to run in a Daytona sandbox. The gateway binds loopback @@ -248,7 +253,9 @@ async function main() { await shutdown() } -main().catch((err) => { +main().catch(async (err) => { console.error(err) - process.exit(1) + // Startup never handed the user a working link, so delete the sandbox even + // when PERSIST_SANDBOX is set - otherwise it lingers with auto-stop off. + await shutdown(1, true) })