Commit graph

502 commits

Author SHA1 Message Date
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
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
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
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
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
A
dc21fa223b
fix: cloud-init streaming script bash syntax error (#1737)
.join("; ") produced invalid bash: &; after background command,
do; after for, then; after if. Use newline-joined string instead.

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:05:54 -08:00
A
b50b27141c
fix: stream cloud-init output instead of blind-polling on DigitalOcean (#1734)
Replace 60×5s blind poll loop ("Cloud-init in progress N/60") with
real-time streaming of /var/log/cloud-init-output.log via tail -f
over SSH. Users now see every apt-get, curl, and error as it happens.

Background checker exits as soon as .cloud-init-complete marker
appears. 5min timeout. Brief 30s fallback poll if streaming fails.

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 11:58:29 -08:00
A
ac5e8495b1
feat: customize cloud-init per agent to fix boot timeouts (#1733)
Agents declare their dependency tier (minimal/node/bun/full), and
cloud-init only installs what's needed. Lightweight agents like
OpenCode and ZeroClaw skip Node.js upgrade, Bun install, and
build-essential — saving 60-90s on boot and eliminating the
DigitalOcean cloud-init timeout.

- Add CloudInitTier type + cloudInitTier field to AgentConfig
- Add shared/cloud-init.ts: tier-to-packages mapping
- Update all 6 clouds (DO, Hetzner, AWS, GCP, Fly, Daytona)
- Bump CLI version to 0.6.8

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 11:43:45 -08:00
A
01ba7257ed
fix: don't re-prompt name for failed spawns, improve retry hint (#1719)
* fix: don't re-prompt name for failed spawns, improve retry hint

- Reuse existing spawn name when rerunning from `spawn list` or
  `spawn last` instead of prompting for a new name (#1712)
- Include --name flag in retry command hint when a spawn name
  was used, e.g. `spawn claude hetzner --name my-box` (#1709)
- Bump CLI version to 0.6.5

Fixes #1712
Fixes #1709

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

* test: add unit tests for buildRetryCommand --name flag

Cover the spawnName parameter added for issue #1709:
- with name and no prompt
- with name and short prompt
- with name and long prompt (prompt-file fallback)
- with undefined/empty name (no --name flag)
- verify --name appears before --prompt in output

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

* fix: quote --name value when spawn name contains spaces

Handle edge case where spawn names may contain spaces or quotes
by properly quoting and escaping the --name flag value.

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

* fix: always quote --name value to prevent shell injection

Always wrap spawn names in double quotes in the retry command hint,
not just when names contain spaces. This prevents shell metacharacters
(;, |, &, etc.) in spawn names from being interpreted if users copy
the displayed retry command.

Agent: issue-fixer
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 11:36:16 -08:00
A
60986e5a05
refactor: remove shared/common.sh and 27 subprocess-heavy test files (#1728)
shared/common.sh (3852 lines) was dead code — the entire architecture
was rewritten to TypeScript in cli/src/. No agent scripts source it
anymore. The only consumer was github-auth.sh which just needed 4
log functions (now inlined).

Remove 27 test files that spawned ~800+ real bash/bun subprocesses per
run (the root cause of slow bun test). Every shared-common-*.test.ts
file forked a real bash shell per test case to source shared/common.sh.
CLI subprocess tests spawned `bun run index.ts` per assertion. These
were integration tests, not unit tests.

Also removes:
- mock-tests CI job from test.yml (ran test/mock.sh which opens browser)
- Stale plan files referencing deleted infrastructure
- All CLAUDE.md/README.md references to the old lib/common.sh pattern

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 11:32:27 -08:00
A
738ad18fee
fix: add 5s delay between DigitalOcean and OpenRouter OAuth flows (#1727)
When both OAuth flows open browser tabs back-to-back, the user may
reactively close the second tab thinking it's a duplicate. Add a 5-second
pause with a message after DO OAuth completes, only when browser auth
was actually used (skipped for env var / saved token paths).

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 11:27:14 -08:00
A
5192912b68
fix: auto-update silently fails in multiple scenarios (#1725)
- Prevent recursive update check during install (SPAWN_NO_UPDATE_CHECK=1)
- Increase fetch timeout from 5s to 10s for slow/cold connections
- Add 1-hour failure backoff to avoid repeated failed update attempts
- Bump CLI version to 0.6.6

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 11:17:54 -08:00
A
f2010ce3bd
fix: add account:read scope to DigitalOcean OAuth flow (#1724)
OAuth token validation calls GET /v2/account which requires the
account:read scope. Without it, the token exchange succeeds but
validation fails with 403, falling through to manual token entry.

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 11:14:26 -08:00
A
be72d573e1
fix: resolve DigitalOcean token input validation bug (#1720)
Reuse a single readline interface across prompt() calls instead of
creating and closing a new one each time. In Bun, repeatedly calling
createInterface/close on the same stdin causes the "close" event to
fire immediately on subsequent interfaces, which resolved the prompt
with an empty string before the user could type — triggering "Token
cannot be empty".

Fixes #1707

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 11:08:01 -08:00
A
e527d79815
feat: DigitalOcean OAuth2 flow for automatic token provisioning (#1716)
* feat: add DigitalOcean OAuth2 flow for automatic token provisioning

Implements the OAuth2 authorization code flow for DigitalOcean as an
alternative to manual API token entry. The flow mirrors the existing
OpenRouter OAuth pattern using Bun.serve() for the local callback.

Changes:
- Add tryDoOAuth() with local Bun.serve callback, CSRF state, and
  code-for-token exchange via DO's /v1/oauth/token endpoint
- Add tryRefreshDoToken() for refreshing expired tokens without
  re-authorization
- Extend config persistence with refresh_token, expires_at, auth_method
- Modify ensureDoToken() flow: env var -> saved config (with refresh) ->
  OAuth browser flow -> manual paste fallback
- OAuth is gated on DO_OAUTH_CLIENT_ID and DO_OAUTH_CLIENT_SECRET env vars
- Add 37 tests covering config persistence, CSRF generation, code
  validation, token expiry, URL construction, and feature toggle
- Bump CLI version to 0.6.5

Closes #1715

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

* feat: hardcode DO OAuth credentials, remove env var gate

Embed client_id and client_secret as constants (same pattern as gh CLI,
doctl, gcloud). OAuth is now always available — no env vars needed.
Public CLI clients cannot keep secrets confidential; security comes from
the authorization code flow itself (user consent, localhost redirect,
CSRF state, single-use codes).

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

* feat: add droplet:delete scope for spawn delete support

The spawn CLI's destroyServer() calls DELETE /droplets/{id} which
requires the droplet:delete scope. All its required sub-scopes
(droplet:read, regions:read, sizes:read, actions:read, image:read)
were already present.

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 13:49:53 -05:00
A
32a54d317b
fix: re-apply npm prefix for kilocode/codex in shared agent-setup (#1713)
The npm-global prefix fix from PR #1699 was lost when agent configs
were refactored from gcp/agents.ts into shared/agent-setup.ts. Without
`npm config set prefix ~/.npm-global`, npm install -g uses the system
prefix (/usr/lib/node_modules) which fails with EACCES for non-root
users on GCP.

This also fixes kilocode's postinstall script (postinstall.mjs) which
uses require.resolve() to find @kilocode/cli-linux-x64 — when npm
writes to the system prefix, the postinstall can't write the binary
symlink into the package's bin/ directory.

Fixes #1698

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 13:49:51 -05:00
A
ad076688e9
refactor: deduplicate bun test suite, remove 5.3k redundant lines (#1717)
Delete 5 entirely-duplicate test files and trim 9 others where the same
bash functions were tested identically in multiple places. Every removed
test has a surviving canonical copy — zero coverage lost.

Deleted (all content duplicated elsewhere):
- shared-common-decomposed-helpers.test.ts
- shared-common-oauth-retry.test.ts
- shared-common-oauth-security.test.ts
- shared-common-server-retry.test.ts
- shared-common-token-provider.test.ts

79 files / 38k lines → 74 files / 33k lines

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 10:30:38 -08:00