fix: extract tarballs directly to $HOME on non-root VMs (#3253)

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) <noreply@anthropic.com>
This commit is contained in:
Ahmed Abushagur 2026-04-09 23:45:16 -07:00 committed by GitHub
parent 3f14bfc31c
commit 561be1cef9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 34 additions and 99 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.32.3",
"version": "0.32.4",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -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);
});
});
});

View file

@ -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/<user>/, 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;
}