fix: add sudo to tarball mirror commands for non-root SSH users (#2922)

* fix: add sudo to tarball mirror commands for non-root SSH users

The mirror step copies files from /root/ to $HOME/ for non-root users
(e.g. ubuntu on AWS Lightsail), but cp and chown ran without sudo.
A non-root user can't read /root/ or chown root-owned files, so the
mirror silently failed (errors suppressed by 2>/dev/null || true).

Adds sudo to cp/chown in both mirror blocks (tryTarballInstall and
uploadAndExtractTarball) and removes error suppression so failures
propagate to the caller.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: verify sudo in tarball mirror commands for both install paths

Adds tests for tryTarballInstall and uploadAndExtractTarball that assert:
- cp and chown use sudo (needed to read /root/ as non-root user)
- error suppression (2>/dev/null || true) is not present

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: Ahmed Abushagur <ahmed@abushagur.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ahmed Abushagur 2026-03-23 15:47:39 -07:00 committed by GitHub
parent 18b1a5f50f
commit 6a6ca87969
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 62 additions and 9 deletions

View file

@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:te
// Suppress stderr (logStep/logWarn) with a spy in beforeEach.
const { tryTarballInstall } = await import("../shared/agent-tarball");
const { tryTarballInstall, uploadAndExtractTarball } = await import("../shared/agent-tarball");
// ── Helpers ──────────────────────────────────────────────────────────────
@ -268,6 +268,23 @@ describe("tryTarballInstall", () => {
}
});
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();
@ -279,3 +296,39 @@ describe("tryTarballInstall", () => {
});
});
});
describe("uploadAndExtractTarball", () => {
let stderrSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
});
it("mirror step uses sudo for cp and chown", async () => {
const runner = createMockRunner();
await uploadAndExtractTarball(runner, "/tmp/fake.tar.gz");
// 2 calls: extract, then mirror
expect(runner.runServer).toHaveBeenCalledTimes(2);
const mirrorCmd = String(runner.runServer.mock.calls[1][0]);
const sudo = '$([ "$(id -u)" != "0" ] && echo sudo || echo "")';
expect(mirrorCmd).toContain(`${sudo} cp -a "/root/$_d/." "$HOME/$_d/"`);
expect(mirrorCmd).toContain(`${sudo} cp /root/.spawn-tarball "$HOME/.spawn-tarball"`);
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("mirror step does not suppress errors", async () => {
const runner = createMockRunner();
await uploadAndExtractTarball(runner, "/tmp/fake.tar.gz");
const mirrorCmd = String(runner.runServer.mock.calls[1][0]);
expect(mirrorCmd).not.toContain("2>/dev/null || true");
});
});

View file

@ -134,16 +134,16 @@ export async function tryTarballInstall(
" 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',
` ${sudo} cp -a "/root/$_d/." "$HOME/$_d/"`,
" fi",
" done",
" # Copy marker file",
' cp /root/.spawn-tarball "$HOME/.spawn-tarball" 2>/dev/null || true',
` ${sudo} cp /root/.spawn-tarball "$HOME/.spawn-tarball"`,
" # Fix ownership — files were extracted as root",
' chown -R "$(id -u):$(id -g)" "$HOME/.spawn-tarball" 2>/dev/null || true',
` ${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',
' chown -R "$(id -u):$(id -g)" "$HOME/$_d" 2>/dev/null || true',
` ${sudo} chown -R "$(id -u):$(id -g)" "$HOME/$_d"`,
" fi",
" done",
"fi",
@ -262,14 +262,14 @@ export async function uploadAndExtractTarball(runner: CloudRunner, localPath: st
" 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',
` ${sudo} cp -a "/root/$_d/." "$HOME/$_d/"`,
" fi",
" done",
' cp /root/.spawn-tarball "$HOME/.spawn-tarball" 2>/dev/null || true',
' chown -R "$(id -u):$(id -g)" "$HOME/.spawn-tarball" 2>/dev/null || true',
` ${sudo} cp /root/.spawn-tarball "$HOME/.spawn-tarball"`,
` ${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',
' chown -R "$(id -u):$(id -g)" "$HOME/$_d" 2>/dev/null || true',
` ${sudo} chown -R "$(id -u):$(id -g)" "$HOME/$_d"`,
" fi",
" done",
"fi",