Commit graph

548 commits

Author SHA1 Message Date
A
aa88e70488
fix: add concurrency guard and workflow_dispatch to CLI release (#1812)
The race condition: two PRs merged 3 seconds apart both triggered the
CLI Release workflow. The second run (v0.7.12) finished last and
overwrote the release with a stale binary, even though the repo HEAD
was at v0.8.0.

- Add concurrency group so concurrent releases cancel the older one
- Add workflow_dispatch trigger for manual re-runs

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-23 13:52:17 -05:00
A
c76d930046
fix: prevent GCP delete from hanging on interactive project prompt (#1813)
When deleting a GCP instance, resolveProject() could trigger an
interactive prompt ("Use project?") that collides with the deletion
spinner, causing the command to hang indefinitely. This happened when
instance metadata was missing the project (pre-ee653ca instances) or
when GCP_PROJECT was set to an empty string.

Fix: run resolveProject() in non-interactive mode during deletion so it
auto-accepts the gcloud config default. Also fail fast instead of
showing an interactive picker when no project is available.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-23 13:52:16 -05:00
A
463c7a2efb
feat: add --custom flag for machine type/region selection (#1810)
* feat: add --custom flag for interactive machine type/region selection

By default, all clouds now skip size/region prompts and use sensible
defaults for faster provisioning. The --custom flag enables interactive
pickers on all clouds, unifying the previously inconsistent behavior
where some clouds always prompted and others never did.

- AWS: promptRegion/promptBundle gated on SPAWN_CUSTOM
- GCP: promptMachineType/promptZone gated on SPAWN_CUSTOM
- Fly: promptVmOptions gated on SPAWN_CUSTOM
- Hetzner: new promptServerType/promptLocation with type/location arrays
- DigitalOcean: new promptDropletSize/promptDoRegion with size/region arrays
- Daytona: new promptSandboxSize with cpu/memory/disk presets
- Sprite: no change (managed platform, no meaningful size options)
- --custom + --headless is an error (incompatible modes)
- Version bump to 0.8.0 (new feature)

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

* style: fix biome format violations in --custom flag code

Auto-format object literals in arrays (expand to multi-line), wrap
long console.error line, and expand inline array in test assertion.

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

---------

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-23 12:29:51 -05:00
A
b51b5aa11e
feat: configure zeroclaw for full autonomy in spawned VMs (#1811)
Adds ~/.zeroclaw/config.toml with autonomy settings (equivalent to
Claude Code's dangerouslySkipPermissions) so zeroclaw runs without
approval prompts inside sandbox VMs.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-23 12:29:48 -05:00
A
ee653ca4a6
fix: persist GCP zone/project in connection metadata for deletion (#1809)
GCP delete was re-prompting for project/zone because saveVmConnection
didn't save metadata. Now createInstance passes zone and project as
metadata, and mergeLastConnection reads it back into history.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-23 08:17:44 -08:00
A
d961df7f61
fix: wrap fly ssh console -C command in bash -c for session resume (#1806) (#1807)
When resuming a Fly.io session via 'spawn list' → Enter, cmdEnterAgent
passed the stored launch command directly to 'fly ssh console -C'. The
stored command uses shell builtins (source), operators (;), and export
statements that require a bash interpreter. Without a shell wrapper,
fly exec'd the command directly (no shell), causing failures.

The fix wraps the command in `bash -c '...'`, matching the pattern already
used by interactiveSession(), runServer(), runServerCapture(), and
uploadFile() — all of which consistently wrap fly -C arguments in bash.

Fixes #1806

Agent: issue-fixer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 10:22:37 -05:00
A
493ca7a1cd
fix: use SSH_INTERACTIVE_OPTS in interactiveSession() for hetzner/do/aws/gcp (#1805)
BatchMode=yes in SSH_BASE_OPTS actively blocks TTY prompts in interactive
sessions. Four cloud providers (Hetzner, DigitalOcean, AWS, GCP) were
using SSH_BASE_OPTS in their interactiveSession() functions despite
SSH_INTERACTIVE_OPTS being purpose-built for this (added in PR #1795).
This also adds Compression=yes, IPQoS=lowdelay, StrictHostKeyChecking=accept-new,
and the -t flag (already included), aligning with the commands.ts reconnect path.

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 09:25:26 -05:00
A
9aa41bfa67
fix: reject non-ASCII filenames in install.sh download validation (#1802)
Fixes #1800 - explicit ASCII check blocks Unicode lookalike bypass

Agent: ux-engineer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 08:35:24 -05:00
A
180b19d9f4
fix: reduce SSH interactive lag (GSSAPIAuthentication + TCPKeepAlive) (#1795)
* fix: reduce SSH interactive lag with GSSAPIAuthentication=no and TCPKeepAlive=no

GSSAPIAuthentication causes latency on every SSH interaction when
the server doesn't support Kerberos (i.e. always for our VMs).
TCPKeepAlive is redundant with ServerAliveInterval and can cause
retransmission issues through NAT/firewalls.

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

* fix: use SSH_INTERACTIVE_OPTS for all interactive sessions

The reconnect (cmdConnect) and agent launch (cmdEnterAgent) paths
were using bare SSH with only StrictHostKeyChecking, missing all
performance flags. Now they use SSH_INTERACTIVE_OPTS which includes:

- GSSAPIAuthentication=no (skip Kerberos timeout)
- TCPKeepAlive=no (avoid NAT retransmission issues)
- ServerAliveInterval=15 (encrypted keepalives)
- Compression=yes (reduce latency on slow/distant links)
- IPQoS=lowdelay (mark packets for low-latency treatment)

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

---------

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-23 03:20:49 -05:00
A
61fc36c557
refactor: enforce isString/isNumber type guards via GritQL lint rule (#1796)
Add `lint/no-typeof-string-number.grit` plugin that bans raw
`typeof x === "string"` and `typeof x === "number"` checks. All
occurrences replaced with `isString(x)` / `isNumber(x)` from
`shared/type-guards.ts`.

This makes narrowing patterns consistent and scannable — every
type check uses the same vocabulary project-wide.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-23 03:20:42 -05:00
A
a26d27f139
style: enforce biome format across codebase, add CI check (#1794)
Run `biome format --write` on all 98 source files (38 needed fixes).
The main change: object literals and long argument lists are now expanded
onto separate lines per Biome's `"expand": "always"` setting, making
code much easier to scan on narrow screens.

Add `biome format` check step to CI lint workflow so formatting
regressions are caught on every PR.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 23:32:12 -08:00
A
86cae8ee32
feat: add SSH key discovery & selection across all providers (#1792)
All 4 providers (Hetzner, DO, AWS, GCP) hardcoded ~/.ssh/id_ed25519 and
duplicated key generation logic. Users with id_rsa or custom-named keys
got unwanted new keys generated. This adds a shared ssh-keys module that:

- Scans ~/.ssh/ for all valid key pairs (matching pub + private files)
- With 0 keys: generates id_ed25519 (same as before)
- With 1 key: uses it silently
- With 2+ keys: prompts multiselect (all selected by default)
- Caches the result at module level for the session
- Centralizes getSshFingerprint() (was duplicated in Hetzner + DO)
- All providers now pass -i flags for selected keys to SSH commands

Net -152 lines of duplicated code across providers.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 23:22:50 -08:00
A
b802dfbc16
refactor: extract saveLaunchCmd to history.ts (#1789)
Eliminates copy-paste of saveLaunchCmd across 8 cloud provider files.
The local/local.ts copy had already diverged (using Bun.write() instead
of writeFileSync()), confirming the maintenance risk.

Fixes #1786

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-22 23:11:14 -08:00
A
ed7ebedde4
fix: clean up stdin/TTY state before interactive session handoff (#1790)
After provisioning, @clack/prompts and readline leave stdin with stale
listeners, raw mode, and buffered input. This causes flaky keyboard input
in the interactive SSH session. Add prepareStdinForHandoff() that closes
the shared readline, removes all stdin listeners, resets raw mode, and
pauses stdin before launching the child process.

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-23 01:56:49 -05:00
A
4a45a2c9c1
refactor: extract saveVmConnection to history.ts (#1788)
Eliminates copy-paste of saveVmConnection across 6 cloud provider files.
Fixes #1787

Agent: complexity-hunter

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 01:56:48 -05:00
A
3a554a5ada
fix: replace instanceof Error with hasMessage() duck-typing in SSH retry paths (#1785)
wrapSshCall (agent-setup.ts) and spriteRetry (sprite.ts) used `instanceof
Error` to extract error messages — an anti-pattern explicitly avoided
throughout the rest of the codebase (consistent with comments in index.ts,
commands.ts, manifest.ts, etc.). When errors cross module or bundling
boundaries, instanceof returns false even for real Error objects, causing
err.message to fall back to String(err) and producing `[object Object]` in
the retry logs. Uses `hasMessage()` from shared/type-guards for consistent
duck-typed narrowing.

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 00:57:03 -05:00
A
fa34d29b7e
fix: explicitly pass SSH identity file for DigitalOcean connections (#1784)
DigitalOcean SSH was failing with "Permission denied (publickey)" because
the SSH client was not explicitly told which identity file to use. When
users have multiple SSH keys or an SSH agent with different keys loaded,
SSH may try the wrong key first and fail — especially with BatchMode=yes
which suppresses interactive fallbacks.

The fix adds `-i ~/.ssh/id_ed25519` to SSH_OPTS (matching AWS's approach)
and passes sshKeyPath to the shared waitForSsh utility, ensuring the
correct key is always used for both the handshake wait and all subsequent
SSH/SCP commands.

Fixes #1783

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 00:11:59 -05:00
A
3988ffe90e
fix: check exitCode in openBrowser() and reinit readline after @clack/prompts (#1782)
openBrowser() never checked the exitCode from Bun.spawnSync, so it silently
returned success even when the browser command failed (headless VMs, no
DISPLAY). Now checks exitCode and always shows the URL as fallback.

selectFromList() uses @clack/prompts which creates/destroys its own readline
on stdin. After it finishes, the shared readline in ui.ts can be corrupted
(Bun #1707). Now explicitly closes and nulls the shared readline after
@clack/prompts returns so the next prompt() call gets a fresh one.

Fixes #1770

Agent: ux-engineer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-22 23:21:06 -05:00
A
abe1f33318
fix: use sentinel values in Daytona saveVmConnection (#1778)
Daytona was writing raw sshHost/sshToken as ip/user in last-connection.json.
history.ts:mergeLastConnection() calls validateUsername() on the user field,
rejecting SSH tokens (>32 chars) and deleting the connection file. This meant
spawn list/delete/resume never showed Daytona sandboxes.

Replace with the "daytona-sandbox" sentinel (already in CONNECTION_SENTINELS
in security.ts:31 and checked by all relevant handlers in commands.ts) — the
same pattern Fly.io and Sprite use for their provider-managed SSH.

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 19:41:51 -08:00
A
16c8a2b90b
fix: use getSpawnDir()/getConnectionPath() in all cloud providers (#1774)
Fixes #1769

All 8 cloud providers hard-coded `${process.env.HOME}/.spawn` for
connection data, bypassing the SPAWN_HOME env var support in history.ts.
Replaced all 16 occurrences with getSpawnDir() and getConnectionPath().

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 19:27:21 -08:00
A
ef2748069f
fix: use child_process.spawn for interactive sessions to fix TTY passthrough (#1780)
Bun.spawn() doesn't properly restore TTY state after @clack/prompts
manipulates stdin raw mode during provisioning. This causes laggy/broken
keyboard input in SSH sessions launched via `spawn run`. Node's
child_process.spawn() with stdio: "inherit" does a clean FD handoff,
matching the already-working pattern in runInteractiveCommand() used by
`spawn ls` resume.

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 19:22:17 -08:00
A
0843c5e708
feat: shared SSH wait utility with TCP pre-check and stderr capture (#1779)
Replace 5 duplicated SSH wait implementations (AWS, DO, Hetzner, GCP,
Sprite) with a shared two-phase utility in cli/src/shared/ssh.ts:

- Phase 1: cheap TCP probe (2s intervals) until port 22 opens
- Phase 2: full SSH handshake (3s intervals) with stderr capture
- Adds BatchMode=yes to prevent interactive prompt hangs
- Removes ~220 lines of duplicated sleep/SSH_OPTS/waitForSsh code

Daytona (token auth) and Fly (WireGuard) left unchanged — too different.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 19:17:09 -08:00
A
b62dc1af33
feat: ban as type assertions, add runtime schema validation with valibot (#1775)
* fix: resolve all biome lint warnings across the codebase

- Replace all noExplicitAny with proper types (unknown, Record<string, unknown>)
- Fix useBlockStatements in picker.ts (braceless if)
- Fix useNumberNamespace in picker.ts (parseInt → Number.parseInt)
- Codebase now passes biome lint with 0 errors and 0 warnings

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

* feat: ban `as` type assertions, add runtime schema validation with valibot

Replace all ~170 unsafe `as` type assertions across the entire codebase
(production + tests) with runtime-validated alternatives:

- Add GritQL biome plugin (`no-type-assertion.grit`) that bans all `as`
  casts except `as const`
- Add valibot for schema-validated JSON parsing (`parseJsonWith`)
- Add shared utilities: `parse.ts` (schema parsing), `type-guards.ts`
- Replace `as` casts in all 5 cloud modules (aws, daytona, hetzner,
  digitalocean, fly) with valibot schemas + type guards
- Replace `as` casts in shared modules (manifest, update-check, oauth,
  commands, history, ui)
- Replace `as any` in all 26 test files with proper `new Response()`
  mocks and typed variables
- Add 13 tests for parseJsonWith/parseJsonRaw
- Add "Embrace Bold Changes" culture rule to CLAUDE.md
- Bump version 0.6.19 → 0.7.0

1859 tests pass, 0 lint errors across 95 files, bundle +6KB from valibot.

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

* refactor: move GritQL plugin into cli/lint/ directory

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

---------

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 18:50:53 -08:00
A
f0a70b66a1
feat: multi-line layout for ls/delete — name first, then agent · cloud · time (#1777)
Entries in `spawn ls` and `spawn delete` now display as two lines:
  - Line 1: spawn name (bold)
  - Line 2: Agent · Cloud · relative time

Removes SSH connection info and prompt previews from the list display
to keep it clean and scannable.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 18:33:06 -08:00
A
b67c6a30e1
fix: use minimal cloud-init tier for Claude Code (#1776)
The `installClaudeCode()` SSH step already handles Node.js and Claude Code
installation with retries and fallbacks, making the cloud-init Node/npm
install redundant. Switch to "minimal" so cloud-init only installs
curl/unzip/git/ca-certificates — finishing faster and eliminating the
duplicate install path.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 18:18:43 -08:00
A
15d769dfa6
fix: remove git dependency from install script to avoid macOS Xcode CLT trigger (#1773)
macOS ships a /usr/bin/git shim that triggers a ~1.5GB Xcode CLT download
when invoked. The install script's `command -v git` check was fooled by
this shim, causing the script to hang or silently fail on fresh macOS.

Removes the git clone path entirely — the curl-based download is fast,
reliable, and has zero external dependencies beyond curl and bun.

Closes #1768

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 17:39:54 -08:00
A
ec210e37af
fix: Result monad for retry logic — prevent duplicate server creation (#1771)
* fix: Result monad for retry logic — prevent duplicate server creation

SSH exit 255 after an interactive session caused runWithRetries to retry
the entire bash script, creating duplicate servers. The old withRetry
also blindly retried all errors including timeouts where the remote
command may have already completed.

Introduces a Result<T> monad (Ok/Err) so callers explicitly signal
whether a failure is retryable (return Err) or fatal (throw). Adds
wrapSshCall() that classifies SSH errors: transient connection failures
are retryable, timeouts are not. Removes retry loop from the top-level
script runner entirely since it spans server creation + interactive
session.

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

* docs: mandate draft-PR-first workflow for all changes

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

* fix: add biome lint to CI and pre-commit hook, fix lint violations

- Add Biome lint job to .github/workflows/lint.yml
- Add TypeScript lint check to .githooks/pre-commit
- Fix useBlockStatements violations in ui.ts and tests
- Add biome lint to CLAUDE.md "After Each Change" checklist

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

* refactor: rename Result.value to Result.data

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

* fix: clean up stale pre-commit hook

- Remove dead check for deleted functions (write_oauth_response_file,
  create_oauth_response_html) — they no longer exist in the codebase
- Fix early exit skipping Biome lint when no .sh files are staged
- Replace echo -e with printf (the hook was using the pattern it bans)

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

* fix: resolve biome lint errors blocking CI

- Fix useImportType: import { type Result } → import type { Result }
- Fix noUnusedImports: remove unused KNOWN_FLAGS import
- Fix noUnusedTemplateLiteral: template literal → string literal

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

---------

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 20:39:42 -05:00
A
2413db6ade
fix: truncate picker lines to terminal width to prevent redraw corruption (#1772)
Long labels (e.g. "Claude Code on GCP Compute Engine -- spawn-trial-000-ahmed")
wrap to multiple rows, but the redraw logic uses a fixed line count to cursor-up.
This causes old content to pile up on every arrow-key press.

Query terminal width via `stty size` and truncate all lines to fit within
a single row, with a 1-char margin to prevent auto-wrap edge cases.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 17:22:46 -08:00
A
8112276121
feat: add delete sub-menu (destroy/remove) and spawn kill alias (#1765)
Pressing `d` in the server picker now shows a sub-menu:
- Destroy server: hard delete (destroys cloud VM + marks deleted)
- Remove from history: soft delete (removes entry, no cloud API call)
- Cancel: go back to picker

Also adds `kill` as an alias for `spawn delete`.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 15:23:49 -08:00
A
97992dc6a2
feat: add retry logic for failure-prone orchestration operations (#1764)
Agent installation, config upload, env setup, and agent configuration
can all fail transiently due to network flakiness or SSH instability
on fresh VMs. Add a shared withRetry() helper and wrap these operations
with 2-attempt retries to improve reliability without over-engineering.

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 15:20:16 -08:00
A
63bce1bd04
security: sanitize TERM env var in interactiveSession to prevent shell injection (#1763)
All 6 cloud providers interpolated process.env.TERM directly into shell
commands without validation. A malicious TERM value (e.g., containing
$(cmd)) would execute on the remote server, potentially exfiltrating
OPENROUTER_API_KEY and other credentials.

Add sanitizeTermValue() allowlist (alphanumeric, dots, hyphens, underscores)
to cli/src/shared/ui.ts and apply it in all interactiveSession functions.

Agent: security-auditor

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-22 18:11:09 -05:00
A
c958d3d41b
feat: unify list/delete commands with inline delete picker (#1762)
Both `spawn list` and `spawn delete` now share a single interactive
picker (`activeServerPicker`) backed by `getActiveServers()`. Pressing
`d` in the picker triggers inline delete-and-refresh without leaving
the list. Failed deletions now mark entries as deleted so users aren't
stuck with phantom servers they can't clear.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 18:10:49 -05:00
A
986a6ff371
fix: add remote path validation to GCP uploadFile (missing vs all other providers) (#1760)
All 6 other cloud providers (Fly, Hetzner, DigitalOcean, AWS, Sprite, Daytona)
validate remotePath with an allowlist regex before passing it to scp. GCP's
uploadFile had no validation at all, breaking the defense-in-depth pattern.

Adds the same allowlist check (^[a-zA-Z0-9/_.~$-]+$) plus dotdot check.
The regex includes $ to allow $HOME prefix paths used by agent-setup.ts.

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-22 18:10:28 -05:00
A
545ddafe4a
fix: extract flags module to fix KNOWN_FLAGS drift in tests (#1757)
KNOWN_FLAGS in unknown-flags.test.ts was copy-pasted from index.ts and
was missing the --name flag, causing silent test gaps. Extract
KNOWN_FLAGS, findUnknownFlag, and expandEqualsFlags into a new flags.ts
module so tests import the real source of truth.

- Create cli/src/flags.ts with KNOWN_FLAGS, findUnknownFlag, expandEqualsFlags
- Update index.ts to import from flags.ts (checkUnknownFlags now uses findUnknownFlag)
- Update unknown-flags.test.ts to import from flags.ts instead of copy-pasting
- Add tests for --name flag, KNOWN_FLAGS completeness, and expandEqualsFlags
- Bump CLI version to 0.6.15

Fixes #1744

Agent: test-engineer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-22 18:10:07 -05:00
A
7e7d4aa3d7
fix: add SSH keepalives, increase cloud-init patience, simplify openclaw launch (#1761)
- Add ServerAliveInterval=15 + ServerAliveCountMax=3 to SSH_OPTS on all
  clouds (DO, Hetzner, AWS, GCP) to prevent silent TCP drops during long
  idle periods (e.g. waiting on slow LLM API calls). Daytona already had
  these.
- Increase DigitalOcean cloud-init fallback poll from 6×5s (30s) to
  20×5s (100s) so full-tier installs (build-essential + bun + node)
  have time to finish when the streaming tail path fails.
- Replace `source ~/.zshrc` with explicit PATH export in openclaw launch
  command to avoid side effects from zshrc inside bash -l.

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 14:54:35 -08:00
A
fdd6a9b6c3
chore: harden biome lint rules and auto-fix codebase (#1759)
* chore: harden biome lint rules and auto-fix codebase

Add strict biome rules for better TypeScript code quality:
- useBlockStatements: enforce braces on all control flow
- useConst: prefer const over let
- useNodejsImportProtocol: require node: prefix for builtins
- noUnusedImports/Variables: error (warn in tests)
- noExplicitAny: warn in source, off in tests
- noDoubleEquals, noAssignInExpressions, noFallthroughSwitchClause
- useNumberNamespace (Number.isNaN over isNaN)
- noImplicitAnyLet, noInferrableTypes, noUselessElse

Auto-fixed 55 files. Tests relaxed for any/unused patterns.

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

* chore: enable biome formatter with expand: always for brace newlines

Enable biome formatter with:
- expand: "always" — braces on their own lines
- indentStyle: space, indentWidth: 2
- lineWidth: 120
- arrowParentheses: always
- trailingCommas: all
- semicolons: always

82 files reformatted. All 1819 tests pass.

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-02-22 14:37:47 -08:00
A
f3a2b85b5b
fix: always confirm cloud resource name with user, even when SPAWN_NAME is set (#1758)
When the CLI collects a display name (SPAWN_NAME), each cloud now shows
the kebab-case derivative as the default in the resource name prompt
instead of silently accepting it. Users can hit Enter to accept or type
an override. Non-interactive mode still skips the prompt.

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 14:25:34 -08:00
A
7c37a793de
fix: eliminate duplicate name prompts, use cloud-native terminology (#1755)
* fix: eliminate duplicate name prompts, use cloud-native terminology

Users were prompted for a name up to 4 times per spawn. Now each cloud
has a single prompt using its native resource terminology (e.g. "Hetzner
server name", "Fly machine name") and getServerName() returns the
already-collected name silently instead of re-prompting.

Closes #1753

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

* fix: never use bare "spawn" as default name, always append random suffix

Extract defaultSpawnName() helper to shared/ui.ts that generates
"spawn-xxxx" with a random suffix. All cloud modules now use it
instead of bare "spawn" for every fallback path.

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

---------

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 14:20:47 -08:00
A
d1b6a20535
fix: update test to match canonical path cleanup in install.sh (#1756)
clone_cli() now uses rm -rf "${canonical_repo}" (the resolved real
path) instead of "${repo_dir}" for safer cleanup. Test assertion
updated to match.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 14:17:04 -08:00
A
7b021fb1f5
fix: set TERM and use login shell for interactive SSH sessions (#1754)
SSH interactive sessions ran the agent command in a non-login,
non-interactive shell — .bashrc/.profile weren't sourced and TERM
wasn't always set, making the shell feel broken (no colors, bad
line editing, missing env).

Fix for all 6 SSH-based clouds (DO, Hetzner, AWS, GCP, Fly, Daytona):
- Forward local TERM (default xterm-256color) to the remote
- Use `exec bash -l -c` for a proper login shell

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 14:14:13 -08:00
A
a67b9c7a75
security: allowlist filenames and resolve symlinks in cli/install.sh (#1751)
Replace blocklist filename validation with a strict allowlist regex
(^[a-zA-Z0-9_-]+\.ts$) to prevent path traversal via encoding tricks
in GitHub API responses (#1749).

Use pwd -P for symlink-resolving canonicalization and delete via the
canonical path instead of the original variable to close the TOCTOU
gap in cleanup logic (#1750).

Agent: security-auditor

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-22 17:09:33 -05:00
A
c5e2790ea0
fix: symlink bun to /usr/local/bin in cloud-init for all providers (#1752)
After installing bun via curl in cloud-init userdata, bun lives in
~/.bun/bin/bun which isn't on the system PATH. Agent scripts use
#!/usr/bin/env bun and fail with "bun: not found". Symlink it into
/usr/local/bin so it's immediately available system-wide.

Applies to: AWS, DigitalOcean, GCP, Hetzner

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 17:09:28 -05:00
A
3e5cd2d076
fix: spawn fails with bun not found after install (#1748)
* fix: add ~/.bun/bin to shell rc files so spawn finds bun after install

The install script was only adding ~/.local/bin to shell profile files
(bashrc/zshrc/bash_profile), but not ~/.bun/bin. Since the spawn binary
uses #!/usr/bin/env bun as its shebang, bun must be in PATH for spawn
to work. After exec $SHELL, only dirs in rc files are available.

Now ensure_in_path() patches shell rc files for both ~/.local/bin (for
spawn) and ~/.bun/bin (for bun), and correctly checks both when deciding
whether to show "Run spawn" vs "exec $SHELL" instructions.

Fixes #1747

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

* fix: quote dir in fish_add_path to prevent command injection

Address security review feedback on PR #1748 — unquoted ${dir} in
fish command string could allow injection if HOME/BUN_INSTALL env
vars contain metacharacters.

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

---------

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-22 13:41:27 -08:00
A
57d4ee7eeb
fix: drop apt nodejs/npm, install Node 22 directly via n (#1746)
apt-get install nodejs npm pulls in hundreds of node-* packages
(libhwasan, node-jsonify, node-eslint-utils, etc.) adding 60-90s
to cloud-init. We immediately replace it with Node 22 via n anyway.

Fix: bootstrap n directly from curl and install Node 22 in one step.
No apt nodejs/npm needed.

Before: apt install nodejs npm → npm install -g n → n 22 (slow)
After:  curl n | bash -s install 22 (fast, no apt bloat)

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 12:40:22 -08:00
A
0a8e6a30ad
fix: symlink bun alongside spawn in /usr/local/bin during install (#1745)
spawn uses #!/usr/bin/env bun shebang, so bun must be in PATH for it
to run. When ~/.local/bin isn't in PATH and we symlink spawn into
/usr/local/bin, bun (typically at ~/.bun/bin/bun) isn't reachable,
causing "bun: not found" on first run. Now symlink bun too.

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 12:36:52 -08:00
A
9d3728fd8d
fix: add build-essential to node cloud-init tier (#1743)
* fix: add build-essential to node cloud-init tier

The "node" tier (used by claude, codex, kilocode) was missing
build-essential. Native npm packages that compile C/C++ addons
fail without it. The "full" tier had it but no agent uses "full".

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

* fix: upgrade openclaw to full cloud-init tier

Openclaw needs the most dependencies (build-essential, nodejs, npm,
bun) but was on the "bun" tier which only installed curl/unzip/git/zsh.
Switch to "full" which includes everything.

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

---------

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 12:27:11 -08:00
A
fd7c878e83
fix: replace broken buildDeleteScript with TypeScript cloud module calls (#1735)
`spawn delete` was broken for all non-Fly clouds (hetzner, digitalocean,
gcp, aws, daytona, sprite) because buildDeleteScript sourced
{cloud}/lib/common.sh files that were removed in the TypeScript rewrite.

Fix by removing buildDeleteScript entirely and rewriting execDeleteServer
to call each cloud's TypeScript destroyServer/destroyInstance directly,
following the same pattern already used for Fly.io. All clouds now use
their native TS module for auth + deletion with no shell dependency.

Fixes #1729

Agent: issue-fixer

Signed-off-by: L <6723574+louisgv@users.noreply.github.com>
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-22 12:22:08 -08:00
A
5aa8a50a94
fix: skip symlink when ~/.local/bin already in PATH, use Touch ID (#1742)
* fix: skip /usr/local/bin symlink when ~/.local/bin is already in PATH

Only symlink when actually needed, and never prompt for a password:
- If ~/.local/bin is in PATH, skip symlink (not needed)
- If not, try direct write to /usr/local/bin first
- Fall back to sudo only if passwordless (NOPASSWD, cached creds,
  or macOS Touch ID via pam_tid)
- Otherwise patch rc files and show `exec $SHELL`

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

* fix: prompt for password as last resort when symlinking spawn

Symlink priority: direct write → passwordless sudo (NOPASSWD/Touch ID)
→ prompt for password → fall back to exec $SHELL.

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-02-22 12:21:07 -08:00
A
bba0e9a7b4
fix: make spawn immediately available after install (#1738)
* fix: make spawn immediately available after install on fresh machines

The install script modified PATH internally to find bun, then checked
that same modified PATH to decide where to install and whether to warn.
On a fresh machine, ~/.local/bin wasn't in the user's real PATH, but
the script thought it was — so spawn was installed there with no
warning, and `spawn` was not found after install.

Fix: always install to ~/.local/bin, then symlink into /usr/local/bin
(which is universally in PATH). Also patch shell rc files for future
sessions. If symlinking fails, fall back to showing `exec $SHELL`.

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

* test: update install.sh tests for new symlink-based install

Remove find_install_dir tests (function removed), update ensure_in_path
tests for symlink + rc-patching behavior, fix section header reference.

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

* fix: replace [[ ]] with case for macOS bash 3.x compatibility

Addresses security review: [[ ... == */pattern ]] is bash 4.x syntax
that fails on macOS's default bash 3.2. Use POSIX case statements.

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-02-22 12:14:34 -08:00
A
30f3758902
fix: set HOME=/root in cloud-init userdata to prevent unbound variable (#1741)
DigitalOcean's cloud-init environment doesn't set HOME. Combined
with set -e, any $HOME or ~ reference (bun install, .bashrc writes)
fails with "HOME: unbound variable" and cloud-init silently aborts.

Fixed in both DigitalOcean and Hetzner (same pattern). AWS doesn't
use set -e so is unaffected.

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 12:13:56 -08:00