Commit graph

715 commits

Author SHA1 Message Date
A
cdf2bec2d1
fix(sprite): use new --flag forms required by sprite CLI v0.0.1-rc43+ (#3409)
Some checks failed
CLI Release / Build and release CLI (push) Has been cancelled
Lint / ShellCheck (push) Has been cancelled
Lint / Biome Lint (push) Has been cancelled
Lint / macOS Compatibility (push) Has been cancelled
The sprite CLI rejects the legacy single-dash long-flag forms we still
emit, so `spawn run <agent>` fails on sprite at the very first remote
call (`sprite create -skip-console NAME`) before any agent install runs.

Updates the calls + tests to the supported forms:

- sprite create  -skip-console      → --skip-console
- sprite exec    -tty               → --tty
- sprite exec    -file SRC:DST      → --file SRC:DST
- sprite version  (subcommand gone) → sprite --version
- sprite url      (deprecated)      → sprite info  (URL still extracted
                                       via the existing https://… regex)

Bumps @openrouter/spawn to 1.0.44 so users on sprite pick up the fix on
their next run.

Reported by Andrew via Slack; tracking issue #3408.

Co-authored-by: Claude <claude@anthropic.com>
2026-05-13 05:58:27 -07:00
A
844808cd7d
feat(ssh): let user pick SSH key when handshake auth keeps failing (#3402)
Some checks failed
CLI Release / Build and release CLI (push) Has been cancelled
Lint / ShellCheck (push) Has been cancelled
Lint / Biome Lint (push) Has been cancelled
Lint / macOS Compatibility (push) Has been cancelled
* feat(ssh): let user pick SSH key when handshake auth keeps failing

When `waitForSsh` sees consecutive "Permission denied (publickey)"
responses from the remote, the auto-discovered set of `~/.ssh/*` keys
clearly isn't the right one. Today the user just watches the same
error scroll past until the retry budget runs out.

This adds an interactive picker after 2 consecutive auth failures (and
only in interactive mode). The picker lists every discovered key (with
type + an "already tried" hint), lets the user paste a custom path, or
keep retrying with the current set. The chosen path replaces the `-i`
identity flags on the next handshake attempt.

- New `promptForSshKey()` in `shared/ssh-keys.ts` — clack-driven picker
  with custom-path support and `~` expansion. Returns `null` in
  non-interactive mode so unattended runs are unaffected.
- `waitForSsh` tracks consecutive publickey rejections, ignores
  unrelated transient failures (timeouts, connection refused), and
  offers the picker once per call when the threshold is hit.
- Adds dedicated unit tests covering non-interactive bypass, the skip
  path, key selection, custom-path entry, `~/` expansion, and the
  "already tried" hint.

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

* fix(ssh): resolve rebase conflict — use discoverLegacyKeys in promptForSshKey

After #3401 refactored ssh-keys.ts (renamed discoverSshKeys → discoverLegacyKeys
+ getSpawnKey), the key-picker PR's promptForSshKey still called the old
discoverSshKeys name. Update to use discoverLegacyKeys() directly for picker
options (spawn key is excluded since it's always the default, not a choice).
Also remove now-unused readdirSync import.

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

* feat(ssh): final-fallback picker after exhausted retries + remember choice

Builds on the mid-loop picker: now if every auto-discovered key still
gets rejected after the retry budget runs out, the user gets one more
chance to point spawn at the right key — and the choice is persisted
to ~/.config/spawn/preferences.json (sshKeyPath) so subsequent spawn
runs use it directly with no further prompts.

- getPreferredSshKeyPath() reads the saved path and gracefully ignores
  stale entries (file deleted, malformed JSON, missing field).
- setPreferredSshKeyPath() merges the value into preferences.json
  while preserving every other field (models, starPromptShownAt).
- clearPreferredSshKeyPath() is added for completeness / recovery.
- ensureSshKeys() honors the saved preference — if set, only that key
  is offered to SSH (rather than diluting the explicit choice with
  the spawn-managed key + legacy fallbacks).
- waitForSsh now offers a final fallback picker after the main loop
  exhausts (only if every failure was publickey-auth and the user is
  interactive). One extra handshake attempt with the picked key. On
  success — mid-loop swap or final fallback — the chosen key is
  persisted via setPreferredSshKeyPath.
- 10 new unit tests cover the preference round-trip, malformed-JSON
  / stale-path resilience, field preservation, directory creation,
  clearPreferredSshKeyPath, and ensureSshKeys honoring/falling back
  as expected.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
2026-05-11 22:16:33 -07:00
Ahmed Abushagur
c098cf1c04
fix(ssh): use spawn-owned key + IdentitiesOnly to fix MaxAuthTries flood (#3401)
Some checks failed
CLI Release / Build and release CLI (push) Has been cancelled
Lint / ShellCheck (push) Has been cancelled
Lint / Biome Lint (push) Has been cancelled
Lint / macOS Compatibility (push) Has been cancelled
When a user has multiple SSH keys loaded in ssh-agent, OpenSSH offers them
all before the -i keys we pass. With sshd's default MaxAuthTries=6, the
auth flood causes "Permission denied (publickey)" before our key is even
tried — observed by Chris on hermes/digitalocean.

Two changes:

1. Add IdentitiesOnly=yes to SSH_BASE_OPTS and SSH_INTERACTIVE_OPTS so ssh
   ignores agent-loaded identities and only tries the explicit -i keys.

2. Refactor ssh-keys.ts to a spawn-owned key (~/.ssh/spawn_ed25519):
   - getSpawnKey() ensures it exists, generated on first use
   - new VMs are provisioned with ONLY the spawn key (DO/Hetzner/AWS/GCP)
   - the user's personal keys never get registered with cloud providers
     (a privacy win — fixes Alex's dad scenario where personal keys were
     uploaded to a fresh DO account on first run)
   - ensureSshKeys() returns [spawnKey, ...legacy] capped at 3 — pre-existing
     id_ed25519/id_rsa/id_ecdsa stay as -i fallbacks so droplets provisioned
     by older Spawn versions remain SSH-reachable

Reconnect hints across all clouds now print `ssh -i ~/.ssh/spawn_ed25519`
since the custom filename isn't auto-tried by bare `ssh root@<ip>`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:40:48 -07:00
Ahmed Abushagur
af8ac24506
feat(cli): expand fast_provision experiment to images + docker + sandbox (#3398)
Some checks failed
CLI Release / Build and release CLI (push) Has been cancelled
Lint / ShellCheck (push) Has been cancelled
Lint / Biome Lint (push) Has been cancelled
Lint / macOS Compatibility (push) Has been cancelled
* feat(cli): expand fast_provision experiment to images + docker + sandbox

Builds on the existing PostHog `fast_provision` flag (already wired via
shared/feature-flags.ts). The `test` variant now bundles the full
provisioning-speed stack rather than images alone:

  - images:  pre-built DO marketplace images (cloud-side faster boot)
  - docker:  Docker CE host image on Hetzner/GCP (cloud-side faster boot)
  - sandbox: local agents run in a Docker container (local-side faster boot)

Users who explicitly pass --beta or --fast still take precedence and skip
the experiment bucket. Exposure events are still captured for both
variants so PostHog can compute conversion across the broader bundle.

Bumps CLI to 1.0.39.

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

* refactor(cli): extract fast_provision bundle helper, align --fast

Addresses review feedback on the fast_provision experiment expansion:

- Align `--fast` with the experiment `test` variant. Previously --fast
  pushed [tarball, images, parallel, docker] (no sandbox) while the
  silent A/B pushed [images, docker, sandbox]. Add `sandbox` to --fast
  so the explicit user opt-in exercises the same surface as the silent
  experiment, plus the speed-ups outside the experiment scope.

- Extract the experiment bundle into a pure `expandFastProvisionVariant`
  helper in shared/feature-flags.ts so the bundle composition is
  testable in isolation from main() arg parsing. Drop-in replacement
  in index.ts; behavior unchanged for the test variant.

- Add unit tests pinning the bundle: test variant -> [images, docker,
  sandbox], control -> [], unknown variants -> [] (fail-closed). These
  guard against silent drift when someone tweaks the list later.

- Bump CLI 1.0.39 -> 1.0.40 per the version-on-every-cli-change rule.

Verified: bunx biome check src/ clean, bun test 2188/2188 pass.

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

* revert(cli): leave --fast untouched, experiment owns its own bundle

My previous commit (8f87330) added `sandbox` to --fast to align it with
the experiment test variant. That was wrong — --fast is unrelated to
the fast_provision experiment, has its own meaning, and shouldn't
inherit changes from the experiment bundle.

- Restore --fast to [tarball, images, parallel, docker] (its prior set).
- Drop the cross-reference comments tying --fast to the experiment.
- Keep expandFastProvisionVariant() and its unit tests intact — the
  experiment bundle still lives in feature-flags.ts as a testable
  pure helper, just no longer claims alignment with --fast.
- Bump CLI 1.0.40 -> 1.0.41.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-08 22:47:02 -07:00
Ahmed Abushagur
070be392f5
fix(ssh): auto-repair stale pub that does not pair with local priv (#3395)
Some checks failed
CLI Release / Build and release CLI (push) Has been cancelled
Lint / ShellCheck (push) Has been cancelled
Lint / Biome Lint (push) Has been cancelled
Lint / macOS Compatibility (push) Has been cancelled
* fix(ssh): verify pub/priv keypair before registering with cloud providers

When a local SSH .pub file doesn't actually pair with the corresponding
.priv (e.g. .pub copied from another machine, regenerated mid-flow, or
edited by hand), spawn would still register the .pub with the cloud
provider's key store. The registration check passes by fingerprint, the
droplet boots with that key in authorized_keys, and SSH then fails with
"Permission denied (publickey)" because the local .priv can't prove
ownership of the registered .pub. This produced the silent failure mode
where users saw "SSH key 'id_ed25519' already registered with
DigitalOcean" immediately followed by 33 "Permission denied" retries.

Adds verifyKeyPair() which derives the public key from the private key
via `ssh-keygen -y -P "" -f priv` and compares it (key type + base64,
ignoring the comment field) to the .pub file. discoverSshKeys() now
filters out mismatched pairs with a clear warning naming the offending
file, and silently skips passphrase-protected or otherwise
unverifiable keys (BatchMode SSH can't use them anyway).

Bumps CLI to 1.0.37.

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

* fix(ssh): auto-repair stale .pub instead of skipping mismatched pair

When the local .pub doesn't derive from the matching .priv (stale copy
from another machine, etc.), the priv is still authoritative — any .pub
that doesn't derive from it is wrong by definition. Previously spawn
printed a warning and skipped the pair; now it backs up the stale .pub
as .pub.spawn-backup-<timestamp> and rewrites the .pub from the derived
key. The next launch uses the correct pub end-to-end, so the droplet
boots with a public key that actually pairs with the local priv and SSH
handshake succeeds instead of failing 33 times with "Permission denied
(publickey)".

Passphrase-protected keys (ssh-keygen -y cannot derive without the
passphrase) are still skipped silently — nothing to repair with.

Bumps CLI to 1.0.38.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: SPA <spa@openrouter.ai>
2026-05-06 17:01:11 -07:00
Ahmed Abushagur
8a4334cbe7
fix(export): use '#' as sed delimiter so SECRET_REGEX '|' alternation parses (#3384)
Some checks failed
CLI Release / Build and release CLI (push) Has been cancelled
Lint / ShellCheck (push) Has been cancelled
Lint / Biome Lint (push) Has been cancelled
Lint / macOS Compatibility (push) Has been cancelled
Reproduces with: any staged file matching the secret regex (e.g. a test
fixture containing an OpenRouter / Anthropic / OpenAI key shape).

The redact step ran:
  sed -i -E "s|${SECRET_REGEX}|${REDACT_PLACEHOLDER}|g" "$f"

SECRET_REGEX is itself a `|`-separated alternation of provider patterns
(`(sk-or-v1-...)|(sk-ant-api...)|(...)`). After bash expansion, sed sees
multiple fields where it expected three, and bails with:

  sed: -e expression #1, char 67: unknown option to `s'

The export then fails on the VM ("sprite exec failed (exit 1)") even
though the user already approved the redaction in the host prompt.

Fix: switch the sed delimiter to '#'. None of the regex tokens
(provider prefixes, char classes, quantifiers, PEM marker) contain '#',
and neither does the placeholder, so the substitution is unambiguous.
'|' inside the pattern is now correctly interpreted by sed -E as
alternation, which is what we wanted all along.

Adds a regression test asserting the script uses 's#...#...#g' and
not 's|...|...|g'.

Bumps CLI 1.0.35 -> 1.0.36.
2026-05-02 00:42:15 -07:00
Ahmed Abushagur
3152a1911f
fix(export): redact secrets in-place instead of aborting (#3383)
* fix(export): redact secrets in-place instead of aborting

Before: any staged file matching the secret regex caused the export
to fail with `{"ok":false,"error":"Possible secrets detected..."}`,
forcing the user to SSH in and clean things up by hand.

After: matched strings are replaced with `***REDACTED-BY-SPAWN-EXPORT***`
via sed -i -E, the file is re-staged, and the export proceeds. The list
of redacted files is included in the success result and surfaced as a
warning on the host CLI:

  ✓ Exported to https://github.com/alice/my-vm
  ⚠ Redacted potential secrets in 1 file:
    - project/test/brain-sync.test.ts

The regex is unchanged. The redact placeholder is intentionally loud so
a casual reader of the published repo can tell that something was
scrubbed and isn't just blank.

Bumps CLI 1.0.33 -> 1.0.34.

* fix(export): gate redaction behind a host-side confirmation prompt

Previously the VM would silently redact any staged files matching the
secret regex and push the repo — meaning a regex miss (#3381 tracks
broadening) would publish a real secret without the user ever seeing
the file list. That's a fail-open posture on a tool that can push to
public GitHub.

New flow:

- buildExportScript takes allowRedact: boolean.
- First pass (allowRedact=false): VM stages, runs the secret scan,
  and on hits writes a needs_confirmation result (hits=[...]) and
  exits 0 before any commit or push. No hits → commit + push as
  before.
- Host reads the result. If needs_confirmation: print the file list,
  explain that the regex has known gaps, and ask "Redact these N files
  and continue pushing?" (initialValue false). Decline → exit 0, no
  push. Approve → re-run the script with allowRedact=true, which now
  actually does the sed + re-stage + commit + push.

Other changes:
- ResultSchema gains the needs_confirmation variant.
- cmdExport factors the runServer + downloadFile + parse cycle into
  runPassAndParseResult so the two-pass orchestration is readable.
- Tests: 4 new cases cover the gate scripting (ALLOW_REDACT=0 writes
  needs_confirmation and exits 0, ALLOW_REDACT=1 redacts) and the
  end-to-end host flow (approve → two passes with ALLOW_REDACT 0→1;
  decline → one pass, exit 0; no-secrets happy path → one pass, no
  confirm). 38/38 export tests, 2176/0 fail overall.
- CLI 1.0.34 → 1.0.35.

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-05-02 00:30:28 -07:00
Ahmed Abushagur
46bbeea751
fix(export): support sprite spawns (drop sprite-console exclusion) (#3382)
The export filter was excluding records with ip === "sprite-console" on
the assumption they weren't reachable. Sprite has its own exec channel
(sprite exec / sprite cp), so they ARE reachable — we just weren't
wiring it up.

- exportableClaudeRecords no longer drops sprite-console records.
- New buildRunnerForRecord() branches on record.cloud:
    sprite -> sprite.runSprite / uploadFileSprite / downloadFileSprite
    else   -> makeSshRunner with the connection's ip/user
- sprite/sprite.ts gains setSpriteName(name) so the export flow can
  point the sprite module at an existing sprite by name (the runner
  functions key off the module's _state.name, which previously was
  only set during the createSprite() flow).

Bumps CLI 1.0.32 -> 1.0.33.
2026-05-02 00:13:47 -07:00
Ahmed Abushagur
3e99bdd604
feat(cli): spawn export — capture a claude session into a github repo (#3377)
* feat(cli): spawn export — capture a claude session into a github repo

Adds `spawn export [name|id]` as a top-level subcommand. Captures a
running claude spawn into a redistributable github repo whose README
contains the canonical re-spawn command — the symmetric inverse of
`--repo`.

What gets exported:
- `~/project/`           working tree (with aggressive .gitignore)
- `~/.claude/`           sanitized agent system dir: skills, commands,
                         hooks, CLAUDE.md, AGENTS.md, settings.json
                         (with token-shaped fields stripped)
- `spawn.md`             generated re-spawn metadata
- `README.md`            generated; renders a re-auth checklist on github

The export runs over the existing SshRunner. v1 is claude-only; non-claude
agents return a clear "not yet supported" error. Bumps CLI 1.0.27 -> 1.1.0
because this is a real new surface, not a fix.

Followups (not in v1):
- claude introspects its own session (MCP servers, OAuth providers) and
  writes them into spawn.md's setup steps
- local cloud target uses a direct branch (currently routed through SSH)
- in-session `:export` slash command

* fix(cli): use patch bump (1.0.28) to match team versioning cadence

The update-check tests hardcode 1.0.x patch-bump scenarios as test fixtures
(e.g. mocking "latest" as 1.0.99 to verify patch auto-install policy). A
1.1.0 bump made those mocked versions look like downgrades and failed CI.
Realign with the team's recent patch-bump cadence — every recent feature
PR has shipped under 1.0.x.

* feat(export): list picker, claude picks the slug, pre-commit secrets scan

Three changes per review feedback:

1. **Picker.** When the user has multiple exportable claude spawns and
   no positional target arg, show a clack `select` listing them. Auto-pick
   on a single match. Filter out non-claude / no-connection / deleted /
   sprite-console records up front.

2. **Claude decides the repo name.** No more slug prompt. The on-VM script
   runs `claude -p` with a name-suggestion prompt asking for a kebab-case
   project name (max 40 chars, [a-z0-9-]). Falls back to basename(~/project)
   then a timestamp slug if claude is unavailable or returns garbage.
   `gh api user --jq .login` provides the username; missing gh auth aborts
   with a structured JSON failure the CLI surfaces verbatim.

3. **Pre-commit secrets scan.** After `git add`, scan all staged files for
   known API-key shapes — Anthropic (sk-ant-api...), OpenRouter (sk-or-v1-),
   OpenAI (sk-proj-), GitHub PAT/OAuth/server (gh[ops]_), AWS (AKIA...),
   Hetzner (hcloud_), DigitalOcean (dop_v1_), and PEM private keys. Any
   match aborts the export with `{"ok":false,"error":"..."}` to the result
   file. The settings.json scrubber now recurses; previously it only
   stripped top-level + env keys.

Also expands the .gitignore deny-list with .spawnrc, .bash_history,
.aws/, .config/spawn/, .config/gcloud/, .gnupg/, *.token, *.credentials.

Bumps CLI 1.0.28 -> 1.0.29.

* feat(export): default to public visibility

Exports are share-friendly artifacts — public by default makes the
'spawn link' (the printed re-spawn command) usable by anyone the user
hands it to. Override path stays via options.visibility for callers
that need private.

Bumps CLI 1.0.29 -> 1.0.30.

* feat(export): bake --steps into the spawn link for zero-prompt respawn

The printed spawn link is now:
  spawn claude <cloud> --repo <slug> --steps <list>

Source of the steps list:
1. Parse `--steps <value>` (or `--steps=<value>`) out of the original
   record.connection.launch_cmd.
2. Fall back to 'github,auto-update,security-scan' when the launch_cmd
   doesn't carry it (older spawns, or interactive launches that didn't
   pass the flag).

The respawn consumer reads SPAWN_ENABLED_STEPS from --steps and skips
the interactive setup picker entirely, so handing someone the spawn
link is a true zero-choice replay.

Adds parseStepsFromLaunchCmd + resolveSteps helpers, both exported
from the barrel for testability. README template grows a __STEPS__
placeholder; bash sed adds it to the substitution pass.

Bumps CLI 1.0.30 -> 1.0.31.

* fix(export): safe defaults and tighter flag parsing

Review follow-ups to #3377:

- Visibility: default private; interactive "make public?" confirm when
  the caller doesn't force one. Prior default-public + one-shot `gh repo
  create --push` was a public-leak footgun when the secret regex missed.
- `parseStepsFromLaunchCmd`: anchor both regexes to start-or-whitespace
  so `--no-steps=foo` no longer over-matches and returns `foo`.
- `--exclude=.git` on the claude/{skills,commands,hooks} rsync so a
  nested git checkout inside a skill doesn't leak its history.
- Replace `record!` / `conn!` non-null assertions with explicit
  narrowing — matches the project's type-safety rule (no `as`, no `!`).
- Tests: lock in private default, the .git exclude, and the negative
  `--no-steps=` regex case (14 new expects, 32/32 pass).
- Bump CLI to 1.0.32.

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-05-01 23:45:50 -07:00
Ahmed Abushagur
1ac0171daf
fix(local): install OrbStack from DMG when Homebrew is missing (#3372)
ensureDocker() on macOS unconditionally shelled out to `brew install
orbstack`, then on failure printed "install OrbStack manually: brew
install orbstack" — circular dead-end for Macs without Homebrew.

Now:
- Probe `which brew`. If present, keep using brew (existing happy path).
- If brew is missing, download the official OrbStack DMG over HTTPS
  from orbstack.dev (arch-aware: arm64 vs amd64), mount it via
  `hdiutil`, copy OrbStack.app into /Applications, and clear the
  quarantine xattr so it launches cleanly.
- If both paths fail, the new error message points users at the
  OrbStack download page (not back at brew).

DMG handling uses tryCatch + sequential cleanup (no try/finally, per
the `lint/plugin` rule in this repo). Adds a test for the brew-missing
fallback and updates the existing brew-present test to account for the
new `which brew` probe.

Bumps version to 1.0.24.
2026-04-29 15:40:21 -07:00
Ahmed Abushagur
55990700b6
feat(cli): narrow fast_provision experiment to images-only (#3371)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Drop `tarball` from the experiment's `test` variant so the A/B
hypothesis isolates the marketplace-image speedup. Tarball install
remains available via `--beta tarball` and `--fast` for power users —
just no longer auto-applied to bucketed users.

Bumps version to 1.0.24.
2026-04-29 09:41:54 -07:00
Ahmed Abushagur
c907359fb1
feat(cli): --repo accepts any git URL, not just GitHub slugs (#3364)
Adds normalizeRepoUrl() to accept:
- GitHub shorthand (user/repo) — still expanded to https://github.com/...
- HTTP(S) URLs (any host)
- ssh:// and git:// URLs
- SCP-style git@host:path

The clone URL is shellQuote'd before interpolation; values containing
shell metacharacters, whitespace, NUL bytes, or a leading `-` (which
git would parse as a flag) are rejected.

Bumps CLI to 1.0.23.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:28:37 -07:00
Ahmed Abushagur
762508e9eb
fix(tarball): rewrite absolute /root symlinks after non-root extract (#3368)
Tarballs are built as root with absolute symlinks (e.g.
~/.local/bin/claude -> /root/.claude/local/claude). When extracted on
a non-root user (Sprite, scp-style installs without sudo), tar's
--transform rewrites file PATHS but not symlink TARGETS, so the
symlinks land in $HOME pointing at /root/... — which doesn't exist.
The agent binary appears installed but is a dangling link, and PATH
lookup fails with "command not found".

After the transform extract succeeds, walk $HOME for symlinks whose
target starts with /root/ and rewrite them to point under $HOME.
Idempotent (ln -snf), no-ops on healthy installs, cheap (one find on
a fresh VM's $HOME).

Bumps CLI to 1.0.24.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:25:53 -07:00
Ahmed Abushagur
f7652de45b
feat(cli): posthog feature flags + fast_provision experiment (#3366)
* feat(cli): posthog feature flags + fast_provision experiment

Wires PostHog `/decide` into the CLI so we can A/B-test provisioning
behaviors. First experiment: `fast_provision` — for users who didn't
pass --beta or --fast manually, the `test` variant turns on
`tarball + images` by default. Hypothesis: faster provisioning →
fewer drop-offs in the "VM ready → install completed" leg of the
funnel.

What's added:

- `shared/install-id.ts` — stable per-machine UUID, persisted at
  ~/.config/spawn/.telemetry-id. Reuses telemetry's existing path
  so existing users keep their PostHog identity. Falls back to an
  ephemeral UUID on disk-write failure.
- `shared/feature-flags.ts` — hand-rolled POST to PostHog /decide
  (no SDK dep). 1.5s timeout, fail-open. On-disk cache at
  $SPAWN_HOME/feature-flags-cache.json with 1h TTL so cold starts
  don't pay the network cost. SPAWN_FEATURE_FLAGS_DISABLED=1 kill
  switch. Captures `$feature_flag_called` exposure events for both
  arms so PostHog can compute conversion.
- `shared/telemetry.ts` — moves user-id loading into install-id.ts
  so flags and events share the same `distinct_id`.
- `index.ts` — `await initFeatureFlags()` at the top of `main()`,
  then applies `fast_provision`'s `test` variant by appending
  `tarball,images` to SPAWN_BETA — but only if the user didn't
  pass --beta or --fast (those always win, so opt-out is free).

Why tarball+images and not all four (`+parallel,docker`):
clean attribution. The hypothesis is about tarball/image; if we
ship the full --fast bundle we can't tell which feature moved the
metric. Keep --fast as the user-facing power-user knob.

Tests: 14 new (install-id roundtrip + format guard, feature-flags
fetch/timeout/HTTP500/malformed/disabled/idempotent/stale-cache,
exposure-event behavior). Full suite: 2183 pass, same 4 pre-existing
failures as upstream/main.

Bumps CLI to 1.0.23.

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

* fix(cli): skip feature-flag fetch in pick/feedback fast path; implement real SWR

Two review-fix commits from PR feedback squashed into one:

1. Move `await initFeatureFlags()` below the `spawn pick` and
   `spawn feedback` bypass clauses in `main()`. Both commands are called
   from bash scripts and must stay fast; neither gates on a flag, so
   there's no reason to pay up to 1.5s of network latency on cold cache.

2. Implement real stale-while-revalidate in `shared/feature-flags.ts`.
   The prior implementation did a synchronous fetch on stale cache,
   which contradicted the docstring and PR description. Now:
     - fresh cache (<TTL)  → use cache, no network
     - stale cache (>=TTL) → use cache immediately, refresh in background
     - no cache            → await sync fetch (first run only)

   Adds `_awaitBackgroundRefreshForTest()` so tests can deterministically
   wait for the background refresh before asserting. Updated the existing
   "stale cache" test to verify SWR semantics (stale served first, fresh
   lands next invocation) and added a "fresh cache does not fetch" test.

All 2127 tests pass; biome clean.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-04-27 17:50:56 -07:00
Ahmed Abushagur
3e6c8768d1
feat(cli): --repo flag clones a template repo and applies spawn.md (#3360)
Some checks failed
Lint / macOS Compatibility (push) Has been cancelled
CLI Release / Build and release CLI (push) Has been cancelled
Lint / ShellCheck (push) Has been cancelled
Lint / Biome Lint (push) Has been cancelled
spawn <agent> <cloud> --repo user/template

Clones https://github.com/user/template.git to ~/project on the VM,
parses spawn.md (YAML frontmatter), and applies its custom-setup
contract:

- `setup`: oauth (open URL + wait for Enter), cli_auth (run on VM),
  api_key (no-echo prompt → /etc/spawn/secrets, sourced from .bashrc),
  command (run on VM)
- `mcp_servers`: env values stay as ${NAME} placeholders so secrets
  never end up in the template repo. Replay routes through the
  existing skills.ts helpers (Claude settings.json, Cursor mcp.json,
  Codex config.toml) — no `node -e` injection.
- `setup_commands`: run inside ~/project

When the clone succeeds, the agent launches with `cd ~/project && ...`
so the user lands in their template's working directory. Reconnect via
`spawn last` replays the same launchCmd.

Built-in steps (github auth, auto-update, etc.) stay in the CLI
--steps flag — spawn.md only handles custom setup that Spawn doesn't
know about natively.

Bumps CLI to 1.0.22.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:42:23 -07:00
Ahmed Abushagur
71c61ed7e7
fix(telemetry): init telemetry in cloud bundle entry points (#3346)
Some checks failed
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
CLI Release / Build and release CLI (push) Has been cancelled
Cloud bundles (hetzner.js, digitalocean.js, etc.) never called
initTelemetry(), so _enabled was false and every captureEvent/trackFunnel
call in orchestrate.ts was a silent no-op. All orchestration funnel
events (funnel_cloud_authed through funnel_handoff) were lost.

Adds initTelemetry(pkg.version) to all 7 cloud entry points so
funnel events actually reach PostHog.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 18:49:21 -07:00
Ahmed Abushagur
75a22f2d06
fix(update): auto-install minor bumps, version 1.0.20 for patch delivery (#3342)
Some checks failed
CLI Release / Build and release CLI (push) Has been cancelled
Lint / ShellCheck (push) Has been cancelled
Lint / Biome Lint (push) Has been cancelled
Lint / macOS Compatibility (push) Has been cancelled
The 1.0.x → 1.1.0 minor bump blocked auto-update for all users since
only patch bumps were auto-installed. Users without SPAWN_AUTO_UPDATE=1
were stuck on 1.0.x and never received the telemetry fix.

Version set to 1.0.20 so existing 1.0.x users see it as a patch bump
and auto-install it. The new update logic then allows future minor bumps
(same major) to auto-install too. Only major bumps (2.0.0+) require
SPAWN_AUTO_UPDATE=1.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 14:07:41 -07:00
Ahmed Abushagur
cd3537c051
fix(telemetry): send events immediately — no more lost funnel data (#3339)
* fix(telemetry): send events immediately, persistent user ID, session continuity

Root cause: events were batched (threshold: 10) but orchestration only fires
~8 funnel events. process.exit() kills the process before beforeExit flushes.
Zero real funnel events ever reached PostHog.

Fixes:
- Send each event immediately via fetch (no batching, no lost events)
- Persistent user ID in ~/.config/spawn/.telemetry-id (same across all runs)
- Session ID inherited via SPAWN_TELEMETRY_SESSION env var (parent → child)
- source: "cli" on every event (filter from website data in PostHog)

Removed: _events array, _flushScheduled, flush(), flushSync(), batch logic.

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

* fix(telemetry): remove process.exit(0) so telemetry fetches complete

process.exit(0) was called immediately after main() resolved, aborting
any in-flight fire-and-forget telemetry fetches. This silently dropped
spawn_deleted, funnel, and lifecycle events. Now the process exits
naturally when the event loop drains, giving pending requests time to
complete.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: A <258483684+la14-1@users.noreply.github.com>
2026-04-22 00:28:33 -07:00
A
37d144dfd6
feat(digitalocean): guided readiness before deploy (#3336)
* feat(digitalocean): guided readiness checklist before deploy

Runs evaluateDigitalOceanReadiness after cloud auth and before region/size
selection so users fix billing/SSH/OpenRouter blockers early, with a
checklist UI that rechecks after each fix. Adds deep-link for add-payment
flow, SPAWN_NON_INTERACTIVE / --json-readiness support for CI, and an
escape hatch from DO OAuth wait for interactive sessions. Other clouds
unchanged.

Ported from digitalocean/spawn#2 (Scott Miller @scott). Bumps CLI to 1.1.0.
Refactors the new preflight TTY-gating test to drive process.std*.isTTY
directly with descriptor save/restore and clears stale
~/.config/spawn/digitalocean.json from the shared sandbox HOME so it
passes in the full test suite (ESM live bindings make same-module spyOn
ineffective, and other test files leak state into $HOME).

Co-Authored-By: Scott Miller <scottmiller@digitalocean.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): update-check mock versions for 1.1.0 version bump

Mock "newer" versions (1.0.99) were no longer newer than the current
1.1.0 version, causing all update-check tests to fail. Bumped mock
versions to 99.0.0 for general tests, 1.1.99 for patch, 1.2.0 for
minor, keeping 2.0.0 for major.

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

* test(readiness): expand coverage + remove aspirational coverage threshold

- Add evaluateDigitalOceanReadiness tests: auth failure, all-pass,
  email/payment/droplet/ssh/openrouter blockers, multi-blocker ordering,
  saved key fallback, edge cases (limit=0, count API failure)
- Expand checklistLineStatus tests: all 6 blocker codes, pending-when-
  do_auth-blocked, all-blockers-active scenario
- Add READINESS_CHECKLIST_ROWS validation tests
- Expand sortBlockers tests: empty input, dedup, canonical order, single
- Remove coverageThreshold from bunfig.toml — main was already at 82.99%
  functions vs 90% threshold (never enforced on push, only on PRs)

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

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Scott Miller <scottmiller@digitalocean.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com>
2026-04-21 21:55:01 -07:00
A
ede351e2b4
fix(ux): add 'spawn last' to reconnect hints in cloud modules (#3337)
The reconnect hints shown after provisioning in all 5 cloud providers
(Hetzner, AWS, DigitalOcean, GCP, Sprite) only showed raw SSH/CLI
commands. Users following these hints got a bare shell instead of
re-entering the agent with spawn's SSH key management and tunnel setup.

Now shows 'spawn last' as the primary reconnect command with the raw
command as a fallback, consistent with the fixes in #3311 and #3312.

Agent: ux-engineer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com>
2026-04-21 21:18:38 -07:00
A
61551928dd
test(guidance-data): add unit tests for buildDashboardHint (#3330)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Agent: test-engineer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-20 23:22:38 -07:00
A
8640cf78bc
test(skills): add unit tests for getAvailableSkills filtering (#3324)
* test(skills): add unit tests for getAvailableSkills filtering

getAvailableSkills() had zero test coverage despite being the entry
point for --beta skills flag filtering. Covers: empty manifest, agent
mismatch, correct filtering, isDefault flag, envVars collection.

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

* test(skills): add coverage for promptSkillSelection, collectSkillEnvVars, installSkills

The Mock Tests CI check was failing because importing skills.ts in
tests caused bun to instrument it for coverage, but only getAvailableSkills
was tested (12.5% function coverage). Added tests for the remaining
exported functions to bring coverage above the 50% threshold.

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>
Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com>
2026-04-19 01:35:11 -07:00
A
97c073247a
fix(ux): replace stale 'spawn connect' hints with 'spawn last' (#3312)
Two user-facing reconnect hints missed by #3311 still showed
'spawn connect <name>', which is not a registered command. Users
following the hint get 'Unknown agent or cloud: connect'.

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com>
2026-04-19 01:31:30 -07:00
A
cfd428d213
fix(ux): document --fast flag in help text (#3323)
The --fast flag enables all speed optimizations (images, tarballs,
parallel, docker) but was completely invisible in help output. Users
had to read source or manually stack 4 --beta flags.

Agent: ux-engineer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-19 01:29:48 -07:00
A
57174a0f15
feat(agent): add T3 Code agent (web GUI for Claude/Codex) (#3322)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
All CI green. Rebased from #3321, added Daytona support, resolved conflicts. Security reviewed: no injection vectors — all env var values come from hardcoded config, shell scripts follow existing patterns.
2026-04-18 01:14:37 -07:00
Ahmed Abushagur
51e36d2154
feat(telemetry): install referrer attribution for growth channels (#3318)
Tracks whether installs came from Reddit, X, or organic by baking a
ref tag into the install command.

Growth bot shares:
  curl -fsSL ... | SPAWN_REF=reddit bash
  curl -fsSL ... | SPAWN_REF=x bash

install.sh: if SPAWN_REF is set, sanitizes it (alphanumeric + hyphens,
max 32 chars) and writes to ~/.config/spawn/.ref. Only written once —
never overwritten on updates.

index.ts: on startup, reads .ref and sets it as telemetry context via
setTelemetryContext("ref", ref). Every PostHog event (funnel, lifecycle,
errors) now carries ref=reddit or ref=x for attributed installs, or no
ref for organic.

PostHog query: filter any event by ref=reddit to see "how many Reddit-
sourced users made it through the funnel" vs organic.

Bumps 1.0.15 -> 1.0.16.

Co-authored-by: A <258483684+la14-1@users.noreply.github.com>
2026-04-18 00:59:22 -07:00
Ahmed Abushagur
dc4fb59f67
fix(openclaw): batch config set calls into single exec (#3319)
Merges 4 separate runner.runServer() calls (model, sandbox, browser,
channel stubs) into one exec with commands chained by `;`. On Sprite
(container-exec, not persistent SSH), many sequential execs exhaust the
connection and cause "connection closed" / "context deadline exceeded"
on later steps like gateway startup.

Before: 4 execs → 14 "Config overwrite" log lines → flaky connection
After:  1 exec  → same config result → stable connection for gateway

Individual commands use `;` not `&&` so a failure in one (e.g. browser
path not found) doesn't skip the rest — these are all non-fatal prefs.

Bumps 1.0.15 -> 1.0.16.
2026-04-18 00:56:37 -07:00
A
513d3448d4
fix(ux): correct reconnect command suggestion from "spawn connect" to "spawn last" (#3311)
Some checks failed
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
CLI Release / Build and release CLI (push) Has been cancelled
"spawn connect" is not a valid top-level CLI command — users following
this guidance after SSH reconnect failure would see "Unknown agent or
cloud: connect". Replace with "spawn last" which correctly reconnects
to the most recent spawn.

Agent: ux-engineer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-16 15:39:36 +07:00
A
84331173fd
fix(link): add pi and cursor to agent auto-detection (#3309)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
KNOWN_AGENTS was missing pi and cursor, so `spawn link` could not
auto-detect these agents on remote servers. Also adds a binary-name
mapping for cursor (whose CLI binary is `agent`).

Bump CLI to 1.0.14.

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-16 07:44:20 +07:00
Ahmed Abushagur
a179fdbbab
fix(telemetry): opt-in default + picker funnel events (#3308)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Two bugs from the #3305 rollout:

1. Test pollution: orchestrate.test.ts imports runOrchestration directly
   and never calls initTelemetry, but _enabled defaulted to true in the
   module so captureEvent happily fired real events at PostHog tagged
   agent=testagent. The onboarding funnel filled up with CI fixture data.

2. Funnel started too late: funnel_* events fired inside runOrchestration,
   which is only called AFTER the interactive picker completes. Users who
   bail at the agent/cloud/setup-options/name prompts were invisible —
   yet that's exactly where real drop-off happens.

Fix 1 — telemetry.ts:
  - Default _enabled = false. Nothing fires until initTelemetry is
    explicitly called. Production (index.ts) calls it; tests that need
    telemetry (telemetry.test.ts) call it with BUN_ENV/NODE_ENV cleared.
  - Belt-and-suspenders: initTelemetry now short-circuits when
    BUN_ENV === "test" || NODE_ENV === "test", so even if future code
    calls it from a test context, events stay local.

Fix 2 — picker instrumentation:
  New events fired before runOrchestration in every entry path:

    spawn_launched         { mode: interactive | agent_interactive | direct | headless }
    menu_shown / menu_selected / menu_cancelled   (only when user has prior spawns)
    agent_picker_shown
    agent_selected         { agent }     — also sets telemetry context
    cloud_picker_shown
    cloud_selected         { cloud }     — also sets telemetry context
    preflight_passed
    setup_options_shown
    setup_options_selected { step_count }
    name_prompt_shown
    name_entered
    picker_completed

  Wired into:
    commands/interactive.ts  cmdInteractive + cmdAgentInteractive
    commands/run.ts          cmdRun (direct `spawn <agent> <cloud>`)
                             cmdRunHeadless (only spawn_launched)

  runOrchestration's existing funnel_* events continue to fire unchanged.
  The final funnel in PostHog:
    spawn_launched → agent_selected → cloud_selected → preflight_passed
    → setup_options_selected → name_entered → picker_completed
    → funnel_started → funnel_cloud_authed → funnel_credentials_ready
    → funnel_vm_ready → funnel_install_completed → funnel_configure_completed
    → funnel_prelaunch_completed → funnel_handoff

Tests:
- telemetry.test.ts: 2 new env-guard tests (BUN_ENV, NODE_ENV), plus
  updated beforeEach to clear both env vars so existing tests still
  exercise initTelemetry.
- Full suite: 2131/2131 pass, biome 0 errors.

Bumps 1.0.12 -> 1.0.13 (patch — auto-propagates under #3296 policy).
2026-04-15 15:43:30 +07:00
A
d1d51fb06d
fix(security): guarantee temp file cleanup in performAutoUpdate (#3307)
Restructure temp file write-execute-cleanup in performAutoUpdate so
cleanup is unconditionally reached after tryCatch captures any exec
error. Previously, the Windows and Unix paths each had separate
tryCatch+cleanup+rethrow sequences that could diverge under future
edits. Now a single tryCatch wraps the platform-branching exec, with
cleanup always running before any error is re-thrown.

Fixes #3306

Agent: security-auditor

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 12:48:12 +07:00
Ahmed Abushagur
1e64d34e5a
feat(telemetry): funnel + lifecycle events for onboarding drop-off (#3305)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
* feat(telemetry): funnel + lifecycle events for onboarding drop-off

Adds low-volume, high-signal product events on top of the existing
errors/warnings telemetry (shared/telemetry.ts). Answers "where do users
bail before reaching a running agent" at the fleet level.

Funnel events (in orchestrate.ts, both fast and sequential paths):

  funnel_started              pipeline begins
  funnel_cloud_authed         cloud.authenticate() ok
  funnel_credentials_ready    OR key + preProvision resolved
  funnel_vm_ready             VM booted and SSH-reachable
  funnel_install_completed    agent install succeeded (tarball or live)
  funnel_configure_completed  agent.configure() ran
  funnel_prelaunch_completed  gateway / dashboard / preLaunch hooks done
  funnel_handoff              about to launch TUI (final step)

Every event carries elapsed_ms since funnel_started, plus agent and cloud
via telemetry context. Per-step counts reveal the drop-off funnel in
PostHog without touching any PII.

Lifecycle events (new shared/lifecycle-telemetry.ts):

  spawn_connected  { spawn_id, agent, cloud, connect_count, date }
    fired from list.ts when the user reconnects via the interactive picker.
    Increments connection.metadata.connect_count and writes last_connected_at
    so subsequent events and the eventual spawn_deleted have the total.

  spawn_deleted    { spawn_id, agent, cloud, lifetime_hours, connect_count, date }
    fired from delete.ts (both interactive confirmAndDelete and headless
    cmdDelete loop) after a successful cloud destroy. lifetime_hours is
    computed from SpawnRecord.timestamp to now. Clamped at 0 for corrupt
    clocks. connect_count is read from metadata.

New captureEvent(name, properties) helper in telemetry.ts:
- Respects SPAWN_TELEMETRY=0 opt-out (no new flag)
- Runs every string property through the existing scrubber (API keys,
  GitHub tokens, bearer, emails, IPs, base64 blobs, home paths)
- Non-string values pass through untouched

Tests: 20 new (15 lifecycle-telemetry + 2 captureEvent + 3 assertion
additions to disabled-telemetry). Full suite: 2129/2129 pass.

Bumps 1.0.10 -> 1.0.11. Patch bump — auto-propagates under #3296 policy.

* fix(test): replace mock.module with spyOn in lifecycle-telemetry tests

mock.module contaminates the global module registry when running under
--coverage, causing telemetry.test.ts and history-cov.test.ts to receive
mocked implementations instead of the real modules. Switch to spyOn with
mockRestore in afterEach so the real modules are preserved across files.

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

---------

Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 11:35:53 +07:00
A
4de37274e4
fix(cli): bump version to 1.0.11 for security fix in #3301 (#3304)
PR #3301 modified packages/cli/src/shared/agent-setup.ts (GitHub token
temp file security fix) but did not bump the CLI version. Without this
bump, users on auto-update won't receive the security fix.

Agent: team-lead

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 07:44:38 +07:00
A
fbf7aaa067
fix(security): use temp file for GitHub token to avoid process listing exposure (#3301)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
* fix(security): use temp file for GitHub token to avoid process listing exposure

Fixes #3300

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

* fix(security): pass GitHub token via heredoc instead of local temp file

The previous fix wrote the token to a temp file on the LOCAL host, but
the command string was executed on the REMOTE server via runner.runServer(),
so `cat` would fail with 'No such file or directory'. Switch to a heredoc
which is parsed by the remote shell and never appears in /proc/*/cmdline.

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

* fix(security): upload token to remote via SCP instead of heredoc

The previous heredoc approach (`cat <<'EOF'`) doesn't work because all
cloud runners wrap commands in `bash -c ${shellQuote(cmd)}`, and heredocs
are not valid inside single-quoted bash -c strings.

Use runner.uploadFile() (SCP) to place the token on the remote server as
a temp file (mode 0600), then cat+rm it in the remote command. This is
the same proven pattern used by uploadConfigFile(). The local temp file
is always cleaned up after upload, and the remote temp file is cleaned up
both on success (inline rm) and on failure (best-effort rm).

Agent: security-auditor
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-04-14 21:56:13 +07:00
A
352c55c068
fix(update-check): validate install script content before execution (#3302)
Add pre-execution validation of downloaded install scripts to catch
corrupted or truncated downloads. Checks minimum size threshold and
expected shebang/header for the platform. Documents current HTTPS-only
security posture and absence of checksum infrastructure.

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-14 20:41:38 +07:00
Ahmed Abushagur
655a909955
fix(update-check): auto-install patch bumps without SPAWN_AUTO_UPDATE (#3296)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
auto-install to same-major.minor bumps. The intent was "give users control
over feature updates" but the effect was "nobody installs security patches"
because the default became notice-only for everything.

This decouples the two ideas and aligns the policy with semver intent:

  - PATCH bumps (1.0.5 -> 1.0.7, same major.minor): auto-install always,
    no opt-in needed. Patches are reserved for bug fixes and security
    hardening. Blast radius is bounded by semver: no behavior changes,
    no new features, no breaking changes.

  - MINOR / MAJOR bumps (1.0.x -> 1.1.0, 1.x.x -> 2.0.0): respect
    SPAWN_AUTO_UPDATE=1 as opt-in. These can contain behavior changes
    and users should decide when to move to them.

  - SPAWN_NO_AUTO_UPDATE=1: new explicit opt-out for CI environments
    or pinned installs that need a fully static CLI.

Caveat — the one-time hurdle: users currently on 1.0.6 won't get 1.0.7
automatically, because they're still running 1.0.6's update-check.ts
which honors the old opt-in gate. Once they reach 1.0.7 via spawn update
(or by setting SPAWN_AUTO_UPDATE=1), every future patch will propagate
automatically and the fleet becomes self-healing on security.

Tests:
- 5 new tests lock in the policy (patch auto without env, minor notice
  without env, minor auto with env, major notice without env, explicit
  opt-out suppresses patch)
- All 21 update-check tests pass (16 existing + 5 new)
- 2109/2109 total suite

Bumps 1.0.6 -> 1.0.7.
2026-04-14 10:38:08 +00:00
Ahmed Abushagur
c6287b9194
feat(cli): hermes web dashboard tunnel support (#3295)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
* feat(cli): hermes web dashboard tunnel support

Hermes Agent v0.9.0 ships a local web dashboard (hermes dashboard, default
127.0.0.1:9119) for config / session / skill / gateway management. This wires
Hermes into spawn's existing SSH-tunnel infrastructure so `spawn run hermes`
auto-exposes the dashboard to the user's local browser.

- agent-setup.ts: new startHermesDashboard() helper — session-scoped
  background launch via setsid/nohup with a port-ready wait loop. No systemd
  (unlike OpenClaw's gateway) because the dashboard only needs to live for
  the duration of the spawn session. Falls back gracefully if hermes isn't
  in PATH or the dashboard fails to come up.
- Wire preLaunch, preLaunchMsg, and tunnel { remotePort: 9119 } into the
  hermes AgentConfig. Mirrors the OpenClaw tunnel pattern at
  orchestrate.ts:628 — startSshTunnel + openBrowser happen automatically.
- manifest.json: update hermes notes to mention the dashboard.
- hermes-dashboard.test.ts: 7 new unit tests verifying the deploy script
  calls `hermes dashboard --port 9119 --host 127.0.0.1 --no-open`, checks
  all three port-probe fallbacks (ss / /dev/tcp / nc), uses setsid+nohup,
  waits for the port, and does NOT install a systemd unit.
- Bump cli version 1.0.6 -> 1.0.7.

Closes #3293

* chore: bump cli to 1.0.8 to leave 1.0.7 for #3296

---------

Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-04-14 08:43:27 +07:00
A
ace5aa94d1
fix(security): pipe install script via temp file instead of bash -c to prevent command injection (#3292)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Fixes #3291

Agent: security-auditor

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-13 15:55:24 +07:00
A
439e5a1446
fix: resolve TypeScript type errors in update-check.test.ts (#3284)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Replace `mock()` + `spyOn().mockImplementation(mockFn)` pattern with
direct `spyOn().mockImplementation(() => ...)` to fix fetch mock type
mismatches. Make execFileSync mocks return Buffer.from("") instead of
void. Add explicit type annotations for callback parameters.

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-13 07:40:59 +07:00
Ahmed Abushagur
9e533fac6e
fix: always fetch manifest from GitHub, 3s timeout for bad wifi (#3272)
Remove the 1h cache-first path that caused 14-day stale manifests.
Every run now fetches fresh from GitHub (3s timeout). Disk cache is
only used as an offline fallback when the network is unreachable.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
2026-04-12 07:54:40 +07:00
A
14155cb7f8
fix(security): validate remotePath in injectInstructionSkill to prevent shell injection (#3276)
Add validateRemotePath() and shellQuote() to instruction_path handling
in skills.ts, matching the pattern used by uploadConfigFile(). Previously,
remotePath from manifest.json was interpolated directly into shell commands
without validation, allowing path traversal and shell injection via a
malicious instruction_path field.

Closes #3275

Agent: security-auditor

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:50:05 -07:00
A
9b05aa90d4
fix(security): validate env var keys in skill injection (#3270)
* fix(security): validate env var keys in skill injection (orchestrate.ts)

Fixes #3269

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

* fix(security): add base64 validation for defense-in-depth in skill env injection

Add validation of base64-encoded values to match the existing pattern
in injectEnvVarsToRunner (line 518), providing defense-in-depth even
though base64 output is highly unlikely to contain invalid characters.

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

* fix(security): base64-encode entire skill env payload before shell interpolation

Matches the injectEnvVarsToRunner pattern: base64-encode the full payload
and decode on the remote side, eliminating any shell interpolation of
individual env lines. Addresses review feedback on double-evaluation risk.

Agent: pr-maintainer
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.6 <noreply@anthropic.com>
2026-04-11 17:47:14 -07:00
A
187595283e
fix: resolve 4 production TypeScript type errors (#3266)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
- local.ts: spread ReadonlyArray into mutable array for Bun.spawn
- run.ts: capture optional fields in local vars for proper narrowing
- delete.ts: filter SpawnRecordSchema output for required id field

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-11 17:16:47 +07:00
A
500ef53cb7
fix: replace plan_mode_required with message-based approval in refactor team (#3257)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
* fix: replace plan_mode_required with message-based approval in refactor team

Agents spawned with plan_mode_required in non-interactive (-p) mode hang
indefinitely waiting for human UI approval that never arrives. While blocked
in the plan approval loop, they cannot process shutdown_request messages,
which prevents TeamDelete from completing cleanly.

This is the third occurrence of the same bug: #3244 (security-auditor),
#3249 (code-health), #3256 (security-auditor again).

Fix: proactive teammates now use message-based plan approval instead of
plan_mode_required. They send their plan proposal to the team lead via
SendMessage, wait up to 3 minutes for an "Approved" reply, and proceed
only if approved. This is fully compatible with non-interactive mode.

Fixes #3256

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

* fix: correct version bump to 1.0.2 and restore stdin sanitization placeholder

Address security review on PR #3257:
- Fix version: downgrade from 1.0.1→1.0.0 was wrong, correct to 1.0.2
- Note: sanitizeStdinInput() restoration requires additional review

Agent: team-lead
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-04-11 03:10:00 +00:00
Ahmed Abushagur
eaf49446f8
feat: --beta skills — pre-install MCP servers and skills on VMs (#3258)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
CLI plumbing for the skills feature. The skills catalog in manifest.json
is populated by the discovery scout (#3252), not manually curated.

Flow:
1. User runs `spawn claude hetzner --beta skills`
2. Skills picker shows available skills for that agent (from manifest.json)
3. User selects skills, enters required env vars (GITHUB_TOKEN, etc.)
4. During provisioning, skills are installed on the VM:
   - MCP servers → merged into agent's config (settings.json, mcp.json)
   - Instruction skills → SKILL.md written to agent's skills directory
   - Prerequisites → apt packages, Chrome, etc. installed first
5. Env vars appended to .spawnrc for MCP server runtime access

Headless: SPAWN_SELECTED_SKILLS=github-mcp,context7 spawn claude hetzner

Supports: Claude Code, Cursor (native MCP config), all other agents
(generic mcp.json fallback).

Signed-off-by: Ahmed Abushagur <ahmed@abushagur.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:02:16 -07:00
Ahmed Abushagur
317227bd41
feat: v1.0.0 golden release — auto-update now opt-in (#3254)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Two changes to update behavior:

1. Auto-update is now opt-in via SPAWN_AUTO_UPDATE=1 (default: notify only)
2. Even with auto-update on, only patch versions install automatically
   (e.g. 1.0.0 → 1.0.5 yes, 1.0.0 → 1.1.0 no)

This pins users to a stable major.minor — bug fixes flow automatically
but new features require an explicit `spawn update`.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:38:01 +00:00
Ahmed Abushagur
561be1cef9
fix: extract tarballs directly to $HOME on non-root VMs (#3253)
Tarballs are built with /root/ paths. On non-root VMs (Sprite), the old
approach extracted to /root/ with sudo, then mirrored files to $HOME/.
This failed on Sprite which doesn't have sudo.

New approach: use tar --transform to remap /root/ → $HOME/ during
extraction. No sudo needed, no mirror step. Falls back to sudo extract
for clouds with passwordless sudo (AWS, GCP).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:45:16 +07:00
A
88c1f37d7e
fix(security): add upper bound to base64 scrub regex to prevent ReDoS (#3251)
Fixes #3250

The unbounded quantifier {40,} with word boundary \b caused exponential
backtracking on long non-matching strings. Adding {40,100} upper bound
and removing \b prevents catastrophic backtracking.

Agent: security-auditor

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-10 10:16:34 +07:00
A
eefd574f7e
test(telemetry): add unit tests for PII scrubbing and PostHog events (#3247)
* test(telemetry): add unit tests for PII scrubbing and PostHog payload structure

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

* fix(test): drain stale telemetry events before each test to fix CI flake

The telemetry module is a singleton whose event buffer accumulates
across test files. Other tests (e.g. sprite destroy) can leave events
in the buffer that pollute assertions. Drain + clear mock before each
test action to isolate test state.

---------

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-10 10:12:46 +07:00
Ahmed Abushagur
3aa34f21d3
feat(telemetry): use PostHog Error Tracking with $exception events (#3245)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
* feat(telemetry): use PostHog Error Tracking with $exception events

Errors now send $exception events with $exception_list, parsed stack
frames, and mechanism metadata — shows up in PostHog Error Tracking
tab with auto-grouping, occurrence counts, and assignee support.
Warnings stay as custom cli_warning events in Activity.

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

* fix: remove stderr monkey-patch, restore explicit capture calls

Remove process.stderr.write interception (recursion risk, fragile ANSI
matching, noise capture). Restore captureError/captureWarning in
logError/logWarn/handleError for clean, intentional telemetry.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: spawn-bot <spawn-bot@openrouter.ai>
2026-04-09 00:52:57 -07:00