Security:
- Fix command injection in modal/lib/common.sh (run_server, upload_file, interactive_session)
- Fix command injection in fly/lib/common.sh (run_server, upload_file, interactive_session)
- All container providers now use printf '%q' for proper shell escaping
Complexity:
- Extract _api_should_retry_on_error() helper in shared/common.sh (-19 lines)
- Refactor scaleway_api and upcloud_api to use shared retry helper (-24 lines)
- Extract _save_fly_token() helper in fly/lib/common.sh (-11 lines)
- Extract validateAndGetAgent() in commands.ts, reducing cmdRun/cmdAgentInfo duplication
- Refactor cmdList column width calculation to use calculateColumnWidth()
UX:
- Add actionable next steps to error messages in shared/common.sh
- Improve CLI bash fallback error messages with guidance (spawn.sh)
- Add OAuth progress indicator during browser authentication wait
- Show invalid model ID value and link to openrouter.ai/models
- Add troubleshooting steps for agent installation failures
Tests:
- Update test assertions in test/run.sh to match refactored patterns
- All tests passing: 74 TypeScript + 75 bash = 149 total, 0 failures
Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The upstream OpenCode installer pipes `curl -# -L | tar xz` which fails
in container exec environments (Sprite, E2B, Modal, Daytona) where the
binary stream gets corrupted through the exec layer, producing
"gzip: stdin: not in gzip format" errors.
Added opencode_install_cmd() to shared/common.sh that downloads the
binary to a file first, then extracts it. Updated all 17 opencode.sh
scripts to use this robust method instead of the upstream installer.
The previous fix (#44) only addressed Sprite with a hardcoded
linux-x86_64 architecture. This fix detects OS/arch dynamically and
applies to all cloud providers.
Fixes#42
Co-authored-by: Sprite <noreply@sprite.dev>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: extract shared test helpers and utilities
Created centralized test-helpers.ts module to eliminate duplication across test files:
**Extracted Helpers:**
- createMockManifest() - Reusable mock manifest data
- createEmptyManifest() - Empty manifest for edge cases
- createConsoleMocks() - Console spy setup
- createProcessExitMock() - Process exit mock
- restoreMocks() - Mock cleanup utility
- mockSuccessfulFetch() - Simplified successful fetch mock
- mockFailedFetch() - Simplified failed fetch mock
- mockFetchWithStatus() - Fetch mock with custom status
- setupTestEnvironment() - Test directory and env setup
- teardownTestEnvironment() - Cleanup utility
**Deduplication Impact:**
- commands.test.ts: Removed 50+ lines of duplicate mock setup
- manifest.test.ts: Removed 80+ lines of duplicate manifest data and setup code
- integration.test.ts: Removed 40+ lines of duplicate setup/teardown
**Benefits:**
- Single source of truth for test fixtures
- Consistent mock patterns across all tests
- Easier maintenance - changes to test setup in one place
- Improved test readability
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* refactor: Add non-interactive mode for agent execution
Implements --prompt and --prompt-file flags to enable non-interactive
agent execution. This allows users to:
- Execute agents with a prompt and exit automatically
- Use spawn in CI/CD pipelines and automation scripts
- Pass prompts via command line or file
Changes:
- TypeScript CLI: Parse --prompt/-p and --prompt-file flags
- Security: Add validatePrompt() to prevent command injection
- Commands: Pass prompt via SPAWN_PROMPT env var to bash scripts
- Bash scripts: Detect SPAWN_PROMPT and fork interactive/non-interactive
- Help text: Document new flags with examples
Implementation:
- claude.sh: Use 'claude -p' for non-interactive execution
- aider.sh: Use 'aider -m' for non-interactive execution
- shared/common.sh: Add execute_agent_non_interactive() helper
Security:
- Validates prompts for command injection patterns
- Length limit: 10KB max
- Blocks $(), backticks, piping to bash/sh
- Uses printf %q for proper shell escaping
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* docs: Add testing guide for non-interactive mode
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Sprite <noreply@sprite.dev>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
The refactor in f9dd9a7 hardcoded /root/ as the upload destination for
Claude Code and OpenClaw config files, breaking all non-root providers
(Lambda, AWS Lightsail, GCP, Sprite, E2B, Modal, Fly). Upload to /tmp/
first then mv to ~/ via run_callback so the remote shell expands ~ to
the correct home directory.
Also add OPENROUTER_API_KEY env var check to sprite scripts (claude,
openclaw, nanoclaw) so the OAuth flow is skipped when the key is already
set, and fix echo -e to printf for macOS bash 3.x compat.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added EXIT traps to ensure temporary files are cleaned up even if scripts crash or are interrupted:
**cli/spawn.sh** (2 mktemp calls):
- Line 219: Added trap after mktemp in fetch_manifest(), clear trap after mv
- Line 537: Added trap after mktemp in cmd_update(), clear trap after mv
- Removed manual rm -f calls in error paths (trap handles cleanup)
**sprite/lib/common.sh** (3 mktemp calls):
- setup_shell_environment(): Consolidated trap for both path_temp and bash_temp
- inject_env_vars_sprite(): Added trap for env_temp, clear after successful upload
**shared/common.sh** (cleanup system):
- Auto-register cleanup trap at end of file when sourced
- This activates the existing track_temp_file() + cleanup_temp_files() system
- Previously register_cleanup_trap() had to be manually called (only 1 script did this)
Impact: Prevents /tmp file leaks when scripts are killed, crashed, or interrupted mid-execution.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Add --max-time 30 to OAuth key exchange curl to prevent indefinite hangs
- Remove unused DIM variable from cli/install.sh
- Remove unused BLUE variable from cli/spawn.sh
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Add -r flag to safe_read() to prevent backslash mangling (SC2162)
- Add shellcheck disable for intentional SSH_OPTS word splitting
- Remove unused 'gaps' variable in improve.sh (SC2034)
- Quote exit_code in return statement for consistency (SC2248)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Modified OAuth server to try ports sequentially (PORT to PORT+10) if initial
port is busy. Server now writes actual port used to a port file, which
try_oauth_flow reads to construct the correct callback URL.
Changes:
- start_oauth_server: Added port range retry logic with EADDRINUSE handling
- start_oauth_server: Now accepts port_file parameter to communicate actual port
- try_oauth_flow: Waits for port allocation and reads actual port used
- try_oauth_flow: Logs allocated port for user visibility
- Backward compatible: PORT env var still respected as starting point
Pattern:
Before: Fixed port 5180 → fails if busy
After: Try 5180, 5181, 5182... → resilient to port conflicts
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Extract hardcoded `sleep 1` values in OAuth code polling and server
startup wait to environment-configurable POLL_INTERVAL constant.
Changes:
- Added POLL_INTERVAL="${SPAWN_POLL_INTERVAL:-1}" at top of shared/common.sh
- Updated wait_for_oauth_code() to use POLL_INTERVAL (line 388)
- Updated OAuth server startup wait to use POLL_INTERVAL (line 489)
Benefits:
- Faster testing with SPAWN_POLL_INTERVAL=0.1
- Configurable for slow networks with SPAWN_POLL_INTERVAL=2
- Consistent with other timeout/delay constants added in previous commit
File modified:
- shared/common.sh
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Fixed all SC2250 shellcheck warnings by adding braces to variable
references throughout the file. This improves code consistency and
follows shellcheck best practices.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Eliminates duplicate SSH key registration logic across 5 cloud providers
(Hetzner, DigitalOcean, Vultr, Linode, Lambda) by introducing a generic
callback-based pattern in shared/common.sh.
Before: Each provider had ~45 lines of nearly identical code for:
- Generating SSH keys if missing
- Getting fingerprints
- Checking if key exists with provider
- Registering key if not exists
- Error handling
After: Providers implement 2 simple callbacks:
- check_callback: provider-specific API call to check if key exists
- register_callback: provider-specific API call to register key
The shared function handles:
- Key generation (via generate_ssh_key_if_missing)
- Fingerprint extraction (via get_ssh_fingerprint)
- Flow control and logging
- Callback orchestration
Changes:
- shared/common.sh: Added ensure_ssh_key_with_provider() function
- hetzner/lib/common.sh: Refactored to use callbacks
- digitalocean/lib/common.sh: Refactored to use callbacks
- vultr/lib/common.sh: Refactored to use callbacks
- linode/lib/common.sh: Refactored to use callbacks
- lambda/lib/common.sh: Refactored to use callbacks
Benefits:
- DRY: Eliminates ~220 lines of duplicate code
- Maintainability: Bug fixes in registration flow benefit all providers
- Consistency: All providers use identical registration logic
- Extensibility: New providers can reuse this pattern
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The Node.js OAuth server handles its own HTTP response — the old
write_oauth_response_file and 3-arg start_oauth_server are gone.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Split all 16 instances of combined local declaration+assignment to
avoid masking return values. This is a mechanical refactor with no
logic changes.
Fixed lines: 219, 279, 283, 357, 363, 381, 385, 396, 408, 450, 618,
622, 623, 639, 664, 759
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The nc (netcat) approach was fundamentally broken:
- macOS BSD nc has different flags than GNU nc
- nc handles exactly one connection — browsers send favicon, prefetch, etc.
- Pipe-based I/O has race conditions and blocks $() capture
- echo -e doesn't work on macOS bash 3.x for HTTP headers
Replace with Node.js http.createServer (via bun or node):
- Proper HTTP server handles multiple connections
- Parses URL query params correctly (no sed/grep on raw HTTP)
- Sends proper HTTP response with correct headers
- Gracefully ignores favicon/prefetch/extra requests
- Shuts itself down after receiving the callback code
- Works identically on macOS, Linux, and Termux
bun is already a dependency (installed by cloud-init), node is
available on most systems. Falls back to manual API key entry
if neither is available.
Co-authored-by: Sprite <noreply@sprite.dev>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
start_oauth_server was called inside $() to capture the PID, but the
backgrounded nc subshell inherited the $() stdout pipe. Since $()
waits for ALL writers to close, it blocked forever until nc exited
(which never happens — it's listening).
Fix: redirect the subshell's stdout/stderr to /dev/null so it doesn't
hold the pipe open. The PID echo still works because it runs in the
parent (after the & backgrounds the child).
Co-authored-by: Sprite <noreply@sprite.dev>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The autonomous refactoring reverted all our macOS fixes in shared/common.sh:
1. nc_listen: removed spurious -p flag check that misfires on macOS BSD nc
(BSD nc's -p means source port, not listen port — wrong syntax)
2. start_oauth_server: replaced echo -e (broken on macOS bash 3.x) with
printf-based write_oauth_response_file called before the subshell.
Removed local vars from subshell (not function scope).
3. ((elapsed++)) / ((attempt++)) → $((var + 1)) to avoid set -e killing
the script when the value is 0 (evaluates falsy).
Co-authored-by: Sprite <noreply@sprite.dev>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The autonomous refactoring added `set -euo pipefail` but the scripts
check optional env vars with `[[ -n "$VAR" ]]` which is a fatal error
under nounset when the var isn't set (e.g. SPRITE_NAME, OPENROUTER_API_KEY).
Fix: downgrade to `set -eo pipefail` across all 42 affected files.
Co-authored-by: Sprite <noreply@sprite.dev>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>