mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-20 01:11:18 +00:00
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:
parent
934dfd309f
commit
a551cb2401
4 changed files with 27 additions and 177 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue