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:
A 2026-02-21 14:25:05 -08:00 committed by GitHub
parent 9c0ebcba63
commit bd78f6dc1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 83 additions and 164 deletions

View file

@ -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");
})();
}

View file

@ -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");
}

View file

@ -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