[GH-ISSUE #5832] [Bug] Hopper launcher fingerprint failures discard the filesystem cause during provider selection #3530

Closed
opened 2026-10-31 00:41:33 +00:00 by bsakaguchi · 2 comments
Owner

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

Before submitting

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

Area

Provider or capability discovery

Observed behavior

A focused test of the production Hopper provider and selection registry reproduced a real EACCES while fingerprinting an execute-only fixture launcher. inspectAvailability() reported the executable regular file available. Selecting Hopper for an already-parsed x86_64 ELF target returned ProviderSelectionError; its candidate rejection was version_unresolved, with reason Provider version or analysis profile could not be resolved.

The rejection retained launcher_path and platform, but neither the filesystem error code nor an explanation that reading the launcher failed. The selection error also had no cause. Restoring owner read permission let the same registry select a fingerprinted Hopper profile successfully.

This is an executed provider-boundary reproduction using a fixture launcher and synthetic target metadata. No Hopper process, public CLI command, or MCP transport was exercised.

Reproduction

On a supported non-root macOS host, place the following temporary test at src/hopper/repro.test.ts in the baseline checkout with its locked dependencies installed. Run npm run test:local -- src/hopper/repro.test.ts, with HOME, XDG_CONFIG_HOME, and TMPDIR redirected to isolated writable directories, then delete the test. The test uses the repository's cleanup fixture and does not launch the fixture launcher or read a target binary.

import { chmod, writeFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { parseConfig } from "../config/parseConfig.js";
import { silentLogger } from "../logger.js";
import { HopperProvider } from "../hopper/HopperProvider.js";
import { AnalysisProviderRegistry } from "../application/binary/AnalysisProviderRegistry.js";
import { ProviderSelectionError } from "../domain/providerSelectionError.js";

it("loses launcher EACCES during Hopper selection and recovers when readable", async () => {
  const directory = await createTestTempDirectory("rea-hopper-fingerprint-repro-");
  const launcher = join(directory, "launcher");
  await writeFile(launcher, "fixture launcher; never executed\n", { mode: 0o100 });
  const config = parseConfig({ HOPPER_LAUNCHER_PATH: launcher });
  if (!config.ok) throw config.error;
  const provider = new HopperProvider(config.value, silentLogger, {});
  const registry = new AnalysisProviderRegistry([provider]);
  const target = {
    path: join(directory, "target.elf"), sha256: "a".repeat(64),
    kind: "executable" as const, format: "elf" as const,
    architecture: "x86_64" as const, availableArchitectures: ["x86_64" as const],
  };
  try {
    await expect(readFile(launcher)).rejects.toMatchObject({ code: "EACCES" });
    expect(provider.inspectAvailability().status).toBe("available");
    expect(provider.inspectTargetSupport(target).status).toBe("supported");
    const rejected = await registry.select(target, "hopper");
    expect(rejected.ok).toBe(false);
    if (rejected.ok || !(rejected.error instanceof ProviderSelectionError))
      throw new Error("Expected provider selection rejection");
    const rejection = rejected.error.rejections[0]!;
    expect(rejection.code).toBe("version_unresolved");
    expect(rejection.reason).toBe("Provider version or analysis profile could not be resolved");
    expect(rejection.diagnostics).toEqual({ launcher_path: launcher, platform: process.platform });
    expect(rejected.error.cause).toBeUndefined();
    console.log(JSON.stringify({ read_error: "EACCES", availability: provider.inspectAvailability().status,
      selection_error: rejected.error._tag, rejection_code: rejection.code,
      reason: rejection.reason, diagnostic_keys: Object.keys(rejection.diagnostics),
      cause_retained: rejected.error.cause !== undefined }));
    await chmod(launcher, 0o500);
    const restored = await registry.select(target, "hopper");
    expect(restored.ok).toBe(true);
    if (!restored.ok) throw restored.error;
    expect(restored.value.binding?.profile.provider.version).toMatch(/^launcher-sha256:/);
    console.log("read permission restored: Hopper profile selected without launching a process");
  } finally {
    await chmod(launcher, 0o500);
    expect((await registry.closeOwnedResources()).ok).toBe(true);
  }
});

The test passed twice through the same production boundary, including once using the neutral filename shown above. A prior placement under tests/ was rejected by the source-test selector before execution; placing the file beside the module selects the adapters project.

Expected behavior

Preserve the meaningful filesystem failure in the typed selection error or its candidate rejection diagnostics, including which read failed and EACCES, so the caller can repair access to the launcher.

This follows AGENTS.md's boundary-error rule: expected failures use the tagged algebra and retain meaningful failure reasons. Admission should still fail when the launcher cannot be identified: the deep-open contract requires a concrete provider version and typed selection details. This report does not request admission with an unknown version.

Impact and scope

P2: the failure loses actionable diagnostic provenance for a bounded, repairable launcher-access condition. The affected caller sees a generic unresolved-profile reason rather than the permission failure. Restoring owner read permission was a verified workaround for profile selection in this fixture.

The executed case covered an x86_64 ELF target descriptor on macOS. Source-derived scope is new profile resolution for targets that pass Hopper's earlier admission guards. Unsupported targets do not reach this read; an existing same-target route can sometimes be reused. Linux, real Hopper launchers, other filesystem errors, and real-world frequency were not tested. No target mutation, evidence corruption, or security boundary crossing was observed.

REA version

rea-agents 6.7.0, source revision a5f982de8d.

Runtime and platform

Node.js v26.12.0; macOS 27.0.1 arm64; fixture launcher only, no Hopper engine version exercised.

Target and artifact context

Synthetic already-parsed executable/ELF/x86_64 BinaryTarget; execute-only regular fixture launcher, mode 0100; no target binary or engine opened.

Interface

Other

Earliest failure stage

Setup or provider admission

Relevant output or evidence

Trimmed output from npm run test:local -- src/hopper/repro.test.ts:

{"read_error":"EACCES","availability":"available","selection_error":"ProviderSelectionError","rejection_code":"version_unresolved","reason":"Provider version or analysis profile could not be resolved","diagnostic_keys":["launcher_path","platform"],"cause_retained":false}
read permission restored: Hopper profile selected without launching a process
✓ |adapters| src/hopper/repro.test.ts (1 test)
Test Files  1 passed (1)
     Tests  1 passed (1)

Source checked at the tested revision:

Public-entry reachability is source-derived: the open lifecycle handler forwards the selected provider to session open; target resolution parses the target and resolves its route; the router calls the registry. Concrete provider selection evaluates available, supported candidates through the production boundary exercised above.

Typed outcome or recovery expected

Keep the refusal to select an unidentifiable launcher, while retaining the launcher-read failure in caller-visible typed details and giving recovery that addresses launcher access. version_unresolved is a candidate code; it is not the top-level error code.

Analysis or suspected cause

Source-derived: the fingerprint helper's string | undefined success result cannot carry a physical read failure to its caller. The caller treats absence as an unresolved profile, so downstream evaluation no longer has the original error to report. A typed failure at that producing boundary could preserve the cause while keeping concrete-version admission intact.

Additional context

No response

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5832 ### 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 Provider or capability discovery ### Observed behavior A focused test of the production Hopper provider and selection registry reproduced a real `EACCES` while fingerprinting an execute-only fixture launcher. `inspectAvailability()` reported the executable regular file available. Selecting Hopper for an already-parsed x86_64 ELF target returned `ProviderSelectionError`; its candidate rejection was `version_unresolved`, with reason `Provider version or analysis profile could not be resolved`. The rejection retained `launcher_path` and `platform`, but neither the filesystem error code nor an explanation that reading the launcher failed. The selection error also had no cause. Restoring owner read permission let the same registry select a fingerprinted Hopper profile successfully. This is an executed provider-boundary reproduction using a fixture launcher and synthetic target metadata. No Hopper process, public CLI command, or MCP transport was exercised. ### Reproduction On a supported non-root macOS host, place the following temporary test at `src/hopper/repro.test.ts` in the baseline checkout with its locked dependencies installed. Run `npm run test:local -- src/hopper/repro.test.ts`, with `HOME`, `XDG_CONFIG_HOME`, and `TMPDIR` redirected to isolated writable directories, then delete the test. The test uses the repository's cleanup fixture and does not launch the fixture launcher or read a target binary. ```ts import { chmod, writeFile, readFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { parseConfig } from "../config/parseConfig.js"; import { silentLogger } from "../logger.js"; import { HopperProvider } from "../hopper/HopperProvider.js"; import { AnalysisProviderRegistry } from "../application/binary/AnalysisProviderRegistry.js"; import { ProviderSelectionError } from "../domain/providerSelectionError.js"; it("loses launcher EACCES during Hopper selection and recovers when readable", async () => { const directory = await createTestTempDirectory("rea-hopper-fingerprint-repro-"); const launcher = join(directory, "launcher"); await writeFile(launcher, "fixture launcher; never executed\n", { mode: 0o100 }); const config = parseConfig({ HOPPER_LAUNCHER_PATH: launcher }); if (!config.ok) throw config.error; const provider = new HopperProvider(config.value, silentLogger, {}); const registry = new AnalysisProviderRegistry([provider]); const target = { path: join(directory, "target.elf"), sha256: "a".repeat(64), kind: "executable" as const, format: "elf" as const, architecture: "x86_64" as const, availableArchitectures: ["x86_64" as const], }; try { await expect(readFile(launcher)).rejects.toMatchObject({ code: "EACCES" }); expect(provider.inspectAvailability().status).toBe("available"); expect(provider.inspectTargetSupport(target).status).toBe("supported"); const rejected = await registry.select(target, "hopper"); expect(rejected.ok).toBe(false); if (rejected.ok || !(rejected.error instanceof ProviderSelectionError)) throw new Error("Expected provider selection rejection"); const rejection = rejected.error.rejections[0]!; expect(rejection.code).toBe("version_unresolved"); expect(rejection.reason).toBe("Provider version or analysis profile could not be resolved"); expect(rejection.diagnostics).toEqual({ launcher_path: launcher, platform: process.platform }); expect(rejected.error.cause).toBeUndefined(); console.log(JSON.stringify({ read_error: "EACCES", availability: provider.inspectAvailability().status, selection_error: rejected.error._tag, rejection_code: rejection.code, reason: rejection.reason, diagnostic_keys: Object.keys(rejection.diagnostics), cause_retained: rejected.error.cause !== undefined })); await chmod(launcher, 0o500); const restored = await registry.select(target, "hopper"); expect(restored.ok).toBe(true); if (!restored.ok) throw restored.error; expect(restored.value.binding?.profile.provider.version).toMatch(/^launcher-sha256:/); console.log("read permission restored: Hopper profile selected without launching a process"); } finally { await chmod(launcher, 0o500); expect((await registry.closeOwnedResources()).ok).toBe(true); } }); ``` The test passed twice through the same production boundary, including once using the neutral filename shown above. A prior placement under `tests/` was rejected by the source-test selector before execution; placing the file beside the module selects the adapters project. ### Expected behavior Preserve the meaningful filesystem failure in the typed selection error or its candidate rejection diagnostics, including which read failed and `EACCES`, so the caller can repair access to the launcher. This follows [AGENTS.md's boundary-error rule](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/AGENTS.md#L31-L31): expected failures use the tagged algebra and retain meaningful failure reasons. Admission should still fail when the launcher cannot be identified: the [deep-open contract](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/docs/mcp-contracts.md#L517-L529) requires a concrete provider version and typed selection details. This report does not request admission with an unknown version. ### Impact and scope P2: the failure loses actionable diagnostic provenance for a bounded, repairable launcher-access condition. The affected caller sees a generic unresolved-profile reason rather than the permission failure. Restoring owner read permission was a verified workaround for profile selection in this fixture. The executed case covered an x86_64 ELF target descriptor on macOS. Source-derived scope is new profile resolution for targets that pass Hopper's earlier admission guards. Unsupported targets do not reach this read; an existing same-target route can sometimes be reused. Linux, real Hopper launchers, other filesystem errors, and real-world frequency were not tested. No target mutation, evidence corruption, or security boundary crossing was observed. ### REA version rea-agents 6.7.0, source revision a5f982de8dbe4b092cc20ab89d492b7e7028b758. ### Runtime and platform Node.js v26.12.0; macOS 27.0.1 arm64; fixture launcher only, no Hopper engine version exercised. ### Target and artifact context Synthetic already-parsed executable/ELF/x86_64 BinaryTarget; execute-only regular fixture launcher, mode 0100; no target binary or engine opened. ### Interface Other ### Earliest failure stage Setup or provider admission ### Relevant output or evidence Trimmed output from `npm run test:local -- src/hopper/repro.test.ts`: ```text {"read_error":"EACCES","availability":"available","selection_error":"ProviderSelectionError","rejection_code":"version_unresolved","reason":"Provider version or analysis profile could not be resolved","diagnostic_keys":["launcher_path","platform"],"cause_retained":false} read permission restored: Hopper profile selected without launching a process ✓ |adapters| src/hopper/repro.test.ts (1 test) Test Files 1 passed (1) Tests 1 passed (1) ``` Source checked at the tested revision: - [Launcher read catch](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/hopper/HopperAnalysisProfile.ts#L293-L326) reduces every non-cancellation read failure to `ok(undefined)`. - [Profile resolution](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/hopper/HopperAnalysisProfile.ts#L128-L134) converts the absent fingerprint into a null profile. - [Null-profile evaluation](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/application/binary/AnalysisProviderEvaluation.ts#L89-L96) supplies the generic reason; [rejection construction](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/application/binary/AnalysisProviderEvaluation.ts#L135-L154) supplies `version_unresolved` without the original read failure. - [Availability inspection](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/hopper/HopperProvider.ts#L90-L111) checks executability and regular-file status. That narrower observation alone is not proof that a profile can resolve. Public-entry reachability is source-derived: the [open lifecycle handler](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/server/registerSessionTools.ts#L174-L195) forwards the selected provider to session open; [target resolution](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/application/binary/BinarySessionOpen.ts#L80-L114) parses the target and resolves its route; the [router](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/application/binary/SessionProviderRouter.ts#L83-L97) calls the registry. [Concrete provider selection](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/application/binary/AnalysisProviderRegistry.ts#L566-L601) evaluates available, supported candidates through the production boundary exercised above. ### Typed outcome or recovery expected Keep the refusal to select an unidentifiable launcher, while retaining the launcher-read failure in caller-visible typed details and giving recovery that addresses launcher access. `version_unresolved` is a candidate code; it is not the top-level error code. ### Analysis or suspected cause Source-derived: the fingerprint helper's `string | undefined` success result cannot carry a physical read failure to its caller. The caller treats absence as an unresolved profile, so downstream evaluation no longer has the original error to report. A typed failure at that producing boundary could preserve the cause while keeping concrete-version admission intact. ### Additional context _No response_
Author
Owner

@XinwangTao commented on GitHub (Oct 21, 2026):

Confirmed — when fingerprinting fails with a real filesystem error (EACCES here), the selection rejection collapses it into version_unresolved and keeps only launcher_path/platform, so the caller cannot distinguish "wrong tool version" from "cannot read the launcher at all". The launcher read catch should surface the underlying error (e.g. carry the code/message in the rejection's details or a cause), letting diagnosis point at the actual permission/path problem instead of a misleading version mismatch.

<!-- gh-comment-id:6312118220 --> @XinwangTao commented on GitHub (Oct 21, 2026): Confirmed — when fingerprinting fails with a real filesystem error (EACCES here), the selection rejection collapses it into version_unresolved and keeps only launcher_path/platform, so the caller cannot distinguish "wrong tool version" from "cannot read the launcher at all". The launcher read catch should surface the underlying error (e.g. carry the code/message in the rejection's details or a cause), letting diagnosis point at the actual permission/path problem instead of a misleading version mismatch.
Author
Owner

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

Checked the reported fix on main at d87e73f2db.

Launcher fingerprinting now returns a typed Result and constructs ProviderAdapterError retaining cause, launcher_path, system_code and read-access recovery. Provider profile inspection keeps projected profile errors rather than replacing them with unexplained absence. The regression injects EACCES from the fingerprint stream and checks the original cause plus launcher/system_code.

Implementation: HopperAnalysisProfile.ts, AnalysisProviderRegistry.ts.

The relevant regression checks in HopperAnalysisProfile.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:6380773762 --> @coygeek commented on GitHub (Oct 24, 2026): Checked the reported fix on `main` at d87e73f2db50be72d33a4b3c7199af35518b441c. Launcher fingerprinting now returns a typed Result and constructs ProviderAdapterError retaining cause, launcher_path, system_code and read-access recovery. Provider profile inspection keeps projected profile errors rather than replacing them with unexplained absence. The regression injects EACCES from the fingerprint stream and checks the original cause plus launcher/system_code. Implementation: [HopperAnalysisProfile.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/hopper/HopperAnalysisProfile.ts#L350), [AnalysisProviderRegistry.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/application/binary/AnalysisProviderRegistry.ts#L936). The relevant regression checks in [HopperAnalysisProfile.test.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/hopper/HopperAnalysisProfile.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#3530
No description provided.