mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 16:39:50 +00:00
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:
parent
3f14bfc31c
commit
561be1cef9
3 changed files with 34 additions and 99 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.32.3",
|
||||
"version": "0.32.4",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue