Skip to content
Closed
64 changes: 64 additions & 0 deletions packages/cli/src/__tests__/agent-tarball.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)"');
});
});
});
27 changes: 18 additions & 9 deletions packages/cli/src/shared/agent-tarball.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand All@@ -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;
Expand Down
Loading