mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
1. Suppress Claude Code curl installer stdout — the remote installer prints its own "Installation complete!" which duplicated the local "Claude Code agent installed successfully" message. 2. Export LANG=C.UTF-8 in both the interactive SSH session command and the .spawnrc env config. Fresh cloud VMs often default to the C locale which cannot render Unicode properly, causing garbled ANSI output in agent TUIs (e.g. "⏵⏵bypasspermissionson" instead of properly spaced text). Fixes #2946 Agent: ux-engineer Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
213 lines
6.6 KiB
TypeScript
213 lines
6.6 KiB
TypeScript
// shared/agents.ts — AgentConfig interface + shared helpers (cloud-agnostic)
|
|
|
|
import { logError, shellQuote } from "./ui.js";
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
/** Cloud-init dependency tier: what packages to pre-install on the VM. */
|
|
export type CloudInitTier = "minimal" | "node" | "bun" | "full";
|
|
|
|
/** An optional post-provision setup step the user can toggle on/off. */
|
|
export interface OptionalStep {
|
|
value: string;
|
|
label: string;
|
|
hint?: string;
|
|
/** Env var that supplies data for this step (e.g. TELEGRAM_BOT_TOKEN). */
|
|
dataEnvVar?: string;
|
|
/** When true, step requires interactive input (e.g. QR scan) — skipped in headless. */
|
|
interactive?: boolean;
|
|
/** When true, step is pre-selected in the multiselect (user can uncheck). */
|
|
defaultOn?: boolean;
|
|
}
|
|
|
|
export interface AgentConfig {
|
|
name: string;
|
|
/** Default model ID passed to configure() (no interactive prompt — override via MODEL_ID env var). */
|
|
modelDefault?: string;
|
|
/** Env var name for setting the model on the remote (e.g. ZEROCLAW_MODEL, LLM_MODEL). */
|
|
modelEnvVar?: string;
|
|
/** Pre-provision hook (runs before server creation, e.g., prompt for GitHub auth). */
|
|
preProvision?: () => Promise<void>;
|
|
/** Install the agent on the remote machine. */
|
|
install: () => Promise<void>;
|
|
/** Return env var pairs for .spawnrc. */
|
|
envVars: (apiKey: string) => string[];
|
|
/** Agent-specific configuration (settings files, etc.). */
|
|
configure?: (apiKey: string, modelId?: string, enabledSteps?: Set<string>) => Promise<void>;
|
|
/** Pre-launch hook (e.g., start gateway daemon). */
|
|
preLaunch?: () => Promise<void>;
|
|
/** Optional tip or warning shown to the user just before the agent launches. */
|
|
preLaunchMsg?: string;
|
|
/** Shell command to launch the agent interactively. */
|
|
launchCmd: () => string;
|
|
/** Cloud-init dependency tier. Defaults to "full" if unset. */
|
|
cloudInitTier?: CloudInitTier;
|
|
/** Skip tarball install attempt (e.g., already using snapshot). */
|
|
skipTarball?: boolean;
|
|
/** SSH tunnel config for web dashboards. */
|
|
tunnel?: TunnelConfig;
|
|
/** Shell command to update the agent to its latest version (used by auto-update timer). */
|
|
updateCmd?: string;
|
|
}
|
|
|
|
/** Configuration for SSH-tunneling a remote port to localhost. */
|
|
export interface TunnelConfig {
|
|
remotePort: number;
|
|
browserUrl?: (localPort: number) => string | undefined;
|
|
}
|
|
|
|
// ─── Agent Optional Steps (static metadata — no CloudRunner needed) ─────────
|
|
|
|
/** Extra setup steps for specific agents (merged with COMMON_STEPS). */
|
|
const AGENT_EXTRA_STEPS: Record<string, OptionalStep[]> = {
|
|
hermes: [
|
|
{
|
|
value: "yolo-mode",
|
|
label: "YOLO mode",
|
|
hint: "let Hermes install tools without approval prompts",
|
|
defaultOn: true,
|
|
},
|
|
],
|
|
openclaw: [
|
|
{
|
|
value: "browser",
|
|
label: "Chrome browser",
|
|
hint: "~400 MB — enables web tools",
|
|
},
|
|
{
|
|
value: "telegram",
|
|
label: "Telegram",
|
|
hint: "connect via bot token from @BotFather",
|
|
dataEnvVar: "TELEGRAM_BOT_TOKEN",
|
|
},
|
|
{
|
|
value: "whatsapp",
|
|
label: "WhatsApp",
|
|
hint: "link via QR code after launch",
|
|
},
|
|
{
|
|
value: "discord",
|
|
label: "Discord",
|
|
hint: "connect via bot token",
|
|
},
|
|
{
|
|
value: "slack",
|
|
label: "Slack",
|
|
hint: "connect via bot + app tokens",
|
|
},
|
|
{
|
|
value: "signal",
|
|
label: "Signal",
|
|
hint: "link via signal-cli",
|
|
},
|
|
{
|
|
value: "googlechat",
|
|
label: "Google Chat",
|
|
hint: "connect via webhook",
|
|
},
|
|
{
|
|
value: "bluebubbles",
|
|
label: "BlueBubbles",
|
|
hint: "iMessage bridge via BlueBubbles server",
|
|
},
|
|
],
|
|
};
|
|
|
|
/** Steps shown for every agent. */
|
|
const COMMON_STEPS: OptionalStep[] = [
|
|
{
|
|
value: "github",
|
|
label: "GitHub CLI",
|
|
hint: "install gh + authenticate on the remote server",
|
|
},
|
|
{
|
|
value: "reuse-api-key",
|
|
label: "Reuse saved OpenRouter key",
|
|
hint: "off = create a fresh key via OAuth",
|
|
},
|
|
{
|
|
value: "custom-model",
|
|
label: "Custom model",
|
|
hint: "enter an OpenRouter model ID manually",
|
|
},
|
|
{
|
|
value: "auto-update",
|
|
label: "Auto-update",
|
|
hint: "keep agent + system packages up to date (every 6h)",
|
|
defaultOn: true,
|
|
},
|
|
];
|
|
|
|
/** Get the optional setup steps for a given agent (no CloudRunner required). */
|
|
export function getAgentOptionalSteps(agentName: string): OptionalStep[] {
|
|
const extra = AGENT_EXTRA_STEPS[agentName];
|
|
return extra
|
|
? [
|
|
...COMMON_STEPS,
|
|
...extra,
|
|
]
|
|
: COMMON_STEPS;
|
|
}
|
|
|
|
/** Validate step names against the known steps for an agent.
|
|
* Returns valid and invalid step names separately. */
|
|
export function validateStepNames(
|
|
agentName: string,
|
|
steps: string[],
|
|
): {
|
|
valid: string[];
|
|
invalid: string[];
|
|
} {
|
|
const known = new Set(getAgentOptionalSteps(agentName).map((s) => s.value));
|
|
const valid: string[] = [];
|
|
const invalid: string[] = [];
|
|
for (const step of steps) {
|
|
if (known.has(step)) {
|
|
valid.push(step);
|
|
} else {
|
|
invalid.push(step);
|
|
}
|
|
}
|
|
return {
|
|
valid,
|
|
invalid,
|
|
};
|
|
}
|
|
|
|
// ─── Shared Helpers ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Generate env config content (shell export lines) for .spawnrc.
|
|
* Values are single-quoted to prevent injection.
|
|
*/
|
|
export function generateEnvConfig(pairs: string[]): string {
|
|
const lines = [
|
|
"",
|
|
"# [spawn:env]",
|
|
"export IS_SANDBOX='1'",
|
|
"# UTF-8 locale — required for agent TUIs that use Unicode (e.g. Claude Code)",
|
|
"export LANG='C.UTF-8'",
|
|
"# Ensure agent binaries are in PATH on reconnect",
|
|
'export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.claude/local/bin:/usr/local/bin:$PATH"',
|
|
];
|
|
for (const pair of pairs) {
|
|
const eqIdx = pair.indexOf("=");
|
|
if (eqIdx === -1) {
|
|
continue;
|
|
}
|
|
const key = pair.slice(0, eqIdx);
|
|
const value = pair.slice(eqIdx + 1);
|
|
// Validate env var name
|
|
if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) {
|
|
logError(`SECURITY: Invalid environment variable name rejected: ${key}`);
|
|
continue;
|
|
}
|
|
// Reject null bytes in value (defense-in-depth)
|
|
if (/\0/.test(value)) {
|
|
logError(`SECURITY: Null byte in environment variable value rejected: ${key}`);
|
|
continue;
|
|
}
|
|
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
}
|
|
return lines.join("\n") + "\n";
|
|
}
|