Skip to content
Merged
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
32 changes: 18 additions & 14 deletions typescript/openclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion typescript/openclaw/openclaw.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"agents": {
"defaults": { "model": { "primary": "anthropic/claude-sonnet-4-5" } }
"defaults": { "model": { "primary": "anthropic/claude-sonnet-4-6" } }
},
"auth": {
"profiles": {
Expand Down
198 changes: 159 additions & 39 deletions typescript/openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,48 +9,60 @@ 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
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)
} catch (e) {
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
// 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: {
Expand All @@ -59,6 +71,93 @@ 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<string> {
for (let attempt = 1; attempt <= GATEWAY_START_ATTEMPTS; attempt++) {
const { cmdId } = await sandbox.process.executeSessionCommand(sessionId, {
Comment thread
mislavivanda marked this conversation as resolved.
command: 'openclaw gateway run',
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(
`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) {
exited = true
break
}
}
// 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')
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
console.log(`Gateway start attempt ${attempt} exited before becoming 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<void> {
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}`,
Comment thread
mislavivanda marked this conversation as resolved.
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<void> {
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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
} catch {
// Transient control-plane error; keep watching.
}
}
}

// Main function
async function main() {
// Create a new Daytona instance
Expand All @@ -79,8 +178,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)
Expand All @@ -90,6 +196,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] },
},
})

Expand All @@ -98,44 +206,56 @@ 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)

const signed = await sandbox.getPreviewLink(OPENCLAW_PORT)
const dashboardUrl = `${signed.url}?token=${gatewayToken}`
// 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.
})
}

// 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) => {
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)
})
Loading
Loading