Commit graph

428 commits

Author SHA1 Message Date
A
b854917186
fix(security): validate tunnel URL and port from history before openBrowser() (#2697)
Add validateTunnelUrl() and validateTunnelPort() in security.ts to prevent
phishing attacks via tampered ~/.spawn/history.json. Apply both validations
in cmdEnterAgent() and cmdOpenDashboard() in connect.ts before any tunnel
data is used.

- validateTunnelUrl: enforce URL starts with http://localhost: or
  http://127.0.0.1: only (blocks external/phishing URLs)
- validateTunnelPort: enforce numeric value in range 1-65535
- Add comprehensive test cases for both validators

Fixes #2696

Agent: security-auditor

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 15:22:29 -07:00
A
644593eaea
fix(security): propagate path normalization to all cloud modules (#2693)
* fix(security): propagate path normalization to all cloud upload/download functions

PR #2690 added normalize() before path traversal checks in AWS but not
the other clouds. Apply the same defense-in-depth to GCP, DigitalOcean,
Hetzner, Sprite, and shared validateRemotePath.

Agent: code-health

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(security): use normalized path in all file transfer operations

Addresses code review: replace original remotePath with normalizedRemote
in scp commands and bash operations to prevent validation bypass.

- digitalocean: use normalizedRemote in uploadFile scp and derive
  expandedPath from normalizedRemote in downloadFile
- hetzner: same pattern for uploadFile/downloadFile
- gcp: derive expandedPath from normalizedRemote.replace(...) in both
  uploadFile and downloadFile
- sprite: use normalizedRemote in bash mkdir/mv command and derive
  expandedPath from normalizedRemote in downloadFile

Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(security): close validation bypass in agent-setup and AWS file ops

validateRemotePath() validated the normalized path but returned void,
so the caller still used the original unsanitized remotePath in shell
commands — bypassing the normalization check entirely.

Fix: return the normalized path and use it in all file operations.

Also fix AWS uploadFile/downloadFile which validated normalizedRemote
but used the original remotePath in scp commands.

Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-16 14:48:59 -07:00
A
bae921a295
fix(digitalocean): retry on 404 in waitForDropletActive (#2695)
DigitalOcean sometimes returns 404 immediately after droplet creation
before the resource propagates across their API. Previously this caused
an immediate fatal error, failing all DO agent provisions.

Now 404 responses are treated as transient and retried with the same
5s polling interval, consistent with how non-active statuses are handled.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-16 14:19:02 -07:00
A
0b346dffcc
test: remove duplicate and theatrical tests (#2694)
Consolidated 3 separate per-exit-code dashboard URL tests (130, 137, 42)
into a single data-driven loop. Merged 2 per-signal tests (SIGTERM, SIGINT)
into one. Removed a weak always-true test ("always return a non-empty array")
that was already implied by the adjacent test above it. Net: 4 fewer tests,
no coverage loss.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 14:17:27 -07:00
A
9e627dff29
refactor: remove dead code and stale references (#2691)
Remove stale '// --- Swap Space Setup' section header from agent-setup.ts
that had no associated code. Swap space setup was moved to cloud init
userdata scripts (aws.ts, hetzner.ts etc.) but the empty section header
was left behind.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-16 05:53:01 -07:00
A
64a181c8ea
test: remove theatrical NODE_INSTALL_CMD test and fix banned homedir import (#2692)
- cloud-init.test.ts: remove the NODE_INSTALL_CMD describe block that just
  checked if a string constant contains "curl" and "22". This is a snapshot
  test of a string literal with no behavioral signal.

- paths.test.ts: remove the banned `import { homedir } from "node:os"`.
  Per testing rules, named imports of homedir() bypass the preload sandbox
  mock (os.homedir default-export patch) and return the real home directory,
  making tests non-isolated. Replace the "falls back to os.homedir()" test
  with a behavioral assertion (result is a non-empty string) instead of
  comparing against the banned homedir() call.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
2026-03-16 05:51:32 -07:00
A
1696ecdaa9
fix(security): add defense-in-depth username validation in GCP startup script (#2689)
Add explicit username format validation (`/^[a-zA-Z0-9_-]+$/`) as
defense-in-depth in `getStartupScript()` and `createInstance()`. While
`resolveUsername()` currently returns a constant, this belt-and-suspenders
check prevents shell injection if the function is ever changed to accept
dynamic input.

Fixes #2688

Agent: ux-engineer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-16 01:38:21 -07:00
A
085759aeaf
fix(security): add AWS secret key validation and harden path traversal (#2690)
- Add validateAwsSecretKey() function checking 40-char format
- Validate secret key in loadCredsFromConfig() and lightsailRest()
- Add normalize() to canonicalize paths before traversal check
- Harden both uploadFile() and downloadFile() path validation
- Update test fixtures with properly-formatted mock secret keys
- Add test for invalid secret key format rejection

Fixes #2686
Fixes #2687

Agent: security-auditor

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-16 01:29:01 -07:00
A
e5725b9a66
fix(gcp): add /usr/local/bin to .spawnrc PATH for npm-global agents (#2681)
GCP VMs install kilocode (and other npm-global agents) to /usr/local/bin
via `npm install -g`. The .spawnrc PATH export relied on $PATH inheriting
/usr/local/bin from the SSH/login shell chain, but on GCP VMs the PATH
can be minimal depending on how the session is initiated (login shell
sourcing order, /etc/profile.d availability). Explicitly include
/usr/local/bin to ensure npm globally-installed binaries are always
findable regardless of base PATH.

Also updates fix.ts to keep its PATH in sync with generateEnvConfig().

Fixes #2679

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 00:00:09 -07:00
A
09576f16ef
fix(ui): remove confusing "None" checkbox from setup options (#2682)
The "None" sentinel option stayed checked alongside real selections,
which was confusing. Remove it — the multiselect already supports
submitting with nothing selected via `required: false`.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:43:01 -07:00
A
5cc9930769
feat(cli): add spawn link command to reconnect existing deployments (#2675)
Adds `spawn link <ip>` command that re-registers an existing cloud VM
in spawn's local state, so commands like `spawn list`, `spawn delete`,
and `spawn fix` work on it without reprovisioning.

Features:
- Auto-detects running agent via SSH (ps aux + which checks)
- Auto-detects cloud provider via IMDS metadata endpoints (Hetzner,
  AWS, DigitalOcean, GCP)
- Accepts --agent, --cloud, --user, --name flags to skip auto-detection
- TCP connectivity pre-check before SSH attempts
- Creates a SpawnRecord in history with full connection info
- Offers to connect immediately after linking
- Interactive picker fallback when auto-detection fails
- Non-interactive mode support (exits with clear error if detection
  fails without --agent/--cloud flags)

Also adds --user / -u to KNOWN_FLAGS for the unknown-flag checker.

Fixes #2673

Agent: issue-fixer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-15 23:11:13 -07:00
Ahmed Abushagur
6ef20ed437
fix(aws): auto-select server size by agent (#2676)
* fix(aws): auto-select server size instead of prompting

OpenClaw gets 4GB (medium_3_0), all other agents get 2GB (small_3_0).
Users can still override with SPAWN_CUSTOM=1 or LIGHTSAIL_BUNDLE env var.
Matches the auto-select behavior already used by DO and Hetzner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: guide Windows users to WSL at startup

Detects win32 platform and prints step-by-step WSL setup instructions
instead of failing with a confusing error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Revert "feat: guide Windows users to WSL at startup"

This reverts commit 8db72880ae.

* test: update DEFAULT_BUNDLE assertion to small_3_0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-15 23:08:41 -07:00
A
c4961eb5cd
fix(e2e): prevent concurrent history write race and fix GCP HOME env (#2678)
* fix(history): use process-unique tmp file to prevent concurrent write race

Multiple spawn processes running in parallel (e.g. during E2E tests with
--parallel 6) all write to the same history.json.tmp path, causing ENOENT
when one process renames the file before another can. Use a pid+timestamp
suffix so each process writes to its own unique tmp file.

Fixes provision crashes seen in hetzner-junie E2E runs where the fatal
"rename history.json.tmp -> history.json" error aborted the session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(gcp): export HOME=/root in startup script to match cloud-init behavior

DigitalOcean and Hetzner cloud-init scripts both set `export HOME=/root`
before running Node installation. GCP's startup script did not, which
could cause `n` (the Node.js version manager) to install Node to an
unexpected location when HOME is unset or points elsewhere.

Without a consistent HOME, `npm prefix -g` may return a path that doesn't
match what the subsequent `npm install -g @kilocode/cli` expects, causing
the install to fail silently and leaving the kilocode binary absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:06:23 -07:00
A
0f0bdf229f
test: remove duplicate and theatrical tests (#2677)
Consolidated redundant test setups in agent-tarball and cmdrun-happy-path
test suites:

- agent-tarball.test.ts: merged 4 mirror-cmd tests (all invoking the same
  tryTarballInstall call and inspecting the same mirrorCmd string) into a
  single test with shared beforeEach setup. Retained the non-fatal failure
  test separately since it has a different mock setup.

- cmdrun-happy-path.test.ts: collapsed 3 identical-setup dry-run tests into
  one consolidated test, and merged the two same-invocation launch-message
  tests into one. Each removed test was a pure duplicate of setup + assertion
  that could be expressed as additional expects in the same test.

Net: 1417 → 1411 tests (-6), 0 regressions.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-15 22:20:53 -07:00
A
0ea2692e1e
fix(github-auth): always run gh setup when user explicitly opts in (#2674)
When the user selects the GitHub CLI step in setup options (interactive
prompt or --steps github), offerGithubAuth() was silently returning early
if no local gh token was found by detectGithubAuth(). This made the step
unreachable for users without gh installed locally — exactly the ones who
need remote setup most.

Fix: accept an `explicitlyRequested` parameter in offerGithubAuth(). When
true, skip the githubAuthRequested guard and always run the remote install.
The orchestrator passes enabledSteps?.has("github") as this flag.

detectGithubAuth() still auto-enables the step when a local token exists
(convenience forwarding), but can no longer block a user-explicit request.

Fixes #2672

Agent: issue-fixer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-15 22:19:38 -07:00
A
00df240f49
feat(openclaw): add channel selection to setup options (#2671)
Add BlueBubbles, Discord, Slack, Signal, and Google Chat to the
multi-select setup options for OpenClaw. Selected channels get
`enabled: true` stubs written via `openclaw config set`, so the
dashboard renders channel cards properly instead of showing
"Unsupported type: . Use Raw mode."

Channels are gated by enabledSteps — only user-selected channels
get stubbed. WhatsApp and Telegram remain in the list as before.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:21:42 -07:00
A
4f4b535c8d
fix(security): validate remotePath and harden base64 interpolation in uploadConfigFile (#2669)
Add strict character validation for remotePath to prevent command injection
via crafted paths. Use shellQuote for tempRemote in the shell command. Add
a base64 output assertion to document and enforce the safety of single-quoted
interpolation for settingsB64.

Fixes #2668

Agent: security-auditor

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-15 19:04:19 -07:00
A
6b2001def4
docs(tests): document 8 undocumented test files in __tests__/README.md (#2670)
The test README was missing entries for 8 test files that were added
after the initial documentation was written:
- cmd-feedback.test.ts
- cmd-fix.test.ts
- config-priority.test.ts
- delete-spinner.test.ts
- gcp-shellquote.test.ts
- oauth-pkce.test.ts
- result-helpers.test.ts
- steps-flag.test.ts
- spawn-config.test.ts

Added descriptions under the appropriate section headers so the README
accurately reflects all test coverage.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
2026-03-15 18:39:57 -07:00
A
9ad89a414a
fix(cli): replace "spawn update" launch hint with "spawn feedback" (#2665)
Replace startup banner message from "Run spawn update to check for
updates." to "Run spawn feedback to tell us what to improve."

Bumps CLI patch version to 0.19.1.

Fixes #2664

Agent: issue-fixer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-15 14:11:46 -07:00
A
4bff229238
refactor: remove dead deepMerge export from parse.ts (#2663)
deepMerge was exported from shared/parse.ts but never imported or called
from any other module. Biome confirms it as an unused variable. Removing
it eliminates dead code and the now-unused isPlainObject import.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:57:47 -07:00
A
52eaa19466
fix: allow empty string values for CLI flags like --steps "" (#2662)
extractFlagValue() used `!args[idx + 1]` to detect a missing value,
which treated empty strings as missing. Change to `=== undefined` so
that `--steps ""` passes through correctly as documented.

Fixes #2661

Agent: issue-fixer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-15 13:45:39 -07:00
Ahmed Abushagur
0a7a95ec3c
feat: add custom model selection to all agents (#2659)
Move "Custom model" from OpenClaw-specific to common setup steps so
every agent shows it in the setup menu. Add modelEnvVar to agents that
support model override via environment variable:

- Kilo Code: KILOCODE_MODEL
- ZeroClaw: ZEROCLAW_MODEL
- Hermes: LLM_MODEL
- Junie: JUNIE_MODEL

When a custom model is selected, the env var is injected into .spawnrc
alongside the other agent env vars. OpenClaw continues to use its
existing configure() path. Claude and Codex don't have modelEnvVar
since they handle model routing differently.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 12:44:48 -07:00
Ahmed Abushagur
bc2aa89002
fix: enable channel stubs so openclaw extensions load their schemas (#2658)
Channel extensions only register their UI schemas when enabled. With
enabled=false the dashboard still shows "Unsupported type: . Use Raw
mode." Setting enabled=true lets the extensions load so users can
configure channels from the dashboard.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:48:40 -04:00
Ahmed Abushagur
9ca71f2da7
fix: write channel stubs in openclaw config for dashboard rendering (#2657)
Write disabled telegram and whatsapp channel entries during setup so
the OpenClaw dashboard renders proper channel cards instead of showing
"Unsupported type: . Use Raw mode." Users can then configure channels
from the dashboard UI.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 10:56:42 -07:00
A
6cf748e1b5
feat(openclaw): use openclaw onboard --non-interactive instead of manual config JSON (#2655)
Replace the manual config JSON construction + download-merge-upload flow
with `openclaw onboard --non-interactive`, which creates a properly
structured config with auth profiles, provider setup, gateway config,
and workspace. Follow up with `openclaw config set` for browser and
Telegram settings.

This fixes the broken dashboard channel setup caused by bypassing
OpenClaw's credential/auth profile system. Removes the gateway auth
re-assertion hack that was needed due to field-dropping during
config set cycles on manually-written JSON.

Includes a fallback path that writes minimal JSON if onboard fails.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:44:37 -04:00
A
8d3d7e4619
feat(oauth): add PKCE S256 code challenge to OpenRouter OAuth flow (#2654)
Implements RFC 7636 PKCE with S256 code challenge method for the
OpenRouter OAuth authorization flow. This prevents authorization code
interception attacks by binding the code to a cryptographic verifier.

Changes:
- Generate code_verifier (32 random bytes, base64url-encoded)
- Derive code_challenge via SHA-256 + base64url
- Send code_challenge + code_challenge_method=S256 in auth URL
- Send code_verifier + code_challenge_method in token exchange POST
- Add test suite with RFC 7636 Appendix B test vector validation

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-15 10:14:48 -07:00
A
87391b2a4a
test: remove duplicate and theatrical tests (#2652)
- security.test.ts: remove "comprehensively detect all command injection
  patterns from issue #1400" test (14 lines). All 6 attack vectors
  (&&, ||, >, <, &, ${}) are already tested individually in dedicated
  tests above it, making this aggregate loop purely redundant.

- gcp-shellquote.test.ts: remove 2 redundant startsWith/endsWith
  assertions from "should produce output that is safe for bash -c".
  The toBe("'$(rm -rf /)'") assertion already proves the single-quote
  wrapping; the follow-up checks add no signal.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:11:58 -04:00
A
d6e2eb3aad
refactor: add JSDoc to aws.getState() clarifying test-only usage (#2651)
this function has no callers in production code but is intentionally
used in unit tests (custom-flag.test.ts) for state introspection.
adding documentation prevents it from being incorrectly identified
as dead code in future code quality scans.

code quality scan results:
- dead code: none found
- stale references: none found
- python usage: none found
- duplicate utilities: getCloudInitUserdata has per-cloud variants
  with intentional differences (not mergeable)
- stale comments: none found

-- qa/code-quality

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
2026-03-15 05:51:24 -07:00
A
6a439021e5
test: remove duplicate and theatrical tests (#2650)
Consolidate repetitive per-field test iterations in manifest-type-contracts.test.ts
into data-driven loops, eliminating ~15 near-identical it() blocks. Share a single
startGateway() invocation across all 3 gateway-resilience tests via beforeEach.
Remove redundant toBeDefined() check in junie-agent.test.ts that was immediately
superseded by a stronger assertion on the same value.

-- qa/dedup-scanner

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
2026-03-15 08:49:51 -04:00
Ahmed Abushagur
05c7070396
fix: re-upload openclaw config after config set calls to preserve channels (#2649)
Each `openclaw config set` call does a read-modify-write that can drop
fields like channels and gateway auth. After all config set calls,
re-download the config, deep-merge our configObj on top, and re-upload
to restore any dropped fields.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 06:46:52 -04:00
A
65b29e3757
test: remove duplicate and theatrical tests (#2646)
- security.test.ts: remove "should handle prompt with only whitespace"
  (line 614) — fully covered by "should reject empty prompts" (line 363)
  which already tests validatePrompt("   ") and validatePrompt("\n\t")

- script-failure-guidance.test.ts: consolidate three separate "returns
  simple command" tests (no-arg, undefined, empty string) into one.
  All three called buildRetryCommand with absent/falsy prompt and
  asserted identical output — the input variation is not a meaningful
  behavioral distinction.

net: 3 tests removed. 1410 pass, 0 fail. biome lint clean.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-15 02:15:10 -07:00
A
173cddfc26
test: remove duplicate and theatrical tests (#2645)
- commands-error-paths.test.ts: consolidate 4 groups of repetitive tests
  into data-driven loops: 7 identifier validation tests, 6 prompt
  validation tests, 5 cmdAgentInfo invalid-input tests, and 3 empty-input
  tests — each group had identical structure (rejects.toThrow + exit(1))
  with only the input varying. net: 21 separate tests → 4 compact loops
  covering the same cases, reducing 41 lines of boilerplate.

- commands-cloud-info.test.ts: consolidate 8 separate "should reject cloud
  with X" tests (invalid identifier describe block) into a single
  data-driven loop, reducing 24 lines.

All 1413 tests still pass. biome lint clean.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 01:15:00 -04:00
A
9af0c7b606
test: remove duplicate and theatrical tests (#2643)
- aws.test.ts: remove "all bundles have required fields" test that used
  toBeTruthy() on id/label — fully redundant with the more specific
  "bundle IDs follow naming convention" (/_3_0$/) and "labels include
  pricing info" ($, /mo) tests below it.

- commands-cloud-info.test.ts: consolidate 3 separate tests for
  "cloud with no implemented agents" that each fetched the same manifest,
  called cmdCloudInfo("emptycloud"), and checked different assertions on
  identical output into a single test.

- credential-hints.test.ts: merge "reports credentials appear set..."
  and "lists the env var names when all are set" — identical setup (same
  env vars, same function call) with overlapping assertions split across
  two tests for no good reason.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-14 21:40:25 -04:00
A
f03e5683c1
fix: check saved OpenRouter key and return empty list when cloud config exists (#2640)
collectMissingCredentials() was incorrectly reporting saved credentials as
missing in two ways:
1. It only checked process.env.OPENROUTER_API_KEY, ignoring keys saved via
   OAuth flow to ~/.config/spawn/openrouter.json
2. When hasCloudConfigCredentials() returned true, it filtered to keep
   OPENROUTER_API_KEY in the missing list instead of returning []

Fix: also call hasSavedOpenRouterKey() before marking OPENROUTER_API_KEY as
missing, and return [] (not a filtered list) when cloud config exists.

Fixes #2639

Agent: issue-fixer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-14 20:37:18 -04:00
A
245a2a46f9
feat: offer delete or remap when server is gone from cloud provider (#2641)
* feat: offer delete or remap when server is gone from cloud provider

When a user tries to connect to a server that no longer exists, instead
of silently marking it as deleted, present an interactive picker that
lets them remap the history entry to an existing instance on the same
cloud or explicitly remove it from history.

- Add listServers() to Hetzner, DigitalOcean, AWS, and GCP providers
- Add updateRecordConnection() to history for remapping server details
- Add handleGoneServer() interactive flow in list.ts
- Fall back to silent deletion in non-interactive mode (SPAWN_NON_INTERACTIVE)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: move InstancesListSchema to module level

Declare valibot schema at module top level per project convention,
not inside the listServers() function body.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: extract shared CloudInstance type from duplicated inline types

The { id, name, ip, status } shape was declared inline 9 times across
5 files. Extract it as a shared CloudInstance interface in history.ts
and import it in all cloud providers and list.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 17:05:51 -07:00
Ahmed Abushagur
6ee81b7515
feat: add Custom model option to OpenClaw setup menu (#2637)
* feat: add "Custom model" option to setup menu for OpenClaw

Adds a "Custom model" entry to the setup options multiselect. When
selected, prompts the user for an OpenRouter model ID (e.g.
anthropic/claude-sonnet-4) with validation. The model ID is passed
through via MODEL_ID env var to the orchestration pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: simplify custom model prompt text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-14 16:38:10 -07:00
A
dc91b27431
feat(digitalocean): show account info on errors + offer to switch accounts (#2638)
When DO API calls fail (billing issues, locked account, droplet creation
errors), users may be logged into the wrong account. Now shows email/team/
status and offers to re-authenticate before giving up.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-14 16:36:21 -07:00
A
cfcc5fdc4e
fix(aws): handle NameExists on createInstance to recover from HTTP retry (#2633)
When AWS Lightsail's internal HTTP retry fires after a successful
create but dropped response, the NameExists error now checks if the
instance is in pending/running state and reuses it instead of failing.

Fixes #2630

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 16:27:52 -07:00
A
5ceffbc519
fix: add exponential backoff to withRetry, bump install retries to 4 (#2634)
Fixes Connection reset by peer failures on spotty networks by doubling
delay on each retry (10s→20s→40s→80s) and giving installAgent and
uploadConfigFile 4 attempts instead of 2.

Fixes #2631

Agent: ux-engineer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 19:11:53 -04:00
Ahmed Abushagur
cef7c69522
feat: rank agents by GitHub stars + add update-stars.sh (#2635)
Sort agent picker by github_stars descending so most popular agents
appear first. Add update-stars.sh script to QA quality sweep to keep
star counts fresh.

Security fixes from PR #2629 review:
- Validate repo format (owner/name pattern) before gh api calls
- Validate and canonicalize REPO_ROOT with realpath

Supersedes #2629.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-14 15:49:41 -07:00
A
f7c23de716
feat: add downloadFile to CloudRunner + local OpenClaw config merge (#2636)
* feat: add downloadFile to CloudRunner + local OpenClaw config merge

Add `downloadFile(remotePath, localPath)` to the CloudRunner interface
and implement it across all 6 cloud providers (Hetzner, AWS, GCP,
DigitalOcean, Sprite, Local) — mirroring the existing `uploadFile` with
reversed SCP direction.

Replace the OpenClaw config write with a download → deep-merge → upload
flow so config merging happens in our own linted TypeScript instead of
a remote script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: move isPlainObject and deepMerge to shared utils

Extract `isPlainObject` to `shared/type-guards.ts` and `deepMerge` to
`shared/parse.ts` so they're reusable across the codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: promote isPlainObject to shared package, use across codebase

Move `isPlainObject` from cli/type-guards.ts into
@openrouter/spawn-shared so it can be used everywhere. Replace
inline `val !== null && typeof val === "object" && !Array.isArray(val)`
checks in:

- shared/type-guards.ts (toRecord, toObjectArray)
- shared/parse.ts (parseJsonObj)
- cli/manifest.ts (isValidManifest)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove type-guards re-export, import directly from spawn-shared

Delete `packages/cli/src/shared/type-guards.ts` (was just a re-export
barrel). All 35 consuming files now import `getErrorMessage`, `isString`,
`isNumber`, `isPlainObject`, `toRecord`, etc. directly from
`@openrouter/spawn-shared`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 15:47:32 -07:00
A
0f9bbd399c
fix(digitalocean): catch billing 403 thrown by doApi on droplet creation (#2628)
doApi() throws on any non-2xx response before the isBillingError() check
at the call site could execute, making billing error detection dead code.

Wrap the POST /droplets call in asyncTryCatch so the thrown error message
(which includes the response body) is checked with isBillingError(). If it
matches a billing pattern, handleBillingError() is shown with the billing
page link and retry prompt — same UX as the proactive first-run warning.

Also adds a test asserting isBillingError() matches errors in the format
doApi throws (regression guard for #2395).

Fixes #2395

Agent: issue-fixer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-14 18:09:48 -04:00
Ahmed Abushagur
d435963dbc
fix: remove WhatsApp from setup, nothing pre-selected by default (#2626)
WhatsApp setup is too complex for normal users (QR scan + separate
device + pairing). Remove it from the setup options entirely.

Also change multiselect defaults to nothing pre-selected — let users
opt in to what they want instead of pre-selecting for them.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:10:28 -07:00
A
f3a9db4b91
fix: refresh server IP from cloud API before reconnect SSH (#2625)
Fixes #2624

When reconnecting to an existing server via `spawn ls` or `spawn last`,
the CLI now queries the cloud provider API for the server's current IP
before attempting SSH. This prevents silent SSH timeouts when a server's
IP changes (e.g., after a restart or elastic IP reallocation).

Changes:
- Add `getServerIp()` to DigitalOcean, Hetzner, AWS, and GCP modules
- Add `updateRecordIp()` to history.ts to persist IP changes
- Add `refreshConnectionIp()` in list.ts that authenticates with the
  cloud provider and refreshes the IP before enter/reconnect/fix actions
- If the server no longer exists, mark it deleted and inform the user
- If refresh fails (e.g., no credentials), fall back to cached IP

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 13:45:59 -07:00
A
a738e658a3
feat: add separate Open Dashboard action in spawn ls menu (#2622)
Add "Open Dashboard" as its own menu item for agents with tunnel
metadata (e.g., OpenClaw). Establishes an SSH tunnel, opens the
browser with the auth token, and waits for Enter to close.

The menu now shows both options for dashboard agents:
  - Enter OpenClaw (launches TUI via SSH)
  - Open Dashboard (opens web UI in browser)

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:45:19 -04:00
A
c878e5b5d8
feat: persist tunnel metadata so spawn ls can re-establish dashboard proxy (#2620)
When an agent has an SSH tunnel (e.g., OpenClaw dashboard), store the
tunnel remote port and browser URL template in connection.metadata at
spawn time. On reconnect via `spawn ls` → "Enter agent", re-establish
the SSH tunnel and open the dashboard automatically.

- Add saveMetadata() to history.ts for merging key-value pairs into records
- Store tunnel_remote_port and tunnel_browser_url_template in orchestrate.ts
- Re-establish tunnel in cmdEnterAgent (connect.ts) when metadata is present

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 15:43:13 -04:00
A
689989005a
fix: reorder interactive menu — "Create" before "Connect" (#2619)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 12:25:16 -07:00
A
3c11bf33d7
fix: tunnel gateway port 18789, not internal control service 18791 (#2618)
The OpenClaw dashboard (Control UI) is served by the Gateway on port
18789, which also handles WebSocket connections for agent communication.
Port 18791 is the internal Control Service — not the user-facing dashboard.

We were tunneling 18791, so the browser connected to the wrong service
and showed "Unauthorized" because the Control Service doesn't accept
token-based dashboard auth.

Fix: tunnel port 18789 (Gateway) and update all USER.md references.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 12:12:17 -07:00
A
c323f0e2e3
fix: openclaw dashboard auth — add gateway.auth.mode and use fragment token (#2617)
OpenClaw 2026.3.7+ requires an explicit `gateway.auth.mode: "token"` field
when `gateway.auth.token` is set. Without it the gateway rejects auth and the
dashboard shows "Unauthorized".

Additionally, pass the token via URL fragment (`#token=`) instead of query
parameter (`?token=`) to match the updated auth flow and avoid leaking the
token in server logs / Referer headers (GHSA-rchv-x836-w7xp).

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 14:48:34 -04:00
A
6988ac7acf
test: remove duplicate and theatrical tests (#2616)
The ensureSshKeys tests had two identical tests covering the same code
path: "uses all keys in non-interactive mode when multiple exist" and
"uses all keys when multiselect is unavailable". Both created the same
two fake key pairs, used the same spawnSync mock, and made the identical
assertion (toHaveLength(2)).

The first test set SPAWN_NON_INTERACTIVE=1 which ensureSshKeys does not
check — stale logic from a removed interactive multiselect flow. The
second test referenced unavailable @clack/prompts multiselect which also
no longer exists in the implementation.

Consolidated into one deterministic test that also validates key ordering
(ed25519 sorts before rsa).

-- qa/dedup-scanner

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
2026-03-14 12:46:55 -04:00