mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-12 14:20:17 +00:00
fix: replace fly machine exec with fly ssh console to fix 408 timeouts (#1623)
fly machine exec uses Fly's HTTP exec API which randomly returns 408 deadline_exceeded on commands >30s. Switch all non-interactive remote execution (runServer, runServerCapture, uploadFile) to fly ssh console -C which uses WireGuard tunneling and is reliable for long-running commands. Also batch ~25 individual remote calls into ~4 combined shell scripts: - waitForCloudInit: 8 calls → 1 (apt, node, bun, PATH setup) - installClaudeCode: 8 calls → 1 (cleanup, install, finalize) - setupClaudeCodeConfig: 5 calls → 1 (inline base64 file writes) - env setup in main.ts: 4 calls → 1 (inline base64 + shell hooks) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
parent
9c0ebcba63
commit
bd78f6dc1f
3 changed files with 83 additions and 164 deletions
|
|
@ -109,82 +109,48 @@ async function uploadConfigFile(
|
|||
// ─── Claude Code ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function installClaudeCode(): Promise<void> {
|
||||
const claudePath =
|
||||
'export PATH=$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH';
|
||||
logStep("Installing Claude Code...");
|
||||
|
||||
// Clean up broken ~/.bash_profile from previous deployments
|
||||
try {
|
||||
await runServer(
|
||||
"if [ -f ~/.bash_profile ] && grep -q 'spawn:env\\|Claude Code PATH\\|spawn:path' ~/.bash_profile 2>/dev/null; then rm -f ~/.bash_profile; fi",
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
// Batch the entire install into a single remote script to avoid multiple
|
||||
// round-trips that each risked 408 deadline_exceeded via fly machine exec.
|
||||
const claudePath = '$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/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}`;
|
||||
|
||||
// Already installed?
|
||||
try {
|
||||
await runServerCapture(`${claudePath} && command -v claude`, 15);
|
||||
logInfo("Claude Code already installed");
|
||||
await finalizeClaudeInstall(claudePath);
|
||||
return;
|
||||
} catch { /* not installed */ }
|
||||
const script = [
|
||||
`export PATH="${claudePath}:$PATH"`,
|
||||
// Clean up broken ~/.bash_profile from previous deployments
|
||||
`if [ -f ~/.bash_profile ] && grep -q 'spawn:env\\|Claude Code PATH\\|spawn:path' ~/.bash_profile 2>/dev/null; then rm -f ~/.bash_profile; fi`,
|
||||
// Already installed?
|
||||
`if command -v claude >/dev/null 2>&1; then ${finalize}; exit 0; fi`,
|
||||
// Method 1: curl installer
|
||||
`echo "==> Installing Claude Code (method 1/2: curl installer)..."`,
|
||||
`curl -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`,
|
||||
// Ensure Node.js for npm method
|
||||
`if ! command -v node >/dev/null 2>&1; then apt-get install -y nodejs npm 2>/dev/null && npm install -g n && n 22 && ln -sf /usr/local/bin/node /usr/bin/node && ln -sf /usr/local/bin/npm /usr/bin/npm && ln -sf /usr/local/bin/npx /usr/bin/npx || true; fi`,
|
||||
// Method 2: npm
|
||||
`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`,
|
||||
// All methods failed
|
||||
`exit 1`,
|
||||
].join('\n');
|
||||
|
||||
// Method 1: curl installer
|
||||
logStep("Installing Claude Code (method 1/2: curl installer)...");
|
||||
try {
|
||||
await runServer("curl -fsSL https://claude.ai/install.sh | bash");
|
||||
await runServerCapture(`${claudePath} && command -v claude`, 15);
|
||||
logInfo("Claude Code installed via curl installer");
|
||||
await finalizeClaudeInstall(claudePath);
|
||||
return;
|
||||
await runServer(script, 300);
|
||||
logInfo("Claude Code installed");
|
||||
} catch {
|
||||
logWarn("curl installer failed");
|
||||
logError("Claude Code installation failed");
|
||||
throw new Error("Claude Code install failed");
|
||||
}
|
||||
|
||||
// Ensure Node.js for npm/bun methods
|
||||
try {
|
||||
await runServerCapture(`${claudePath} && command -v node`, 15);
|
||||
} catch {
|
||||
logStep("Installing Node.js runtime (required for claude package)...");
|
||||
try {
|
||||
await runServer(
|
||||
"apt-get install -y nodejs npm && npm install -g n && n 22 && ln -sf /usr/local/bin/node /usr/bin/node && ln -sf /usr/local/bin/npm /usr/bin/npm && ln -sf /usr/local/bin/npx /usr/bin/npx",
|
||||
);
|
||||
} catch {
|
||||
logWarn("Could not install Node.js");
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: npm
|
||||
logStep("Installing Claude Code (method 2/2: npm)...");
|
||||
try {
|
||||
await runServer(`${claudePath} && npm install -g @anthropic-ai/claude-code`);
|
||||
await runServerCapture(`${claudePath} && command -v claude`, 15);
|
||||
logInfo("Claude Code installed via npm");
|
||||
await finalizeClaudeInstall(claudePath);
|
||||
return;
|
||||
} catch {
|
||||
logWarn("npm install failed");
|
||||
}
|
||||
|
||||
logError("Claude Code installation failed");
|
||||
throw new Error("Claude Code install failed");
|
||||
}
|
||||
|
||||
async function finalizeClaudeInstall(claudePath: string): Promise<void> {
|
||||
logStep("Setting up Claude Code shell integration...");
|
||||
try {
|
||||
await runServer(`${claudePath} && claude install --force`);
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
await runServer(
|
||||
`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`,
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function setupClaudeCodeConfig(apiKey: string): Promise<void> {
|
||||
return (async () => {
|
||||
logStep("Configuring Claude Code...");
|
||||
await runServer("mkdir -p ~/.claude");
|
||||
|
||||
const escapedKey = jsonEscape(apiKey);
|
||||
const settingsJson = `{
|
||||
|
|
@ -200,14 +166,19 @@ function setupClaudeCodeConfig(apiKey: string): Promise<void> {
|
|||
"dangerouslySkipPermissions": true
|
||||
}
|
||||
}`;
|
||||
await uploadConfigFile(settingsJson, "$HOME/.claude/settings.json");
|
||||
|
||||
const globalState = `{
|
||||
"hasCompletedOnboarding": true,
|
||||
"bypassPermissionsModeAccepted": true
|
||||
}`;
|
||||
await uploadConfigFile(globalState, "$HOME/.claude.json");
|
||||
await runServer("touch ~/.claude/CLAUDE.md");
|
||||
// Inline base64 file writes in a single remote call instead of
|
||||
// separate mkdir + uploadFile + mv calls for each config file.
|
||||
const settingsB64 = Buffer.from(settingsJson).toString("base64");
|
||||
const stateB64 = Buffer.from(globalState).toString("base64");
|
||||
|
||||
await runServer(
|
||||
`mkdir -p ~/.claude && printf '%s' '${settingsB64}' | base64 -d > ~/.claude/settings.json && chmod 600 ~/.claude/settings.json && printf '%s' '${stateB64}' | base64 -d > ~/.claude.json && chmod 600 ~/.claude.json && touch ~/.claude/CLAUDE.md`,
|
||||
);
|
||||
logInfo("Claude Code configured");
|
||||
})();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -610,23 +610,17 @@ export async function runServer(
|
|||
const fullCmd = `export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`;
|
||||
const flyCmd = getCmd()!;
|
||||
|
||||
const args = [flyCmd, "machine", "exec", flyMachineId, "--app", flyAppName, "--", "bash", "-c", fullCmd];
|
||||
const escapedCmd = fullCmd.replace(/'/g, "'\\''");
|
||||
// Use fly ssh console (WireGuard) instead of fly machine exec (HTTP) to avoid
|
||||
// 408 deadline_exceeded on long-running commands.
|
||||
const args = [flyCmd, "ssh", "console", "-a", flyAppName, "-C", `bash -c '${escapedCmd}'`];
|
||||
|
||||
if (timeoutSecs) {
|
||||
// Look for timeout/gtimeout
|
||||
const timeoutBin = Bun.spawnSync(["which", "timeout"], { stdio: ["ignore", "pipe", "ignore"] }).exitCode === 0
|
||||
? "timeout"
|
||||
: Bun.spawnSync(["which", "gtimeout"], { stdio: ["ignore", "pipe", "ignore"] }).exitCode === 0
|
||||
? "gtimeout"
|
||||
: null;
|
||||
|
||||
if (timeoutBin) {
|
||||
args.unshift(timeoutBin, String(timeoutSecs));
|
||||
}
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(args, { stdio: ["inherit", "inherit", "inherit"] });
|
||||
const proc = Bun.spawn(args, { stdio: ["inherit", "inherit", "inherit"], env: process.env });
|
||||
// Local safety timer — WireGuard has no HTTP deadline but we still want a ceiling.
|
||||
const timeout = (timeoutSecs || 300) * 1000;
|
||||
const timer = setTimeout(() => { try { proc.kill(); } catch {} }, timeout);
|
||||
const exitCode = await proc.exited;
|
||||
clearTimeout(timer);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`);
|
||||
}
|
||||
|
|
@ -640,18 +634,16 @@ export async function runServerCapture(
|
|||
const fullCmd = `export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`;
|
||||
const flyCmd = getCmd()!;
|
||||
|
||||
const args = [flyCmd, "machine", "exec", flyMachineId, "--app", flyAppName, "--", "bash", "-c", fullCmd];
|
||||
const escapedCmd = fullCmd.replace(/'/g, "'\\''");
|
||||
const args = [flyCmd, "ssh", "console", "-a", flyAppName, "-C", `bash -c '${escapedCmd}'`];
|
||||
|
||||
const proc = Bun.spawn(args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (timeoutSecs) {
|
||||
timer = setTimeout(() => proc.kill(), timeoutSecs * 1000);
|
||||
}
|
||||
const proc = Bun.spawn(args, { stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
||||
const timeout = (timeoutSecs || 300) * 1000;
|
||||
const timer = setTimeout(() => { try { proc.kill(); } catch {} }, timeout);
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const exitCode = await proc.exited;
|
||||
if (timer) clearTimeout(timer);
|
||||
clearTimeout(timer);
|
||||
|
||||
if (exitCode !== 0) throw new Error(`run_server_capture failed (exit ${exitCode})`);
|
||||
return stdout.trim();
|
||||
|
|
@ -667,13 +659,12 @@ export async function uploadFile(
|
|||
}
|
||||
const flyCmd = getCmd()!;
|
||||
const fs = require("fs");
|
||||
const content = fs.readFileSync(localPath);
|
||||
const content: Buffer = fs.readFileSync(localPath);
|
||||
const b64 = content.toString("base64");
|
||||
const proc = Bun.spawn(
|
||||
[flyCmd, "machine", "exec", flyMachineId, "--app", flyAppName, "--", "bash", "-c", `cat > ${remotePath}`],
|
||||
{ stdio: ["pipe", "ignore", "ignore"] },
|
||||
[flyCmd, "ssh", "console", "-a", flyAppName, "-C", `bash -c 'printf "%s" ${b64} | base64 -d > ${remotePath}'`],
|
||||
{ stdio: ["ignore", "ignore", "ignore"], env: process.env },
|
||||
);
|
||||
proc.stdin!.write(content);
|
||||
proc.stdin!.end();
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) throw new Error(`upload_file failed for ${remotePath}`);
|
||||
}
|
||||
|
|
@ -686,7 +677,7 @@ export async function interactiveSession(cmd: string): Promise<number> {
|
|||
|
||||
const proc = Bun.spawn(
|
||||
[flyCmd, "ssh", "console", "-a", flyAppName, "--pty", "-C", `bash -c '${escapedCmd}'`],
|
||||
{ stdio: ["inherit", "inherit", "inherit"] },
|
||||
{ stdio: ["inherit", "inherit", "inherit"], env: process.env },
|
||||
);
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
|
|
@ -750,57 +741,25 @@ export async function waitForSsh(maxAttempts = 20): Promise<void> {
|
|||
export async function waitForCloudInit(): Promise<void> {
|
||||
await waitForSsh();
|
||||
|
||||
logStep("Installing packages...");
|
||||
try {
|
||||
await runWithRetry(3, 10, 300, "apt-get update -y && apt-get install -y curl unzip git");
|
||||
} catch {
|
||||
logWarn("Package install failed, continuing anyway...");
|
||||
}
|
||||
logStep("Installing packages (Node.js, bun)...");
|
||||
// Batch all package installs into a single remote script to avoid multiple
|
||||
// round-trips (each of which was previously a separate fly machine exec call).
|
||||
const setupScript = [
|
||||
`echo "==> Installing base packages..."`,
|
||||
`apt-get update -y && apt-get install -y curl unzip git || true`,
|
||||
`echo "==> Checking Node.js..."`,
|
||||
`if ! command -v node >/dev/null 2>&1; then { curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs; } || apt-get install -y nodejs || true; fi`,
|
||||
`echo "node: $(node --version 2>/dev/null || echo not installed)"`,
|
||||
`echo "==> Checking bun..."`,
|
||||
`if ! command -v bun >/dev/null 2>&1 && [ ! -f "$HOME/.bun/bin/bun" ]; then curl -fsSL https://bun.sh/install | bash || true; fi`,
|
||||
`for rc in ~/.bashrc ~/.zshrc; do grep -q '.bun/bin' "$rc" 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"' >> "$rc"; done`,
|
||||
].join('\n');
|
||||
|
||||
logStep("Installing Node.js...");
|
||||
try {
|
||||
await runWithRetry(
|
||||
3,
|
||||
10,
|
||||
180,
|
||||
"curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs",
|
||||
);
|
||||
await runWithRetry(3, 10, 300, setupScript);
|
||||
} catch {
|
||||
// ignore
|
||||
logWarn("Package install had errors, continuing...");
|
||||
}
|
||||
|
||||
// Verify node
|
||||
try {
|
||||
await runServerCapture("which node && node --version", 15);
|
||||
const ver = await runServerCapture("node --version", 10);
|
||||
logInfo(`Node.js installed: ${ver}`);
|
||||
} catch {
|
||||
logWarn("Node.js not found after nodesource install, falling back to default Debian package...");
|
||||
try {
|
||||
await runWithRetry(2, 5, 120, "apt-get install -y nodejs");
|
||||
const ver = await runServerCapture("node --version", 10);
|
||||
logInfo(`Node.js installed from default Debian repos: ${ver}`);
|
||||
} catch {
|
||||
logError("Node.js is NOT installed — npm-based agents will not work");
|
||||
}
|
||||
}
|
||||
|
||||
logStep("Installing bun...");
|
||||
try {
|
||||
await runWithRetry(2, 5, 120, "curl -fsSL https://bun.sh/install | bash");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Add to PATH in shell configs
|
||||
const pathLine = 'echo "export PATH=\\"\\$HOME/.local/bin:\\$HOME/.bun/bin:\\$PATH\\"" >> ~/.bashrc';
|
||||
const pathLineZsh = 'echo "export PATH=\\"\\$HOME/.local/bin:\\$HOME/.bun/bin:\\$PATH\\"" >> ~/.zshrc';
|
||||
try {
|
||||
await runServer(pathLine, 30);
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
await runServer(pathLineZsh, 30);
|
||||
} catch { /* ignore */ }
|
||||
logInfo("Base tools installed");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
getServerName,
|
||||
waitForCloudInit,
|
||||
runServer,
|
||||
uploadFile,
|
||||
interactiveSession,
|
||||
} from "./fly";
|
||||
import { getOrPromptApiKey, getModelIdInteractive } from "./oauth";
|
||||
|
|
@ -76,29 +75,19 @@ async function main() {
|
|||
await agent.install();
|
||||
|
||||
// 8. Inject environment variables via .spawnrc
|
||||
// Inline base64 write + shell hook in a single remote call instead of
|
||||
// separate uploadFile + mv + 2× shell hook calls.
|
||||
logStep("Setting up environment variables...");
|
||||
const envContent = generateEnvConfig(agent.envVars(apiKey));
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const tmpFile = path.join(os.tmpdir(), `spawn_env_${Date.now()}`);
|
||||
fs.writeFileSync(tmpFile, envContent, { mode: 0o600 });
|
||||
|
||||
const tempRemote = `/tmp/spawn_env_${Date.now()}`;
|
||||
const envB64 = Buffer.from(envContent).toString("base64");
|
||||
try {
|
||||
await uploadFile(tmpFile, tempRemote);
|
||||
await runServer(
|
||||
`cp '${tempRemote}' ~/.spawnrc && chmod 600 ~/.spawnrc; rm -f '${tempRemote}'`,
|
||||
`printf '%s' '${envB64}' | base64 -d > ~/.spawnrc && chmod 600 ~/.spawnrc; ` +
|
||||
`grep -q 'source ~/.spawnrc' ~/.bashrc 2>/dev/null || echo '[ -f ~/.spawnrc ] && source ~/.spawnrc' >> ~/.bashrc; ` +
|
||||
`grep -q 'source ~/.spawnrc' ~/.zshrc 2>/dev/null || echo '[ -f ~/.spawnrc ] && source ~/.spawnrc' >> ~/.zshrc`,
|
||||
);
|
||||
// Hook .spawnrc into shell configs
|
||||
await runServer(
|
||||
"grep -q 'source ~/.spawnrc' ~/.bashrc 2>/dev/null || echo '[ -f ~/.spawnrc ] && source ~/.spawnrc' >> ~/.bashrc",
|
||||
).catch(() => logWarn("Could not hook .spawnrc into .bashrc"));
|
||||
await runServer(
|
||||
"grep -q 'source ~/.spawnrc' ~/.zshrc 2>/dev/null || echo '[ -f ~/.spawnrc ] && source ~/.spawnrc' >> ~/.zshrc",
|
||||
).catch(() => logWarn("Could not hook .spawnrc into .zshrc"));
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpFile); } catch { /* ignore */ }
|
||||
} catch {
|
||||
logWarn("Environment setup had errors");
|
||||
}
|
||||
|
||||
// GitHub CLI setup
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue