[GH-ISSUE #3209] [Bug] Hopper setup reports a completed installation as a download failure when opening the app fails #1146

Closed
opened 2026-10-15 18:31:29 +00:00 by bsakaguchi · 1 comment
Owner

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

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

On macOS, rea setup --yes --install-hopper --format json reports download_failed when the final application-open command fails after the application bundle has already been installed. It returns status: "needs_human", omits installed_hopper from appliedActions, and tells the caller to check network and filesystem access even though the installed launcher remains on disk.

An independent verifier reproduced this through the built production CLI with fake vendor responses and macOS commands. The production installer wrote and renamed the fake application bundle. Changing only the fake open outcome to success removed the download error and restored appliedActions: ["installed_hopper"]. Both runs exited 1 because setup still required first-run demo/license UI completion; exit 1 alone is not the defect.

Reproduction

  1. Start with a clean checkout of revision 58300885207133422af235e0f2b10658cf958b03 on macOS, use the repository's supported Node toolchain, and prepare the built runtime.
npm ci
npm run build:cached

The verifier used an already-prepared checkout and did not rerun dependency installation or the build during verification. The commands above prepare a clean checkout for the same recipe.

  1. Create the fixture inline in the checkout root. It intercepts every fetch, supplies matching package length and digest, fakes the macOS commands, and confines installation to a disposable home. It runs the failing-open case and a successful-open control, then removes its disposable directory.
cat > repro.mjs <<'JS'
import { createHash } from 'node:crypto';
import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync,
  readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const fixture = fileURLToPath(import.meta.url);
const mode = process.argv[2];
if (mode === 'fake-command') {
  const [cmd, ...args] = process.argv.slice(3);
  appendFileSync(process.env.REA_REPRO_EVENTS, cmd + '\n');
  if (cmd === 'sw_vers') process.stdout.write('15.6\n');
  else if (cmd === 'hdiutil' && args[0] === 'attach') {
    const mount = args[args.indexOf('-mountpoint') + 1];
    const dir = join(mount, 'Hopper Disassembler.app', 'Contents', 'MacOS');
    mkdirSync(dir, { recursive: true });
    writeFileSync(join(dir, 'hopper'), 'fake installed launcher', { mode: 0o755 });
  } else if (cmd === 'ditto') cpSync(args[0], args[1], { recursive: true });
  else if (cmd === 'open' && process.env.REA_REPRO_OPEN_FAIL === '1') {
    process.stderr.write('controlled open failure\n');
    process.exitCode = 1;
  } else if (!['hdiutil', 'codesign', 'open'].includes(cmd)) process.exitCode = 127;
} else if (process.env.REA_REPRO_PRELOAD === '1') {
  const bytes = Buffer.from('local fake Hopper DMG');
  const releaseUrl = 'https://www.hopperapp.com/include/files-api.php?request=releases&public=true';
  const packageUrl = 'https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg';
  globalThis.fetch = async (input) => {
    appendFileSync(process.env.REA_REPRO_EVENTS, 'fetch\n');
    if (String(input) === releaseUrl) return new Response(JSON.stringify({
      'OS X': { filename: packageUrl, file_length: String(bytes.length),
        file_hash: createHash('sha1').update(bytes).digest('hex') }
    }));
    if (String(input) === packageUrl) return new Response(bytes);
    throw new Error('Unexpected network request blocked');
  };
} else {
  if (process.platform !== 'darwin') throw new Error('Run this recipe on macOS');
  const scratch = mkdtempSync(join(tmpdir(), 'rea-open-repro-'));
  const home = join(scratch, 'home');
  const bin = join(scratch, 'bin');
  mkdirSync(home); mkdirSync(bin);
  const quote = (s) => "'" + s.replaceAll("'", "'\"'\"'") + "'";
  for (const cmd of ['sw_vers', 'hdiutil', 'codesign', 'ditto', 'open']) {
    writeFileSync(join(bin, cmd), '#!/bin/sh\nexec ' + quote(process.execPath)
      + ' ' + quote(fixture) + ' fake-command ' + cmd + ' "$@"\n', { mode: 0o755 });
  }
  try {
    for (const fail of ['1', '0']) {
      const events = join(scratch, 'events-' + fail);
      const env = { HOME: home, XDG_CONFIG_HOME: join(home, 'config'),
        XDG_CACHE_HOME: join(home, 'cache'), XDG_DATA_HOME: join(home, 'data'),
        PATH: bin, TMPDIR: scratch, REA_REPRO_PRELOAD: '1',
        REA_REPRO_EVENTS: events, REA_REPRO_OPEN_FAIL: fail };
      const child = spawnSync(process.execPath, ['--import', fixture,
        'scripts/rea.mjs', 'setup', '--yes', '--install-hopper', '--format', 'json'],
        { env, encoding: 'utf8', timeout: 60000 });
      if (child.error) throw child.error;
      if (!child.stdout) throw new Error(child.stderr);
      const result = JSON.parse(child.stdout);
      console.log(JSON.stringify({ openFails: fail === '1', exit: child.status,
        status: result.status, code: result.code ?? null,
        appliedActions: result.appliedActions, remediation: result.remediation,
        installedLauncherExists: existsSync(join(home, 'Applications',
          'Hopper Disassembler.app', 'Contents', 'MacOS', 'hopper')),
        events: readFileSync(events, 'utf8').trim().split('\n') }));
    }
  } finally { rmSync(scratch, { recursive: true, force: true }); }
}
JS
  1. Run the fixture and remove its file.
node repro.mjs
rm repro.mjs
  1. Compare the two JSON lines. The failing-open case has code: "download_failed", appliedActions: [], and installedLauncherExists: true. The successful-open control has code: null, appliedActions: ["installed_hopper"], and the same installed-launcher existence. Both exit 1. The independent verifier ran this exact consolidated fixture and obtained these results.

Expected behavior

docs/installation.md:500-504 documents approved macOS setup downloading and verifying the package, validating and atomically installing the application bundle, and then opening Hopper for its demo/license prompt. AGENTS.md, Architecture and Cleanup, requires preserving partial facts and meaningful failure reasons. An open failure after installation should preserve the installed fact and identify the failed application-open step with applicable recovery advice. It should not report a download or unpack failure. Setup may still require first-run UI completion and need not exit successfully.

Impact and scope

P2. A final-open failure gives users and automation a false download diagnosis, misleading recovery advice, and an empty installation-action record despite the installed bundle remaining intact. Source inspection also shows setup returning before subsequent selected-client configuration; the reproduction selected no clients, so that downstream effect was not observed at runtime.

The verified scope is CLI error reporting with controlled external effects. No real official Hopper package, real signature verification, or GUI session was exercised. The reproduction does not establish the frequency or causes of real macOS open failures, data loss, or a crossed security boundary. The documented workaround is to finish Hopper's demo/license UI manually in the user session and reuse the existing launcher in later setup. Real GUI activation was not performed during verification.

REA version

6.4.0 (main at 5830088520)

Runtime and platform

Node 26.10.0; macOS 27.0 arm64. The fixture reports macOS 15.6 to the setup host guard. It substitutes vendor responses and macOS subprocesses while retaining production setup, package-size and digest checks, destination filesystem operations, and CLI result handling. The repository's .nvmrc pins Node 24.18.0; verification used Node 26.10.0 with an already-prepared runtime.

Target and artifact context

Hopper setup with local fake DMG bytes, matching release metadata and SHA-1, and a fake application bundle containing an executable launcher. No analysis target or real Hopper package is involved.

Interface

CLI

Earliest failure stage

Setup or provider admission

Relevant output or evidence

Failing-open case:
{"openFails":true,"exit":1,"status":"needs_human","code":"download_failed","appliedActions":[],"installedLauncherExists":true}
Remediation: REA could not download or unpack the official Hopper package. Check network and filesystem access, then retry setup.
Successful-open control:
{"openFails":false,"exit":1,"status":"needs_human","code":null,"appliedActions":["installed_hopper"],"installedLauncherExists":true}

These are reduced fields from the verified result lines. Recorded command events show both intercepted fetches, mounting, both signature checks, copying, the final open attempt, and detach. The fake installed launcher remained on disk and temporary installer directories were removed. All six existing src/application/MacHopper.test.ts tests passed during verification; they do not inject an open rejection.

Typed outcome or recovery expected

Preserve the completed installation fact and accurately report the application-open failure or warning with relevant recovery advice. Do not classify it as download/unpack failure. Keep recording installed_hopper when opening succeeds, without requiring exit 0 before first-run readiness. Genuine download failures must retain their classification, integrity and publisher failures must prevent installation, and cleanup must remove the temporary mount directory while leaving the installed application intact. This report does not prescribe a particular new result type.

Analysis or suspected cause

Source-derived at the reported baseline. src/application/MacHopper.ts:156-165 checks the installed launcher and awaits openApplication before returning the installed result. The surrounding catch converts the open rejection into reason download. src/application/SetupHost.ts:197-207 maps that result through src/application/SetupInstallFailure.ts:179-184, whose download remediation describes downloading or unpacking. src/application/Setup.ts:372-393 returns the failure without recording installed_hopper.

The supported entry point is src/cli/setupCommands.ts:204-279, with approved install planning in src/application/SetupPlan.ts:54-70 and Node/macOS guards in src/application/SetupHost.ts:113-130. The reproduction passes these guards. src/application/Setup.ts:301-317 establishes the subsequent-client-configuration early return from source only.

Additional context

Related reports are #165 and #2559. #165 concerns collapsing existing installer reasons in the setup failure mapper, and #2559 concerns Linux cleanup overwriting installation outcomes; this report concerns a macOS final-open exception losing the completed installation fact before the mapper runs.

Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3209 ### 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 On macOS, `rea setup --yes --install-hopper --format json` reports `download_failed` when the final application-open command fails after the application bundle has already been installed. It returns `status: "needs_human"`, omits `installed_hopper` from `appliedActions`, and tells the caller to check network and filesystem access even though the installed launcher remains on disk. An independent verifier reproduced this through the built production CLI with fake vendor responses and macOS commands. The production installer wrote and renamed the fake application bundle. Changing only the fake `open` outcome to success removed the download error and restored `appliedActions: ["installed_hopper"]`. Both runs exited 1 because setup still required first-run demo/license UI completion; exit 1 alone is not the defect. ### Reproduction 1. Start with a clean checkout of revision `58300885207133422af235e0f2b10658cf958b03` on macOS, use the repository's supported Node toolchain, and prepare the built runtime. ```sh npm ci npm run build:cached ``` The verifier used an already-prepared checkout and did not rerun dependency installation or the build during verification. The commands above prepare a clean checkout for the same recipe. 2. Create the fixture inline in the checkout root. It intercepts every fetch, supplies matching package length and digest, fakes the macOS commands, and confines installation to a disposable home. It runs the failing-open case and a successful-open control, then removes its disposable directory. ```sh cat > repro.mjs <<'JS' import { createHash } from 'node:crypto'; import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const fixture = fileURLToPath(import.meta.url); const mode = process.argv[2]; if (mode === 'fake-command') { const [cmd, ...args] = process.argv.slice(3); appendFileSync(process.env.REA_REPRO_EVENTS, cmd + '\n'); if (cmd === 'sw_vers') process.stdout.write('15.6\n'); else if (cmd === 'hdiutil' && args[0] === 'attach') { const mount = args[args.indexOf('-mountpoint') + 1]; const dir = join(mount, 'Hopper Disassembler.app', 'Contents', 'MacOS'); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'hopper'), 'fake installed launcher', { mode: 0o755 }); } else if (cmd === 'ditto') cpSync(args[0], args[1], { recursive: true }); else if (cmd === 'open' && process.env.REA_REPRO_OPEN_FAIL === '1') { process.stderr.write('controlled open failure\n'); process.exitCode = 1; } else if (!['hdiutil', 'codesign', 'open'].includes(cmd)) process.exitCode = 127; } else if (process.env.REA_REPRO_PRELOAD === '1') { const bytes = Buffer.from('local fake Hopper DMG'); const releaseUrl = 'https://www.hopperapp.com/include/files-api.php?request=releases&public=true'; const packageUrl = 'https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg'; globalThis.fetch = async (input) => { appendFileSync(process.env.REA_REPRO_EVENTS, 'fetch\n'); if (String(input) === releaseUrl) return new Response(JSON.stringify({ 'OS X': { filename: packageUrl, file_length: String(bytes.length), file_hash: createHash('sha1').update(bytes).digest('hex') } })); if (String(input) === packageUrl) return new Response(bytes); throw new Error('Unexpected network request blocked'); }; } else { if (process.platform !== 'darwin') throw new Error('Run this recipe on macOS'); const scratch = mkdtempSync(join(tmpdir(), 'rea-open-repro-')); const home = join(scratch, 'home'); const bin = join(scratch, 'bin'); mkdirSync(home); mkdirSync(bin); const quote = (s) => "'" + s.replaceAll("'", "'\"'\"'") + "'"; for (const cmd of ['sw_vers', 'hdiutil', 'codesign', 'ditto', 'open']) { writeFileSync(join(bin, cmd), '#!/bin/sh\nexec ' + quote(process.execPath) + ' ' + quote(fixture) + ' fake-command ' + cmd + ' "$@"\n', { mode: 0o755 }); } try { for (const fail of ['1', '0']) { const events = join(scratch, 'events-' + fail); const env = { HOME: home, XDG_CONFIG_HOME: join(home, 'config'), XDG_CACHE_HOME: join(home, 'cache'), XDG_DATA_HOME: join(home, 'data'), PATH: bin, TMPDIR: scratch, REA_REPRO_PRELOAD: '1', REA_REPRO_EVENTS: events, REA_REPRO_OPEN_FAIL: fail }; const child = spawnSync(process.execPath, ['--import', fixture, 'scripts/rea.mjs', 'setup', '--yes', '--install-hopper', '--format', 'json'], { env, encoding: 'utf8', timeout: 60000 }); if (child.error) throw child.error; if (!child.stdout) throw new Error(child.stderr); const result = JSON.parse(child.stdout); console.log(JSON.stringify({ openFails: fail === '1', exit: child.status, status: result.status, code: result.code ?? null, appliedActions: result.appliedActions, remediation: result.remediation, installedLauncherExists: existsSync(join(home, 'Applications', 'Hopper Disassembler.app', 'Contents', 'MacOS', 'hopper')), events: readFileSync(events, 'utf8').trim().split('\n') })); } } finally { rmSync(scratch, { recursive: true, force: true }); } } JS ``` 3. Run the fixture and remove its file. ```sh node repro.mjs rm repro.mjs ``` 4. Compare the two JSON lines. The failing-open case has `code: "download_failed"`, `appliedActions: []`, and `installedLauncherExists: true`. The successful-open control has `code: null`, `appliedActions: ["installed_hopper"]`, and the same installed-launcher existence. Both exit 1. The independent verifier ran this exact consolidated fixture and obtained these results. ### Expected behavior `docs/installation.md:500-504` documents approved macOS setup downloading and verifying the package, validating and atomically installing the application bundle, and then opening Hopper for its demo/license prompt. `AGENTS.md`, Architecture and Cleanup, requires preserving partial facts and meaningful failure reasons. An open failure after installation should preserve the installed fact and identify the failed application-open step with applicable recovery advice. It should not report a download or unpack failure. Setup may still require first-run UI completion and need not exit successfully. ### Impact and scope P2. A final-open failure gives users and automation a false download diagnosis, misleading recovery advice, and an empty installation-action record despite the installed bundle remaining intact. Source inspection also shows setup returning before subsequent selected-client configuration; the reproduction selected no clients, so that downstream effect was not observed at runtime. The verified scope is CLI error reporting with controlled external effects. No real official Hopper package, real signature verification, or GUI session was exercised. The reproduction does not establish the frequency or causes of real macOS open failures, data loss, or a crossed security boundary. The documented workaround is to finish Hopper's demo/license UI manually in the user session and reuse the existing launcher in later setup. Real GUI activation was not performed during verification. ### REA version 6.4.0 (main at 58300885207133422af235e0f2b10658cf958b03) ### Runtime and platform Node 26.10.0; macOS 27.0 arm64. The fixture reports macOS 15.6 to the setup host guard. It substitutes vendor responses and macOS subprocesses while retaining production setup, package-size and digest checks, destination filesystem operations, and CLI result handling. The repository's `.nvmrc` pins Node 24.18.0; verification used Node 26.10.0 with an already-prepared runtime. ### Target and artifact context Hopper setup with local fake DMG bytes, matching release metadata and SHA-1, and a fake application bundle containing an executable launcher. No analysis target or real Hopper package is involved. ### Interface CLI ### Earliest failure stage Setup or provider admission ### Relevant output or evidence ```text Failing-open case: {"openFails":true,"exit":1,"status":"needs_human","code":"download_failed","appliedActions":[],"installedLauncherExists":true} Remediation: REA could not download or unpack the official Hopper package. Check network and filesystem access, then retry setup. Successful-open control: {"openFails":false,"exit":1,"status":"needs_human","code":null,"appliedActions":["installed_hopper"],"installedLauncherExists":true} ``` These are reduced fields from the verified result lines. Recorded command events show both intercepted fetches, mounting, both signature checks, copying, the final open attempt, and detach. The fake installed launcher remained on disk and temporary installer directories were removed. All six existing `src/application/MacHopper.test.ts` tests passed during verification; they do not inject an open rejection. ### Typed outcome or recovery expected Preserve the completed installation fact and accurately report the application-open failure or warning with relevant recovery advice. Do not classify it as download/unpack failure. Keep recording `installed_hopper` when opening succeeds, without requiring exit 0 before first-run readiness. Genuine download failures must retain their classification, integrity and publisher failures must prevent installation, and cleanup must remove the temporary mount directory while leaving the installed application intact. This report does not prescribe a particular new result type. ### Analysis or suspected cause Source-derived at the reported baseline. `src/application/MacHopper.ts:156-165` checks the installed launcher and awaits `openApplication` before returning the installed result. The surrounding catch converts the open rejection into reason `download`. `src/application/SetupHost.ts:197-207` maps that result through `src/application/SetupInstallFailure.ts:179-184`, whose download remediation describes downloading or unpacking. `src/application/Setup.ts:372-393` returns the failure without recording `installed_hopper`. The supported entry point is `src/cli/setupCommands.ts:204-279`, with approved install planning in `src/application/SetupPlan.ts:54-70` and Node/macOS guards in `src/application/SetupHost.ts:113-130`. The reproduction passes these guards. `src/application/Setup.ts:301-317` establishes the subsequent-client-configuration early return from source only. ### Additional context Related reports are #165 and #2559. #165 concerns collapsing existing installer reasons in the setup failure mapper, and #2559 concerns Linux cleanup overwriting installation outcomes; this report concerns a macOS final-open exception losing the completed installation fact before the mapper runs.
bsakaguchi 2026-10-15 18:31:29 +00:00
  • closed this issue
  • added the
    bug
    label
Author
Owner

@morluto commented on GitHub (Oct 14, 2026):

Delivered on main in fffb07419.

The installer wrapped download, commit and application launch in one failure classification. It now validates staged contents, owns replacement/rollback paths, and preserves the installed result when a later OS open fails.

Validation: Setup planning and real-filesystem Hopper install ownership tests, including competing destination, rollback and retained cleanup. Hopper itself was not launched for this validation. Full exact-revision qualification passed.

<!-- gh-comment-id:6177644692 --> @morluto commented on GitHub (Oct 14, 2026): Delivered on main in [fffb07419](https://github.com/morluto/rea/commit/fffb0741936067530d1ce9777461cd53e87398c2). The installer wrapped download, commit and application launch in one failure classification. It now validates staged contents, owns replacement/rollback paths, and preserves the installed result when a later OS open fails. Validation: Setup planning and real-filesystem Hopper install ownership tests, including competing destination, rollback and retained cleanup. Hopper itself was not launched for this validation. Full exact-revision [qualification](https://github.com/morluto/rea/actions/runs/38629313022) passed.
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#1146
No description provided.