diff --git a/packages/cli/src/__tests__/agent-tarball.test.ts b/packages/cli/src/__tests__/agent-tarball.test.ts index 522c1bba..8b01067f 100644 --- a/packages/cli/src/__tests__/agent-tarball.test.ts +++ b/packages/cli/src/__tests__/agent-tarball.test.ts @@ -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; - - 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"); - }); -}); diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 8930b913..fb4bc619 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -285,6 +285,7 @@ async function setupCodexConfig(runner: CloudRunner): Promise { 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 { envVars: (apiKey) => [ `OPENROUTER_API_KEY=${apiKey}`, "ZEROCLAW_PROVIDER=openrouter", + "ZEROCLAW_RUNTIME=native", ], configure: (apiKey) => setupZeroclawConfig(runner, apiKey), launchCmd: () => diff --git a/packages/cli/src/shared/agent-tarball.ts b/packages/cli/src/shared/agent-tarball.ts index 166356ae..56fe863e 100644 --- a/packages/cli/src/shared/agent-tarball.ts +++ b/packages/cli/src/shared/agent-tarball.ts @@ -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 { - 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 { - 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; -} diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index b58db5ab..ff11e04f 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -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); }