From ba73031ba47d0e589eb059bb07aac2306c4ba0b9 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Tue, 10 Mar 2026 13:46:47 -0700 Subject: [PATCH 1/4] fix: mirror tarball files to $HOME for non-root SSH users (GCP, AWS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tarballs are built with absolute /root/ paths, but GCP and AWS Lightsail SSH as a regular user whose $HOME is /home//. After extraction, binaries like `claude` end up at /root/.claude/local/bin/ but the launchCmd looks in $HOME/.claude/local/bin/ — causing "command not found". Add a post-extraction step that copies /root/ dotfiles to $HOME/ when the SSH user isn't root. This fixes `spawn claude gcp` failing with exit code 127 after tarball install. Co-Authored-By: Claude Opus 4.6 --- .../cli/src/__tests__/agent-tarball.test.ts | 5 ++++- packages/cli/src/shared/agent-tarball.ts | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/agent-tarball.test.ts b/packages/cli/src/__tests__/agent-tarball.test.ts index 54393bb4f..6e003752f 100644 --- a/packages/cli/src/__tests__/agent-tarball.test.ts +++ b/packages/cli/src/__tests__/agent-tarball.test.ts @@ -84,11 +84,14 @@ describe("tryTarballInstall", () => { const result = await tryTarballInstall(runner, "openclaw", fetchFn); expect(result).toBe(true); - expect(runner.runServer).toHaveBeenCalledTimes(1); + // 2 calls: download+extract, then mirror files for non-root users + expect(runner.runServer).toHaveBeenCalledTimes(2); const cmd = String(runner.runServer.mock.calls[0][0]); expect(cmd).toContain("curl -fsSL"); expect(cmd).toContain("tar xz -C /"); expect(cmd).toContain(".spawn-tarball"); + const mirrorCmd = String(runner.runServer.mock.calls[1][0]); + expect(mirrorCmd).toContain("cp -a"); }); it("returns false when release does not exist (404)", async () => { diff --git a/packages/cli/src/shared/agent-tarball.ts b/packages/cli/src/shared/agent-tarball.ts index f2d141ba7..5b93e9e05 100644 --- a/packages/cli/src/shared/agent-tarball.ts +++ b/packages/cli/src/shared/agent-tarball.ts @@ -113,6 +113,27 @@ 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"', + ' cp -a "/root/$_d/." "$HOME/$_d/" 2>/dev/null || true', + " fi", + " done", + " # Copy marker file", + ' cp /root/.spawn-tarball "$HOME/.spawn-tarball" 2>/dev/null || true', + "fi", + ].join("\n"); + try { + await runner.runServer(mirrorCmd, 30); + } catch { + logWarn("Tarball file mirroring failed (non-fatal)"); + } + logInfo("Agent installed from pre-built tarball"); return true; } From 9a8b9086c1daf80c1cfa2a0a1654cec538ef80c5 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Tue, 10 Mar 2026 15:53:38 -0700 Subject: [PATCH 2/4] test: add anti-regression tests for tarball home directory mirroring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 4 tests in a "non-root home directory mirroring" describe block to prevent regression of the /root/ → $HOME/ copy step for non-root SSH users (GCP, AWS Lightsail). Co-Authored-By: Claude Opus 4.6 --- .../cli/src/__tests__/agent-tarball.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/cli/src/__tests__/agent-tarball.test.ts b/packages/cli/src/__tests__/agent-tarball.test.ts index 6e003752f..a02c46f19 100644 --- a/packages/cli/src/__tests__/agent-tarball.test.ts +++ b/packages/cli/src/__tests__/agent-tarball.test.ts @@ -168,4 +168,58 @@ describe("tryTarballInstall", () => { expect(result).toBe(false); expect(runner.runServer).not.toHaveBeenCalled(); }); + + describe("non-root home directory mirroring", () => { + it("mirrors dotfiles from /root/ to $HOME for non-root users", async () => { + const fetchFn = mockFetch(new Response(JSON.stringify(RELEASE_PAYLOAD))); + const runner = createMockRunner(); + + await tryTarballInstall(runner, "openclaw", fetchFn); + + const mirrorCmd = String(runner.runServer.mock.calls[1][0]); + expect(mirrorCmd).toContain("cp -a"); + expect(mirrorCmd).toContain('"$HOME/$_d"'); + for (const dir of [ + ".claude", + ".local", + ".npm-global", + ".cargo", + ".opencode", + ".hermes", + ".bun", + ]) { + expect(mirrorCmd).toContain(dir); + } + }); + + it("mirrors the .spawn-tarball marker file", async () => { + const fetchFn = mockFetch(new Response(JSON.stringify(RELEASE_PAYLOAD))); + const runner = createMockRunner(); + + await tryTarballInstall(runner, "openclaw", fetchFn); + + const mirrorCmd = String(runner.runServer.mock.calls[1][0]); + expect(mirrorCmd).toContain('cp /root/.spawn-tarball "$HOME/.spawn-tarball"'); + }); + + 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); + }); + + it("guards mirror behind non-root check (id -u)", async () => { + const fetchFn = mockFetch(new Response(JSON.stringify(RELEASE_PAYLOAD))); + const runner = createMockRunner(); + + await tryTarballInstall(runner, "openclaw", fetchFn); + + const mirrorCmd = String(runner.runServer.mock.calls[1][0]); + expect(mirrorCmd).toContain('if [ "$(id -u)" != "0" ]; then'); + }); + }); }); From c7b8d5559a25bc02cb369b82ef085cd468a7e357 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Tue, 10 Mar 2026 16:16:30 -0700 Subject: [PATCH 3/4] fix: chown mirrored tarball files to SSH user + add regression test Files copied from /root/ retained root ownership, causing permission errors when agents tried to write to their config dirs. Adds chown -R after the cp -a step. Also adds a test to prevent regression. Co-Authored-By: Claude Opus 4.6 --- packages/cli/package.json | 2 +- packages/cli/src/__tests__/agent-tarball.test.ts | 10 ++++++++++ packages/cli/src/shared/agent-tarball.ts | 7 +++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index e5fb50f30..da3dfa5f4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.16.0", + "version": "0.16.1", "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 a02c46f19..259b33add 100644 --- a/packages/cli/src/__tests__/agent-tarball.test.ts +++ b/packages/cli/src/__tests__/agent-tarball.test.ts @@ -221,5 +221,15 @@ describe("tryTarballInstall", () => { const mirrorCmd = String(runner.runServer.mock.calls[1][0]); expect(mirrorCmd).toContain('if [ "$(id -u)" != "0" ]; then'); }); + + it("fixes ownership of mirrored files with chown", async () => { + const fetchFn = mockFetch(new Response(JSON.stringify(RELEASE_PAYLOAD))); + const runner = createMockRunner(); + + await tryTarballInstall(runner, "openclaw", fetchFn); + + const mirrorCmd = String(runner.runServer.mock.calls[1][0]); + expect(mirrorCmd).toContain('chown -R "$(id -u):$(id -g)"'); + }); }); }); diff --git a/packages/cli/src/shared/agent-tarball.ts b/packages/cli/src/shared/agent-tarball.ts index 5b93e9e05..927a088b7 100644 --- a/packages/cli/src/shared/agent-tarball.ts +++ b/packages/cli/src/shared/agent-tarball.ts @@ -126,6 +126,13 @@ export async function tryTarballInstall( " done", " # Copy marker file", ' cp /root/.spawn-tarball "$HOME/.spawn-tarball" 2>/dev/null || true', + " # Fix ownership — files were extracted as root", + ' chown -R "$(id -u):$(id -g)" "$HOME/.spawn-tarball" 2>/dev/null || true', + " for _d in .claude .local .npm-global .cargo .opencode .hermes .bun; do", + ' if [ -d "$HOME/$_d" ]; then', + ' chown -R "$(id -u):$(id -g)" "$HOME/$_d" 2>/dev/null || true', + " fi", + " done", "fi", ].join("\n"); try { From 83b2dda1f7dba898f820bc836ed56efb1edc9193 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Tue, 10 Mar 2026 17:31:29 -0700 Subject: [PATCH 4/4] refactor: replace try/catch with .then()/.catch() wrappers in agent-tarball Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/shared/agent-tarball.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/shared/agent-tarball.ts b/packages/cli/src/shared/agent-tarball.ts index 927a088b7..168ad7a82 100644 --- a/packages/cli/src/shared/agent-tarball.ts +++ b/packages/cli/src/shared/agent-tarball.ts @@ -105,11 +105,15 @@ export async function tryTarballInstall( } // Phase 3: Remote execution - try { - await runner.runServer(downloadCmd, 150); - } catch (err) { - logWarn("Tarball download/extract failed on remote VM"); - logDebug(getErrorMessage(err)); + const extracted = await runner.runServer(downloadCmd, 150).then( + () => true, + (err) => { + logWarn("Tarball download/extract failed on remote VM"); + logDebug(getErrorMessage(err)); + return false; + }, + ); + if (!extracted) { return false; } @@ -135,11 +139,9 @@ export async function tryTarballInstall( " done", "fi", ].join("\n"); - try { - await runner.runServer(mirrorCmd, 30); - } catch { + await runner.runServer(mirrorCmd, 30).catch(() => { logWarn("Tarball file mirroring failed (non-fatal)"); - } + }); logInfo("Agent installed from pre-built tarball"); return true;