From 561be1cef9f64ee275523e01c451a81403c8fa40 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 9 Apr 2026 23:45:16 -0700 Subject: [PATCH] fix: extract tarballs directly to $HOME on non-root VMs (#3253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tarballs are built with /root/ paths. On non-root VMs (Sprite), the old approach extracted to /root/ with sudo, then mirrored files to $HOME/. This failed on Sprite which doesn't have sudo. New approach: use tar --transform to remap /root/ → $HOME/ during extraction. No sudo needed, no mirror step. Falls back to sudo extract for clouds with passwordless sudo (AWS, GCP). Co-authored-by: Claude Opus 4.6 (1M context) --- packages/cli/package.json | 2 +- .../cli/src/__tests__/agent-tarball.test.ts | 70 ++----------------- packages/cli/src/shared/agent-tarball.ts | 61 +++++++--------- 3 files changed, 34 insertions(+), 99 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 8cd9a8af..eb24580b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.32.3", + "version": "0.32.4", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/agent-tarball.test.ts b/packages/cli/src/__tests__/agent-tarball.test.ts index 8b01067f..e8cf96c0 100644 --- a/packages/cli/src/__tests__/agent-tarball.test.ts +++ b/packages/cli/src/__tests__/agent-tarball.test.ts @@ -78,21 +78,22 @@ describe("tryTarballInstall", () => { expect(getUrl()).toContain("/releases/tags/agent-openclaw-latest"); }); - it("runs curl | tar xz -C / on the remote VM", async () => { + it("runs curl | tar xz on the remote VM with non-root transform", async () => { const fetchFn = mockFetch(new Response(JSON.stringify(RELEASE_PAYLOAD))); const runner = createMockRunner(); const result = await tryTarballInstall(runner, "openclaw", fetchFn); expect(result).toBe(true); - // 2 calls: download+extract, then mirror files for non-root users - expect(runner.runServer).toHaveBeenCalledTimes(2); + expect(runner.runServer).toHaveBeenCalledTimes(1); const cmd = String(runner.runServer.mock.calls[0][0]); expect(cmd).toContain("curl -fsSL"); - expect(cmd).toContain("tar xz -C /"); + expect(cmd).toContain("tar xz"); expect(cmd).toContain(".spawn-tarball"); - const mirrorCmd = String(runner.runServer.mock.calls[1][0]); - expect(mirrorCmd).toContain("cp -a"); + // Non-root: uses --transform to remap /root/ to $HOME/ + expect(cmd).toContain("--transform"); + // Fallback to sudo for clouds with passwordless sudo + expect(cmd).toContain("sudo tar xz -C /"); }); it("returns false when release does not exist (404)", async () => { @@ -238,61 +239,4 @@ describe("tryTarballInstall", () => { expect(cmd).not.toContain("exit 1"); }); }); - - describe("non-root home directory mirroring", () => { - let mirrorCmd: string; - - beforeEach(async () => { - const fetchFn = mockFetch(new Response(JSON.stringify(RELEASE_PAYLOAD))); - const runner = createMockRunner(); - await tryTarballInstall(runner, "openclaw", fetchFn); - mirrorCmd = String(runner.runServer.mock.calls[1][0]); - }); - - it("mirrors dotfiles from /root/ to $HOME with non-root guard and ownership fix", () => { - expect(mirrorCmd).toContain("cp -a"); - expect(mirrorCmd).toContain('"$HOME/$_d"'); - expect(mirrorCmd).toContain('if [ "$(id -u)" != "0" ]; then'); - expect(mirrorCmd).toContain('chown -R "$(id -u):$(id -g)"'); - expect(mirrorCmd).toContain('cp /root/.spawn-tarball "$HOME/.spawn-tarball"'); - for (const dir of [ - ".claude", - ".local", - ".npm-global", - ".cargo", - ".opencode", - ".hermes", - ".bun", - ]) { - expect(mirrorCmd).toContain(dir); - } - }); - - it("uses sudo for cp and chown in mirror commands", () => { - // sudo is needed because non-root users can't read /root/ or chown root-owned files - const sudo = '$([ "$(id -u)" != "0" ] && echo sudo || echo "")'; - // cp from /root/ needs sudo - expect(mirrorCmd).toContain(`${sudo} cp -a "/root/$_d/." "$HOME/$_d/"`); - // cp marker file needs sudo - expect(mirrorCmd).toContain(`${sudo} cp /root/.spawn-tarball "$HOME/.spawn-tarball"`); - // chown needs sudo - 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("does not suppress errors with 2>/dev/null || true", () => { - // Errors must propagate so failures are visible, not silently swallowed - expect(mirrorCmd).not.toContain("2>/dev/null || true"); - }); - - it("returns true even when mirror step fails (non-fatal)", async () => { - const fetchFn = mockFetch(new Response(JSON.stringify(RELEASE_PAYLOAD))); - const runner = createMockRunner(); - runner.runServer.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error("cp failed")); - - const result = await tryTarballInstall(runner, "openclaw", fetchFn); - - expect(result).toBe(true); - }); - }); }); diff --git a/packages/cli/src/shared/agent-tarball.ts b/packages/cli/src/shared/agent-tarball.ts index 56fe863e..0e9d176d 100644 --- a/packages/cli/src/shared/agent-tarball.ts +++ b/packages/cli/src/shared/agent-tarball.ts @@ -95,24 +95,43 @@ export async function tryTarballInstall( logStep("Downloading pre-built agent tarball..."); // Build arch-aware download command: remote VM picks the right URL based on uname -m - // Use sudo for tar extraction — on clouds like AWS Lightsail, SSH user is 'ubuntu' (non-root) - // but tarballs extract to /root/. The ubuntu user has passwordless sudo. - const sudo = '$([ "$(id -u)" != "0" ] && echo sudo || echo "")'; + // + // Tarballs are built with absolute /root/ paths. Two strategies: + // - Root user: extract directly to / (fast, no transform needed) + // - Non-root user: use tar --transform to remap /root/ to $HOME/ during extraction. + // This avoids needing sudo entirely (Sprite VMs don't have it). + // Falls back to sudo-based extraction for clouds with passwordless sudo (AWS, GCP). + const extractCmd = [ + 'if [ "$(id -u)" = "0" ]; then', + " tar xz -C /", + "else", + // Try transform first (no sudo needed) — remap /root/ paths to $HOME/ + ' tar xz --transform "s|^root/|${HOME#/}/|" -C / 2>/dev/null ||', + // Fallback: sudo extract + mirror (for clouds with passwordless sudo) + " sudo tar xz -C / 2>/dev/null", + "fi", + ].join("\n"); + + // Arch detection + URL selection + download + extract + verify marker + const markerCheck = [ + "if [ -f /root/.spawn-tarball ]; then true", + 'elif [ -f "$HOME/.spawn-tarball" ]; then true', + "else false; fi", + ].join("; "); + let downloadCmd: string; if (x86Url && armUrl) { downloadCmd = "_arch=$(uname -m); " + `if [ "$_arch" = "aarch64" ] || [ "$_arch" = "arm64" ]; then ` + `_url='${armUrl}'; else _url='${x86Url}'; fi; ` + - `curl -fsSL --connect-timeout 10 --max-time 120 "$_url" | ${sudo} tar xz -C / && ${sudo} test -f /root/.spawn-tarball`; + `curl -fsSL --connect-timeout 10 --max-time 120 "$_url" | (${extractCmd}) && (${markerCheck})`; } else { - // Only one arch available — validate the remote VM matches before downloading. - // If the arch doesn't match, fail so the caller falls back to live install. const isArm = !!armUrl; const archGuard = isArm ? '_arch=$(uname -m); if [ "$_arch" != "aarch64" ] && [ "$_arch" != "arm64" ]; then echo "Tarball is arm64 but VM is $_arch" >&2; exit 1; fi; ' : '_arch=$(uname -m); if [ "$_arch" = "aarch64" ] || [ "$_arch" = "arm64" ]; then echo "Tarball is x86_64 but VM is $_arch" >&2; exit 1; fi; '; - downloadCmd = `${archGuard}curl -fsSL --connect-timeout 10 --max-time 120 '${url}' | ${sudo} tar xz -C / && ${sudo} test -f /root/.spawn-tarball`; + downloadCmd = `${archGuard}curl -fsSL --connect-timeout 10 --max-time 120 '${url}' | (${extractCmd}) && (${markerCheck})`; } // Phase 3: Remote execution — catch-all because any failure means "fall back to live install" @@ -123,34 +142,6 @@ export async function tryTarballInstall( return false; } - // Phase 4: Mirror /root/ files to $HOME/ for non-root SSH users (e.g. GCP, AWS Lightsail). - // Tarballs are built with absolute /root/ paths, but some clouds SSH as a regular user - // whose $HOME is /home//, not /root/. Without this, binaries are unreachable. - 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", - " # Copy marker file", - ` ${sudo} cp /root/.spawn-tarball "$HOME/.spawn-tarball"`, - " # Fix ownership — files were extracted as root", - ` ${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"); - // Non-fatal — mirror failure should not block tarball install - const mirrorResult = await asyncTryCatch(() => runner.runServer(mirrorCmd, 30)); - if (!mirrorResult.ok) { - logWarn("Tarball file mirroring failed (non-fatal)"); - } - logInfo("Agent installed from pre-built tarball"); return true; }