spawn/packages/cli/src/shared/agent-setup.ts
Ahmed Abushagur dda6d53db7
fix: skip model selection prompt, default to openrouter/auto (#2322)
New users don't know what LLM models are — prompting them to pick one
with no context is confusing and openrouter/auto can route to weak
models. Remove the interactive model prompt entirely; agents use their
modelDefault silently (or MODEL_ID env var for power users).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 00:54:46 -08:00

712 lines
28 KiB
TypeScript

// shared/agent-setup.ts — Shared agent helpers + definitions for SSH-based clouds
// Cloud-agnostic: receives runServer/uploadFile via CloudRunner interface.
import type { AgentConfig } from "./agents";
import type { Result } from "./ui";
import { unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { hasMessage } from "./type-guards";
import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, withRetry } from "./ui";
/**
* Wrap an SSH-based async operation into a Result for use with withRetry.
* - Transient SSH/connection errors → Err (retryable)
* - Timeouts → throw (non-retryable: command may have already run)
* - Everything else → throw (non-retryable: unknown failure)
*/
export async function wrapSshCall(op: Promise<void>): Promise<Result<void>> {
try {
await op;
return Ok(undefined);
} catch (err) {
const msg = hasMessage(err) ? err.message : String(err);
// Timeouts are NOT retryable — the command may have completed on the
// remote but we lost the connection before seeing the exit code.
if (msg.includes("timed out") || msg.includes("timeout")) {
throw err;
}
// All other SSH errors (connection refused, reset, etc.) are retryable.
return Err(new Error(msg));
}
}
// ─── CloudRunner interface ──────────────────────────────────────────────────
export interface CloudRunner {
runServer(cmd: string, timeoutSecs?: number): Promise<void>;
uploadFile(localPath: string, remotePath: string): Promise<void>;
}
// ─── Install helpers ────────────────────────────────────────────────────────
async function installAgent(
runner: CloudRunner,
agentName: string,
installCmd: string,
timeoutSecs?: number,
): Promise<void> {
logStep(`Installing ${agentName}...`);
try {
await withRetry(`${agentName} install`, () => wrapSshCall(runner.runServer(installCmd, timeoutSecs)), 2, 10);
} catch {
logError(`${agentName} installation failed`);
throw new Error(`${agentName} install failed`);
}
logInfo(`${agentName} installation completed`);
}
/**
* Upload a config file to the remote machine via a temp file and mv.
*/
async function uploadConfigFile(runner: CloudRunner, content: string, remotePath: string): Promise<void> {
const tmpFile = join(tmpdir(), `spawn_config_${Date.now()}_${Math.random().toString(36).slice(2)}`);
writeFileSync(tmpFile, content, {
mode: 0o600,
});
try {
await withRetry(
"config upload",
() =>
wrapSshCall(
(async () => {
const tempRemote = `/tmp/spawn_config_${Date.now()}`;
await runner.uploadFile(tmpFile, tempRemote);
await runner.runServer(
`mkdir -p $(dirname "${remotePath}") && chmod 600 '${tempRemote}' && mv '${tempRemote}' "${remotePath}"`,
);
})(),
),
2,
5,
);
} finally {
try {
unlinkSync(tmpFile);
} catch {
/* ignore */
}
}
}
// ─── Claude Code ─────────────────────────────────────────────────────────────
async function installClaudeCode(runner: CloudRunner): Promise<void> {
logStep("Installing Claude Code...");
const claudePath = "$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$HOME/.n/bin";
const pathSetup = `for rc in ~/.bashrc ~/.zshrc; do grep -q '.claude/local/bin' "$rc" 2>/dev/null || printf '\\n# Claude Code PATH\\nexport PATH="$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH"\\n' >> "$rc"; done`;
const finalize = `claude install --force 2>/dev/null || true; ${pathSetup}`;
const script = [
`export PATH="${claudePath}:$PATH"`,
`if [ -f ~/.bash_profile ] && grep -q 'spawn:env\\|Claude Code PATH\\|spawn:path' ~/.bash_profile 2>/dev/null; then rm -f ~/.bash_profile; fi`,
`if command -v claude >/dev/null 2>&1; then ${finalize}; exit 0; fi`,
`echo "==> Installing Claude Code (method 1/2: curl installer)..."`,
"curl --proto '=https' -fsSL https://claude.ai/install.sh | bash || true",
`export PATH="${claudePath}:$PATH"`,
`if command -v claude >/dev/null 2>&1; then ${finalize}; exit 0; fi`,
"if ! command -v node >/dev/null 2>&1; then export N_PREFIX=$HOME/.n; curl --proto '=https' -fsSL https://raw.githubusercontent.com/tj/n/master/bin/n | bash -s install 22 || true; export PATH=$N_PREFIX/bin:$PATH; fi",
`echo "==> Installing Claude Code (method 2/2: npm)..."`,
"npm install -g @anthropic-ai/claude-code || true",
`export PATH="${claudePath}:$PATH"`,
`if command -v claude >/dev/null 2>&1; then ${finalize}; exit 0; fi`,
"exit 1",
].join("\n");
try {
await runner.runServer(script, 300);
logInfo("Claude Code installed");
} catch {
logError("Claude Code installation failed");
throw new Error("Claude Code install failed");
}
}
async function setupClaudeCodeConfig(runner: CloudRunner, apiKey: string): Promise<void> {
logStep("Configuring Claude Code...");
const escapedKey = jsonEscape(apiKey);
const settingsJson = `{
"theme": "dark",
"editor": "vim",
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "0",
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"ANTHROPIC_AUTH_TOKEN": ${escapedKey}
},
"permissions": {
"defaultMode": "bypassPermissions",
"dangerouslySkipPermissions": true
}
}`;
const settingsB64 = Buffer.from(settingsJson).toString("base64");
// Build ~/.claude.json on the remote using $HOME so the workspace trust
// entry uses the actual home directory path (e.g. /root, /home/user).
// This pre-accepts the "Quick safety check" trust dialog for the home dir.
const stateScript = [
"mkdir -p ~/.claude",
`printf '%s' '${settingsB64}' | base64 -d > ~/.claude/settings.json`,
"chmod 600 ~/.claude/settings.json",
'printf \'{"hasCompletedOnboarding":true,"bypassPermissionsModeAccepted":true,"projects":{"%s":{"hasTrustDialogAccepted":true}}}\\n\' "$HOME" > ~/.claude.json',
"chmod 600 ~/.claude.json",
"touch ~/.claude/CLAUDE.md",
].join(" && ");
await runner.runServer(stateScript);
logInfo("Claude Code configured");
}
// ─── GitHub Auth ─────────────────────────────────────────────────────────────
let githubAuthRequested = false;
let githubToken = "";
let hostGitName = "";
let hostGitEmail = "";
/** Read a git config value from the host machine, returning "" on failure. */
function readHostGitConfig(key: string): string {
try {
const result = Bun.spawnSync(
[
"git",
"config",
"--global",
key,
],
{
stdio: [
"ignore",
"pipe",
"ignore",
],
},
);
if (result.exitCode === 0) {
return new TextDecoder().decode(result.stdout).trim();
}
} catch {
/* ignore — git may not be installed on host */
}
return "";
}
async function detectGithubAuth(): Promise<void> {
if (process.env.GITHUB_TOKEN) {
githubToken = process.env.GITHUB_TOKEN;
} else {
try {
const result = Bun.spawnSync(
[
"gh",
"auth",
"token",
],
{
stdio: [
"ignore",
"pipe",
"ignore",
],
},
);
if (result.exitCode === 0) {
githubToken = new TextDecoder().decode(result.stdout).trim();
}
} catch {
/* ignore */
}
}
if (githubToken) {
githubAuthRequested = true;
}
// Capture host git identity to propagate to the remote VM
hostGitName = readHostGitConfig("user.name");
hostGitEmail = readHostGitConfig("user.email");
}
export async function offerGithubAuth(runner: CloudRunner): Promise<void> {
if (process.env.SPAWN_SKIP_GITHUB_AUTH) {
return;
}
if (!githubAuthRequested) {
return;
}
let ghCmd = "curl --proto '=https' -fsSL https://openrouter.ai/labs/spawn/shared/github-auth.sh | bash";
let localTmpFile = "";
if (githubToken) {
const escaped = githubToken.replace(/'/g, "'\\''");
localTmpFile = join(tmpdir(), `gh_token_${Date.now()}_${Math.random().toString(36).slice(2)}`);
writeFileSync(localTmpFile, `export GITHUB_TOKEN='${escaped}'`, {
mode: 0o600,
});
const remoteTmpFile = `/tmp/gh_token_${Date.now()}`;
try {
await runner.uploadFile(localTmpFile, remoteTmpFile);
ghCmd = `. ${remoteTmpFile} && rm -f ${remoteTmpFile} && ${ghCmd}`;
} catch {
try {
unlinkSync(localTmpFile);
} catch {
/* ignore */
}
localTmpFile = "";
}
}
logStep("Installing and authenticating GitHub CLI...");
try {
await runner.runServer(ghCmd);
} catch {
logWarn("GitHub CLI setup failed (non-fatal, continuing)");
} finally {
if (localTmpFile) {
try {
unlinkSync(localTmpFile);
} catch {
/* ignore */
}
}
}
// Propagate host git identity to the remote VM
if (hostGitName || hostGitEmail) {
logStep("Configuring git identity...");
const cmds: string[] = [];
if (hostGitName) {
const escaped = hostGitName.replace(/'/g, "'\\''");
cmds.push(`git config --global user.name '${escaped}'`);
}
if (hostGitEmail) {
const escaped = hostGitEmail.replace(/'/g, "'\\''");
cmds.push(`git config --global user.email '${escaped}'`);
}
try {
await runner.runServer(cmds.join(" && "));
logInfo("Git identity configured from host");
} catch {
logWarn("Git identity setup failed (non-fatal, continuing)");
}
}
}
// ─── Codex CLI Config ────────────────────────────────────────────────────────
async function setupCodexConfig(runner: CloudRunner, _apiKey: string): Promise<void> {
logStep("Configuring Codex CLI for OpenRouter...");
const config = `model = "openai/gpt-5-codex"
model_provider = "openrouter"
[model_providers.openrouter]
name = "OpenRouter"
base_url = "https://openrouter.ai/api/v1"
env_key = "OPENROUTER_API_KEY"
wire_api = "responses"
`;
await uploadConfigFile(runner, config, "$HOME/.codex/config.toml");
}
// ─── OpenClaw Config ─────────────────────────────────────────────────────────
async function setupOpenclawConfig(runner: CloudRunner, apiKey: string, modelId: string): Promise<void> {
logStep("Configuring openclaw...");
await runner.runServer("mkdir -p ~/.openclaw");
const gatewayToken = crypto.randomUUID().replace(/-/g, "");
const escapedKey = jsonEscape(apiKey);
const escapedToken = jsonEscape(gatewayToken);
const escapedModel = jsonEscape(modelId);
const config = `{
"env": {
"OPENROUTER_API_KEY": ${escapedKey}
},
"gateway": {
"mode": "local",
"auth": {
"token": ${escapedToken}
}
},
"agents": {
"defaults": {
"model": {
"primary": ${escapedModel}
}
}
}
}`;
await uploadConfigFile(runner, config, "$HOME/.openclaw/openclaw.json");
}
export async function startGateway(runner: CloudRunner): Promise<void> {
logStep("Starting OpenClaw gateway daemon...");
// On Linux with systemd: install a supervised service (Restart=always) +
// hourly cron heartbeat as a belt-and-suspenders backup.
// On macOS/other: fall back to setsid/nohup (unsupervised).
// Base64-encode files to avoid heredoc/quoting issues across cloud SSH.
// Port check: ss is available on all modern Linux; /dev/tcp works on macOS/some bash.
// Debian/Ubuntu bash is compiled WITHOUT /dev/tcp support, so we must not rely on it alone.
const portCheck =
'ss -tln 2>/dev/null | grep -q ":18789 " || ' +
"(echo >/dev/tcp/127.0.0.1/18789) 2>/dev/null || " +
"nc -z 127.0.0.1 18789 2>/dev/null";
const wrapperScript = [
"#!/bin/bash",
'source "$HOME/.spawnrc" 2>/dev/null',
'export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH"',
"exec openclaw gateway",
].join("\n");
// __USER__ and __HOME__ are sed-substituted at deploy time
const unitFile = [
"[Unit]",
"Description=OpenClaw Gateway",
"After=network.target",
"",
"[Service]",
"Type=simple",
"ExecStart=/usr/local/bin/openclaw-gateway-wrapper",
"Restart=always",
"RestartSec=5",
"User=__USER__",
"Environment=HOME=__HOME__",
"StandardOutput=append:/tmp/openclaw-gateway.log",
"StandardError=append:/tmp/openclaw-gateway.log",
"",
"[Install]",
"WantedBy=multi-user.target",
].join("\n");
const wrapperB64 = Buffer.from(wrapperScript).toString("base64");
const unitB64 = Buffer.from(unitFile).toString("base64");
const script = [
"source ~/.spawnrc 2>/dev/null",
"export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH",
"if command -v systemctl >/dev/null 2>&1; then",
' _sudo=""',
' [ "$(id -u)" != "0" ] && _sudo="sudo"',
" printf '%s' '" + wrapperB64 + "' | base64 -d | $_sudo tee /usr/local/bin/openclaw-gateway-wrapper > /dev/null",
" $_sudo chmod +x /usr/local/bin/openclaw-gateway-wrapper",
" printf '%s' '" + unitB64 + "' | base64 -d > /tmp/openclaw-gateway.unit.tmp",
' sed -i "s|__USER__|$(whoami)|;s|__HOME__|$HOME|" /tmp/openclaw-gateway.unit.tmp',
" $_sudo mv /tmp/openclaw-gateway.unit.tmp /etc/systemd/system/openclaw-gateway.service",
" $_sudo systemctl daemon-reload",
" $_sudo systemctl enable openclaw-gateway 2>/dev/null",
" $_sudo systemctl restart openclaw-gateway",
' _cron_restart="systemctl restart openclaw-gateway"',
' [ "$(id -u)" != "0" ] && _cron_restart="sudo systemctl restart openclaw-gateway"',
' (crontab -l 2>/dev/null | grep -v openclaw-gateway; echo "0 * * * * nc -z 127.0.0.1 18789 2>/dev/null || $_cron_restart >> /tmp/openclaw-gateway.log 2>&1") | crontab - 2>/dev/null || true',
"else",
' _oc_bin=$(command -v openclaw) || { echo "openclaw not found in PATH"; exit 1; }',
` if ${portCheck}; then echo "Gateway already running"; exit 0; fi`,
' if command -v setsid >/dev/null 2>&1; then setsid "$_oc_bin" gateway > /tmp/openclaw-gateway.log 2>&1 < /dev/null &',
' else nohup "$_oc_bin" gateway > /tmp/openclaw-gateway.log 2>&1 < /dev/null & fi',
"fi",
"elapsed=0; while [ $elapsed -lt 300 ]; do",
` if ${portCheck}; then echo "Gateway ready after \${elapsed}s"; exit 0; fi`,
" printf '.'; sleep 1; elapsed=$((elapsed + 1))",
"done",
'echo "Gateway failed to start after 300s"; tail -20 /tmp/openclaw-gateway.log 2>/dev/null; exit 1',
].join("\n");
await runner.runServer(script);
logInfo("OpenClaw gateway started");
}
// ─── ZeroClaw Config ─────────────────────────────────────────────────────────
async function setupZeroclawConfig(runner: CloudRunner, _apiKey: string): Promise<void> {
logStep("Configuring ZeroClaw for autonomous operation...");
// Remove any pre-existing config (e.g. from Docker image extraction) before
// running onboard, which generates a fresh config with the correct API key.
await runner.runServer("rm -f ~/.zeroclaw/config.toml");
// Run onboard first to set up provider/key
await runner.runServer(
`source ~/.spawnrc 2>/dev/null; export PATH="$HOME/.cargo/bin:$PATH"; zeroclaw onboard --api-key "\${OPENROUTER_API_KEY}" --provider openrouter`,
);
// Patch autonomy settings in-place. `zeroclaw onboard` already generates
// [security] and [shell] sections — so we sed the values instead of
// appending duplicate sections.
const patchScript = [
"cd ~/.zeroclaw",
// Update existing security values (or append section if missing)
'if grep -q "^\\[security\\]" config.toml 2>/dev/null; then',
" sed -i 's/^autonomy = .*/autonomy = \"full\"/' config.toml",
" sed -i 's/^supervised = .*/supervised = false/' config.toml",
" sed -i 's/^allow_destructive = .*/allow_destructive = true/' config.toml",
"else",
" printf '\\n[security]\\nautonomy = \"full\"\\nsupervised = false\\nallow_destructive = true\\n' >> config.toml",
"fi",
// Update existing shell policy (or append section if missing)
'if grep -q "^\\[shell\\]" config.toml 2>/dev/null; then',
" sed -i 's/^policy = .*/policy = \"allow_all\"/' config.toml",
"else",
" printf '\\n[shell]\\npolicy = \"allow_all\"\\n' >> config.toml",
"fi",
].join("\n");
await runner.runServer(patchScript);
logInfo("ZeroClaw configured for autonomous operation");
}
// ─── Swap Space Setup ─────────────────────────────────────────────────────────
/**
* Ensure swap space exists on the remote machine.
* Used before memory-intensive builds (e.g., Rust compilation) on
* resource-constrained instances (512 MB RAM). Idempotent — skips if
* swap is already configured. Non-fatal if sudo is unavailable.
*/
async function ensureSwapSpace(runner: CloudRunner, sizeMb = 1024): Promise<void> {
if (typeof sizeMb !== "number" || sizeMb <= 0 || !Number.isInteger(sizeMb)) {
throw new Error(`Invalid swap size: ${sizeMb}`);
}
logStep(`Ensuring ${sizeMb} MB swap space for compilation...`);
const script = [
"if swapon --show 2>/dev/null | grep -q /swapfile; then",
" echo '==> Swap already configured, skipping'",
"else",
` echo '==> Creating ${sizeMb} MB swap file...'`,
` sudo fallocate -l ${sizeMb}M /swapfile 2>/dev/null || sudo dd if=/dev/zero of=/swapfile bs=1M count=${sizeMb} status=none`,
" sudo chmod 600 /swapfile",
" sudo mkswap /swapfile >/dev/null",
" sudo swapon /swapfile",
" echo '==> Swap enabled'",
"fi",
].join("\n");
try {
await runner.runServer(script);
logInfo("Swap space ready");
} catch {
logWarn("Swap setup failed (non-fatal) — build may still succeed on larger instances");
}
}
// ─── OpenCode Install Command ────────────────────────────────────────────────
function openCodeInstallCmd(): string {
return 'OC_ARCH=$(uname -m); case "$OC_ARCH" in aarch64) OC_ARCH=arm64;; x86_64) OC_ARCH=x64;; esac; OC_OS=$(uname -s | tr A-Z a-z); mkdir -p /tmp/opencode-install "$HOME/.opencode/bin" && curl --proto \'=https\' -fsSL -o /tmp/opencode-install/oc.tar.gz "https://github.com/sst/opencode/releases/latest/download/opencode-${OC_OS}-${OC_ARCH}.tar.gz" && if tar -tzf /tmp/opencode-install/oc.tar.gz | grep -qE \'(^/|\\.\\.)\'; then echo "Tarball contains unsafe paths" >&2; exit 1; fi && tar xzf /tmp/opencode-install/oc.tar.gz -C /tmp/opencode-install && mv /tmp/opencode-install/opencode "$HOME/.opencode/bin/" && rm -rf /tmp/opencode-install && grep -q ".opencode/bin" "$HOME/.bashrc" 2>/dev/null || echo \'export PATH="$HOME/.opencode/bin:$PATH"\' >> "$HOME/.bashrc"; grep -q ".opencode/bin" "$HOME/.zshrc" 2>/dev/null || echo \'export PATH="$HOME/.opencode/bin:$PATH"\' >> "$HOME/.zshrc" 2>/dev/null; export PATH="$HOME/.opencode/bin:$PATH"';
}
// ─── npm prefix helper ────────────────────────────────────────────────────────
/**
* Shell snippet that detects whether npm's global bin is in PATH.
* Sets _NPM_G_FLAGS to "--prefix ~/.npm-global" when npm's global bin dir
* is NOT reachable from PATH (e.g. Sprite VMs where node is under
* /.sprite/languages/node/nvm/... but that bin dir isn't in PATH).
*
* IMPORTANT: We use --prefix per-command instead of `npm config set prefix`
* because writing .npmrc with a prefix conflicts with nvm (even when nvm
* isn't loaded, npm from an nvm install detects .npmrc prefix and errors).
*/
const NPM_PREFIX_SETUP =
'_NPM_G_FLAGS=""; ' +
'_npm_gbin="$(npm prefix -g 2>/dev/null || echo /usr/local)/bin"; ' +
'if ! [ -w "$(npm prefix -g 2>/dev/null || echo /usr/local)" ] || ' +
'! printf "%s" ":${PATH}:" | grep -qF ":${_npm_gbin}:"; then ' +
'mkdir -p ~/.npm-global/bin; _NPM_G_FLAGS="--prefix $HOME/.npm-global"; fi; ' +
'export PATH="$HOME/.npm-global/bin:$PATH"';
// ─── Default Agent Definitions ───────────────────────────────────────────────
const ZEROCLAW_INSTALL_URL =
"https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/a117be64fdaa31779204beadf2942c8aef57d0e5/scripts/bootstrap.sh";
function createAgents(runner: CloudRunner): Record<string, AgentConfig> {
return {
claude: {
name: "Claude Code",
cloudInitTier: "minimal",
preProvision: detectGithubAuth,
install: () => installClaudeCode(runner),
envVars: (apiKey) => [
`OPENROUTER_API_KEY=${apiKey}`,
"ANTHROPIC_BASE_URL=https://openrouter.ai/api",
`ANTHROPIC_AUTH_TOKEN=${apiKey}`,
"ANTHROPIC_API_KEY=",
"CLAUDE_CODE_SKIP_ONBOARDING=1",
"CLAUDE_CODE_ENABLE_TELEMETRY=0",
],
configure: (apiKey) => setupClaudeCodeConfig(runner, apiKey),
launchCmd: () =>
"source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH; claude",
},
codex: {
name: "Codex CLI",
cloudInitTier: "node",
preProvision: detectGithubAuth,
install: () =>
installAgent(
runner,
"Codex CLI",
`${NPM_PREFIX_SETUP} && npm install -g \${_NPM_G_FLAGS} @openai/codex && ` +
"{ grep -qF '.npm-global/bin' ~/.bashrc 2>/dev/null || echo 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.bashrc; } && " +
"{ [ ! -f ~/.zshrc ] || grep -qF '.npm-global/bin' ~/.zshrc 2>/dev/null || echo 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.zshrc; }",
),
envVars: (apiKey) => [
`OPENROUTER_API_KEY=${apiKey}`,
],
configure: (apiKey) => setupCodexConfig(runner, apiKey),
launchCmd: () => "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; codex",
},
openclaw: {
name: "OpenClaw",
cloudInitTier: "full",
preProvision: detectGithubAuth,
modelDefault: "openrouter/auto",
install: () =>
installAgent(
runner,
"openclaw",
`source ~/.bashrc 2>/dev/null; ${NPM_PREFIX_SETUP} && npm install -g \${_NPM_G_FLAGS} openclaw && ` +
"{ grep -qF '.npm-global/bin' ~/.bashrc 2>/dev/null || echo 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.bashrc; } && " +
"{ [ ! -f ~/.zshrc ] || grep -qF '.npm-global/bin' ~/.zshrc 2>/dev/null || echo 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.zshrc; }",
),
envVars: (apiKey) => [
`OPENROUTER_API_KEY=${apiKey}`,
`ANTHROPIC_API_KEY=${apiKey}`,
"ANTHROPIC_BASE_URL=https://openrouter.ai/api",
],
configure: (apiKey, modelId) => setupOpenclawConfig(runner, apiKey, modelId || "openrouter/auto"),
preLaunch: () => startGateway(runner),
preLaunchMsg:
"Set up one channel at a time in the OpenClaw TUI. Wait for each channel to fully complete before pasting the next token — concurrent token pastes can cause setup to hang.",
launchCmd: () =>
"source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; openclaw tui",
},
opencode: {
name: "OpenCode",
cloudInitTier: "minimal",
preProvision: detectGithubAuth,
install: () => installAgent(runner, "OpenCode", openCodeInstallCmd()),
envVars: (apiKey) => [
`OPENROUTER_API_KEY=${apiKey}`,
],
launchCmd: () => "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; opencode",
},
kilocode: {
name: "Kilo Code",
cloudInitTier: "node",
preProvision: detectGithubAuth,
install: () =>
installAgent(
runner,
"Kilo Code",
`${NPM_PREFIX_SETUP} && npm install -g \${_NPM_G_FLAGS} @kilocode/cli && ` +
"{ grep -qF '.npm-global/bin' ~/.bashrc 2>/dev/null || echo 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.bashrc; } && " +
"{ [ ! -f ~/.zshrc ] || grep -qF '.npm-global/bin' ~/.zshrc 2>/dev/null || echo 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.zshrc; }",
),
envVars: (apiKey) => [
`OPENROUTER_API_KEY=${apiKey}`,
"KILO_PROVIDER_TYPE=openrouter",
`KILO_OPEN_ROUTER_API_KEY=${apiKey}`,
],
launchCmd: () => "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; kilocode",
},
zeroclaw: {
name: "ZeroClaw",
cloudInitTier: "minimal",
preProvision: detectGithubAuth,
install: async () => {
// Add swap before building — low-memory instances (e.g., AWS nano 512 MB)
// OOM during Rust compilation if --prefer-prebuilt falls back to source.
await ensureSwapSpace(runner);
await installAgent(
runner,
"ZeroClaw",
`curl --proto '=https' -LsSf ${ZEROCLAW_INSTALL_URL} | bash -s -- --install-rust --install-system-deps --prefer-prebuilt`,
600, // 10 min: swap-backed compilation is slower than the 5-min default
);
},
envVars: (apiKey) => [
`OPENROUTER_API_KEY=${apiKey}`,
"ZEROCLAW_PROVIDER=openrouter",
],
configure: (apiKey) => setupZeroclawConfig(runner, apiKey),
launchCmd: () =>
"export PATH=$HOME/.cargo/bin:$PATH; source ~/.cargo/env 2>/dev/null; source ~/.spawnrc 2>/dev/null; zeroclaw agent",
},
hermes: {
name: "Hermes Agent",
cloudInitTier: "minimal",
preProvision: detectGithubAuth,
install: () =>
installAgent(
runner,
"Hermes Agent",
"curl --proto '=https' -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash",
300,
),
envVars: (apiKey) => [
`OPENROUTER_API_KEY=${apiKey}`,
"OPENAI_BASE_URL=https://openrouter.ai/api/v1",
`OPENAI_API_KEY=${apiKey}`,
],
launchCmd: () =>
"source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.local/bin:$HOME/.hermes/hermes-agent/venv/bin:$PATH; hermes",
},
junie: {
name: "Junie",
cloudInitTier: "node",
preProvision: detectGithubAuth,
install: () =>
installAgent(
runner,
"Junie",
`${NPM_PREFIX_SETUP} && npm install -g \${_NPM_G_FLAGS} @jetbrains/junie-cli && ` +
"{ grep -qF '.npm-global/bin' ~/.bashrc 2>/dev/null || echo 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.bashrc; } && " +
"{ [ ! -f ~/.zshrc ] || grep -qF '.npm-global/bin' ~/.zshrc 2>/dev/null || echo 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.zshrc; }",
),
envVars: (apiKey) => [
`JUNIE_OPENROUTER_API_KEY=${apiKey}`,
`OPENROUTER_API_KEY=${apiKey}`,
],
launchCmd: () => "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; junie",
},
};
}
function resolveAgent(agents: Record<string, AgentConfig>, name: string): AgentConfig {
const agent = agents[name.toLowerCase()];
if (!agent) {
logError(`Unknown agent: ${name}`);
logError(`Available agents: ${Object.keys(agents).join(", ")}`);
throw new Error(`Unknown agent: ${name}`);
}
return agent;
}
/**
* Factory that creates agents + resolveAgent for a given CloudRunner.
* Replaces the identical 16-line boilerplate in each cloud's agents.ts.
*/
export function createCloudAgents(runner: CloudRunner): {
agents: Record<string, AgentConfig>;
resolveAgent: (name: string) => AgentConfig;
} {
const agentMap = createAgents(runner);
return {
agents: agentMap,
resolveAgent: (name: string) => resolveAgent(agentMap, name),
};
}