- Revert local security warning to openclaw-only (was blocking all agents)
- Update spawn skill to document how to run prompts on child VMs:
- Always use `bash -lc` (binaries in ~/.local/bin/ need login shell)
- Claude uses `-p` not `--print` or `--headless`
- Add `--dangerously-skip-permissions` for unattended child VMs
- Don't waste tokens with `which`/`find` or creating non-root users
- Sync all on-disk skill files with embedded version
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Five separate it() blocks each checking one agent's env vars (openclaw,
zeroclaw, hermes, kilocode, opencode) were collapsed into a single
data-driven table test. The new test checks all 8 env-var expectations
in one loop with clear per-assertion failure messages.
Tests removed: 5 individual envVars tests
Tests added: 1 consolidated table test
Net: -4 tests (1951 vs 1955), same coverage
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
- E2E: _digitalocean_max_parallel() now returns 0 (not 1) when no capacity
- E2E: run_agents_for_cloud() skips cloud with actionable error when capacity is 0
- CLI: checkAccountStatus() includes droplet names in limit-reached error message
Fixes#3059
Agent: code-health
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
resolveEntityKey() and checkEntity() checked manifest.agents[input] directly,
bypassing the disabled filter in agentKeys(). This let users run `spawn cursor
<cloud>` even though cursor is disabled, wasting time provisioning a VM for an
agent that can't route through OpenRouter. Now both functions check the disabled
flag and show the disabled_reason to the user.
Also removes stale cursor references from spawn skill templates injected into
child VMs.
Agent: code-health
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
The cursor installer changed its binary install location from
~/.cursor/bin/agent to ~/.local/bin/agent (as of 2026-03-25 release).
Updates:
- agent-setup.ts: fix PATH in install, launchCmd, updateCmd, and
the pathScript written to ~/.bashrc/~/.zshrc
- verify.sh: fix E2E binary check to look in ~/.local/bin first
- Bump CLI to 0.27.3
-- qa/e2e-tester
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
* test: remove duplicate in-memory cache tests and fix missing cache reset
Two tests verifying in-memory cache returns the same instance without
re-fetching were duplicated across manifest.test.ts and
manifest-cache-lifecycle.test.ts. The strongest version (checks both object
identity and fetch call count) already lives in the combined-fallback-chain
describe block in manifest-cache-lifecycle.test.ts, so the two weaker
duplicates are removed.
Also fixes missing _resetCacheForTesting() calls in beforeEach for the
in-memory cache behavior and combined fallback chain describe blocks —
without it, in-memory state from a prior test could contaminate later tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: remove duplicate and theatrical tests
Consolidate 5 near-identical manifest rejection tests into a single
data-driven loop, and collapse 4 identical logging-function smoke tests
into a data-driven loop. Both changes eliminate copy-paste repetition
while preserving exact test coverage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the warning only appeared for openclaw. Per security review, the
risk disclosure (full filesystem/shell/network access) applies equally to
all local agents.
Agent: pr-maintainer
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Cursor CLI uses a proprietary ConnectRPC protocol and validates API keys
against Cursor's own servers — it cannot route through OpenRouter. All
infra (scripts, setup code, matrix entries) is preserved for re-enabling
when Cursor adds BYOK/custom endpoint support.
Adds `disabled` field to AgentDef and filters disabled agents from the
picker via agentKeys().
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
- Replace repeated 'SSH port closed (N/36)' with periodic updates every 5 attempts
- Add clear 'Provisioning complete. Connecting...' line before agent attach
Fixes#3053
Agent: ux-engineer
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
The skill now documents that --headless only provisions (doesn't run
the prompt), that agent binaries are at ~/.local/bin/ (not on PATH),
and that --print should be used for one-shot prompts as root instead
of fighting with permission restrictions.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Four test files existed on disk but were not documented in the README index:
- pull-history.test.ts
- recursive-spawn.test.ts
- spawn-skill.test.ts
- star-prompt.test.ts
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
6 TTY interaction tests each repeated 20+ lines of identical stty/spawnSync
mock setup. Extracted into a shared makeSttySpawnSyncSpy() helper inside the
describe block, eliminating ~150 lines of duplicated boilerplate while keeping
all 32 tests passing (biome clean, bun test passing).
-- qa/dedup-scanner
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
Replace StrictHostKeyChecking=no with accept-new across all E2E cloud
drivers (aws, gcp, digitalocean, hetzner), the shared SSH_BASE_OPTS
constant, and pull-history.ts. accept-new trusts new hosts on first
connection (needed for freshly provisioned VMs) but verifies on
subsequent connections, preventing MITM attacks on reconnect.
Fixes#3031
Agent: style-reviewer
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Validate each connection field (ip, user, server_id, server_name) from
history individually before including it in headless output. Invalid
fields are silently omitted rather than reported via headlessError(),
preventing attacker-controlled data in tampered history files from being
surfaced in error messages.
Fixes#3032
Agent: test-engineer
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Replace base64-into-shell interpolation with SCP-based uploadConfigFile()
for Claude Code settings.json and Cursor CLI config files. This eliminates
the attack surface of injecting encoded payloads into shell command strings.
Add chmod 600 on ~/.openclaw/openclaw.json after writing the Telegram bot
token to prevent other users on the VM from reading the token in plaintext.
Fixes#3033Fixes#3034
Agent: security-auditor
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
* docs: sync README commands table with help.ts source of truth
remove 5 command rows from the README commands table that are not present
in packages/cli/src/commands/help.ts getHelpUsageSection():
- spawn list --flat
- spawn list --json
- spawn tree
- spawn tree --json
- spawn history export
these commands exist in code (index.ts, list.ts) but are not listed in the
canonical help section, which is the Gate 2 source of truth per qa/record-keeper
protocol.
* fix: restore documentation for working commands (spawn tree, list --flat, --json, history export)
Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: add 5 missing commands to help.ts getHelpUsageSection()
Add spawn tree, spawn tree --json, spawn list --flat, spawn list --json,
and spawn history export to the help text. These commands are implemented
in the codebase but were missing from --help output.
Addresses reviewer feedback to add commands to help.ts source of truth
rather than removing them from README.
Bump version 0.26.6 -> 0.26.7
Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
---------
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: pull child spawn history back to parent for `spawn tree`
When the interactive session ends (or headless mode completes), the
parent downloads the child VM's history.json and merges records into
local history. Before downloading, it runs `spawn pull-history` on the
child, which recursively pulls from all grandchildren — so the full
tree collapses up to the root regardless of depth.
Changes:
- Add getParentFields() — sets parent_id/depth on saveSpawnRecord calls
- Add pullChildHistory() — downloads + merges child history after session
- Add `spawn pull-history` command for recursive SSH-based history pull
- Add 11 tests for parseAndMergeChildHistory
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: trigger CI recompute
Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): validate user/ip params before SSH exec in pull-history
Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): use shared validators for SSH params in pull-history and delete
Replace inline regex checks in pull-history.ts with validateUsername()
and validateConnectionIP() from security.ts, matching the pattern used
across connect.ts, fix.ts, and link.ts. Also add the same validation
to delete.ts:pullChildHistory which had no SSH parameter validation.
orchestrate.ts uses the runner abstraction (not raw user@ip), so its
SSH params come from the cloud provider, not untrusted history records.
Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Consolidate 15 repetitive it() blocks in spawn-skill.test.ts into
data-driven table tests:
- getSpawnSkillPath: 8 separate 'returns correct path for X' tests
collapsed into one table-driven it() iterating all 8 agent/path pairs
- isAppendMode: 7 separate 'returns false for X' tests (one per
non-hermes agent) collapsed into a single loop-based it() — all
tested the same code path with the same expected value
Coverage is unchanged: all agent/path pairs are still asserted, the
hermes=true case and the nonexistent=undefined case are preserved as
individual tests. Test count drops from 45 to 30 in this file.
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
* feat: add Cursor CLI agent across all clouds
Adds Cursor's terminal-based AI coding agent (the `agent` command from
cursor.com/cli) to the spawn matrix. Routes LLM requests through
OpenRouter via --endpoint flag and CURSOR_API_KEY env var.
- manifest.json: new cursor agent entry + all 6 cloud matrix entries
- agent-setup.ts: install, configure, launch, and update definitions
- Shell scripts for all 6 clouds (local, hetzner, aws, do, gcp, sprite)
- Config: writes ~/.cursor/cli-config.json with full permissions
- Icon: cursor.png from cursor.com/apple-touch-icon.png
- All cloud READMEs updated with cursor.sh usage
- CLI version bumped to 0.26.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add spawn skill injection for Cursor CLI
Writes a .cursor/rules/spawn.mdc rule file with alwaysApply: true
during setup, teaching the Cursor agent how to use the spawn CLI
to provision child cloud VMs. Uses the same base64 upload pattern
as other agent config files.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Ahmed Abushagur <ahmed@abushagur.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: A <258483684+la14-1@users.noreply.github.com>
Shows a non-intrusive "⭐ Enjoying Spawn? Star us on GitHub!" message
to returning users (2+ successful spawns) after a successful spawn
session completes. Shown at most once per 30 days.
- New `maybeShowStarPrompt()` in `shared/star-prompt.ts`
- Tracks `starPromptShownAt` in `~/.config/spawn/preferences.json`
- Called after `execScript()` returns success in cmdRun, cmdInteractive,
and cmdAgentInteractive (skipped in headless mode)
- The `execScript()` return type changed from `void` to `boolean`
to indicate whether the script ran successfully
- Added 7 unit tests covering all gate conditions
Fixes#3020
Agent: issue-fixer
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
PR #3015 added --yes and -y flags to the delete command but didn't add
them to KNOWN_FLAGS in flags.ts. This caused `spawn delete --name foo --yes`
to fail with "Unknown flag: --yes" because checkUnknownFlags runs before
dispatchDeleteCommand strips these flags.
Also adds delete-specific flags to --help documentation.
Agent: code-health
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Agents running on spawned VMs couldn't delete child spawns because
`spawn delete` requires an interactive terminal for the picker UI.
Added --name and --yes flags: when both are provided in non-interactive
mode, the server matching the name is deleted without prompts. This
enables agents to manage their own child VMs programmatically.
Updated all skill files to teach agents the headless delete syntax.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: A <258483684+la14-1@users.noreply.github.com>
sprite console does not accept arguments — it's a pure interactive shell.
When entering an agent on Sprite, use `sprite exec -s NAME -tty` which
supports passing commands via `-- bash -lc CMD`.
Signed-off-by: Ahmed Abushagur <ahmed@abushagur.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
remove 3 tests that duplicate scenarios already covered in
cmd-link.test.ts:
- "saves record" (same as "saves a spawn record when agent/cloud given")
- "exits with error for invalid IP" (same as in cmd-link)
- "generates default name" (same as "generates a default name")
remaining 7 tests cover unique paths (IMDS detection, which-binary
fallback, spinner behavior, short flags) not in cmd-link.test.ts.
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace hand-constructed openrouter.json path with getSpawnCloudConfigPath("openrouter")
for single-source-of-truth path resolution. Remove unused _cloudName parameter since
the function delegates ALL cloud credentials unconditionally.
Agent: ux-engineer
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Add /^[A-Za-z0-9+/=]+$/ validation after each .toString("base64") call
in delegateCloudCredentials() and injectEnvVars(), consistent with the
pattern established in agent-setup.ts by #2988.
Agent: security-auditor
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
the `validators` describe block in ui-cov.test.ts duplicated 6 tests
that already exist with full edge-case coverage in ui-utils.test.ts:
- validateServerName (2 tests) → duplicated by 5 tests in ui-utils.test.ts
- validateRegionName (2 tests) → duplicated by 4 tests in ui-utils.test.ts
- validateModelId (2 tests) → duplicated by 6 tests in ui-utils.test.ts
removed tests only checked one accept+one reject per validator, providing
no signal beyond what ui-utils.test.ts already covers exhaustively. also
removed the now-unused imports from the import statement.
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ux): replace download spinner with stderr logging, reset terminal before SSH handoff
Fixes two UX issues from live E2E session (#3001):
1. Download spinner (p.spinner from @clack/prompts) wrote ANSI escape codes
to stdout. When stdout is captured (E2E harness, piped output), these
sequences appeared as raw text rather than rendered colors. Replace
p.spinner() in downloadScriptWithFallback and downloadBundle with
logStep/logInfo/logError from shared/ui.ts, which write to stderr and
correctly check isTTY before emitting ANSI codes.
2. Garbled output at start of interactive session (overlapping status lines
from the remote agent's TUI) may be caused by residual ANSI state from
@clack/prompts (hidden cursor, active color attributes). Emit
ESC[?25h ESC[0m to stderr before prepareStdinForHandoff() to explicitly
restore cursor visibility and reset all attributes before the SSH session
takes over.
Agent: issue-fixer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: resolve ANSI spinner corruption and garbled output in interactive mode (#3001)
Three root causes fixed:
1. Spinner wrote to stdout while all other CLI status output goes to stderr,
causing ANSI escape sequence interleaving and corruption when both streams
are merged on a terminal. Redirected all p.spinner() calls to process.stderr.
2. unicode-detect.ts (which sets TERM=linux for SSH sessions to force ASCII
fallback) was only imported in commands/shared.ts but not in shared/ui.ts.
Cloud module entry points (hetzner/main.ts, etc.) that import shared/ui.ts
loaded @clack/prompts without the TERM override, causing Unicode spinner
frames in environments that can't render them.
3. After an interactive SSH session ends, the remote agent's TUI (e.g. Claude
Code) may leave the terminal in raw mode with altered attributes. Added
terminal reset (ANSI attribute reset + stty sane) after spawnInteractive()
returns to prevent garbled post-session output.
Agent: ux-engineer
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>
`spyOn(Bun, "serve")` works without the `as never` type assertion.
These casts violated the documented no-type-assertion rule
(`.claude/rules/type-safety.md`). Also removes the associated
`biome-ignore` directives that were suppressing lint warnings.
Agent: style-reviewer
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
The CLI help output only listed 3 of 5 beta features (tarball, images,
docker). The error output on invalid beta flags and the README both
correctly listed all 5. This adds the missing parallel and recursive
entries to --help for consistency.
Agent: code-health
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
delegateCloudCredentials only copied the current cloud's config file
(e.g. sprite.json when spawning on Sprite). Child VMs couldn't spawn
on other clouds because their tokens weren't forwarded.
Now iterates all known clouds and copies every credential file that
exists locally, so the agent can spawn children on any cloud.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two tests in update-check-cov.test.ts were exact duplicates of tests in
update-check.test.ts:
- "skips when recently checked successfully" duplicated "should skip fetch
when last successful check was recent"
- "does not skip when checked timestamp is old (>1h)" duplicated "should
fetch when last successful check is older than 1 hour"
Also removed the now-unused writeUpdateChecked helper function.
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
- Remove `export` from `getTerminalWidth` in commands/info.ts — only
used internally, not exported from commands/index.ts barrel
- Remove `export` from `makeDockerExec` in shared/orchestrate.ts — only
used internally by `makeDockerRunner`, no external callers
- Bump CLI version to 0.26.6
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Sprite has a bun shim at /.sprite/bin/bun that delegates to
$HOME/.bun/bin/bun, but that binary doesn't exist on fresh VMs.
`command -v bun` returns true (finds the shim) so the install script
skips bun installation, then bun fails when actually invoked.
Fixed in two places:
- installSpawnCli: source shell profiles, test `bun --version` (not
just existence), and install bun fresh if it doesn't work
- install.sh: replace `command -v bun` with `bun --version` to detect
broken shims
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: spawn step skipped when no explicit --steps passed
The spawn skill injection condition used `enabledSteps?.has("spawn")`
which is falsy when enabledSteps is undefined (no --steps flag). Now
checks the recursive beta flag directly and falls through when no
explicit steps are selected, matching how auto-update works.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: embed skill content in spawn-skill.ts instead of reading from disk
The skills/ directory exists in the repo but isn't bundled when the CLI
is installed via npm. readSkillContent() couldn't find the files at
runtime, causing "No spawn skill file for agent" on every deploy.
Fixed by embedding all skill content directly as string constants in the
module. Removed fs-based getSkillsDir/readSkillContent/getSpawnSkillSourceFile
in favor of a single AGENT_SKILLS config map with inline content.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When `--beta recursive` is active, a new "Spawn CLI" setup step injects
agent-native instruction files teaching each agent how to use the `spawn`
CLI to create child VMs. Skill files live in `skills/` at the repo root
and use each agent's native format (YAML frontmatter for Claude/Codex/
OpenClaw, plain markdown for others, append mode for Hermes).
- Add `skills/` directory with 8 agent-specific skill files
- Add `spawn-skill.ts` module with path mapping, file reading, and injection
- Register "spawn" as a conditional setup step gated by `--beta recursive`
- Wire `injectSpawnSkill()` into orchestrate.ts postInstall flow
- Add 52 tests covering path mapping, append mode, file existence, injection
- Bump CLI version to 0.26.0 (minor: new feature)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds non-empty guard to makeDockerExec to make the security boundary
explicit and prevent silent misuse with empty commands.
Fixes#2985
Agent: code-health
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously only `settingsB64` had a validation check. Added the same
`/^[A-Za-z0-9+/=]+$/` guard for wrapperB64, unitB64, and timerB64
before they are interpolated into shell commands, closing the consistency gap.
Fixes#2986
Agent: security-auditor
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The `skips when no credential files exist` test in recursive-spawn.test.ts
was failing in the full suite (1911 pass, 1 fail) because other test files
(oauth-cov.test.ts, cmd-uninstall-cov.test.ts) write openrouter.json and
hetzner.json to $HOME/.config/spawn/ without cleanup, contaminating the
shared sandbox HOME used by bun's test runner. The test passed in isolation
but failed 100% of the time in the full suite.
Fix: add a beforeEach inside the delegateCloudCredentials describe block
that removes $HOME/.config/spawn/ before each test, making the test
self-contained and immune to cross-file pollution.
Agent: code-health
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: add recursive spawn (--beta recursive)
Enables VMs to spawn child VMs. When --beta recursive is active:
- Injects SPAWN_PARENT_ID, SPAWN_DEPTH, SPAWN_BETA=recursive into .spawnrc
- Installs spawn CLI on the VM via install.sh
- Delegates cloud + OpenRouter credentials to the VM
- Tracks parent_id and depth on SpawnRecord for tree relationships
- Adds `spawn tree` command for full recursive tree view
- Adds `spawn history export` for pulling child history via SSH
- Adds `spawn list --json` and `spawn list --flat` flags
- Adds tree rendering in `spawn list` when parent-child relationships exist
- Adds cascade delete support in delete.ts
- Adds mergeChildHistory() for backward-pass history sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: add recursive spawn to README
Add --beta recursive to beta features table, new commands
(spawn tree, spawn history export, spawn list --flat/--json)
to commands table, and a dedicated Recursive Spawn section
with usage examples for tree view and cascade delete.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add cmdTree coverage tests to fix mock test CI
The CI coverage threshold (90% functions, 80% lines) was failing
because tree.ts had 0% coverage. Added tests that exercise cmdTree
with empty history, tree rendering, JSON output, flat records,
and deleted/depth labels. tree.ts now has 100% coverage.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(security): validate cloudName and use valibot in pullChildHistory
- Add cloudName validation against ^[a-z0-9-]+$ to prevent
command injection in delegateCloudCredentials
- Export SpawnRecordSchema from history.ts and replace loose
type guard with valibot schema validation in pullChildHistory
- Resolve merge conflicts with main (include both docker and
recursive beta features)
Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* test: add installSpawnCli and delegateCloudCredentials coverage
Export and test installSpawnCli (success + timeout failure paths)
and delegateCloudCredentials (no creds, with creds, write failure,
mkdir failure paths) to improve orchestrate.ts function coverage.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: gritQL rule false positives and delete.ts coverage
- use TsAsExpression() AST node instead of backtick pattern to avoid
matching import aliases as type assertions
- export and test findDescendants() and pullChildHistory() to bring
delete.ts line coverage above the 35% threshold
- add 8 new tests for descendant finding and history pull edge cases
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: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: A <258483684+la14-1@users.noreply.github.com>
* fix: pin all GitHub Actions to commit SHAs and version-lock tools
Addresses supply chain hardening findings from issue #2982:
- Pin all 6 GitHub Actions to full commit SHAs with version comments:
- actions/checkout@v4 → SHA 34e1148...
- oven-sh/setup-bun@v2 → SHA 0c5077e...
- actions/github-script@v7 → SHA f28e40c...
- docker/login-action@v3 → SHA c94ce9f...
- docker/build-push-action@v6 → SHA 10e90e3...
- hashicorp/setup-packer@main → SHA c3d53c5... (v3.2.0)
- Pin Packer version: latest → 1.15.0 (in packer-snapshots.yml)
- Pin bun version: latest → 1.3.11 (in agent-tarballs.yml)
- Pin shellcheck: replace apt-get (no version) with pinned download
of v0.10.0 from GitHub releases with SHA256 integrity check
These changes eliminate the primary LiteLLM-style attack vector:
a compromised action maintainer can no longer force-push malicious
code to an existing tag and have it run in CI.
Fixes#2982
Agent: issue-fixer
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: exclude import aliases from no-type-assertion lint rule
The `JsNamedImportSpecifier` exclusion prevents `import { foo as bar }`
patterns from being flagged as type assertions. Previously, any `as`
keyword in import/export statements triggered the ban because the GritQL
pattern `$value as $type` matched import specifiers as well as actual
TypeScript type assertions.
This also removes the `as _foo` import aliases in the script-failure-guidance
test file (replaced with direct imports + distinctly-named wrapper functions)
which were the original manifestation of this bug.
All 1944 tests pass. Biome check clean across 169 files.
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>
Remove weaker duplicates found during QA quality sweep:
orchestrate-cov.test.ts: remove "orchestrate restart loop" describe block
(2 tests) — duplicates tests already in orchestrate.test.ts with fewer
assertions (missing "my-agent --run" and "Restarting in 5s" checks).
cmd-delete-cov.test.ts: remove theatrical "intercepts stderr writes to
update spinner" test — handler was a no-op mock, only asserted return
value, never verified actual stderr interception. Duplicate of
"calls custom deleteHandler and reports success" in the same file.
Real stderr/spinner behavior is covered by delete-spinner.test.ts.
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Suppress remote command output in Hetzner runServer() by piping
stdout/stderr instead of inheriting. This prevents raw ANSI escape
sequences from remote install commands (spinners, progress bars)
from leaking into the local terminal as garbled characters, and
eliminates duplicate status messages that were repeated 15+ times.
Captured stderr is logged via logDebug on failure for debugging.
- Add LC_ALL=C.UTF-8 to both the interactive SSH session and the
.spawnrc env config to ensure consistent UTF-8 locale across all
locale categories, preventing garbled Unicode rendering in Claude
Code's TUI welcome interface.
Agent: ux-engineer
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: remove docker from --fast and fix docker cp into container
Two fixes for --beta docker:
1. Remove "docker" from --fast beta features — --fast was auto-enabling
--beta docker, pulling ghcr images that hang the session.
Users must now opt in explicitly with --beta docker.
2. Fix uploadFile in docker mode — .spawnrc was uploaded to the host
but never copied into the container. Add docker cp after SCP upload
so env vars and configs reach the agent inside the container.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: keep docker in --fast beta features
The docker cp fix resolves the hang — no need to remove docker from
--fast. The issue was missing file copy into the container, not the
docker mode itself.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: extract makeDockerRunner helper, fix uploadFile into container
Add makeDockerRunner() that wraps a CloudRunner so all commands and
file uploads target the Docker container. Replaces inline lambdas in
hetzner/main.ts and gcp/main.ts with a clean one-liner.
The key fix: uploadFile now docker cp's files into the container after
SCP — previously .spawnrc (API keys, env vars) only landed on the host,
so the agent inside the container had no config and hung.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(security): shellQuote remotePath in docker cp command
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The "falls back to most recent record with connection when no spawnId"
test in history-spawn-id.test.ts duplicates the same-named test in
history-cov.test.ts. The history-cov version is more thorough: it uses
two records where the first lacks a connection, exercising the
"skip records without connection" logic. The history-spawn-id version
only had one record, providing no additional signal.
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: remove local tarball download, use remote-only tarball install
The local-download-then-SCP-upload path was unnecessary complexity —
downloading a tarball to the user's machine just to re-upload it to the
VM is wasteful. The VM downloads directly from GitHub instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: force zeroclaw native runtime to prevent Docker container hang
ZeroClaw auto-detects Docker and launches in a container (pulling
ghcr.io/openrouterteam/spawn-zeroclaw), which hangs the interactive
session. Force native mode via ZEROCLAW_RUNTIME=native env var and
adapter = "native" in config.toml.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: disable openclaw Docker sandbox to prevent container hang
Same issue as zeroclaw — openclaw auto-detects Docker and runs agents
in containers, hanging the interactive session. Disable via
agents.defaults.sandbox.mode = off in config and fallback JSON.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: disable codex Docker sandbox to prevent container hang
Codex CLI also auto-detects Docker for sandboxing. Set
sandbox_mode = "danger-full-access" in config.toml — the VM itself
provides isolation, Docker sandboxing just causes hangs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The 'junie agent envVars include JUNIE_OPENROUTER_API_KEY' test in
agent-setup-cov.test.ts was a weaker duplicate of the more precise
coverage in junie-agent.test.ts, which verifies the exact env var value.
1890 → 1889 tests (1 duplicate removed, 0 regressions).
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract duplicate dockerExec helper from gcp/main.ts and hetzner/main.ts
into shared makeDockerExec() in orchestrate.ts. Both local functions were
identical — wrapping commands with docker exec using DOCKER_CONTAINER_NAME
and shellQuote.
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove 5 duplicate test cases from orchestrate-cov.test.ts that were
already covered by orchestrate.test.ts with stronger assertions:
- orchestrate checkAccountReady throws (duplicate, weaker version)
- orchestrate preProvision throws (duplicate, weaker version)
- tarball falls back to install when tarball returns false (exact duplicate)
- tarball skips for local cloud (exact duplicate)
- skipTarball agent flag (exact duplicate)
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Add 180s timeout to uploadFileSprite to prevent indefinite hangs during
tarball uploads. Without a timeout, large tarballs or stalled Sprite
connections block the entire provisioning pipeline past the 720s E2E
provision timeout, causing agent binary not-found failures for openclaw,
zeroclaw, and codex.
Also skip the redundant remote tarball download fallback when a local
tarball was already downloaded but its upload/extract failed -- the
remote download would face the same extraction issues. This saves ~150s
in the fallback chain, leaving enough time for the live install to
complete within the provision timeout.
Fixes#2960
Agent: code-health
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>