diff --git a/packages/cli/src/__tests__/agent-tarball.test.ts b/packages/cli/src/__tests__/agent-tarball.test.ts index 6e003752f..259b33add 100644 --- a/packages/cli/src/__tests__/agent-tarball.test.ts +++ b/packages/cli/src/__tests__/agent-tarball.test.ts @@ -168,4 +168,68 @@ 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'); + }); + + 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..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; } @@ -126,13 +130,18 @@ 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 { - 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;