Commit graph

474 commits

Author SHA1 Message Date
Ahmed Abushagur
5efbcf9ee7
feat: add --fast flag for parallel server boot + setup (#2796)
* feat: add --fast flag for parallel server boot + setup

Adds `--fast` flag that runs server creation concurrently with API key
prompt, account check, pre-provision hooks, tarball download, and env
config generation. Once SSH is up, uploads tarball and applies config.

--fast implies --beta tarball and --beta images, enabling snapshots
and pre-built tarballs automatically.

Flow without --fast (sequential):
  auth → API key → preProvision → size → create → boot → install → configure

Flow with --fast (parallel):
  auth → size → [create+boot | API key | preProvision | tarball download | accountCheck]
              → upload tarball → inject env → configure

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

* feat: add --beta parallel as standalone opt-in for parallel setup

--beta parallel enables the parallel orchestration without implying
tarball/images. --fast still implies all three (tarball + images +
parallel).

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:26:54 -07:00
A
6772ed1cd7
fix(cli): validate agentKey in buildFixScript and fixSpawn before manifest lookup (#2792)
Some checks failed
Lint / ShellCheck (push) Successful in 1m5s
CLI Release / Build and release CLI (push) Failing after 18s
Lint / Biome Lint (push) Failing after 4s
Lint / macOS Compatibility (push) Successful in 14s
Add validateIdentifier() calls to buildFixScript() and fixSpawn() to
ensure agent keys from spawn history match [a-z0-9_-]+ before using
them to index manifest.agents. This prevents potential prototype
pollution or unexpected behavior from tampered history files.

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-19 06:36:06 -07:00
A
787087144c
fix(cli): bump version to 0.23.2 for missed patch releases (#2787)
Some checks failed
CLI Release / Build and release CLI (push) Failing after 5s
Lint / Biome Lint (push) Failing after 4s
Lint / macOS Compatibility (push) Successful in 17s
Lint / ShellCheck (push) Successful in 57s
Two CLI changes landed after the last version bump (0.23.1) without
incrementing the version:
- d9575acd: fix(cli): exit with code 1 on spawn fix error paths
- 148cc9e7: refactor: extract duplicate waitForSshSnapshotBoot to shared/ssh.ts

The CLI has auto-update enabled — without a version bump, users won't
pick up these fixes on next run.

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-19 01:00:10 -07:00
A
148cc9e7ee
refactor: extract duplicate waitForSshSnapshotBoot to shared/ssh.ts (#2783)
The waitForSshOnly function was identically duplicated in hetzner.ts and
digitalocean.ts. Extract the shared logic into waitForSshSnapshotBoot() in
shared/ssh.ts and replace the duplicate cloud implementations with thin
wrappers that resolve module-local state before delegating.

-- qa/code-quality

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-18 22:10:25 -07:00
A
d9575acd43
fix(cli): exit with code 1 on spawn fix error paths (#2781)
cmdFix error paths (spawn not found, non-interactive with multiple
servers, picker mismatch) previously returned without setting a
non-zero exit code. Scripts checking $? would incorrectly see success.

Now exits with code 1 on all error paths in cmdFix. fixSpawn() is
unchanged since it is also called from the list picker where returning
to loop is correct behavior.

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-18 20:43:31 -07:00
A
15a62a9ad0
fix(cli): use tryCatch for JSON.parse in loadPreferredModel (#2782)
tryCatchIf(isFileError) only catches filesystem errors (ENOENT, EACCES),
but JSON.parse throws SyntaxError on corrupted preferences.json. This
was the same bug fixed in 16a2f180 across 4 files, but orchestrate.ts
was missed. A corrupted ~/.spawn/preferences.json would crash the CLI
instead of gracefully falling back to no preferred model.

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-18 20:15:17 -07:00
Ahmed Abushagur
7289f3ef36
feat(hetzner): add snapshot support + Packer image builds (#2774)
Some checks failed
CLI Release / Build and release CLI (push) Failing after 31s
Lint / ShellCheck (push) Successful in 40s
Lint / Biome Lint (push) Failing after 14s
Lint / macOS Compatibility (push) Successful in 18s
CLI changes:
- Add findSpawnSnapshot() to query Hetzner /images?type=snapshot API
  for pre-built spawn-{agent}-* images (matches by description prefix)
- Add waitForSshOnly() for snapshot boots (skips cloud-init polling)
- Update createServer() to accept optional snapshotId — boots from
  snapshot instead of ubuntu-24.04, skips cloud-init userdata
- Wire up orchestrator with skipAgentInstall flag

Packer changes:
- Add packer/hetzner.pkr.hcl using hcloud plugin, mirroring the DO
  template (tier scripts, agent install, cleanup, manifest)
- Unify packer-snapshots.yml to build both DO and Hetzner in a single
  workflow with cloud×agent matrix and per-cloud cleanup steps

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:46:48 -07:00
A
04eb54b409
test: consolidate repetitive validateLaunchCmd and validatePreLaunchCmd valid-input tests (#2771)
7 agent-specific it() blocks for validateLaunchCmd (all calling .not.toThrow()
on trivially different inputs) collapsed into one data-driven loop. Similarly,
6 individual validatePreLaunchCmd valid-pattern tests collapsed into one loop.

Reduces it() count in security-connection-validation.test.ts from 93 to 81 with
zero change in coverage - every command variant is still exercised.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:16:38 -07:00
A
16a2f1807c
fix(cli): use tryCatch instead of tryCatchIf for JSON.parse callsites (#2770)
tryCatchIf(isFileError) only catches filesystem errors (ENOENT, EACCES),
but JSON.parse throws SyntaxError on corrupted input. Since tryCatchIf
rethrows non-matching errors, a corrupted config file crashes the CLI
instead of returning the intended null/false fallback.

Affected: readCache(), local manifest loader, loadApiToken(),
loadSavedOpenRouterKey(), hasCloudConfigCredentials()

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-18 12:54:41 -07:00
A
fc98700a24
fix(digitalocean): use s-2vcpu-4gb-intel for openclaw to support nyc3 region (#2769)
s-2vcpu-4gb is not available in nyc3 (the default E2E region), causing
openclaw provisioning to fail with 422. s-2vcpu-4gb-intel offers the same
specs (2 vCPUs, 4 GB RAM) and is available in all regions including nyc3.

-- qa/e2e-tester

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
2026-03-18 11:26:19 -07:00
A
b46524887d
feat(hetzner): fetch locations from API, re-prompt on unavailable location (#2766)
Hetzner disabled fsn1 (Falkenstein), causing a fatal HTTP 412 error for
all users using the default location. This change:

- Fetches available locations dynamically from GET /locations API
- Falls back to a hardcoded list if the API call fails
- On location-unavailable errors (HTTP 412 resource_unavailable),
  prompts the user to pick a different location instead of crashing
- Changes default location from fsn1 to nbg1 (Nuremberg)
- Excludes previously-failed locations from the re-pick list

Closes #2764

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Security Reviewer <security@openrouter.ai>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-18 10:39:42 -07:00
A
1ad385117e
test: consolidate redundant platform tests in shell.test.ts (#2767)
macOS and Linux return identical results for getLocalShell, getWhichCommand,
getInstallScriptUrl, and getInstallCmd. Collapsed the duplicate per-platform
tests into a data-driven loop over ["darwin", "linux"], reducing repetition
while preserving the same coverage. Also added the missing Linux case for
getInstallCmd (was only tested for Windows and macOS).

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 10:28:09 -07:00
A
4e31e8dd4c
docs(tests): document 5 undocumented test files in README (#2762)
Some checks failed
CLI Release / Build and release CLI (push) Failing after 19s
Lint / Biome Lint (push) Failing after 3s
Lint / macOS Compatibility (push) Successful in 14s
Lint / ShellCheck (push) Successful in 58s
Added missing entries to packages/cli/src/__tests__/README.md for:
- auto-update.test.ts — setupAutoUpdate systemd service unit generation
- kill-with-timeout.test.ts — killWithTimeout SIGKILL grace period logic
- shell.test.ts — platform-aware shell detection utilities
- digitalocean-token.test.ts — DigitalOcean token storage and API helpers
- hetzner-pagination.test.ts — Hetzner API multi-page pagination

All 1467 tests pass. No code changes.

-- qa/code-quality

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
2026-03-18 07:01:19 -07:00
A
47b8bd30cc
test: remove duplicate and theatrical tests (#2763)
removed the "integration with getScriptFailureGuidance" describe block
from credential-hints.test.ts. all three tests were redundant:

- "always includes setup instructions regardless of env state": tested
  for vague "setup instructions" string, already verified by the
  "when all required env vars are missing" describe block above.

- "always returns at least one line": pure existence check, already
  proven by the "when no authHint is provided" tests which assert exact
  length of 1.

- "returns more lines when authHint is provided": tests line-count
  implementation detail rather than behavior; behavior is fully covered
  by the per-scenario describe blocks.

1467 to 1464 tests. zero regressions. biome lint: 0 errors.


-- qa/dedup-scanner

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 06:37:40 -07:00
A
af300ba248
fix(digitalocean): paginate SSH keys/droplets and harden key registration check (#2758)
Add doGetAll() pagination helper (matching Hetzner's hetznerGetAll pattern)
and use it for all three unpaginated DO API calls:
- ensureSshKey(): /account/keys (was silently truncated at 20 keys)
- createServer(): /account/keys (same issue for SSH key ID collection)
- listServers(): /droplets (was silently truncated at 20 droplets)

Replace fragile `regText.includes('"id"')` string check with proper
`parseJsonObj(regText)?.ssh_key` validation for SSH key registration.

Fixes #2748
Fixes #2749

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-18 01:18:06 -07:00
A
d4774fdc8e
fix(sprite): append to ~/.bash_profile and gate exec zsh on interactive shells (#2756)
- Use >> instead of > to append to ~/.bash_profile (preserves existing config)
- Gate exec zsh on interactive shells: [[ $- == *i* ]] && exec /usr/bin/zsh -l
- Bump CLI version to 0.21.7

Fixes #2740

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-17 23:55:33 -07:00
A
75c75d42d4
fix(ui): propagate Ctrl+C/Esc cancellation instead of returning empty string (#2757)
When p.isCancel() detected user cancellation in prompt() and
selectFromList(), the result was silently converted to "" instead of
exiting. This caused infinite retry loops in billing prompts, silent
fallthrough in oauth key entry, and unintended defaults in name prompts.

Now both functions call process.exit(0) on cancel for a clean exit.

Fixes #2745

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-17 23:54:32 -07:00
A
fef312cd47
fix(update): cache successful update checks for 1 hour (#2755)
checkForUpdates() previously fetched the latest version from GitHub on
every single CLI invocation, blocking for up to 10s on slow/offline
connections. Now it writes a timestamp to ~/.config/spawn/.update-checked
after a successful check and skips the network call if the cache is
less than 1 hour old.

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-17 23:08:05 -07:00
A
133b94939e
fix(hetzner): ensure cloud-init marker is always written despite early exit (#2747)
Remove `set -e` from userdata script and add an EXIT trap to guarantee
/root/.cloud-init-complete is written even if apt-get or other setup
steps fail. Add `|| true` to apt-get commands for extra resilience.

Previously, the userdata script used `set -e` causing it to abort on
any command failure before reaching the marker write at the end. This
made waitForCloudInit() always time out with "Cloud-init marker not
found, continuing anyway..." adding ~5 minutes to every Hetzner
provisioning.

Fixes #2739

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-17 23:02:16 -07:00
A
1b978c03ce
fix(tarball): validate VM architecture when only one arch asset exists (#2753)
When a GitHub Release contains only one architecture-specific tarball
(e.g., x86_64 only), the download command now checks `uname -m` on
the remote VM and fails with exit 1 if the arch doesn't match. This
prevents installing an x86_64 binary on ARM (or vice versa) and ensures
the orchestrator falls back to live installation.

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-17 22:59:04 -07:00
A
035ee3ca63
fix(ssh): always escalate to SIGKILL in killWithTimeout (#2752)
proc.killed is true as soon as kill() is called, not when the process
exits. This meant SIGKILL escalation was always skipped, leaving stuck
processes hanging indefinitely. Remove the faulty guard and always
attempt SIGKILL after the grace period — try/catch handles already-dead
processes.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 05:54:38 +00:00
A
a557fb1002
fix(cli): handle --help and --version flags after positional args (#2750)
Previously, `spawn claude sprite --help` would warn about extra args
and proceed to provision a server. Now trailing help/version flags are
detected and handled correctly in both the default command path and
verb alias path (e.g., `spawn run claude sprite --help`).

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:29:48 -07:00
Ahmed Abushagur
39f62b8c75
fix(windows): use dirname() instead of unix-only regex for config paths (#2738)
The regex `configPath.replace(/\/[^/]+$/, "")` only matches forward
slashes, so on Windows (which uses backslashes) it returns the full
path unchanged. `mkdirSync` then creates `digitalocean.json` as a
directory, causing EISDIR on the next write.

Replace with `dirname()` from `node:path` which handles both separators.
Affects digitalocean.ts, hetzner.ts, and aws.ts (oauth.ts already used
dirname correctly).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: PR Reviewer <pr-reviewer@spawn>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-17 22:22:30 -07:00
A
800c446ca4
fix(security): resolve symlinks in prompt file validation to prevent bypass (#2744)
validatePromptFilePath used path.resolve() which only normalizes the
string but doesn't follow symlinks. An attacker could create a symlink
(e.g., innocent.txt -> ~/.ssh/id_rsa) to bypass sensitive path checks
and exfiltrate credentials. Now uses realpathSync() to canonicalize
the path before pattern matching.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:21:11 -07:00
A
1e190924bf
fix(aws): wait for public IP before returning from waitForInstance (#2746)
Lightsail can report state=running before assigning a public IP. Continue
polling until both state is running and IP is non-empty, preventing SSH
connection failures from an empty IP address.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:16:57 -07:00
A
1ac7b9a0d1
fix(hetzner): paginate SSH key and server list API calls to prevent truncation at 25 items (#2741)
Hetzner API defaults to 25 items per page. Users with >25 SSH keys would
hit SSH lockout on server creation because the newly registered key landed
on page 2+ and was omitted from the ssh_keys payload.

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-17 22:11:45 -07:00
A
f35696434a
fix(security): use writeFileSync for credential files — Bun.write ignores mode option (#2742)
Bun.write does not support the `mode` option, so credential config files
(Hetzner, DigitalOcean, AWS, OpenRouter) were created with 0644 permissions
instead of the intended 0600, exposing API tokens to other local users.

Switch to node:fs writeFileSync which correctly applies file permissions.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:09:36 -07:00
A
7fe1bdf6b3
fix(junie): remove JUNIE_MODEL env var to fix 'Unknown model: openrouter/auto' crash (#2735)
Junie only accepts its own shorthand model names (gpt, opus, sonnet, etc.)
and not OpenRouter model IDs. Removing modelEnvVar lets junie handle its
own model routing via the OpenRouter API key instead.

Fixes #2734

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-17 21:22:32 -07:00
Ahmed Abushagur
c11879d547
fix(windows): download JS bundle instead of bash wrapper on Windows (#2730)
The bash wrapper scripts (.sh) contain bash syntax that PowerShell
cannot parse. On Windows, download the pre-built JS bundle from
GitHub releases and run it directly via `bun run {cloud}.js {agent}`,
which is exactly what the bash wrapper ultimately does.

Affects both interactive (execScript) and headless (cmdRunHeadless)
code paths. macOS/Linux behavior unchanged.

Closes #2726

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-17 19:09:44 -07:00
A
b1de116690
refactor: replace manual multi-level type guards with toRecord/isString in index.ts (#2731)
Two instances of the pattern `err && typeof err === "object" && "code" in err`
violated the type-safety rule requiring valibot or shared type-guard utilities
instead of manual multi-level type checks. Replaced with `toRecord(err)` and
`isString()` from @openrouter/spawn-shared for consistent, rule-compliant error
code extraction. Also bumps CLI patch version per cli-version.md.

-- qa/code-quality

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-17 18:40:16 -07:00
Ahmed Abushagur
6e92cc832b
feat: add systemd auto-update service for agents on cloud VMs (#2728)
Installs a systemd timer + oneshot service that updates the agent binary
and system packages every 6 hours without disrupting running instances.

Agent update safety:
- Binary agents (Go, Rust): Linux keeps old inode in memory; safe to replace
- npm agents: Node.js caches modules at startup; running processes unaffected
- New version takes effect on next restart via the existing restart loop

System update safety:
- Disables Ubuntu's unattended-upgrades to prevent dpkg lock contention
- Uses flock -w 300 on /var/lib/dpkg/lock-frontend before apt operations
- DEBIAN_FRONTEND=noninteractive with --force-confdef/--force-confold

User-facing:
- "Auto-update" option in setup multiselect (default on, user can uncheck)
- Skipped for local cloud and non-systemd systems
- Non-fatal: setup failure doesn't block agent launch
- Logs to /var/log/spawn-auto-update.log

Timer: 15min after boot, then every 6h with 30min random jitter.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:34:12 -07:00
Ahmed Abushagur
66b16d8651
feat: add Windows PowerShell support — remove bash dependency for local execution (#2727)
Replace hardcoded "bash" shell references with platform-aware utilities so
spawn works natively from PowerShell on Windows without WSL or Git Bash.

- New shared/shell.ts: isWindows(), getLocalShell(), getInstallScriptUrl(),
  getInstallCmd(), getWhichCommand() with platform override for testability
- local/local.ts: use getLocalShell() for runLocal() and interactiveSession()
- commands/run.ts: spawnScript/runScriptHeadless use getLocalShell()
- commands/update.ts: Windows downloads install.ps1, runs via PowerShell
- update-check.ts: Windows auto-update uses install.ps1; "where" replaces "which"
- shared/orchestrate.ts: PowerShell-compatible .spawnrc setup for local Windows
- Remote SSH commands unchanged — remote servers are always Linux

Closes #2726

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-17 16:35:23 -07:00
A
ba94f681b3
feat(cli): add spawn uninstall command (#2724)
* feat(cli): add `spawn uninstall` command

Adds a new `uninstall` subcommand that cleanly reverses the install:
- Removes ~/.local/bin/spawn binary and /usr/local/bin/spawn symlink
- Cleans spawn PATH entries from shell RC files (.bashrc, .zshrc, etc.)
- Removes ~/.cache/spawn/ cache directory
- Optionally removes ~/.spawn/ (history) and ~/.config/spawn/ (keys/config)
- Shows confirmation prompt before any destructive action

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

* refactor: use start/end markers for shell RC blocks

- Add shared RC_MARKER_START/RC_MARKER_END constants in paths.ts
- Update install.sh to write `# >>> spawn >>>` / `# <<< spawn <<<` block markers
- Update uninstall.ts to remove content between markers (with legacy fallback)
- Addresses review feedback: shared markers make RC entries easier to audit/remove

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

* refactor: share legacy RC marker from paths.ts

Move the legacy "# Added by spawn installer" string to RC_MARKER_LEGACY
in shared/paths.ts so both install.sh and uninstall.ts reference the
same source of truth for all marker strings.

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>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-17 16:33:09 -07:00
A
1733903a1f
fix(digitalocean): add OAuth recovery in doApi for mid-session 401 errors (#2723)
When a DigitalOcean token expires mid-session (after ensureDoToken succeeds),
API calls like ensureSshKey, createServer, listServers, destroyServer would
crash with "Fatal: DigitalOcean API error 401" because doApi had no recovery
path for 401 responses.

Now doApi detects 401, attempts OAuth browser flow recovery via tryDoOAuth(),
and retries the request with the new token. A re-entrancy guard prevents
infinite loops (doApi → tryDoOAuth → doApi → ...). If OAuth recovery fails,
the original 401 error is thrown as before.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:13:42 -07:00
A
00863b0172
fix(digitalocean): handle 401 gracefully in testDoToken instead of crashing (#2722)
testDoToken() used asyncTryCatchIf(isNetworkError, ...) which only caught
network errors. A 401 HTTP response threw a regular Error that escaped the
guard, propagating to main().catch() and printing "Fatal: DigitalOcean API
error 401...". Changed to asyncTryCatch() to catch all errors, returning
false for invalid tokens so ensureDoToken() naturally falls through to
OAuth recovery.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:14:30 -07:00
A
6509973154
test: remove duplicate terminal-width boilerplate in cmd-listing-output tests (#2721)
Consolidate 10 single-assertion cmdMatrix tests (5 wide-terminal + 5
narrow-terminal) into 2 comprehensive tests using beforeEach/afterEach for
terminal-width setup. Also fix a pre-existing environment-dependent failure
where HCLOUD_TOKEN being set on the host caused the auth-hint test to see
"ready" instead of "needs".

Changes:
- "grid view (wide terminal)": 5 tests → 1 test (8 fewer cmdMatrix() calls)
- "compact view (narrow terminal)": 5 tests → 1 test (same)
- Fix "should display auth hints" to clear host env vars before asserting

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:22:05 -07:00
A
c6087534aa
fix: populate connection fields in --headless --output json result (#2716)
After runBashHeadless() succeeds, read the spawn record saved during
orchestration and populate ip_address, ssh_user, server_id, and
server_name in the SpawnResult output.

Closes #2715

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:29:44 -07:00
A
0e5bfd830b
fix(e2e): double GCP cloud-init wait timeout to 10 minutes for Node install (#2713)
* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* chore: update agent GitHub star counts

* fix(gcp): double cloud-init wait timeout to 120 attempts (10 min)

GCP startup scripts installing Node.js 22 via `n` from curl take longer
than 5 min on cold starts. The previous 60-attempt (5 min) poll timed
out with "Startup script may not have completed, continuing..." and
proceeded to run `npm install -g @kilocode/cli` before npm was available,
causing `npm: command not found` errors.

Increase `maxAttempts` from 60 to 120 (10 min) in `waitForCloudInit` to
give the Node install enough time to complete on GCP cold starts.

Confirmed by E2E run: GCP kilocode failed with npm not found after all 60
poll attempts exhausted; all other GCP agents passed (they don't need Node).

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-17 11:51:41 -07:00
Ahmed Abushagur
34785a9a63
feat(hermes): add YOLO mode toggle to setup menu (#2711)
Add HERMES_YOLO_MODE as a setup option for Hermes Agent, enabled by
default. This disables Hermes's security approval prompts so it can
self-install skill dependencies (e.g. himalaya for email) at runtime
on dedicated cloud VMs.

Users can uncheck it in the setup multiselect if they prefer Hermes
to prompt before installing tools.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 10:09:41 -07:00
A
5004a4db52
test: replace loose cloud-type count assertion with enumerated known-set check (#2709)
The "should have a reasonable number of distinct cloud types" test used
toBeGreaterThanOrEqual(2) and toBeLessThanOrEqual(10) — bounds so wide
they would never catch a real type-naming mistake. Replace it with an
explicit allowlist check so adding an unknown type fails immediately.

Current valid types (api, cli, local) are all in the set; vm, container,
sandbox, and cloud are pre-approved to avoid blocking planned additions.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 09:55:15 -07:00
A
eec83898e4
fix(kilocode): add binary verification after npm install to recover from silent postinstall failures (#2707)
@kilocode/cli v7+ uses a native binary postinstall that downloads a
platform-specific binary. On some clouds (notably GCP with cloudInitTier
"node"), this postinstall can fail silently, leaving the npm bin symlink
pointing to a JS wrapper with no actual native binary to exec.

The fix adds a KILOCODE_BINARY_VERIFY shell snippet that runs after npm
install and:
1. Checks if kilocode is already working (fast path)
2. If not, finds the npm package dir and re-runs the postinstall
3. If still not found, searches for the native binary in the package dir
   and symlinks it into a PATH-accessible location

Fixes #2706

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-17 08:30:00 -07:00
A
33014e3eed
docs: add cmd-link.test.ts to test README index (#2705)
cmd-link.test.ts was added but omitted from the test file index in README.md.
This keeps the index accurate as a reference for all 68 test files.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 05:51:51 -07:00
A
e8471b6136
test: remove duplicate Result constructors describe block (#2704)
The "Result constructors" describe block in with-retry-result.test.ts
(testing Ok/Err from shared/ui.js) was a duplicate of coverage already
provided by result-helpers.test.ts, which tests the same Ok/Err exports
from shared/result.ts (ui.ts re-exports them). The 3 trivial constructor
tests add no signal beyond what the withRetry and wrapSshCall tests
already exercise implicitly.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 02:23:24 -07:00
A
7f43e6bb9b
test: fix theatrical promptBundle test with real assertion (#2703)
promptBundle sets _state.selectedBundle via env var but the test was
calling promptBundle() without asserting anything about the result.
Added selectedBundle to getState() return value so tests can verify
the env var path is actually exercised.

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 22:08:10 -07:00
A
8dd1db7fbb
test: remove duplicate and theatrical tests (#2700)
- Remove 2-test "flag registration" block from custom-flag.test.ts — both
  assertions (KNOWN_FLAGS.has("--custom") and findUnknownFlag returning null)
  were already covered by the KNOWN_FLAGS completeness test in unknown-flags.test.ts.

- Fix stale KNOWN_FLAGS completeness test: it was testing only 18 of 26 known
  flags, making it always-pass when new flags are added to flags.ts without
  updating the test. Now the test is bidirectionally exhaustive — every flag in
  the expected list must be in KNOWN_FLAGS, and every flag in KNOWN_FLAGS must
  be in the expected list. This absorbs the --steps/--config coverage.

- Remove findUnknownFlag(["--steps"]) / findUnknownFlag(["--config"]) test from
  steps-flag.test.ts — now redundant since the exhaustive completeness test
  already exercises those flags.

Net: -3 tests removed, +18 expect() calls added (exhaustive bidirectional check).

Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-03-16 20:12:47 -07:00
A
5b2eddb763
fix(sprite): replace personal VM URL with official CDN for keep-alive script (#2701)
The sprite-keep-running.sh script was downloaded from a hardcoded personal
VM URL (kurt-claw-f.sprites.app) which would break all Sprite deployments
if that VM goes offline. Use the official CDN proxy at openrouter.ai/labs/spawn/.

Fixes #2699

-- refactor/code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 20:04:49 -07:00
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