[GH-ISSUE #5920] [Bug] Linux Hopper setup drops archive-write failures into download_failed #3597

Closed
opened 2026-10-31 00:42:14 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 21, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/5920

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Setup, packaging, or upgrade

Observed behavior

After a successful simulated download, a real EACCES from the production archive-write operation is reduced by installLinuxHopper to exactly {"status":"failed","reason":"download"}. No stage, path, error code or cause survives in that result. The setup mapper produces download_failed.

The remediation already mentions both downloading/unpacking and filesystem access. The defect is the loss of the observed local failure and its stage, rather than advice that exclusively blames the network. The focused run used the real installer and write operation with controlled earlier ports; no public CLI or package manager was executed.

Reproduction

The case below was executed as part of one three-case focused source test run; all three characterization tests passed. Passing characterizes the current incorrect classification. Use the repository-pinned Node 24.18.0 and installed lockfile dependencies. Save this case as src/application/repro.test.ts, run it with:

node node_modules/vitest/vitest.mjs run --project services src/application/repro.test.ts --maxWorkers 1

Run the permission cases as an ordinary non-root account on macOS or another POSIX host. The Linux application code was exercised on macOS. No actual Hopper executable or package was used.

import { createHash } from "node:crypto";
import { chmod, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { installLinuxHopper, systemLinuxHopperInstallHost, type LinuxHopperInstallHost } from "./LinuxHopper.js";
import { ok } from "../domain/result.js";
import { setupInstallFailure } from "./SetupInstallFailure.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";

function linuxHost(base: LinuxHopperInstallHost): LinuxHopperInstallHost {
  return {
    ...base,
    distribution: async () => ({ id: "ubuntu", versionId: "24.04", packageFamily: "deb", hopperSetupSupported: true }),
    release: async () => ok({ version: "fixture", filename: "https://www.hopperapp.com:443/downloader/public/fixture.deb", signature: "AQ==", file_length: "3", file_hash: createHash("sha1").update(new Uint8Array([1, 2, 3])).digest("hex"), main_exe_hash: "0".repeat(64), public: "1", beta: "0" }),
    download: async () => ({ ok: true, bytes: new Uint8Array([1, 2, 3]) }),
    createTemporaryDirectory: async () => ".",
    writeArchive: async () => {},
    verifyArchive: async () => { throw new Error("unexpected verification"); },
    cleanup: async () => {},
  };
}

it("post-download archive write EACCES is reduced to download with no cause", async () => {
  const directory = await createTestTempDirectory("rea-hopper-classification-");
  const denied = join(directory, "denied");
  await mkdir(denied, { mode: 0o500 });
  const system = systemLinuxHopperInstallHost({});
  const host = linuxHost(system);
  let downloaded = false;
  host.download = async () => { downloaded = true; return { ok: true, bytes: new Uint8Array([1, 2, 3]) }; };
  host.createTemporaryDirectory = async () => denied;
  host.writeArchive = system.writeArchive;
  try {
    await expect(system.writeArchive(join(denied, "control.deb"), new Uint8Array([1]))).rejects.toMatchObject({ code: "EACCES" });
    const result = await installLinuxHopper(host);
    expect(downloaded).toBe(true);
    expect(result).toEqual({ status: "failed", reason: "download" });
    if (result.status !== "failed") throw new Error("unexpected success");
    expect(setupInstallFailure(result.reason, undefined, "linux").code).toBe("download_failed");
    console.log("download succeeded; production writeArchive EACCES =>", JSON.stringify(result));
    await chmod(denied, 0o700);
    await expect(system.writeArchive(join(denied, "control.deb"), new Uint8Array([1]))).resolves.toBeUndefined();
  } finally { await chmod(denied, 0o700); await system.close?.(); }
});

Remove the test after running it. The fixture restores permissions and closes the Linux publisher where applicable.

Expected behavior

A failed local archive write should preserve the fact that downloading succeeded and expose the write failure and its cause. A caller should be able to distinguish a local EACCES from a failed download. The repository requires preserving uncertainty and meaningful failure reasons at boundaries: AGENTS.md.

Impact and scope

P2: bounded but actionable setup-diagnostic loss. A failed local write cannot be distinguished from download failure using the returned result. Restoring directory write permission made the same write succeed in the fixture. Real Linux package installation, disk exhaustion, later installation-stage throws and natural frequency were not tested.

REA version

rea-agents 6.7.0; source revision 5241597594.

Runtime and platform

Node.js v24.18.0; macOS 27.0.1 arm64; lockfile Vitest 5.0.3. No Hopper engine exercised.

Target and artifact context

Three simulated download bytes and a real directory without write permission; no analysis target, signature verification or package installation.

Interface

Other

Earliest failure stage

Setup or provider admission

Relevant output or evidence

The executed case independently confirmed EACCES from the production write operation, then observed:

download succeeded; production writeArchive EACCES => {"status":"failed","reason":"download"}

The download and local write are separate operations. The outer catch erases the error. SetupHost forwards only the resulting reason and any specifically typed publisher/runtime detail; the fallback mapping produces download_failed.

Typed outcome or recovery expected

A failed local archive write should preserve the fact that downloading succeeded and expose the write failure and its cause. A caller should be able to distinguish a local EACCES from a failed download.

Analysis or suspected cause

The workflow catch reserves only cancellation as a special case and otherwise uses download, irrespective of which operation threw. Preserve the active operation and meaningful error at this producing boundary. The report does not assume that publisher verification ports normally throw instead of returning their typed failures.

Additional context

The existing reports about macOS final-open failures, package-manager command output and temporary cleanup concern other operations or different result boundaries.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5920 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Setup, packaging, or upgrade ### Observed behavior After a successful simulated download, a real `EACCES` from the production archive-write operation is reduced by `installLinuxHopper` to exactly `{"status":"failed","reason":"download"}`. No stage, path, error code or cause survives in that result. The setup mapper produces `download_failed`. The remediation already mentions both downloading/unpacking and filesystem access. The defect is the loss of the observed local failure and its stage, rather than advice that exclusively blames the network. The focused run used the real installer and write operation with controlled earlier ports; no public CLI or package manager was executed. ### Reproduction The case below was executed as part of one three-case focused source test run; all three characterization tests passed. Passing characterizes the current incorrect classification. Use the repository-pinned Node 24.18.0 and installed lockfile dependencies. Save this case as `src/application/repro.test.ts`, run it with: ```sh node node_modules/vitest/vitest.mjs run --project services src/application/repro.test.ts --maxWorkers 1 ``` Run the permission cases as an ordinary non-root account on macOS or another POSIX host. The Linux application code was exercised on macOS. No actual Hopper executable or package was used. ```ts import { createHash } from "node:crypto"; import { chmod, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; import { installLinuxHopper, systemLinuxHopperInstallHost, type LinuxHopperInstallHost } from "./LinuxHopper.js"; import { ok } from "../domain/result.js"; import { setupInstallFailure } from "./SetupInstallFailure.js"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; function linuxHost(base: LinuxHopperInstallHost): LinuxHopperInstallHost { return { ...base, distribution: async () => ({ id: "ubuntu", versionId: "24.04", packageFamily: "deb", hopperSetupSupported: true }), release: async () => ok({ version: "fixture", filename: "https://www.hopperapp.com:443/downloader/public/fixture.deb", signature: "AQ==", file_length: "3", file_hash: createHash("sha1").update(new Uint8Array([1, 2, 3])).digest("hex"), main_exe_hash: "0".repeat(64), public: "1", beta: "0" }), download: async () => ({ ok: true, bytes: new Uint8Array([1, 2, 3]) }), createTemporaryDirectory: async () => ".", writeArchive: async () => {}, verifyArchive: async () => { throw new Error("unexpected verification"); }, cleanup: async () => {}, }; } it("post-download archive write EACCES is reduced to download with no cause", async () => { const directory = await createTestTempDirectory("rea-hopper-classification-"); const denied = join(directory, "denied"); await mkdir(denied, { mode: 0o500 }); const system = systemLinuxHopperInstallHost({}); const host = linuxHost(system); let downloaded = false; host.download = async () => { downloaded = true; return { ok: true, bytes: new Uint8Array([1, 2, 3]) }; }; host.createTemporaryDirectory = async () => denied; host.writeArchive = system.writeArchive; try { await expect(system.writeArchive(join(denied, "control.deb"), new Uint8Array([1]))).rejects.toMatchObject({ code: "EACCES" }); const result = await installLinuxHopper(host); expect(downloaded).toBe(true); expect(result).toEqual({ status: "failed", reason: "download" }); if (result.status !== "failed") throw new Error("unexpected success"); expect(setupInstallFailure(result.reason, undefined, "linux").code).toBe("download_failed"); console.log("download succeeded; production writeArchive EACCES =>", JSON.stringify(result)); await chmod(denied, 0o700); await expect(system.writeArchive(join(denied, "control.deb"), new Uint8Array([1]))).resolves.toBeUndefined(); } finally { await chmod(denied, 0o700); await system.close?.(); } }); ``` Remove the test after running it. The fixture restores permissions and closes the Linux publisher where applicable. ### Expected behavior A failed local archive write should preserve the fact that downloading succeeded and expose the write failure and its cause. A caller should be able to distinguish a local `EACCES` from a failed download. The repository requires preserving uncertainty and meaningful failure reasons at boundaries: [AGENTS.md](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/AGENTS.md#L19-L31). ### Impact and scope P2: bounded but actionable setup-diagnostic loss. A failed local write cannot be distinguished from download failure using the returned result. Restoring directory write permission made the same write succeed in the fixture. Real Linux package installation, disk exhaustion, later installation-stage throws and natural frequency were not tested. ### REA version rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e. ### Runtime and platform Node.js v24.18.0; macOS 27.0.1 arm64; lockfile Vitest 5.0.3. No Hopper engine exercised. ### Target and artifact context Three simulated download bytes and a real directory without write permission; no analysis target, signature verification or package installation. ### Interface Other ### Earliest failure stage Setup or provider admission ### Relevant output or evidence The executed case independently confirmed `EACCES` from the production write operation, then observed: ```text download succeeded; production writeArchive EACCES => {"status":"failed","reason":"download"} ``` The [download and local write](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/LinuxHopper.ts#L335-L340) are separate operations. The [outer catch](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/LinuxHopper.ts#L404-L411) erases the error. [SetupHost](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/SetupHost.ts#L213-L234) forwards only the resulting reason and any specifically typed publisher/runtime detail; the [fallback mapping](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/SetupInstallFailure.ts#L206-L212) produces `download_failed`. ### Typed outcome or recovery expected A failed local archive write should preserve the fact that downloading succeeded and expose the write failure and its cause. A caller should be able to distinguish a local `EACCES` from a failed download. ### Analysis or suspected cause The workflow catch reserves only cancellation as a special case and otherwise uses download, irrespective of which operation threw. Preserve the active operation and meaningful error at this producing boundary. The report does not assume that publisher verification ports normally throw instead of returning their typed failures. ### Additional context The existing reports about macOS final-open failures, package-manager command output and temporary cleanup concern other operations or different result boundaries.
Author
Owner

@coygeek commented on GitHub (Oct 24, 2026):

Checked the reported fix on main at d87e73f2db.

The installer tracks archive_write separately from download, preserves path/systemCode/detail in operationFailure and maps this stage to archive_write_failed with local staging recovery. The regression throws EACCES after a successful download and asserts archive_write, preserved diagnostic and cleanup. It simulates the write error rather than testing natural Linux filesystem denial or a real package install.

Implementation: LinuxHopper.ts, LinuxHopper.ts.

The relevant regression checks in LinuxHopper.test.ts passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.

<!-- gh-comment-id:6380721248 --> @coygeek commented on GitHub (Oct 24, 2026): Checked the reported fix on `main` at d87e73f2db50be72d33a4b3c7199af35518b441c. The installer tracks archive_write separately from download, preserves path/systemCode/detail in operationFailure and maps this stage to archive_write_failed with local staging recovery. The regression throws EACCES after a successful download and asserts archive_write, preserved diagnostic and cleanup. It simulates the write error rather than testing natural Linux filesystem denial or a real package install. Implementation: [LinuxHopper.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/application/LinuxHopper.ts#L399), [LinuxHopper.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/application/LinuxHopper.ts#L487). The relevant regression checks in [LinuxHopper.test.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/application/LinuxHopper.test.ts) passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3597
No description provided.