fix: remove local tarball download path (#2970)

* fix: remove local tarball download, use remote-only tarball install

The local-download-then-SCP-upload path was unnecessary complexity —
downloading a tarball to the user's machine just to re-upload it to the
VM is wasteful. The VM downloads directly from GitHub instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: force zeroclaw native runtime to prevent Docker container hang

ZeroClaw auto-detects Docker and launches in a container (pulling
ghcr.io/openrouterteam/spawn-zeroclaw), which hangs the interactive
session. Force native mode via ZEROCLAW_RUNTIME=native env var and
adapter = "native" in config.toml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: disable openclaw Docker sandbox to prevent container hang

Same issue as zeroclaw — openclaw auto-detects Docker and runs agents
in containers, hanging the interactive session. Disable via
agents.defaults.sandbox.mode = off in config and fallback JSON.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: disable codex Docker sandbox to prevent container hang

Codex CLI also auto-detects Docker for sandboxing. Set
sandbox_mode = "danger-full-access" in config.toml — the VM itself
provides isolation, Docker sandboxing just causes hangs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ahmed Abushagur 2026-03-24 21:42:31 -07:00 committed by GitHub
parent 934dfd309f
commit a551cb2401
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 27 additions and 177 deletions

View file

@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:te
// Suppress stderr (logStep/logWarn) with a spy in beforeEach.
const { tryTarballInstall, uploadAndExtractTarball } = await import("../shared/agent-tarball");
const { tryTarballInstall } = await import("../shared/agent-tarball");
// ── Helpers ──────────────────────────────────────────────────────────────
@ -296,39 +296,3 @@ describe("tryTarballInstall", () => {
});
});
});
describe("uploadAndExtractTarball", () => {
let stderrSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
});
it("mirror step uses sudo for cp and chown", async () => {
const runner = createMockRunner();
await uploadAndExtractTarball(runner, "/tmp/fake.tar.gz");
// 2 calls: extract, then mirror
expect(runner.runServer).toHaveBeenCalledTimes(2);
const mirrorCmd = String(runner.runServer.mock.calls[1][0]);
const sudo = '$([ "$(id -u)" != "0" ] && echo sudo || echo "")';
expect(mirrorCmd).toContain(`${sudo} cp -a "/root/$_d/." "$HOME/$_d/"`);
expect(mirrorCmd).toContain(`${sudo} cp /root/.spawn-tarball "$HOME/.spawn-tarball"`);
expect(mirrorCmd).toContain(`${sudo} chown -R "$(id -u):$(id -g)" "$HOME/.spawn-tarball"`);
expect(mirrorCmd).toContain(`${sudo} chown -R "$(id -u):$(id -g)" "$HOME/$_d"`);
});
it("mirror step does not suppress errors", async () => {
const runner = createMockRunner();
await uploadAndExtractTarball(runner, "/tmp/fake.tar.gz");
const mirrorCmd = String(runner.runServer.mock.calls[1][0]);
expect(mirrorCmd).not.toContain("2>/dev/null || true");
});
});

View file

@ -285,6 +285,7 @@ async function setupCodexConfig(runner: CloudRunner): Promise<void> {
logStep("Configuring Codex CLI for OpenRouter...");
const config = `model = "openai/gpt-5.3-codex"
model_provider = "openrouter"
sandbox_mode = "danger-full-access"
[model_providers.openrouter]
name = "OpenRouter"
@ -390,6 +391,9 @@ async function setupOpenclawConfig(
model: {
primary: modelId,
},
sandbox: {
mode: "off",
},
},
},
},
@ -412,6 +416,15 @@ async function setupOpenclawConfig(
}
}
// Disable Docker sandboxing — when Docker is installed on the VM, openclaw
// auto-detects it and runs agents inside containers, which hangs the session.
await asyncTryCatchIf(isOperationalError, () =>
runner.runServer(
"export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " +
"openclaw config set agents.defaults.sandbox.mode off >/dev/null",
),
);
// Configure browser via CLI (openclaw config set) — the supported way to set
// browser options. Redirect stdout to suppress doctor warnings on each call.
const browserResult = await asyncTryCatchIf(isOperationalError, () =>
@ -608,6 +621,13 @@ async function setupZeroclawConfig(runner: CloudRunner, _apiKey: string): Promis
"else",
" printf '\\n[shell]\\npolicy = \"allow_all\"\\n' >> config.toml",
"fi",
// Force native runtime (no Docker) — zeroclaw auto-detects Docker and
// launches in a container otherwise, which hangs the interactive session.
'if grep -q "^\\[runtime\\]" config.toml 2>/dev/null; then',
" sed -i 's/^adapter = .*/adapter = \"native\"/' config.toml",
"else",
" printf '\\n[runtime]\\nadapter = \"native\"\\n' >> config.toml",
"fi",
].join("\n");
await runner.runServer(patchScript);
logInfo("ZeroClaw configured for autonomous operation");
@ -1003,6 +1023,7 @@ function createAgents(runner: CloudRunner): Record<string, AgentConfig> {
envVars: (apiKey) => [
`OPENROUTER_API_KEY=${apiKey}`,
"ZEROCLAW_PROVIDER=openrouter",
"ZEROCLAW_RUNTIME=native",
],
configure: (apiKey) => setupZeroclawConfig(runner, apiKey),
launchCmd: () =>

View file

@ -4,12 +4,9 @@
import type { CloudRunner } from "./agent-setup.js";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getErrorMessage } from "@openrouter/spawn-shared";
import * as v from "valibot";
import { asyncTryCatch, tryCatch } from "./result.js";
import { asyncTryCatch } from "./result.js";
import { logDebug, logInfo, logStep, logWarn } from "./ui.js";
const REPO = "OpenRouterTeam/spawn";
@ -157,125 +154,3 @@ export async function tryTarballInstall(
logInfo("Agent installed from pre-built tarball");
return true;
}
// ─── Parallel tarball: local download + SCP upload ──────────────────────────
interface LocalTarball {
localPath: string;
cleanup: () => void;
}
/**
* Download the agent tarball to a local temp file (for parallel boot + download).
* Always downloads x86_64 (all current cloud VMs are x86_64).
* Returns null on any failure caller falls back to remote download.
*/
export async function downloadTarballLocally(
agentName: string,
fetchFn: typeof fetch = fetch,
): Promise<LocalTarball | null> {
logStep("Downloading tarball locally...");
const r = await asyncTryCatch(async () => {
const tag = `agent-${agentName}-latest`;
const resp = await fetchFn(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, {
headers: {
Accept: "application/vnd.github+json",
},
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
return null;
}
const json: unknown = await resp.json();
const parsed = v.safeParse(ReleaseSchema, json);
if (!parsed.success) {
return null;
}
const x86Asset = parsed.output.assets.find((a) => a.name.includes("-x86_64-") && a.name.endsWith(".tar.gz"));
const downloadUrl = x86Asset?.browser_download_url;
if (!downloadUrl) {
return null;
}
const urlPattern = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/releases\/download\/[^\s'"`;|&$()]+$/;
if (!urlPattern.test(downloadUrl)) {
return null;
}
const dlResp = await fetchFn(downloadUrl, {
signal: AbortSignal.timeout(120_000),
redirect: "follow",
});
if (!dlResp.ok || !dlResp.body) {
return null;
}
const localPath = join(tmpdir(), `spawn-agent-${agentName}-${Date.now()}.tar.gz`);
await Bun.write(localPath, dlResp);
logInfo("Tarball downloaded locally");
return {
localPath,
cleanup: () => {
tryCatch(() => unlinkSync(localPath));
},
};
});
if (!r.ok) {
logDebug(`Local tarball download failed: ${getErrorMessage(r.error)}`);
return null;
}
return r.data;
}
/**
* Upload a locally-downloaded tarball to the remote VM and extract it.
*/
export async function uploadAndExtractTarball(runner: CloudRunner, localPath: string): Promise<boolean> {
logStep("Uploading tarball to server...");
const remotePath = "/tmp/spawn-agent-parallel.tar.gz";
const sudo = '$([ "$(id -u)" != "0" ] && echo sudo || echo "")';
const uploadResult = await asyncTryCatch(() => runner.uploadFile(localPath, remotePath));
if (!uploadResult.ok) {
logWarn("Tarball upload failed");
logDebug(getErrorMessage(uploadResult.error));
return false;
}
const extractCmd =
`${sudo} tar xz -C / -f ${remotePath} && ` + `${sudo} test -f /root/.spawn-tarball && ` + `rm -f ${remotePath}`;
const extractResult = await asyncTryCatch(() => runner.runServer(extractCmd, 150));
if (!extractResult.ok) {
logWarn("Tarball extraction failed on remote VM");
logDebug(getErrorMessage(extractResult.error));
return false;
}
// Mirror /root/ files for non-root SSH users
const mirrorCmd = [
'if [ "$(id -u)" != "0" ]; then',
" for _d in .claude .local .npm-global .cargo .opencode .hermes .bun; do",
' if [ -d "/root/$_d" ]; then',
' mkdir -p "$HOME/$_d"',
` ${sudo} cp -a "/root/$_d/." "$HOME/$_d/"`,
" fi",
" done",
` ${sudo} cp /root/.spawn-tarball "$HOME/.spawn-tarball"`,
` ${sudo} chown -R "$(id -u):$(id -g)" "$HOME/.spawn-tarball"`,
" for _d in .claude .local .npm-global .cargo .opencode .hermes .bun; do",
' if [ -d "$HOME/$_d" ]; then',
` ${sudo} chown -R "$(id -u):$(id -g)" "$HOME/$_d"`,
" fi",
" done",
"fi",
].join("\n");
await asyncTryCatch(() => runner.runServer(mirrorCmd, 30));
logInfo("Agent installed from pre-built tarball (parallel)");
return true;
}

View file

@ -11,7 +11,7 @@ import { getErrorMessage } from "@openrouter/spawn-shared";
import * as v from "valibot";
import { generateSpawnId, saveLaunchCmd, saveMetadata, saveSpawnRecord } from "../history.js";
import { offerGithubAuth, setupAutoUpdate, wrapSshCall } from "./agent-setup.js";
import { downloadTarballLocally, tryTarballInstall, uploadAndExtractTarball } from "./agent-tarball.js";
import { tryTarballInstall } from "./agent-tarball.js";
import { generateEnvConfig } from "./agents.js";
import { getOrPromptApiKey } from "./oauth.js";
import { getSpawnPreferencesPath } from "./paths.js";
@ -187,7 +187,7 @@ export async function runOrchestration(
const resolveApiKey = options?.getApiKey ?? getOrPromptApiKey;
// These all run concurrently with server boot
const [bootResult, apiKeyResult, , , tarballResult] = await Promise.allSettled([
const [bootResult, apiKeyResult] = await Promise.allSettled([
serverBootPromise,
resolveApiKey(agentName, cloud.cloudName),
cloud.checkAccountReady
@ -200,7 +200,6 @@ export async function runOrchestration(
: Promise.resolve({
ok: true,
}),
!cloud.skipAgentInstall && !agent.skipTarball ? downloadTarballLocally(agentName) : Promise.resolve(null),
]);
// Server boot must succeed — retry if it failed
@ -246,21 +245,12 @@ export async function runOrchestration(
}
const envContent = generateEnvConfig(envPairs);
// Install agent — parallel tarball upload, fallback to remote, then live
// Install agent — remote tarball, fallback to live install
if (cloud.skipAgentInstall) {
logInfo("Snapshot boot — skipping agent install");
} else {
let installed = false;
const localTarball = tarballResult.status === "fulfilled" ? tarballResult.value : null;
if (localTarball) {
installed = await uploadAndExtractTarball(cloud.runner, localTarball.localPath);
localTarball.cleanup();
}
// Only try remote tarball download when we didn't already have a local tarball.
// If the local tarball was available but upload/extract failed, the remote
// download would face the same extraction issues — skip it to save ~150s
// and fall through to live install immediately.
if (!installed && !localTarball && useTarball && !agent.skipTarball) {
if (useTarball && !agent.skipTarball) {
const tarball = options?.tryTarball ?? tryTarballInstall;
installed = await tarball(cloud.runner, agentName);
}