diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index eb843910..052007c4 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -831,6 +831,18 @@ function headlessError( process.exit(exitCode); } +/** + * Resolve a trusted local Spawn checkout path for SPAWN_CLI_DIR. + * + * On macOS, `/tmp` commonly resolves to `/private/tmp`, so compare against + * the checkout's real path instead of the raw env var spelling. + */ +function resolveTrustedCliDir(cliDir: string): string { + const resolvedCliDir = path.resolve(cliDir); + const realCliDir = tryCatchIf(isFileError, () => fs.realpathSync(resolvedCliDir)); + return realCliDir.ok ? realCliDir.data : resolvedCliDir; +} + /** Run a script in headless mode (all output to stderr, no interactive session) */ function runScriptHeadless(script: string, prompt?: string, debug?: boolean, spawnName?: string): Promise { validateScriptContent(script); @@ -1006,7 +1018,7 @@ export async function cmdRunHeadless(agent: string, cloud: string, opts: Headles if (cliDir) { const hasBadChars = (s: string) => s.includes("..") || s.includes("/") || s.includes("\\"); if (!hasBadChars(resolvedCloud) && !hasBadChars(resolvedAgent)) { - const resolvedCliDir = path.resolve(cliDir); + const resolvedCliDir = resolveTrustedCliDir(cliDir); const candidatePath = path.join(resolvedCliDir, "packages", "cli", "src", resolvedCloud, "main.ts"); const realResult = tryCatchIf(isFileError, () => fs.realpathSync(candidatePath)); if (realResult.ok) { @@ -1071,7 +1083,7 @@ export async function cmdRunHeadless(agent: string, cloud: string, opts: Headles const safeAgent = !hasBadChars(resolvedAgent); if (safeCloud && safeAgent) { - const resolvedCliDir = path.resolve(cliDir); + const resolvedCliDir = resolveTrustedCliDir(cliDir); const candidatePath = path.join(resolvedCliDir, "sh", resolvedCloud, `${resolvedAgent}.sh`); const realResult = tryCatchIf(isFileError, () => fs.realpathSync(candidatePath)); if (realResult.ok) { diff --git a/packages/cli/src/daytona/daytona.ts b/packages/cli/src/daytona/daytona.ts index d7814c32..2712ffa9 100644 --- a/packages/cli/src/daytona/daytona.ts +++ b/packages/cli/src/daytona/daytona.ts @@ -276,36 +276,36 @@ export async function ensureDaytonaAuthenticated(): Promise { } function resolveSandboxSizeFromEnv(): SandboxSize | null { - const sizeId = process.env.DAYTONA_SANDBOX_SIZE; - if (sizeId) { - const matched = SANDBOX_SIZES.find((size) => size.id === sizeId); - if (!matched) { - throw new Error(`Invalid DAYTONA_SANDBOX_SIZE: ${sizeId}`); - } - return matched; - } - const cpu = process.env.DAYTONA_CPU; const memory = process.env.DAYTONA_MEMORY; const disk = process.env.DAYTONA_DISK; - if (!cpu && !memory && !disk) { + if (cpu || memory || disk) { + const parsedCpu = Number.parseInt(cpu || String(DEFAULT_SANDBOX_SIZE.cpu), 10); + const parsedMemory = Number.parseInt(memory || String(DEFAULT_SANDBOX_SIZE.memory), 10); + const parsedDisk = Number.parseInt(disk || String(DEFAULT_SANDBOX_SIZE.disk), 10); + if (!Number.isInteger(parsedCpu) || !Number.isInteger(parsedMemory) || !Number.isInteger(parsedDisk)) { + throw new Error("DAYTONA_CPU, DAYTONA_MEMORY, and DAYTONA_DISK must be integers"); + } + + return { + id: "custom", + cpu: parsedCpu, + memory: parsedMemory, + disk: parsedDisk, + label: `${parsedCpu} vCPU · ${parsedMemory} GiB RAM · ${parsedDisk} GiB disk`, + }; + } + + const sizeId = process.env.DAYTONA_SANDBOX_SIZE; + if (!sizeId) { return null; } - const parsedCpu = Number.parseInt(cpu || String(DEFAULT_SANDBOX_SIZE.cpu), 10); - const parsedMemory = Number.parseInt(memory || String(DEFAULT_SANDBOX_SIZE.memory), 10); - const parsedDisk = Number.parseInt(disk || String(DEFAULT_SANDBOX_SIZE.disk), 10); - if (!Number.isInteger(parsedCpu) || !Number.isInteger(parsedMemory) || !Number.isInteger(parsedDisk)) { - throw new Error("DAYTONA_CPU, DAYTONA_MEMORY, and DAYTONA_DISK must be integers"); + const matched = SANDBOX_SIZES.find((size) => size.id === sizeId); + if (!matched) { + throw new Error(`Invalid DAYTONA_SANDBOX_SIZE: ${sizeId}`); } - - return { - id: "custom", - cpu: parsedCpu, - memory: parsedMemory, - disk: parsedDisk, - label: `${parsedCpu} vCPU · ${parsedMemory} GiB RAM · ${parsedDisk} GiB disk`, - }; + return matched; } /** diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index ff13d3c8..f743a34a 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -548,7 +548,11 @@ export async function startGateway(runner: CloudRunner): Promise { "#!/bin/bash", 'source "$HOME/.spawnrc" 2>/dev/null', 'export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH"', - "exec openclaw gateway", + "while true; do", + " openclaw gateway", + ' echo "openclaw gateway exited, restarting in 5s" >> /tmp/openclaw-gateway.log', + " sleep 5", + "done", ].join("\n"); // __USER__ and __HOME__ are sed-substituted at deploy time @@ -586,11 +590,12 @@ export async function startGateway(runner: CloudRunner): Promise { const script = [ "source ~/.spawnrc 2>/dev/null", "export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH", - "if command -v systemctl >/dev/null 2>&1; then", + "printf '%s' '" + wrapperB64 + "' | base64 -d > /tmp/openclaw-gateway-wrapper.tmp", + "chmod +x /tmp/openclaw-gateway-wrapper.tmp", + "if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then", ' _sudo=""', ' [ "$(id -u)" != "0" ] && _sudo="sudo"', - " printf '%s' '" + wrapperB64 + "' | base64 -d | $_sudo tee /usr/local/bin/openclaw-gateway-wrapper > /dev/null", - " $_sudo chmod +x /usr/local/bin/openclaw-gateway-wrapper", + " $_sudo mv /tmp/openclaw-gateway-wrapper.tmp /usr/local/bin/openclaw-gateway-wrapper", " printf '%s' '" + unitB64 + "' | base64 -d > /tmp/openclaw-gateway.unit.tmp", ' sed -i "s|__USER__|$(whoami)|;s|__HOME__|$HOME|" /tmp/openclaw-gateway.unit.tmp', " $_sudo mv /tmp/openclaw-gateway.unit.tmp /etc/systemd/system/openclaw-gateway.service", @@ -601,13 +606,13 @@ export async function startGateway(runner: CloudRunner): Promise { ' [ "$(id -u)" != "0" ] && _cron_restart="sudo systemctl restart openclaw-gateway"', ' (crontab -l 2>/dev/null | grep -v openclaw-gateway; echo "0 * * * * nc -z 127.0.0.1 18789 2>/dev/null || $_cron_restart >> /tmp/openclaw-gateway.log 2>&1") | crontab - 2>/dev/null || true', "else", - ' _oc_bin=$(command -v openclaw) || { echo "openclaw not found in PATH"; exit 1; }', + " mv /tmp/openclaw-gateway-wrapper.tmp /tmp/openclaw-gateway-wrapper", ` if ${portCheck}; then echo "Gateway already running"; exit 0; fi`, - ' if command -v setsid >/dev/null 2>&1; then setsid "$_oc_bin" gateway > /tmp/openclaw-gateway.log 2>&1 < /dev/null &', - ' else nohup "$_oc_bin" gateway > /tmp/openclaw-gateway.log 2>&1 < /dev/null & fi', + " if command -v setsid >/dev/null 2>&1; then setsid /tmp/openclaw-gateway-wrapper > /tmp/openclaw-gateway.log 2>&1 < /dev/null &", + " else nohup /tmp/openclaw-gateway-wrapper > /tmp/openclaw-gateway.log 2>&1 < /dev/null & fi", "fi", "elapsed=0; while [ $elapsed -lt 300 ]; do", - ` if ${portCheck}; then echo "Gateway ready after \${elapsed}s"; exit 0; fi`, + ` if ${portCheck}; then echo "Gateway ready after $elapsed sec"; exit 0; fi`, " printf '.'; sleep 1; elapsed=$((elapsed + 1))", "done", 'echo "Gateway failed to start after 300s"; tail -20 /tmp/openclaw-gateway.log 2>/dev/null; exit 1', @@ -645,6 +650,22 @@ const NPM_PREFIX_SETUP = // (e.g. Sprite VMs with flaky IPv6 routing to the npm registry) 'export NODE_OPTIONS="${NODE_OPTIONS:-} --dns-result-order=ipv4first"'; +/** + * Validator-safe npm setup for base64-encoded helper scripts. + * + * setupAutoUpdate() rejects `${...}` inside encoded script templates, so the + * auto-update path needs a shell snippet that avoids brace expansion while + * still preserving the same prefix and PATH behavior as installs. + */ +const NPM_AUTO_UPDATE_SETUP = + '_NPM_G_FLAGS=""; ' + + '_npm_prefix="$(npm prefix -g 2>/dev/null || echo /usr/local)"; ' + + '_npm_gbin="$_npm_prefix/bin"; ' + + 'if ! [ -w "$_npm_prefix" ] || ! printf "%s" ":$PATH:" | grep -qF ":$_npm_gbin:"; then ' + + 'mkdir -p "$HOME/.npm-global/bin"; _NPM_G_FLAGS="--prefix $HOME/.npm-global"; fi; ' + + 'export PATH="$HOME/.npm-global/bin:$PATH"; ' + + 'case " $NODE_OPTIONS " in *" --dns-result-order=ipv4first "*) ;; *) export NODE_OPTIONS="$NODE_OPTIONS --dns-result-order=ipv4first" ;; esac'; + /** * Shell snippet that persists ~/.npm-global/bin in PATH across all shell config * files: ~/.bashrc, ~/.profile, ~/.bash_profile, and ~/.zshrc. @@ -853,7 +874,7 @@ export async function setupAutoUpdate(runner: CloudRunner, agentName: string, up } const script = [ - "if ! command -v systemctl >/dev/null 2>&1; then exit 0; fi", + "if ! command -v systemctl >/dev/null 2>&1 || [ ! -d /run/systemd/system ]; then exit 0; fi", '_sudo=""', '[ "$(id -u)" != "0" ] && _sudo="sudo"', "printf '%s' '" + wrapperB64 + "' | base64 -d | $_sudo tee /usr/local/bin/spawn-auto-update > /dev/null", @@ -869,7 +890,7 @@ export async function setupAutoUpdate(runner: CloudRunner, agentName: string, up const result = await asyncTryCatch(() => runner.runServer(script)); if (result.ok) { - logInfo("Agent auto-update service installed (runs every 6 hours)"); + logInfo("Agent auto-update setup completed"); } else { logWarn("Auto-update setup failed (non-fatal, agent still works)"); } @@ -916,9 +937,7 @@ function createAgents(runner: CloudRunner): Record { ], configure: () => setupCodexConfig(runner), launchCmd: () => "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; codex", - updateCmd: - 'export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$PATH"; ' + - "npm install -g ${_NPM_G_FLAGS:-} @openai/codex@latest", + updateCmd: `${NPM_AUTO_UPDATE_SETUP} && ` + "npm install -g $_NPM_G_FLAGS @openai/codex@latest", }, openclaw: (() => { @@ -950,9 +969,7 @@ function createAgents(runner: CloudRunner): Record { remotePort: 18789, browserUrl: (localPort: number) => `http://localhost:${localPort}/#token=${dashboardToken}`, }, - updateCmd: - 'export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$PATH"; ' + - "npm install -g ${_NPM_G_FLAGS:-} openclaw@latest", + updateCmd: `${NPM_AUTO_UPDATE_SETUP} && ` + "npm install -g $_NPM_G_FLAGS openclaw@latest", }; })(), @@ -985,9 +1002,7 @@ function createAgents(runner: CloudRunner): Record { `KILO_OPEN_ROUTER_API_KEY=${apiKey}`, ], launchCmd: () => "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; kilocode", - updateCmd: - 'export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$PATH"; ' + - "npm install -g ${_NPM_G_FLAGS:-} @kilocode/cli@latest", + updateCmd: `${NPM_AUTO_UPDATE_SETUP} && ` + "npm install -g $_NPM_G_FLAGS @kilocode/cli@latest", }, hermes: { @@ -1044,9 +1059,7 @@ function createAgents(runner: CloudRunner): Record { `OPENROUTER_API_KEY=${apiKey}`, ], launchCmd: () => "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; junie", - updateCmd: - 'export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$PATH"; ' + - "npm install -g ${_NPM_G_FLAGS:-} @jetbrains/junie-cli@latest", + updateCmd: `${NPM_AUTO_UPDATE_SETUP} && ` + "npm install -g $_NPM_G_FLAGS @jetbrains/junie-cli@latest", }, pi: { @@ -1063,9 +1076,7 @@ function createAgents(runner: CloudRunner): Record { `OPENROUTER_API_KEY=${apiKey}`, ], launchCmd: () => "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; pi", - updateCmd: - 'export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$PATH"; ' + - "npm install -g ${_NPM_G_FLAGS:-} @mariozechner/pi-coding-agent@latest", + updateCmd: `${NPM_AUTO_UPDATE_SETUP} && ` + "npm install -g $_NPM_G_FLAGS @mariozechner/pi-coding-agent@latest", }, cursor: { diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 59a44b38..79cd6f61 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -596,7 +596,11 @@ async function postInstall( // Auto-update service if (cloud.cloudName !== "local" && agent.updateCmd && (!enabledSteps || enabledSteps.has("auto-update"))) { - await setupAutoUpdate(cloud.runner, agentName, agent.updateCmd); + if (cloud.cloudName === "daytona") { + logInfo("Auto-update unavailable on Daytona — skipping"); + } else { + await setupAutoUpdate(cloud.runner, agentName, agent.updateCmd); + } } // Spawn CLI + skill injection (recursive spawn) diff --git a/sh/e2e/e2e.sh b/sh/e2e/e2e.sh index ff396b69..4f7b67c3 100755 --- a/sh/e2e/e2e.sh +++ b/sh/e2e/e2e.sh @@ -672,8 +672,10 @@ final_cleanup() { done fi if [ -n "${LOG_DIR:-}" ] && [ -d "${LOG_DIR:-}" ]; then + SAFE_TMP_ROOT="${TMP_ROOT:-${TMPDIR:-/tmp}}" + SAFE_TMP_ROOT="${SAFE_TMP_ROOT%/}" case "${LOG_DIR}" in - /tmp/spawn-e2e.*) + "${SAFE_TMP_ROOT}"/spawn-e2e.*) rm -rf "${LOG_DIR}" ;; *) @@ -708,7 +710,9 @@ fi export E2E_FAST_MODE="${FAST_MODE}" # Create temp log directory -LOG_DIR=$(mktemp -d "${TMPDIR:-/tmp}/spawn-e2e.XXXXXX") +TMP_ROOT="${TMPDIR:-/tmp}" +TMP_ROOT="${TMP_ROOT%/}" +LOG_DIR=$(mktemp -d "${TMP_ROOT}/spawn-e2e.XXXXXX") export LOG_DIR log_info "Log directory: ${LOG_DIR}" diff --git a/sh/e2e/lib/verify.sh b/sh/e2e/lib/verify.sh index ec3238e1..a29373a6 100644 --- a/sh/e2e/lib/verify.sh +++ b/sh/e2e/lib/verify.sh @@ -245,7 +245,9 @@ input_test_openclaw() { printf '%s' "${attempt}" | cloud_exec "${app}" "cat > /tmp/.e2e-attempt" local output - # The prompt, timeout, and attempt are read from staged temp files — no interpolation in this command. + # Use plain-text output here. OpenClaw's JSON mode returns an envelope whose + # payload may omit the final assistant text, while the plain-text mode emits + # the reply body directly, which is what this marker test needs to assert. output=$(cloud_exec "${app}" "\ source ~/.spawnrc 2>/dev/null; source ~/.bashrc 2>/dev/null; \ export PATH=\$HOME/.npm-global/bin:\$HOME/.bun/bin:\$HOME/.local/bin:/usr/local/bin:\$PATH; \ @@ -253,7 +255,7 @@ input_test_openclaw() { _ATTEMPT=\$(cat /tmp/.e2e-attempt); \ rm -rf /tmp/e2e-test && mkdir -p /tmp/e2e-test && cd /tmp/e2e-test && git init -q; \ PROMPT=\$(cat /tmp/.e2e-prompt | base64 -d); \ - timeout \"\$_TIMEOUT\" openclaw agent --message \"\$PROMPT\" --session-id \"e2e-test-\$_ATTEMPT\" --json --timeout 60" 2>&1) || true + timeout \"\$_TIMEOUT\" openclaw agent --message \"\$PROMPT\" --session-id \"e2e-test-\$_ATTEMPT\" --timeout 60" 2>&1) || true if printf '%s' "${output}" | grep -qx "${INPUT_TEST_MARKER}"; then log_ok "openclaw input test — marker found in response"