fix(hopper)!: verify current Linux releases with the publisher key

Replace fixed Linux Hopper release pins with current vendor feed metadata,
complete detached-PGP package verification under the stable publisher primary
key, and authenticated package evidence for offline admission. Share parsed
publisher facts across approved setup, doctor and the pre-execution guard.
Preserve existing ownership, cancellation, private-display and cleanup rules.

Fixes #3577

Normalize empty XDG settings, preserve backups during approved cache repair,
and retain publisher cleanup through cancellation and shutdown. Exercise cold
offline verification without reusing process-local proof state. Serialize
approved cache publication across processes, restore old evidence on failure,
and share the existing lease owner from process without changing its identities.

Validation: 438 focused tests (six platform-related skips), full static/documentation checks, real Ubuntu
24.04 official Hopper 6.6.0 setup/CLI/MCP, offline ordinary-user admission,
launcher mutation rejection, and cold/warm verification measurements. Official
RPM metadata mismatch is tracked separately in #3578 and fails closed.

BREAKING CHANGE: Older Linux Hopper builds admitted by the fixed allowlist
now require retained publisher-signed package evidence or an approved update
to the current public demo. Update explicit HOPPER_LAUNCHER_PATH values when
migrating; the live feed exposes only its current release.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 12:15:55 +08:00
1 parent b7dcf782aa
commit 70cd4cbfb8
49 files changed
+3195 -223

No files matched your search

+31
View File
@@ -541,6 +541,37 @@ search permissions (`0700`) on a directory you own before retrying.
On supported Linux distributions, approved setup verifies Hopper's official
`.deb`, `.rpm`, or Arch package before invoking the native package manager.
The official release feed selects the current public demo without a version
pin. REA verifies the package's detached PGP signature against Hopper's stable
primary fingerprint `7C2D73BAFAF5B9A710A69DA1A26DF815099B1BCD`, then checks
that the signed package and installed launcher agree with `main_exe_hash`.
Rotating signing subkeys remain bound to that primary identity.
The feed row itself is not PGP-signed; doctor labels its display version as
`reported_release_version`, separately from the authenticated launcher identity.
The approval plan includes the caller-selected cache location. Approved setup
retains the signed archive, public key and release metadata under
`${XDG_CACHE_HOME:-$HOME/.cache}/rea/hopper-releases/<launcher-sha256>`.
An empty `XDG_CACHE_HOME` uses `$HOME/.cache`. Approved setup can repair a
damaged caller-owned entry after authenticating a fresh package; the previous
entry is preserved beside it as `<launcher-sha256>.backup-<id>`. Ordinary doctor
and launches reject damaged evidence and never repair it implicitly.
Approved publications of the same canonical cache entry share a process-backed
lease. Concurrent setup waits without moving another writer's evidence; a
failed replacement restores the previous directory when possible and reports
any retained backup path. Unavailable reads or native inspection tools do not
authorize replacing existing evidence.
Doctor and launches authenticate this evidence for offline use; an editable
digest receipt alone cannot authorize a launcher. Verification reuses at most
eight immutable package proofs per process, keyed by the complete package,
key, signature, package format and expected launcher digest. Cached file reads
still check identity and content. Without retained evidence, inspecting an
existing install requires the matching current official package and network
access. Successful live inspection can be reused within that process after
rehashing the launcher; it does not write a persistent cache or claim that a
previously observed release is still the latest. Older uncached builds require
an approved update while the feed exposes only its current release.
REA runs the supported demo build on a private Xvfb display and selects Hopper's
offered demo mode for each analysis session; it does not require the user's
desktop display. Unattended package-manager access requires
+6 -1
View File
@@ -2,7 +2,7 @@ REA npm dependency inventory
Prepared from the root package-lock.json; refresh this inventory when locked dependencies change.
License identifiers are upstream lockfile declarations, not a legal assessment.
Runtime includes transitive and optional platform packages; development includes build, test and website tooling.
Locked package entries: 721; runtime: 177.
Locked package entries: 722; runtime: 178.
Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt.
@algolia/abtesting@1.25.0 | development | MIT
@@ -2740,6 +2740,11 @@ oniguruma-to-es@3.1.1 | development | MIT
Package: https://www.npmjs.com/package/oniguruma-to-es/v/3.1.1
Archive: https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz
Integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==
openpgp@6.3.2 | runtime | LGPL-3.0+
Lockfile path: node_modules/openpgp
Package: https://www.npmjs.com/package/openpgp/v/6.3.2
Archive: https://registry.npmjs.org/openpgp/-/openpgp-6.3.2.tgz
Integrity: sha512-wcZTzHz41LV8Y48zH/JlD1JT8YdNmpWOuMAjQ/podvvCwsjBEU37K3znJh4UQcj3hksE5z9TzvhbUbwzsGvlLQ==
oxc-parser@0.150.0 | development | MIT
Lockfile path: node_modules/oxc-parser
Package: https://www.npmjs.com/package/oxc-parser/v/0.150.0
+175 -1
View File
@@ -1,7 +1,7 @@
REA third-party npm runtime copyright, license and NOTICE texts
Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries.
External engines are separately obtained; see THIRD_PARTY_NOTICES.md.
Runtime dependency fingerprint (SHA-256): 49b181923ab240c68c64d35ecdd8f93cb5cd516c765f2282d0e0eca0bcadd13e
Runtime dependency fingerprint (SHA-256): 131100b2e1e6ad5e55b10a0baf4be2d7a0838fd724fb307b9bafd73446b0d3ec
===== @babel/helper-string-parser@8.0.6 =====
Declared license: MIT
@@ -5171,6 +5171,180 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
===== openpgp@6.3.2 =====
Declared license: LGPL-3.0+
Archive: https://registry.npmjs.org/openpgp/-/openpgp-6.3.2.tgz
Integrity: sha512-wcZTzHz41LV8Y48zH/JlD1JT8YdNmpWOuMAjQ/podvvCwsjBEU37K3znJh4UQcj3hksE5z9TzvhbUbwzsGvlLQ==
Scope: npm runtime dependency. GPLv3 terms incorporated by this LGPL text are preserved in licenses/GPL-3.0.txt.
--- package/LICENSE ---
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
===== pako@1.0.11 =====
Declared license: (MIT AND Zlib)
Archive: https://registry.npmjs.org/pako/-/pako-1.0.11.tgz
+19
View File
@@ -34,6 +34,7 @@
"isomorphic-git": "1.38.7",
"jsonc-parser": "3.3.1",
"lossless-json": "4.3.1",
"openpgp": "6.3.2",
"parse5": "8.0.1",
"pino": "10.4.0",
"playwright-core": "1.58.2",
@@ -7871,6 +7872,24 @@
"regex-recursion": "^6.0.2"
}
},
"node_modules/openpgp": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/openpgp/-/openpgp-6.3.2.tgz",
"integrity": "sha512-wcZTzHz41LV8Y48zH/JlD1JT8YdNmpWOuMAjQ/podvvCwsjBEU37K3znJh4UQcj3hksE5z9TzvhbUbwzsGvlLQ==",
"license": "LGPL-3.0+",
"engines": {
"node": ">= 18.0.0",
"typescript": ">= 5.0.0"
},
"peerDependencies": {
"@openpgp/web-stream-tools": "~0.3.0"
},
"peerDependenciesMeta": {
"@openpgp/web-stream-tools": {
"optional": true
}
}
},
"node_modules/oxc-parser": {
"version": "0.150.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.150.0.tgz",
+1
View File
@@ -337,6 +337,7 @@
"isomorphic-git": "1.38.7",
"jsonc-parser": "3.3.1",
"lossless-json": "4.3.1",
"openpgp": "6.3.2",
"parse5": "8.0.1",
"pino": "10.4.0",
"playwright-core": "1.58.2",
+12 -10
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Inspect or activate Hopper's version-pinned demo dialog on a private X display."""
"""Inspect or activate Hopper's authenticated demo dialog on a private X display."""
from __future__ import annotations
@@ -8,6 +8,7 @@ import ctypes
import hashlib
import json
import os
import re
from pathlib import Path
import secrets
import select
@@ -18,12 +19,6 @@ import threading
import time
# Keep this digest aligned with LinuxHopper.ts and rerun real-Hopper verification
# before accepting another vendor build.
SUPPORTED_HOPPER_SHA256 = {
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
"1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13",
}
EXPECTED_SCREEN = (1280, 1024)
DIALOG_TITLES = {"Registration", "Registration — Hopper Disassembler"}
HOPPER_WINDOW_TITLE = "Hopper Disassembler"
@@ -121,6 +116,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--probe", action="store_true")
parser.add_argument("--owner-stdin", action="store_true")
parser.add_argument("--hopper")
parser.add_argument("--expected-launcher-sha256")
parser.add_argument("--socket")
parser.add_argument(
"--strategy",
@@ -661,7 +657,11 @@ def run_probe(args: argparse.Namespace) -> dict[str, object]:
def run_launch(args: argparse.Namespace) -> int:
if args.hopper is None or args.socket is None:
if (
args.hopper is None or args.socket is None
or args.expected_launcher_sha256 is None
or re.fullmatch(r"[a-f0-9]{64}", args.expected_launcher_sha256) is None
):
raise AdapterFailure(
INVALID_LAUNCH_COMMAND,
"invalid_launch_command",
@@ -673,7 +673,9 @@ def run_launch(args: argparse.Namespace) -> int:
hopper = Path(args.hopper)
socket = Path(args.socket)
try:
supported = digest(hopper) in SUPPORTED_HOPPER_SHA256
# The parent owns publisher/package interpretation. Recheck its admitted
# launcher bytes immediately before execution, preserving all X11 guards.
supported = digest(hopper) == args.expected_launcher_sha256
except OSError as error:
raise AdapterFailure(
INVALID_LAUNCH_COMMAND,
@@ -684,7 +686,7 @@ def run_launch(args: argparse.Namespace) -> int:
raise AdapterFailure(
UNSUPPORTED_HOPPER_BUILD,
"unsupported_hopper_build",
"unsupported Hopper binary",
"Hopper binary differs from the authenticated release",
)
command = args.command[1:] if args.command[:1] == ["--"] else args.command
if not command or Path(command[0]).resolve() != hopper.resolve():
+15 -6
View File
@@ -13,6 +13,7 @@ import { promisify } from "node:util";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { assertCleanLinuxHopperRuntime } from "./lib/clean-linux-hopper-runtime.mjs";
import { isUnsupportedHopperLaunchFailure } from "./verify-package-platform.mjs";
const execFileAsync = promisify(execFile);
const verifierRun = createVerifierRun();
@@ -89,16 +90,24 @@ try {
await appendFile(modifiedHopper, new Uint8Array([0]));
await chmod(modifiedHopper, 0o700);
try {
await execFileAsync(command, ["analyze", "/usr/bin/true"], {
env: { ...process.env, HOPPER_LAUNCHER_PATH: modifiedHopper },
timeout: 30_000,
});
await execFileAsync(
command,
["analyze", "/usr/bin/true", "--provider", "hopper", "--json"],
{
env: { ...process.env, HOPPER_LAUNCHER_PATH: modifiedHopper },
timeout: 30_000,
},
);
throw new Error("modified Hopper build was not rejected");
} catch (error) {
const output = `${error?.stdout ?? ""}\n${error?.stderr ?? ""}`;
if (!output.includes("unsupported_hopper_build"))
if (
error?.code !== 1 ||
typeof error.stdout !== "string" ||
!isUnsupportedHopperLaunchFailure(JSON.parse(error.stdout))
)
throw new Error(
"modified Hopper build did not return its stable failure code",
{ cause: error },
);
}
} finally {
+9
View File
@@ -1,5 +1,6 @@
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { supportedClients } from "../dist/application/SupportedClients.js";
/** Create an isolated prefix, fake PATH, client configs, and environment. */
@@ -121,6 +122,14 @@ export const packageHopperEnvironment = (root, platform = process.platform) => {
};
return {
HOPPER_LAUNCHER_PATH: "/bin/sh",
// Feed availability must not turn this foreign-launcher rejection test into
// an unrelated networking failure. Successful vendor verification is real E2E coverage.
NODE_OPTIONS: [
process.env.NODE_OPTIONS,
`--import=${pathToFileURL(join(root, "tests/fixtures/hopperRejectionFeed.mjs")).href}`,
]
.filter(Boolean)
.join(" "),
HOPPER_LOADER_ARGS_JSON: JSON.stringify([
"-c",
'node_path=$1; shift; "$node_path" "$@"',
+20 -5
View File
@@ -88,14 +88,29 @@ export async function verifyPackagePlatform({
}
}
/** Require the declared unsupported build or an exact missing-Xvfb diagnostic, never arbitrary provider failure. */
/** One owner for unsupported-launch evidence across package and real-Hopper verification. */
export const isUnsupportedHopperLaunchFailure = (failure) => {
const details = failure?.details;
const unsupportedBuild =
details?.failure_code === "unsupported_hopper_build" &&
details.exit_code === 72;
const publisherRejection =
details?.stage === "launch" &&
details.launch_rejection?.boundary === "publisher_verification" &&
details.launch_rejection.reason === "unsupported_hopper_build" &&
details.exit_code === undefined &&
details.launcher === undefined;
return (
failure?.code === "provider_unavailable" &&
(publisherRejection || unsupportedBuild)
);
};
/** Require an exact pre-execution rejection, observed helper exit, or missing-Xvfb diagnostic. */
export const assertLinuxPackageProviderFailure = (execution) => {
const failure = json(execution.stdout);
const details = failure?.details;
const diagnostic = details?.diagnostics;
const unsupportedBuild =
details?.failure_code === "unsupported_hopper_build" &&
details.exit_code === 72;
const missingDisplayDependency =
details?.failure_code === "runtime_dependency_unavailable" &&
details.exit_code === 79 &&
@@ -108,7 +123,7 @@ export const assertLinuxPackageProviderFailure = (execution) => {
if (
execution.status !== 1 ||
failure?.code !== "provider_unavailable" ||
(!unsupportedBuild && !missingDisplayDependency)
(!isUnsupportedHopperLaunchFailure(failure) && !missingDisplayDependency)
)
throw new Error(
`packaged Linux Hopper verification did not fail closed: ${JSON.stringify(failure)}`,
+1 -1
View File
@@ -23,7 +23,7 @@ import {
requireMcpOperationResult,
} from "./lib/mcp-verifier-results.mjs";
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js";
import { parseConfig } from "../dist/config/parseConfig.js";
import {
verifyHopperSourceBinding,
+1 -1
View File
@@ -12,7 +12,7 @@ import {
requirePseudocode,
} from "../dist/application/RealHopperAssertions.js";
import { HOPPER_PROVIDER_IDENTITY } from "../dist/hopper/HopperProviderCapabilities.js";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js";
import { REA_WORKFLOW_PROVIDER } from "../dist/application/InvestigationProviders.js";
import { loadRealHopperFixtureTargets } from "./lib/real-hopper-fixture.mjs";
import { verifyHopperStringObjects } from "./lib/real-hopper-search.mjs";
+11 -1
View File
@@ -1,5 +1,6 @@
import { CATALOG_IDENTITY } from "../catalogIdentity.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { ok } from "../domain/result.js";
import type { DoctorHost } from "./Doctor.js";
/** Healthy recording host for focused doctor and projection tests. */
@@ -14,7 +15,16 @@ export const createDoctorHostFixture = (
linuxDistribution: () => Promise.resolve(undefined),
validTarget: (path) => Promise.resolve(path.includes("Hopper")),
executable: (path) => Promise.resolve(path.includes("Hopper")),
supportedLinuxHopper: () => Promise.resolve(true),
inspectLinuxHopper: () =>
Promise.resolve(
ok({
sha256: "0".repeat(64),
version: "fixture",
primaryFingerprint: "0".repeat(40),
signingFingerprint: "1".repeat(40),
source: "signed_package_cache" as const,
}),
),
linuxDemoRuntimeCheck: () =>
Promise.resolve({
name: "hopper-demo-runtime",
+12 -2
View File
@@ -4,6 +4,8 @@ import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
import { runDoctor } from "./Doctor.js";
import { err } from "../domain/result.js";
import { HopperReleaseError } from "./HopperReleaseFeed.js";
describe("doctor runtime check", () => {
it("reports Node 25 as unsupported", async () => {
@@ -617,12 +619,20 @@ describe("doctor Linux and Hopper discovery", () => {
hopperSetupSupported: true,
}),
executable: (candidate) => Promise.resolve(candidate === path),
supportedLinuxHopper: () => Promise.resolve(false),
inspectLinuxHopper: () =>
Promise.resolve(
err(
new HopperReleaseError(
"unsupported_hopper_build",
"Unverified fixture build",
),
),
),
}),
);
expect(
result.checks.find(({ name }) => name === "hopper-version")?.remediation,
).toContain("Unset or update HOPPER_LAUNCHER_PATH");
).toContain("Check HOPPER_LAUNCHER_PATH");
});
});
+8 -3
View File
@@ -19,11 +19,14 @@ import {
macHopperRenamedLauncherCandidates,
} from "./MacHopperPaths.js";
import {
linuxHopperBinarySupported,
inspectLinuxHopperLauncher,
linuxSharedLibrariesAvailable,
readLinuxDistribution,
type LinuxDistribution,
} from "./LinuxHopper.js";
import type { Result } from "../domain/result.js";
import type { HopperReleaseError } from "./HopperReleaseFeed.js";
import type { VerifiedLinuxHopperLauncher } from "./LinuxHopperPublisher.js";
import {
readInstalledSkillIdentity,
type InstalledSkillIdentity,
@@ -101,7 +104,9 @@ export interface DoctorHost {
/** Route-specific target admission details, when supplied by the host. */
inspectTarget?(path: string): Promise<DoctorCheck>;
executable(path: string): Promise<boolean>;
supportedLinuxHopper(path: string): Promise<boolean>;
inspectLinuxHopper(
path: string,
): Promise<Result<VerifiedLinuxHopperLauncher, HopperReleaseError>>;
linuxDemoRuntimeCheck(): Promise<DoctorCheck>;
brewHopperPath(): Promise<string | undefined>;
manualHopperPaths(): Promise<readonly string[]>;
@@ -512,7 +517,7 @@ export const systemDoctorHost = (
),
linuxDemoRuntimeCheck:
options.linuxDemoRuntimeCheck ?? uncomposedLinuxDemoRuntimeCheck,
supportedLinuxHopper: linuxHopperBinarySupported,
inspectLinuxHopper: (path) => inspectLinuxHopperLauncher(path, environment),
async brewHopperPath() {
return probeHomebrew(async (command) => {
let caskroom: string;
+30 -13
View File
@@ -209,17 +209,34 @@ const optionalHopperVersionCheck = async (
hopperPath: string | undefined,
): Promise<DoctorCheck | undefined> => {
if (host.platform !== "linux" || hopperPath === undefined) return undefined;
return check(
"hopper-version",
await host.supportedLinuxHopper(hopperPath),
hopperPath,
{
remediation: unsupportedHopperRemediation(
host.configuredHopperPath === hopperPath,
),
classification: "config_drift",
},
);
const inspected = await host.inspectLinuxHopper(hopperPath);
const buildCheck = check("hopper-version", inspected.ok, hopperPath, {
remediation: unsupportedHopperRemediation(
host.configuredHopperPath === hopperPath,
),
classification:
!inspected.ok &&
[
"release_metadata",
"download",
"verification_unavailable",
"cancelled",
"cleanup_failed",
].includes(inspected.error.reason)
? "unknown"
: "config_drift",
});
return {
...buildCheck,
details: inspected.ok
? {
reported_release_version: inspected.value.version,
launcher_sha256: inspected.value.sha256,
primary_fingerprint: inspected.value.primaryFingerprint,
source: inspected.value.source,
}
: { reason: inspected.error.reason, message: inspected.error.message },
};
};
const optionalHopperDemoCheck = async (
@@ -297,8 +314,8 @@ const providerDoctorChecks = (
const unsupportedHopperRemediation = (configured: boolean): string =>
configured
? "Unset or update HOPPER_LAUNCHER_PATH, install the Hopper build supported by this REA release, or update REA."
: "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build.";
? "Check HOPPER_LAUNCHER_PATH and its publisher verification evidence. Run rea setup to install and retain an authenticated official Hopper package for offline use."
: "Run rea setup to install and retain an authenticated official Hopper package for offline use.";
const parseMajor = (version: string | undefined): number =>
Number.parseInt(version?.split(".")[0] ?? "0", 10);
+18 -4
View File
@@ -4,9 +4,23 @@ export const HOPPER_DOWNLOAD_BYTES = 100_000_000;
/** Bound observed response bytes before retaining them, including chunked bodies. */
export const downloadHopperPackage = async (
url: string,
options: { readonly signal?: AbortSignal } = {},
options: {
readonly signal?: AbortSignal;
readonly maxBytes?: number;
readonly timeoutMs?: number;
} = {},
): Promise<{ readonly ok: boolean; readonly bytes: Uint8Array }> => {
const deadline = AbortSignal.timeout(120_000);
const timeoutMs = options.timeoutMs ?? 120_000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000)
throw new RangeError("Invalid Hopper response time budget");
const deadline = AbortSignal.timeout(timeoutMs);
const maxBytes = options.maxBytes ?? HOPPER_DOWNLOAD_BYTES;
if (
!Number.isSafeInteger(maxBytes) ||
maxBytes < 1 ||
maxBytes > HOPPER_DOWNLOAD_BYTES
)
throw new RangeError("Invalid Hopper response byte budget");
const response = await fetch(url, {
headers: { accept: "application/json", "user-agent": "rea-installer" },
signal:
@@ -17,7 +31,7 @@ export const downloadHopperPackage = async (
const refused = { ok: false, bytes: new Uint8Array() };
if (
!response.ok ||
Number(response.headers.get("content-length")) > HOPPER_DOWNLOAD_BYTES
Number(response.headers.get("content-length")) > maxBytes
) {
await response.body?.cancel();
return refused;
@@ -30,7 +44,7 @@ export const downloadHopperPackage = async (
while (true) {
const chunk = await reader.read();
if (chunk.done) return { ok: true, bytes: Buffer.concat(chunks, bytes) };
if (chunk.value.byteLength > HOPPER_DOWNLOAD_BYTES - bytes) {
if (chunk.value.byteLength > maxBytes - bytes) {
await reader.cancel();
return refused;
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { parseLinuxHopperReleases } from "./HopperReleaseFeed.js";
const release = {
version: "99.2.1",
filename:
"https://www.hopperapp.com:443/downloader/public/Hopper-99.2.1-Linux-demo.deb",
file_length: "123",
file_hash: "a".repeat(40),
main_exe_hash: "b".repeat(64),
signature: "AQ==",
public: "1",
beta: "0",
published_at: "preserved vendor field",
};
describe("Linux Hopper release feed boundary", () => {
it("preserves future release identity and partial platform facts", () => {
const parsed = parseLinuxHopperReleases({
"Ubuntu / Mint": release,
"OS X": { signature: "Sparkle DSA" },
});
expect(parsed).toMatchObject({
ok: true,
value: {
deb: { ok: true, value: release },
rpm: { ok: false },
arch: { ok: false },
},
});
if (!parsed.ok || !parsed.value.deb.ok) throw new Error("Missing release");
expect(parsed.value.deb.value.filename).toBe(release.filename);
});
it.each([
{ signature: undefined },
{ signature: "AQ==garbage" },
{ main_exe_hash: "b".repeat(40) },
{ file_length: "100000001" },
{ file_length: "NaN" },
{ public: "0" },
{ beta: "1" },
{
filename:
"https://other.example/downloader/public/Hopper-99.2.1-Linux-demo.deb",
},
{ filename: release.filename + "?token=caller" },
{
filename: release.filename.replace("https://", "https://user:password@"),
},
{ filename: release.filename.replace(".deb", ".rpm") },
{ filename: release.filename.replace("demo.deb", "full.deb") },
])(
"rejects metadata outside the selected public package contract: %j",
(changes) => {
expect(
parseLinuxHopperReleases({
"Ubuntu / Mint": { ...release, ...changes },
}),
).toMatchObject({
ok: true,
value: { deb: { ok: false, error: { reason: "release_metadata" } } },
});
},
);
it.each([null, [], "html error page"])(
"keeps malformed feed roots unknown",
(raw) => {
expect(parseLinuxHopperReleases(raw)).toMatchObject({
ok: false,
error: { reason: "release_metadata" },
});
},
);
});
+125
View File
@@ -0,0 +1,125 @@
import { z } from "zod";
import { err, ok, type Result } from "../domain/result.js";
import { HOPPER_DOWNLOAD_BYTES } from "./HopperDownload.js";
/** Vendor endpoints, shared by installation and existing-build inspection. */
export const HOPPER_RELEASES_URL =
"https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
export const HOPPER_PUBLIC_KEY_URL =
"https://www.hopperapp.com/hopper-public-key.asc";
export const HOPPER_DOWNLOAD_PREFIX =
"https://www.hopperapp.com:443/downloader/public/";
/** Stable publisher identity; signing subkeys are admitted through this primary. */
export const HOPPER_LINUX_PRIMARY_FINGERPRINT =
"7C2D73BAFAF5B9A710A69DA1A26DF815099B1BCD";
export type LinuxPackageFamily = "deb" | "rpm" | "arch";
const platforms = {
deb: "Ubuntu / Mint",
rpm: "Fedora",
arch: "Arch",
} as const;
const suffixes = { deb: ".deb", rpm: ".rpm", arch: ".pkg.tar.xz" } as const;
export class HopperReleaseError extends Error {
readonly _tag = "HopperReleaseError";
constructor(
readonly reason:
| "release_metadata"
| "download"
| "integrity"
| "publisher_verification_failed"
| "verification_unavailable"
| "unsupported_hopper_build"
| "cancelled"
| "cleanup_failed"
| "cache_missing",
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = this._tag;
}
}
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
const releaseSchema = z
.object({
version: z.string().min(1),
filename: z.string().url(),
file_length: z
.string()
.regex(/^[1-9]\d*$/u)
.refine((text) => {
const count = Number(text);
return Number.isSafeInteger(count) && count <= HOPPER_DOWNLOAD_BYTES;
}),
file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
main_exe_hash: sha256,
signature: z
.string()
.min(1)
.refine(
(text) => Buffer.from(text, "base64").toString("base64") === text,
),
public: z.literal("1"),
beta: z.literal("0"),
})
.passthrough();
/** Parsed once at the feed boundary; preserve the vendor's complete release row. */
export type LinuxHopperRelease = z.infer<typeof releaseSchema>;
export type LinuxHopperReleaseFeed = Readonly<
Record<LinuxPackageFamily, Result<LinuxHopperRelease, HopperReleaseError>>
>;
export const parseLinuxHopperRelease = (
raw: unknown,
family: LinuxPackageFamily,
): Result<LinuxHopperRelease, HopperReleaseError> => {
const parsed = releaseSchema.safeParse(raw);
if (!parsed.success)
return err(
new HopperReleaseError(
"release_metadata",
"The Hopper release omitted valid package identity or signature metadata.",
),
);
const url = new URL(parsed.data.filename);
if (
url.origin !== "https://www.hopperapp.com" ||
url.username !== "" ||
url.password !== "" ||
url.search !== "" ||
url.hash !== "" ||
!url.pathname.startsWith("/downloader/public/Hopper-") ||
!url.pathname.endsWith(`-Linux-demo${suffixes[family]}`)
)
return err(
new HopperReleaseError(
"release_metadata",
"The Hopper release is outside the official public Linux demo channel.",
),
);
return ok(parsed.data);
};
export const parseLinuxHopperReleases = (
raw: unknown,
): Result<LinuxHopperReleaseFeed, HopperReleaseError> => {
const feed = z.record(z.string(), z.unknown()).safeParse(raw);
if (!feed.success)
return err(
new HopperReleaseError(
"release_metadata",
"The Hopper releases response is not an object.",
),
);
return ok({
deb: parseLinuxHopperRelease(feed.data[platforms.deb], "deb"),
rpm: parseLinuxHopperRelease(feed.data[platforms.rpm], "rpm"),
arch: parseLinuxHopperRelease(feed.data[platforms.arch], "arch"),
});
};
+79 -25
View File
@@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest";
import {
formatCommandForDisplay,
installLinuxHopper,
linuxHopperLauncherDigestSupported,
linuxPackageManagerCommands,
linuxSharedLibrariesAvailable,
parseLinuxDistribution,
@@ -18,6 +17,12 @@ import {
type LinuxPackageInstallFailure,
type LinuxPackageInstallOutcome,
} from "./LinuxHopper.js";
import { err, ok } from "../domain/result.js";
import {
HopperReleaseError,
type LinuxHopperRelease,
} from "./HopperReleaseFeed.js";
import type { VerifiedLinuxHopperPackage } from "./LinuxHopperPublisher.js";
describe("Linux package-manager command display", () => {
it("preserves argument boundaries for paths with spaces and quotes", () => {
@@ -43,6 +48,9 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
archiveOk = true;
installSucceeds = true;
cleanupFails = false;
signatureValid = true;
cacheWritable = true;
verifiedBeforeInstall = false;
packageFailure: LinuxPackageInstallFailure = {
command: "pacman -U --noconfirm /tmp/rea test/hopper.arch",
exitCode: 1,
@@ -57,6 +65,8 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
distribution = (): Promise<LinuxDistribution | undefined> =>
Promise.resolve(this.distributionValue);
release = (family: LinuxPackageFamily) =>
Promise.resolve(ok(releasesFor(new Uint8Array([1, 2, 3]))[family]));
download = (url: string): Promise<LinuxHopperDownload> => {
this.downloads.push(url);
return Promise.resolve({ ok: this.archiveOk, bytes: this.archive });
@@ -64,10 +74,52 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
createTemporaryDirectory = (): Promise<string> =>
Promise.resolve("/tmp/rea test");
writeArchive = (): Promise<void> => Promise.resolve();
verifyArchive = (family: LinuxPackageFamily, release: LinuxHopperRelease) => {
if (
createHash("sha1").update(this.archive).digest("hex") !==
release.file_hash
)
return Promise.resolve(
err(new HopperReleaseError("integrity", "Fixture byte mismatch")),
);
if (!this.signatureValid)
return Promise.resolve(
err(
new HopperReleaseError(
"publisher_verification_failed",
"Fixture signature rejection",
),
),
);
this.verifiedBeforeInstall = true;
return Promise.resolve(
ok({
family,
release,
packageSha256: createHash("sha256").update(this.archive).digest("hex"),
publicKey: "fixture",
primaryFingerprint: "0".repeat(40),
signingFingerprint: "1".repeat(40),
}),
);
};
retainVerifiedArchive = (_verified: VerifiedLinuxHopperPackage) =>
Promise.resolve(
this.cacheWritable
? ok(undefined)
: err(
new HopperReleaseError(
"verification_unavailable",
"Fixture cache permission rejection",
),
),
);
installPackage = (
family: LinuxPackageFamily,
archive: string,
): Promise<LinuxPackageInstallOutcome> => {
if (!this.verifiedBeforeInstall)
throw new Error("Package manager ran before publisher verification");
this.installed.push({ family, archive });
const outcome: LinuxPackageInstallOutcome = this.installSucceeds
? { status: "installed" }
@@ -160,20 +212,6 @@ describe("Linux Hopper host classification", () => {
});
describe("Linux Hopper installation", () => {
it.each([
[
"Debian and Arch",
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
],
["RPM", "1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13"],
])("accepts the pinned %s launcher build", (_, digest) => {
expect(linuxHopperLauncherDigestSupported(digest)).toBe(true);
});
it("rejects an unverified launcher build", () => {
expect(linuxHopperLauncherDigestSupported("0".repeat(64))).toBe(false);
});
it.each([
["deb", "apt-get", ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]],
[
@@ -239,6 +277,23 @@ describe("Linux Hopper package installation", () => {
});
describe("Linux Hopper installation failure classification", () => {
it.each([
["signatureValid", "publisher_verification_failed"],
["cacheWritable", "verification_unavailable"],
] as const)(
"stops before privileged installation when %s fails",
async (field, reason) => {
const host = new RecordingLinuxHost();
host[field] = false;
expect(await installFixture(host)).toMatchObject({
status: "failed",
reason,
verificationFailure: { reason, message: expect.any(String) },
});
expect(host.installed).toEqual([]);
expect(host.cleaned).toEqual(["/tmp/rea test"]);
},
);
it.each([
["unsupported host", "unsupported_host"],
["package request failure", "download"],
@@ -263,7 +318,7 @@ describe("Linux Hopper installation failure classification", () => {
host.launcherStatusValue = "runtime_dependencies";
if (scenario === "unsupported build")
host.launcherStatusValue = "unsupported_hopper_build";
expect(await installFixture(host)).toEqual({
expect(await installFixture(host)).toMatchObject({
status: "failed",
reason,
...(scenario === "package manager failure"
@@ -363,14 +418,16 @@ describe("Linux Hopper temporary cleanup ownership", () => {
function releasesFor(
bytes: Uint8Array,
): Record<
LinuxPackageFamily,
{ filename: string; file_length: string; file_hash: string }
> {
): Record<LinuxPackageFamily, LinuxHopperRelease> {
const release = (extension: string) => ({
filename: `https://www.hopperapp.com:443/downloader/public/Hopper.${extension}`,
version: "future-fixture",
filename: `https://www.hopperapp.com:443/downloader/public/Hopper-future-fixture-Linux-demo.${extension}`,
file_length: String(bytes.byteLength),
file_hash: createHash("sha1").update(bytes).digest("hex"),
main_exe_hash: "0".repeat(64),
signature: "AQ==",
public: "1" as const,
beta: "0" as const,
});
return {
deb: release("deb"),
@@ -379,7 +436,4 @@ function releasesFor(
};
}
const installFixture = (host: RecordingLinuxHost) =>
installLinuxHopper(host, {
releases: releasesFor(new Uint8Array([1, 2, 3])),
});
const installFixture = (host: RecordingLinuxHost) => installLinuxHopper(host);
+175 -102
View File
@@ -1,55 +1,37 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileBoundedOutput } from "../process/ExecFileBoundedOutput.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { downloadHopperPackage } from "./HopperDownload.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import { err, type Result } from "../domain/result.js";
import {
downloadHopperPackage,
HOPPER_DOWNLOAD_BYTES,
} from "./HopperDownload.js";
HOPPER_RELEASES_URL,
HOPPER_PUBLIC_KEY_URL,
HOPPER_DOWNLOAD_PREFIX,
HOPPER_LINUX_PRIMARY_FINGERPRINT,
HopperReleaseError,
type LinuxHopperRelease,
type LinuxPackageFamily,
} from "./HopperReleaseFeed.js";
import {
LinuxHopperPublisher,
retryLinuxHopperPublisherCleanup,
type VerifiedLinuxHopperPackage,
type VerifiedLinuxHopperLauncher,
} from "./LinuxHopperPublisher.js";
export type { LinuxPackageFamily } from "./HopperReleaseFeed.js";
/** Package-manager output retained per stream without stopping the command. */
const PACKAGE_MANAGER_OUTPUT_BYTES = 64 * 1024;
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
const SUPPORTED_HOPPER_SHA256 = new Set([
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
"1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13",
]);
interface LinuxHopperRelease {
readonly filename: string;
readonly file_length: string;
readonly file_hash: string;
}
const SUPPORTED_RELEASES: Readonly<
Record<LinuxPackageFamily, LinuxHopperRelease>
> = {
deb: {
filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.deb`,
file_length: "35755772",
file_hash: "e4f79dff602648a8ff4a88b773875b6bfac0dc65",
},
rpm: {
filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.rpm`,
file_length: "32264475",
file_hash: "b9a65f2f583a386d440d4401d9140aef3bf305de",
},
arch: {
filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.pkg.tar.xz`,
file_length: "29405968",
file_hash: "92d780b61cab38eda95dc4112dee4fbeb0920f46",
},
};
export type LinuxPackageFamily = "deb" | "rpm" | "arch";
/** Exact stable external effects disclosed before a Linux Hopper installation. */
export interface LinuxHopperInstallDisclosure {
readonly downloadUrl: string;
readonly expectedBytes: number;
readonly expectedSha1: string;
readonly networkOrigins: readonly string[];
readonly integrity: string;
readonly commands: readonly string[];
}
@@ -58,11 +40,13 @@ export const linuxHopperInstallDisclosure = (
family: LinuxPackageFamily,
isRoot: boolean,
): LinuxHopperInstallDisclosure => {
const release = SUPPORTED_RELEASES[family];
return {
downloadUrl: release.filename,
expectedBytes: Number(release.file_length),
expectedSha1: release.file_hash,
networkOrigins: [
HOPPER_RELEASES_URL,
HOPPER_PUBLIC_KEY_URL,
HOPPER_DOWNLOAD_PREFIX,
],
integrity: `Detached PGP signature · primary ${HOPPER_LINUX_PRIMARY_FINGERPRINT} · package byte identity and installed launcher SHA-256 from the official feed`,
commands: linuxPackageManagerCommands(
family,
`<verified-hopper.${family}>`,
@@ -133,6 +117,10 @@ export type LinuxPackageInstallOutcome =
/** External effects required to install the official Linux package. */
export interface LinuxHopperInstallHost {
distribution(): Promise<LinuxDistribution | undefined>;
release(
family: LinuxPackageFamily,
signal?: AbortSignal,
): Promise<Result<LinuxHopperRelease, HopperReleaseError>>;
download(
url: string,
options: { readonly signal?: AbortSignal },
@@ -143,8 +131,23 @@ export interface LinuxHopperInstallHost {
family: LinuxPackageFamily,
archive: string,
): Promise<LinuxPackageInstallOutcome>;
launcherStatus(path: string): Promise<LinuxHopperLauncherStatus>;
verifyArchive(
family: LinuxPackageFamily,
release: LinuxHopperRelease,
archive: string,
signal?: AbortSignal,
): Promise<Result<VerifiedLinuxHopperPackage, HopperReleaseError>>;
retainVerifiedArchive(
verified: VerifiedLinuxHopperPackage,
archive: string,
signal?: AbortSignal,
): Promise<Result<void, HopperReleaseError>>;
launcherStatus(
path: string,
expectedSha256: string,
): Promise<LinuxHopperLauncherStatus>;
cleanup(path: string): Promise<void>;
close?(): Promise<void>;
}
/** Structured outcome for every expected Linux installation failure. */
@@ -163,6 +166,9 @@ export type LinuxHopperInstallResult =
| "launcher_missing"
| "runtime_dependencies"
| "unsupported_hopper_build"
| "publisher_verification_failed"
| "verification_unavailable"
| "cleanup_failed"
| "cancelled";
/**
* The failing package-manager command and its output. Present only when
@@ -170,6 +176,8 @@ export type LinuxHopperInstallResult =
* package manager said instead of assuming an authorization problem.
*/
readonly packageFailure?: LinuxPackageInstallFailure;
/** Publisher-owned failure, retained for the public setup diagnostic. */
readonly verificationFailure?: HopperReleaseError;
} & { readonly cleanupFailure?: LinuxHopperTemporaryCleanupFailure });
/** Failed removal of an owned temporary install directory. */
@@ -270,9 +278,6 @@ export const installLinuxHopper = async (
host: LinuxHopperInstallHost = systemLinuxHopperInstallHost(process.env),
options: {
readonly signal?: AbortSignal;
readonly releases?: Readonly<
Record<LinuxPackageFamily, LinuxHopperRelease>
>;
} = {},
): Promise<LinuxHopperInstallResult> => {
if (options.signal?.aborted === true)
@@ -284,16 +289,56 @@ export const installLinuxHopper = async (
let result: LinuxHopperInstallResult;
try {
result = await (async (): Promise<LinuxHopperInstallResult> => {
const release = (options.releases ?? SUPPORTED_RELEASES)[
distribution.packageFamily
];
const selected = await host.release(
distribution.packageFamily,
options.signal,
);
if (!selected.ok)
return {
status: "failed",
verificationFailure: selected.error,
reason:
selected.error.reason === "cancelled"
? "cancelled"
: "release_metadata",
};
const release = selected.value;
const archiveDownload = await host.download(release.filename, options);
if (!archiveDownload.ok) return { status: "failed", reason: "download" };
if (!packageIntegrityMatches(archiveDownload.bytes, release))
return { status: "failed", reason: "integrity" };
temporary = await host.createTemporaryDirectory();
const archive = join(temporary, `hopper.${distribution.packageFamily}`);
await host.writeArchive(archive, archiveDownload.bytes);
const verified = await host.verifyArchive(
distribution.packageFamily,
release,
archive,
options.signal,
);
if (!verified.ok)
return {
status: "failed",
verificationFailure: verified.error,
reason:
verified.error.reason === "cache_missing"
? "verification_unavailable"
: verified.error.reason,
};
const retained = await host.retainVerifiedArchive(
verified.value,
archive,
options.signal,
);
if (!retained.ok)
return {
status: "failed",
verificationFailure: retained.error,
reason:
retained.error.reason === "cache_missing"
? "verification_unavailable"
: retained.error.reason,
};
if (options.signal?.aborted === true)
return { status: "failed", reason: "cancelled" };
const install = await host.installPackage(
distribution.packageFamily,
archive,
@@ -305,7 +350,10 @@ export const installLinuxHopper = async (
packageFailure: install.failure,
};
const launcherPath = "/opt/hopper/bin/Hopper";
const launcherStatus = await host.launcherStatus(launcherPath);
const launcherStatus = await host.launcherStatus(
launcherPath,
release.main_exe_hash,
);
if (launcherStatus !== "ready")
return {
status: "failed",
@@ -360,49 +408,82 @@ export const linuxHopperLauncherCandidates = (
/** Compose package-manager and linker checks from the selected caller environment. */
export const systemLinuxHopperInstallHost = (
environment: Readonly<NodeJS.ProcessEnv>,
): LinuxHopperInstallHost => ({
distribution: readLinuxDistribution,
download: downloadHopperPackage,
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
installPackage: (family, archive) =>
installSystemPackage(family, archive, environment),
async launcherStatus(path) {
try {
await access(path);
const linked = await execFileOutput("ldd", [path], { env: environment });
if (!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`))
return "runtime_dependencies";
return (await linuxHopperBinarySupported(path))
? "ready"
: "unsupported_hopper_build";
} catch (cause: unknown) {
// best-effort cleanup: optional launcher probing; unreadable means missing.
void cause;
return "missing";
}
},
cleanup: (path) => rm(path, { recursive: true, force: true }),
});
/** Match the Linux Hopper build validated by the packaged demo-dialog adapter. */
export const linuxHopperBinarySupported = async (
path: string,
): Promise<boolean> => {
try {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return linuxHopperLauncherDigestSupported(hash.digest("hex"));
} catch (cause: unknown) {
// best-effort cleanup: optional build probing; unreadable means unsupported.
void cause;
return false;
}
): LinuxHopperInstallHost => {
const publisher = systemLinuxHopperPublisher(environment);
return {
distribution: readLinuxDistribution,
release: (family, signal) => publisher.currentRelease(family, signal),
download: downloadHopperPackage,
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
installPackage: (family, archive) =>
installSystemPackage(family, archive, environment),
verifyArchive: (family, release, archive, signal) =>
publisher.verifyArchive(family, release, archive, signal),
retainVerifiedArchive: (verified, archive, signal) =>
publisher.retainVerifiedArchive(verified, archive, signal),
async launcherStatus(path, expectedSha256) {
try {
await access(path);
const linked = await execFileOutput("ldd", [path], {
env: environment,
});
if (
!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`)
)
return "runtime_dependencies";
return (await hashStableFile(path)).sha256 === expectedSha256
? "ready"
: "unsupported_hopper_build";
} catch (cause: unknown) {
// best-effort cleanup: optional launcher probing; unreadable means missing.
void cause;
return "missing";
}
},
cleanup: (path) => rm(path, { recursive: true, force: true }),
close: async () => {
await publisher.close();
await retryLinuxHopperPublisherCleanup();
},
};
};
/** Match a launcher extracted from one of the pinned official Linux packages. */
export const linuxHopperLauncherDigestSupported = (digest: string): boolean =>
SUPPORTED_HOPPER_SHA256.has(digest);
export const systemLinuxHopperPublisher = (
environment: Readonly<NodeJS.ProcessEnv>,
): LinuxHopperPublisher =>
new LinuxHopperPublisher(
environment,
async () => (await readLinuxDistribution())?.packageFamily,
);
/** Read-only authentication; persistent package retention belongs to approved setup. */
export const inspectLinuxHopperLauncher = async (
path: string,
environment: Readonly<NodeJS.ProcessEnv>,
signal?: AbortSignal,
): Promise<Result<VerifiedLinuxHopperLauncher, HopperReleaseError>> => {
const publisher = systemLinuxHopperPublisher(environment);
const result = await publisher.inspectLauncher(path, signal);
try {
await publisher.close();
await retryLinuxHopperPublisherCleanup();
return result;
} catch (cause: unknown) {
return err(
new HopperReleaseError(
"cleanup_failed",
"Hopper publisher verification retains incomplete cleanup.",
{
cause: new AggregateError(
result.ok ? [cause] : [result.error, cause],
"Publisher verification outcomes",
),
},
),
);
}
};
/** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */
export const linuxSharedLibrariesAvailable = (output: string): boolean =>
@@ -492,14 +573,6 @@ export const linuxPackageManagerCommands = (
});
};
const packageIntegrityMatches = (
bytes: Uint8Array,
release: LinuxHopperRelease,
): boolean =>
bytes.byteLength === Number(release.file_length) &&
bytes.byteLength <= HOPPER_DOWNLOAD_BYTES &&
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
const packageFamilyFor = (
id: string,
idLike: string | undefined,
+833
View File
@@ -0,0 +1,833 @@
import { createHash, randomUUID } from "node:crypto";
import { copyFile, lstat, mkdir, rename, writeFile } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { z } from "zod";
import {
ArtifactResourceScope,
type ArtifactResourceOwner,
} from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { err, ok, type Result } from "../domain/result.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import { verifyDetachedPgpSignature } from "../process/DetachedPgpSignature.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import { waitForAbortableDelay } from "../process/ProviderDeadline.js";
import {
acquireHopperReleaseCacheLease,
type HopperTargetLease,
} from "../process/HopperLease.js";
import {
downloadHopperPackage,
HOPPER_DOWNLOAD_BYTES,
} from "./HopperDownload.js";
import {
HOPPER_LINUX_PRIMARY_FINGERPRINT,
HOPPER_PUBLIC_KEY_URL,
HOPPER_RELEASES_URL,
HopperReleaseError,
parseLinuxHopperRelease,
parseLinuxHopperReleases,
type LinuxHopperRelease,
type LinuxPackageFamily,
} from "./HopperReleaseFeed.js";
import { withRegularFile } from "./RegularFileRead.js";
/** Metadata/key inspection budget, independent of the compressed package budget. */
const RELEASE_METADATA_BYTES = 1024 * 1024;
/** Small feed/key requests must not consume the full package-download deadline. */
const RELEASE_METADATA_TIMEOUT_MS = 10_000;
const cacheRecordSchema = z.object({
format: z.literal("rea-signed-hopper-release-v1"),
family: z.enum(["deb", "rpm", "arch"]),
release: z.unknown(),
});
export interface VerifiedLinuxHopperPackage {
readonly release: LinuxHopperRelease;
readonly family: LinuxPackageFamily;
readonly packageSha256: string;
readonly publicKey: string;
readonly primaryFingerprint: string;
readonly signingFingerprint: string;
}
export interface VerifiedLinuxHopperLauncher {
readonly sha256: string;
/** Display value from release metadata; the package signature binds bytes, not this row. */
readonly version: string;
readonly primaryFingerprint: string;
readonly signingFingerprint: string;
readonly source:
| "signed_package_cache"
| "signed_vendor_release"
| "verified_process_cache";
}
/** Retain failed ephemeral cleanup independently of the caller's lifetime. */
const pendingCleanup = new Set<ArtifactResourceScope>();
/** Immutable cryptographic facts, bounded to eight packages for this process lifetime. */
const packageProofs = new Map<
string,
Pick<VerifiedLinuxHopperPackage, "primaryFingerprint" | "signingFingerprint">
>();
const liveLauncherProofs = new Map<string, VerifiedLinuxHopperLauncher>();
const retainProof = <T>(cache: Map<string, T>, key: string, proof: T): void => {
cache.delete(key);
cache.set(key, proof);
const oldest = cache.keys().next().value;
if (cache.size > 8 && oldest !== undefined) cache.delete(oldest);
};
/** One owner for the selected cache location, shared with the approval plan. */
export const linuxHopperReleaseCacheDirectory = (
environment: Readonly<NodeJS.ProcessEnv>,
): Result<string, HopperReleaseError> => {
try {
const supplied = environment.XDG_CACHE_HOME || undefined;
if (supplied !== undefined && !isAbsolute(supplied))
return err(
new HopperReleaseError(
"verification_unavailable",
"XDG_CACHE_HOME must be an absolute filesystem path.",
),
);
return ok(
join(
supplied ??
join(homeDirectoryFromEnvironment(environment, "linux"), ".cache"),
"rea",
"hopper-releases",
),
);
} catch (cause: unknown) {
return err(
new HopperReleaseError(
"verification_unavailable",
"The Hopper release cache location could not be resolved.",
{ cause },
),
);
}
};
/** Authenticated package facts shared by setup, doctor, and the launch boundary. */
export class LinuxHopperPublisher {
readonly #resources = new ArtifactResourceScope();
#feed: ReturnType<typeof parseLinuxHopperReleases> | undefined;
constructor(
readonly environment: Readonly<NodeJS.ProcessEnv>,
readonly packageFamily: () => Promise<LinuxPackageFamily | undefined>,
) {}
get cacheRoot(): string {
const location = linuxHopperReleaseCacheDirectory(this.environment);
if (!location.ok) throw location.error;
return location.value;
}
async currentRelease(
family: LinuxPackageFamily,
signal?: AbortSignal,
): Promise<Result<LinuxHopperRelease, HopperReleaseError>> {
try {
signal?.throwIfAborted();
if (this.#feed === undefined) {
const response = await downloadHopperPackage(HOPPER_RELEASES_URL, {
...(signal === undefined ? {} : { signal }),
maxBytes: RELEASE_METADATA_BYTES,
timeoutMs: RELEASE_METADATA_TIMEOUT_MS,
});
if (!response.ok)
throw new HopperReleaseError(
"release_metadata",
"The official Hopper release feed is unavailable.",
);
this.#feed = parseLinuxHopperReleases(
JSON.parse(decodeUtf8(response.bytes)),
);
}
if (!this.#feed.ok) return this.#feed;
return this.#feed.value[family];
} catch (cause: unknown) {
return err(releaseFailure(cause, signal, "release_metadata"));
}
}
async verifyArchive(
family: LinuxPackageFamily,
release: LinuxHopperRelease,
archive: string,
signal?: AbortSignal,
suppliedPublicKey?: string,
): Promise<Result<VerifiedLinuxHopperPackage, HopperReleaseError>> {
try {
const bytes = await this.#read(archive, HOPPER_DOWNLOAD_BYTES, signal);
if (
bytes.length !== Number(release.file_length) ||
createHash("sha1").update(bytes).digest("hex") !== release.file_hash
)
return err(
new HopperReleaseError(
"integrity",
"The Hopper package does not match the selected release bytes.",
),
);
let publicKey = suppliedPublicKey;
if (publicKey === undefined) {
const response = await downloadHopperPackage(HOPPER_PUBLIC_KEY_URL, {
...(signal === undefined ? {} : { signal }),
maxBytes: RELEASE_METADATA_BYTES,
timeoutMs: RELEASE_METADATA_TIMEOUT_MS,
});
if (!response.ok)
throw new HopperReleaseError(
"verification_unavailable",
"The official Hopper public key is unavailable.",
);
publicKey = decodeUtf8(response.bytes);
}
const packageSha256 = createHash("sha256").update(bytes).digest("hex");
// Every read still validates file identity, byte bounds and selected metadata.
// Reuse only proof of the exact signed bytes/key/signature/payload interpretation.
const proofKey = createHash("sha256")
.update(
JSON.stringify([
family,
packageSha256,
publicKey,
release.signature,
release.main_exe_hash,
]),
)
.digest("hex");
signal?.throwIfAborted();
const existingProof = packageProofs.get(proofKey);
if (existingProof !== undefined)
return ok({
release,
family,
packageSha256,
publicKey,
...existingProof,
});
const verified = await verifyDetachedPgpSignature(
bytes,
Buffer.from(release.signature, "base64"),
publicKey,
HOPPER_LINUX_PRIMARY_FINGERPRINT,
signal,
);
if (!verified.ok)
return err(
new HopperReleaseError(
verified.error.reason === "cancelled"
? "cancelled"
: "publisher_verification_failed",
verified.error.message,
{ cause: verified.error },
),
);
const mainExeHash = await this.#withTemporary(async (root) => {
// Extract only after authentication, from this operation's private byte snapshot.
const snapshot = join(root, "package");
await writeFile(snapshot, bytes, { flag: "wx", mode: 0o600 });
return this.#packageLauncherHash(family, snapshot, root, signal);
});
if (mainExeHash !== release.main_exe_hash)
return err(
new HopperReleaseError(
"integrity",
`The signed ${family} package launcher SHA-256 ${mainExeHash} differs from the release main_exe_hash ${release.main_exe_hash}.`,
),
);
retainProof(packageProofs, proofKey, verified.value);
return ok({
release,
family,
packageSha256,
publicKey,
...verified.value,
});
} catch (cause: unknown) {
return err(releaseFailure(cause, signal, "verification_unavailable"));
}
}
/** Durable evidence contains the signed bytes; an editable digest receipt cannot authorize a build. */
async retainVerifiedArchive(
verified: VerifiedLinuxHopperPackage,
archive: string,
signal?: AbortSignal,
): Promise<Result<void, HopperReleaseError>> {
try {
signal?.throwIfAborted();
await mkdir(this.cacheRoot, { recursive: true, mode: 0o700 });
const parent = await lstat(this.cacheRoot);
if (
!parent.isDirectory() ||
parent.isSymbolicLink() ||
parent.uid !== process.getuid?.()
)
throw new HopperReleaseError(
"verification_unavailable",
"The Hopper release cache is not a caller-owned directory.",
);
const target = join(this.cacheRoot, verified.release.main_exe_hash);
const lease = await this.#acquireCacheLease(
verified.release.main_exe_hash,
signal,
);
await this.#withOwnedResource(
{
kind: "lease",
resource: lease.socketPath ?? "hopper-release-cache-lease",
lease,
},
async () => {
signal?.throwIfAborted();
await this.#withTemporary(async (stage) => {
await copyFile(archive, join(stage, "archive"));
if (
(await hashStableFile(join(stage, "archive"), signal)).sha256 !==
verified.packageSha256
)
throw new HopperReleaseError(
"integrity",
"The verified package changed before cache retention.",
);
await writeFile(join(stage, "public-key.asc"), verified.publicKey, {
flag: "wx",
mode: 0o600,
});
await writeFile(
join(stage, "release.json"),
JSON.stringify({
format: "rea-signed-hopper-release-v1",
family: verified.family,
release: verified.release,
}) + "\n",
{ flag: "wx", mode: 0o600 },
);
await this.#publishCacheStage(
stage,
target,
verified.release.main_exe_hash,
signal,
);
}, this.cacheRoot);
},
);
return ok(undefined);
} catch (cause: unknown) {
return err(releaseFailure(cause, signal, "verification_unavailable"));
}
}
async #publishCacheStage(
stage: string,
target: string,
digest: string,
signal?: AbortSignal,
): Promise<void> {
const existing = await this.#cachedPackage(digest, signal);
if (existing.ok) return;
if (existing.error.reason === "cache_missing") {
await rename(stage, target);
return;
}
if (
![
"integrity",
"release_metadata",
"publisher_verification_failed",
].includes(existing.error.reason)
)
throw existing.error;
signal?.throwIfAborted();
const previous = await lstat(target);
if (
!previous.isDirectory() ||
previous.isSymbolicLink() ||
previous.uid !== process.getuid?.()
)
throw existing.error;
// Only approved retention under the publication lease may repair an entry.
// Preserve malformed evidence without following links or deleting content.
const backup = `${target}.backup-${randomUUID()}`;
await rename(target, backup);
try {
const moved = await lstat(backup);
if (moved.dev !== previous.dev || moved.ino !== previous.ino)
throw new Error("The Hopper cache entry changed during repair.");
await rename(stage, target);
} catch (cause: unknown) {
const replacement = await this.#cachedPackage(digest, signal);
if (replacement.ok) return;
await this.#failedCachePublication(
target,
backup,
[cause, replacement.error],
replacement.error.reason === "cleanup_failed",
);
}
}
async #acquireCacheLease(
digest: string,
signal?: AbortSignal,
): Promise<HopperTargetLease> {
const deadline = performance.now() + 120_000;
const runId = randomUUID();
let delay = 25;
while (true) {
signal?.throwIfAborted();
const acquired = await acquireHopperReleaseCacheLease({
cacheDirectory: this.cacheRoot,
launcherSha256: digest,
runId,
});
if (acquired.acquired) return acquired.lease;
const remaining = deadline - performance.now();
if (remaining <= 0)
throw new HopperReleaseError(
"verification_unavailable",
"Another approved Hopper setup still owns this release cache. Retry after it finishes.",
);
await waitForAbortableDelay(Math.min(delay, remaining), signal);
delay = Math.min(delay * 2, 200);
}
}
async #failedCachePublication(
target: string,
backup: string,
causes: unknown[],
cleanupFailed = false,
): Promise<never> {
let preserved = backup;
try {
// Under the publication lease, restore the previous directory on failure.
// POSIX rename cannot replace another nonempty evidence directory.
await rename(backup, target);
preserved = target;
} catch (cause: unknown) {
causes.push(cause);
}
throw new HopperReleaseError(
cleanupFailed ? "cleanup_failed" : "verification_unavailable",
`The Hopper cache repair did not finish; previous evidence is preserved at ${preserved}. Retry approved setup.`,
{
cause: new AggregateError(
causes,
"Hopper cache publication and recovery outcomes",
),
},
);
}
async inspectLauncher(
path: string,
signal?: AbortSignal,
): Promise<Result<VerifiedLinuxHopperLauncher, HopperReleaseError>> {
try {
const observed = await hashStableFile(path, signal);
const cached = await this.#cachedPackage(observed.sha256, signal);
if (cached.ok) {
if ((await hashStableFile(path, signal)).sha256 !== observed.sha256)
return err(
new HopperReleaseError(
"integrity",
"The Hopper launcher changed during publisher verification.",
),
);
return ok(launcherIdentity(cached.value, "signed_package_cache"));
}
if (cached.error.reason !== "cache_missing") return cached;
signal?.throwIfAborted();
const processProof = liveLauncherProofs.get(observed.sha256);
if (processProof !== undefined)
return ok({ ...processProof, source: "verified_process_cache" });
const family = await this.packageFamily();
if (family === undefined)
return err(
new HopperReleaseError(
"verification_unavailable",
"No native Linux package family is available for Hopper publisher verification.",
),
);
const selected = await this.currentRelease(family, signal);
if (!selected.ok) return selected;
if (selected.value.main_exe_hash !== observed.sha256)
return err(
new HopperReleaseError(
"unsupported_hopper_build",
"The selected Hopper launcher does not match an authenticated cached release or the official current Linux demo.",
),
);
const verified = await this.#withTemporary(async (root) => {
const response = await downloadHopperPackage(
selected.value.filename,
signal === undefined ? {} : { signal },
);
if (!response.ok)
throw new HopperReleaseError(
"download",
"The official Hopper package is unavailable for publisher verification.",
);
const archive = join(root, "archive");
await writeFile(archive, response.bytes, { flag: "wx", mode: 0o600 });
const verified = await this.verifyArchive(
family,
selected.value,
archive,
signal,
);
if (!verified.ok) throw verified.error;
return verified.value;
});
if ((await hashStableFile(path, signal)).sha256 !== observed.sha256)
return err(
new HopperReleaseError(
"integrity",
"The Hopper launcher changed during publisher verification.",
),
);
const identity = launcherIdentity(verified, "signed_vendor_release");
retainProof(liveLauncherProofs, observed.sha256, identity);
return ok(identity);
} catch (cause: unknown) {
return err(releaseFailure(cause, signal, "verification_unavailable"));
}
}
async close(): Promise<void> {
try {
await this.#resources.close();
pendingCleanup.delete(this.#resources);
} catch (cause: unknown) {
pendingCleanup.add(this.#resources);
throw cause;
}
}
async #cachedPackage(
digest: string,
signal?: AbortSignal,
): Promise<Result<VerifiedLinuxHopperPackage, HopperReleaseError>> {
try {
const root = join(this.cacheRoot, digest);
try {
const directory = await lstat(root);
if (!directory.isDirectory() || directory.isSymbolicLink())
throw new HopperReleaseError(
"integrity",
"The retained Hopper cache entry is not a regular directory.",
);
} catch (cause: unknown) {
if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT") {
return err(
new HopperReleaseError(
"cache_missing",
"No retained signed Hopper package identifies this launcher.",
{ cause },
),
);
}
throw cause;
}
// Distinguish proven malformed/missing evidence from unavailable reads or
// inspection tools. Only the former authorize approved cache replacement.
for (const name of ["release.json", "public-key.asc", "archive"])
await this.#assertCacheFile(join(root, name));
const metadata = await this.#read(
join(root, "release.json"),
RELEASE_METADATA_BYTES,
signal,
);
let value: unknown;
try {
value = JSON.parse(decodeUtf8(metadata));
} catch (cause: unknown) {
throw new HopperReleaseError(
"release_metadata",
"The retained Hopper package metadata is malformed.",
{ cause },
);
}
const parsed = cacheRecordSchema.safeParse(value);
if (!parsed.success)
throw new HopperReleaseError(
"release_metadata",
"The retained Hopper package metadata is malformed.",
);
const release = parseLinuxHopperRelease(
parsed.data.release,
parsed.data.family,
);
if (!release.ok) return release;
if (release.value.main_exe_hash !== digest)
throw new HopperReleaseError(
"integrity",
"The retained Hopper metadata does not identify this launcher.",
);
const keyBytes = await this.#read(
join(root, "public-key.asc"),
RELEASE_METADATA_BYTES,
signal,
);
let key: string;
try {
key = decodeUtf8(keyBytes);
} catch (cause: unknown) {
throw new HopperReleaseError(
"publisher_verification_failed",
"The retained Hopper public key is malformed.",
{ cause },
);
}
return this.verifyArchive(
parsed.data.family,
release.value,
join(root, "archive"),
signal,
key,
);
} catch (cause: unknown) {
return err(releaseFailure(cause, signal, "verification_unavailable"));
}
}
async #assertCacheFile(path: string): Promise<void> {
try {
const stats = await lstat(path);
if (!stats.isFile() || stats.isSymbolicLink())
throw new HopperReleaseError(
"integrity",
`Required Hopper evidence is not a regular file: ${path}.`,
);
} catch (cause: unknown) {
if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT")
throw new HopperReleaseError(
"integrity",
`Required Hopper evidence is missing: ${path}.`,
{ cause },
);
throw cause;
}
}
async #packageLauncherHash(
family: LinuxPackageFamily,
archive: string,
root: string,
signal?: AbortSignal,
): Promise<string> {
const commandOptions = {
env: this.environment,
signal,
timeout: 120_000,
stopSignal: "SIGKILL" as const,
maxBuffer: RELEASE_METADATA_BYTES,
};
if (family === "rpm") {
const output = await execFileOutput(
"rpm",
[
"-qp",
"--nosignature",
"--nodigest",
"--qf",
"%{FILEDIGESTALGO}\n[%{FILENAMES}\t%{FILEDIGESTS}\n]",
archive,
],
commandOptions,
);
const rows = output.stdout.split("\n");
if (rows.shift() !== "8")
throw new HopperReleaseError(
"integrity",
"The signed RPM header does not use SHA-256 file digests.",
);
const matches = rows.filter((row) =>
row.startsWith("/opt/hopper/bin/Hopper\t"),
);
const digest = matches[0]?.split("\t")[1];
if (
matches.length !== 1 ||
digest === undefined ||
!/^[a-f0-9]{64}$/u.test(digest)
)
throw new HopperReleaseError(
"integrity",
"The signed RPM header has no unique Hopper launcher digest.",
);
return digest;
}
const extracted = join(root, "extracted");
await mkdir(extracted, { mode: 0o700 });
if (family === "deb")
await execFileOutput(
"dpkg-deb",
["--extract", archive, extracted],
commandOptions,
);
else
await execFileOutput(
"tar",
["-xf", archive, "-C", extracted, "--", "opt/hopper/bin/Hopper"],
commandOptions,
);
return (
await hashStableFile(
join(extracted, "opt", "hopper", "bin", "Hopper"),
signal,
)
).sha256;
}
#read(path: string, limit: number, signal?: AbortSignal): Promise<Buffer> {
return withRegularFile(
path,
async (handle, stats) => {
if (stats.size > limit)
throw new HopperReleaseError(
"integrity",
"Retained Hopper evidence exceeds its resource byte budget.",
);
const bytes = await readBoundedFileBytes(handle, stats.size, signal);
if (bytes === undefined)
throw new HopperReleaseError(
"integrity",
"Retained Hopper evidence changed during inspection.",
);
return bytes;
},
{ signal, resources: this.#resources, symlinks: "reject" },
);
}
async #withTemporary<T>(
operation: (path: string) => Promise<T>,
parent?: string,
): Promise<T> {
const root = await PrivateRuntimeRoot.create({
prefix: "rea-hopper-publisher-",
...(parent === undefined ? {} : { parent }),
});
return this.#withOwnedResource(
{
kind: "private-runtime-root",
resource: root.path,
root,
},
() => operation(root.path),
);
}
async #withOwnedResource<T>(
owner: ArtifactResourceOwner,
operation: () => Promise<T>,
): Promise<T> {
let outcome:
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly cause: unknown };
try {
outcome = { ok: true, value: await operation() };
} catch (cause: unknown) {
outcome = { ok: false, cause };
}
const cleanup = await this.#resources.release(owner);
if (cleanup.kind === "failed") {
pendingCleanup.add(this.#resources);
const causes = outcome.ok
? [cleanup.cause]
: [outcome.cause, cleanup.cause];
throw new HopperReleaseError(
"cleanup_failed",
`Hopper publisher verification retained cleanup ownership for ${owner.resource}.`,
{
cause: new AggregateError(
causes,
"Publisher inspection and cleanup outcomes",
),
},
);
}
if (!outcome.ok) throw outcome.cause;
return outcome.value;
}
}
/** Retry only resources retained by this producer after failed cleanup. */
export const retryLinuxHopperPublisherCleanup = async (): Promise<void> => {
const causes: unknown[] = [];
const observations = [];
for (const scope of pendingCleanup) {
try {
await scope.close();
pendingCleanup.delete(scope);
} catch (cause: unknown) {
causes.push(cause);
observations.push(
ArtifactReaderFailure.cleanupObservation(
cause,
"hopper-publisher-verification",
),
);
}
}
if (causes.length > 0) {
const cleanup = {
reason: observations.map(({ reason }) => reason).join("; "),
resources: [
...new Set(observations.flatMap(({ resources }) => resources)),
],
};
throw new ArtifactReaderFailure(
"unavailable",
`Hopper publisher cleanup remains incomplete: ${cleanup.resources.join(", ")}`,
{
cause: new AggregateError(causes, "Hopper publisher cleanup failures"),
cleanup,
},
);
}
};
const decodeUtf8 = (bytes: Uint8Array): string =>
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
const releaseFailure = (
cause: unknown,
signal: AbortSignal | undefined,
fallback: HopperReleaseError["reason"],
): HopperReleaseError =>
cause instanceof HopperReleaseError && cause.reason === "cleanup_failed"
? cause
: signal?.aborted === true
? new HopperReleaseError(
"cancelled",
"Hopper publisher verification was cancelled.",
{ cause },
)
: cause instanceof HopperReleaseError
? cause
: new HopperReleaseError(
fallback,
"Hopper publisher verification could not complete.",
{ cause },
);
const launcherIdentity = (
verified: VerifiedLinuxHopperPackage,
source: VerifiedLinuxHopperLauncher["source"],
): VerifiedLinuxHopperLauncher => ({
sha256: verified.release.main_exe_hash,
version: verified.release.version,
primaryFingerprint: verified.primaryFingerprint,
signingFingerprint: verified.signingFingerprint,
source,
});
+39
View File
@@ -5,6 +5,7 @@ import type { SetupProgressEvent } from "./SetupTypes.js";
import { FakeSetupHost, options } from "./Setup.fixture.js";
import { SetupSkillInstallError } from "./SetupSkill.js";
import { err, ok } from "../domain/result.js";
import { HopperReleaseError } from "./HopperReleaseFeed.js";
const linkedSkillFailure = () =>
new SetupSkillInstallError({
@@ -16,6 +17,44 @@ const linkedSkillFailure = () =>
cause: undefined,
});
describe("Linux publisher cache approval", () => {
it.each([true, false])(
"discloses the selected cache or blocks an unresolved path before approval: %s",
async (valid) => {
const host = Object.assign(new FakeSetupHost("linux"), {
linuxHopperReleaseCache: valid
? ok("/caller/cache/rea/hopper-releases")
: err(
new HopperReleaseError(
"verification_unavailable",
"Invalid selected cache path",
),
),
});
host.distribution = {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
};
const result = await runSetup(options(false, true), host);
if (valid)
expect(
result.plannedActions.find(({ kind }) => kind === "install_hopper")
?.detail,
).toContain("/caller/cache/rea/hopper-releases/<launcher-sha256>");
else
expect(result).toMatchObject({
status: "needs_human",
plannedActions: [],
});
expect(host.hopperInstalls).toBe(0);
expect(host.configurations).toBe(0);
expect(host.skillInstalls).toBe(0);
},
);
});
describe("selected skill admission", () => {
it.each(["codex", "shared"] as const)(
"keeps %s choices when an unselected client is linked",
+39 -4
View File
@@ -20,8 +20,10 @@ import {
LinuxHopperTemporaryCleanupOwner,
readLinuxDistribution,
systemLinuxHopperInstallHost,
type LinuxHopperInstallHost,
} from "./LinuxHopper.js";
import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js";
import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperPublisher.js";
import {
clientEvidencePaths,
supportedClients,
@@ -156,6 +158,12 @@ export const systemSetupHost = (
return {
platform,
homeDirectory,
...(platform === "linux"
? {
linuxHopperReleaseCache:
linuxHopperReleaseCacheDirectory(environment),
}
: {}),
skillDestinations: (clientIds) =>
skillDestinations(homeDirectory, clientIds, environment, platform),
...setupRegistration(platform, homeDirectory, environment),
@@ -164,7 +172,7 @@ export const systemSetupHost = (
linuxDistribution: readLinuxDistribution,
close: () =>
closeSetupResources(resources, macHopperHost, doctorHost, () =>
pendingLinuxHopperCleanup.retry(linuxHopperHost.cleanup),
closeLinuxHopperInstallHost(linuxHopperHost, pendingLinuxHopperCleanup),
),
initialSetupState: async (
scope?: DoctorScope,
@@ -206,9 +214,19 @@ export const systemSetupHost = (
const result = await installLinuxHopper(linuxHopperHost);
if (result.cleanupFailure !== undefined)
pendingLinuxHopperCleanup.retain(result.cleanupFailure);
return result.status === "installed"
? { status: "installed", launcherPath: result.launcherPath }
: setupInstallFailure(result.reason, result.packageFailure);
if (result.status === "installed")
return { status: "installed", launcherPath: result.launcherPath };
const failure = setupInstallFailure(
result.reason,
result.packageFailure,
"linux",
);
return result.verificationFailure !== undefined
? {
...failure,
remediation: `${result.verificationFailure.message} ${failure.remediation}`,
}
: failure;
}
if (macHopperHost === undefined)
return setupInstallFailure("unsupported_host");
@@ -425,6 +443,23 @@ const configurationCleanupError = (
const errorMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
const closeLinuxHopperInstallHost = async (
host: LinuxHopperInstallHost,
temporaryOwner: LinuxHopperTemporaryCleanupOwner,
): Promise<string | undefined> => {
const reasons: string[] = [];
const temporary = await temporaryOwner.retry(host.cleanup);
if (temporary !== undefined) reasons.push(temporary);
try {
await host.close?.();
} catch (cause: unknown) {
reasons.push(
`Linux Hopper publisher cleanup failed: ${errorMessage(cause)}`,
);
}
return reasons.length === 0 ? undefined : reasons.join("; ");
};
/** Detect supported agents from their config files or stable installation markers. */
export const detectClients = async (
home: string,
+23 -2
View File
@@ -1,4 +1,5 @@
import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
import { HOPPER_LINUX_PRIMARY_FINGERPRINT } from "./HopperReleaseFeed.js";
import type { LinuxPackageInstallFailure } from "./LinuxHopper.js";
export type SetupFailureCode =
@@ -50,6 +51,8 @@ export type HopperInstallFailureReason =
| "mount"
| "bundle"
| "publisher_verification_failed"
| "verification_unavailable"
| "cleanup_failed"
| "copy";
const normalizePackageOutput = (output: string): string =>
@@ -99,6 +102,7 @@ const packageManagerRemediation = (
export const setupInstallFailure = (
reason: HopperInstallFailureReason,
packageFailure?: LinuxPackageInstallFailure,
platform: NodeJS.Platform = "darwin",
): SetupHopperInstallFailure => {
switch (reason) {
case "unsupported_host":
@@ -139,7 +143,7 @@ export const setupInstallFailure = (
status: "failed",
code: "unsupported_hopper_build",
remediation:
"The installed Hopper launcher does not match the build supported by this REA release. Unset HOPPER_LAUNCHER_PATH, reinstall through rea setup, or update REA.",
"The installed Hopper launcher does not match the authenticated vendor package. Check HOPPER_LAUNCHER_PATH and reinstall the official Hopper demo through rea setup.",
};
case "cancelled":
return {
@@ -173,7 +177,24 @@ export const setupInstallFailure = (
return {
status: "failed",
code: "publisher_verification_failed",
remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`,
remediation:
platform === "linux"
? `The Hopper package signature did not verify under the pinned primary PGP fingerprint ${HOPPER_LINUX_PRIMARY_FINGERPRINT}; the package manager was not run. Download the official package and retry setup.`
: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`,
};
case "verification_unavailable":
return {
status: "failed",
code: "publisher_verification_failed",
remediation:
"REA could not authenticate or retain the signed Hopper package. Check vendor access, native package inspection tools and caller-owned cache permissions, then retry setup.",
};
case "cleanup_failed":
return {
status: "failed",
code: "cleanup_failed",
remediation:
"Hopper publisher verification retained temporary cleanup ownership. Retry cleanup before running setup again.",
};
case "copy":
return {
+15 -3
View File
@@ -161,6 +161,14 @@ export const planSetupActions = async (input: {
const client = byId.get(id);
return client === undefined ? [] : [client];
});
const hopperCache = input.host.linuxHopperReleaseCache;
if (input.discovery.installHopper && hopperCache?.ok === false)
return {
plannedActions: [],
selectedClients,
clientsToConfigure: [],
blocker: hopperCache.error.message,
};
if (
selectedClients.length > 0 &&
input.host.registrationCommandBlocker !== undefined
@@ -199,6 +207,9 @@ export const planSetupActions = async (input: {
const plannedActions = setupPlan({
platform: input.host.platform,
homeDirectory: input.host.homeDirectory,
...(hopperCache?.ok === true
? { hopperCacheDirectory: hopperCache.value }
: {}),
installHopper: input.discovery.installHopper,
installSkill: input.installSkill,
skillClientIds: input.skillClientIds,
@@ -264,6 +275,7 @@ const plannedClientWrites = (
const setupPlan = (input: {
readonly platform: NodeJS.Platform;
readonly homeDirectory: string;
readonly hopperCacheDirectory?: string;
readonly installHopper: boolean;
readonly installSkill: boolean;
readonly skillClientIds: readonly string[];
@@ -289,7 +301,7 @@ const setupPlan = (input: {
: "system package manager",
detail:
input.platform === "linux"
? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, REA uses a private display and selects Hopper's offered demo mode for each analysis session."
? `Download the current official Hopper demo, verify its PGP publisher and launcher bytes, and retain archive, public-key.asc and release.json under ${input.hopperCacheDirectory ?? `${input.homeDirectory}/.cache/rea/hopper-releases`}/<launcher-sha256> for offline verification. Preserve replaced damaged evidence beside it as <launcher-sha256>.backup-<id>. Install Hopper plus its Xvfb demo-session dependencies. REA uses a private display and selects Hopper's offered demo mode for each analysis session.`
: "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
external: true,
operation: "install" as const,
@@ -346,9 +358,9 @@ const linuxDisclosure = (family: LinuxPackageFamily) => {
process.getuid?.() === 0,
);
return {
networkOrigins: [disclosure.downloadUrl],
networkOrigins: disclosure.networkOrigins,
commands: disclosure.commands,
integrity: `${String(disclosure.expectedBytes)} bytes · SHA-1 ${disclosure.expectedSha1}`,
integrity: disclosure.integrity,
};
};
+2
View File
@@ -1,5 +1,6 @@
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
import type { LinuxDistribution } from "./LinuxHopper.js";
import type { HopperReleaseError } from "./HopperReleaseFeed.js";
import type {
SkillDestination,
SkillInstallResult,
@@ -67,6 +68,7 @@ export type ClientConfigurationInspection =
export interface SetupHost {
readonly platform: NodeJS.Platform;
readonly homeDirectory: string;
readonly linuxHopperReleaseCache?: Result<string, HopperReleaseError>;
skillDestinations(clientIds: readonly string[]): readonly SkillDestination[];
readonly registrationCommand: readonly string[];
/** Why client registration cannot proceed from this host. */
+9 -1
View File
@@ -8,8 +8,13 @@ import { ArtifactReaderFailure } from "./ArtifactReader.js";
import type { SafeOutputTree } from "./SafeOutputTree.js";
import type { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
/** One artifact resource and its format-specific cleanup owner. */
/** One artifact resource and the capability that owns its cleanup. */
export type ArtifactResourceOwner =
| {
readonly kind: "lease";
readonly resource: string;
readonly lease: { release(): Promise<void> };
}
| {
readonly kind: "javascript-source-analysis";
readonly resource: string;
@@ -156,6 +161,9 @@ export class ArtifactResourceScope {
const cleanup = async (owner: ArtifactResourceOwner): Promise<void> => {
switch (owner.kind) {
case "lease":
await owner.lease.release();
return;
case "javascript-source-analysis":
await owner.analysis.close();
return;
+3
View File
@@ -422,6 +422,9 @@ const providerErrorDetails = (
return {
stage: "launch",
provider_state: "unknown",
...(error.launchRejection === undefined
? {}
: { launch_rejection: { ...error.launchRejection } }),
retry_action: error.ownerRunId === undefined ? "retry" : "unknown",
...(error.ownerRunId === undefined
? {}
+11
View File
@@ -148,23 +148,34 @@ export class HopperProcessError extends HopperError {
}
}
/** Parsed facts about rejecting an application before execution. */
export interface HopperLaunchRejection {
/** The selected application was rejected before it was executed. */
readonly boundary: "publisher_verification";
/** Parsed failure reason supplied by the verification owner. */
readonly reason: string;
}
/** Hopper or the repository bridge could not be started. */
export class HopperStartError extends HopperError {
readonly _tag = "HopperStartError";
override readonly userMessage: string | undefined;
readonly ownerRunId: string | undefined;
readonly launcherFailure: HopperLauncherOutcome | undefined;
readonly launchRejection: HopperLaunchRejection | undefined;
constructor(
options?: ErrorOptions & {
readonly userMessage?: string;
readonly ownerRunId?: string;
readonly launcherFailure?: HopperLauncherOutcome;
readonly launchRejection?: HopperLaunchRejection;
},
) {
super("Hopper application bridge could not be started", options);
this.userMessage = options?.userMessage;
this.ownerRunId = options?.ownerRunId;
this.launcherFailure = options?.launcherFailure;
this.launchRejection = options?.launchRejection;
}
}
-1
View File
@@ -69,7 +69,6 @@ const doctorHost = (
validTarget: () => Promise.resolve(true),
executable: (path) =>
Promise.resolve(hopperAvailable && path.includes("Hopper")),
supportedLinuxHopper: () => Promise.resolve(true),
linuxDemoRuntimeCheck: () =>
Promise.resolve({
name: "hopper-demo-runtime",
+65 -3
View File
@@ -9,6 +9,8 @@ import {
HopperStartError,
} from "../domain/hopperErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import {
@@ -30,10 +32,12 @@ import {
import {
acquireHopperTargetLease,
acquireLinuxHopperApplicationLease,
} from "./HopperTargetLease.js";
import type { HopperTargetLease } from "./HopperTargetLease.js";
} from "../process/HopperLease.js";
import type { HopperTargetLease } from "../process/HopperLease.js";
import { HOPPER_SHUTDOWN_TIMEOUT_MS } from "./HopperSessionValues.js";
import { verifyMacHopperNativeHelper } from "./HopperMacPublisher.js";
import { inspectLinuxHopperLauncher } from "../application/LinuxHopper.js";
import { retryLinuxHopperPublisherCleanup } from "../application/LinuxHopperPublisher.js";
const execFileAsync = promisify(execFile);
const HOPPER_BACKGROUND_STARTUP_MS = 5_000;
@@ -73,6 +77,8 @@ export type BridgeLaunch = {
/** Application-owned capability that starts the in-Hopper bridge. */
export interface BridgeLauncher {
/** Retry owned pre-launch resources even when no application was started. */
cleanup?(): Promise<Result<null, ProviderCleanupError>>;
launch(
session: BridgeSession,
options?: { readonly signal?: AbortSignal },
@@ -110,6 +116,8 @@ export type HopperApplicationLauncherOptions =
export interface HopperApplicationLauncherDependencies {
readonly verifyNativeHelper?: typeof verifyMacHopperNativeHelper;
readonly verifyLinuxLauncher?: typeof inspectLinuxHopperLauncher;
readonly retryPublisherCleanup?: typeof retryLinuxHopperPublisherCleanup;
readonly platform?: NodeJS.Platform;
readonly acquireTargetLease?: typeof acquireHopperTargetLease;
readonly acquireApplicationLease?: typeof acquireLinuxHopperApplicationLease;
@@ -140,6 +148,32 @@ export class HopperApplicationLauncher implements BridgeLauncher {
readonly dependencies: HopperApplicationLauncherDependencies = {},
) {}
async cleanup(): Promise<Result<null, ProviderCleanupError>> {
if (!usesLinuxDemo(this.options)) return ok(null);
try {
await (
this.dependencies.retryPublisherCleanup ??
retryLinuxHopperPublisherCleanup
)();
return ok(null);
} catch (cause: unknown) {
const observation = ArtifactReaderFailure.cleanupObservation(
cause,
"hopper-publisher-verification",
);
return err(
new ProviderCleanupError(
"hopper",
observation.resources,
{
reason: observation.reason,
},
{ cause },
),
);
}
}
async launch(
session: BridgeSession,
options: { readonly signal?: AbortSignal } = {},
@@ -218,6 +252,26 @@ export class HopperApplicationLauncher implements BridgeLauncher {
if (signal?.aborted === true) return err(new HopperCancelledError());
const display = await this.#privateDisplay(signal);
if (!display.ok) return err(display.error);
let expectedLinuxSha256: string | undefined;
if (usesLinuxDemo(this.options)) {
const inspected = await (
this.dependencies.verifyLinuxLauncher ?? inspectLinuxHopperLauncher
)(this.options.launcherPath, this.options.environment, signal);
if (!inspected.ok)
return inspected.error.reason === "cancelled"
? err(new HopperCancelledError())
: err(
new HopperStartError({
userMessage: inspected.error.message,
cause: inspected.error,
launchRejection: {
boundary: "publisher_verification",
reason: inspected.error.reason,
},
}),
);
expectedLinuxSha256 = inspected.value.sha256;
}
const nativeHelperVerified =
(this.dependencies.platform ?? process.platform) === "darwin" &&
this.options.launchMode === "native" &&
@@ -236,6 +290,7 @@ export class HopperApplicationLauncher implements BridgeLauncher {
session,
argumentsForTarget,
display.strategy,
expectedLinuxSha256,
);
const started = await launchHopperProcess({
options: this.options,
@@ -449,6 +504,7 @@ export const linuxDemoLaunch = (
session: BridgeSession,
hopperArgs: readonly string[],
strategy: LinuxPrivateDisplayRunnableStrategy | undefined,
expectedSha256?: string,
):
| {
readonly command: string;
@@ -458,6 +514,10 @@ export const linuxDemoLaunch = (
| undefined => {
if (!usesLinuxDemo(options) || options.demoHelperPath === undefined)
return undefined;
if (expectedSha256 === undefined || !/^[a-f0-9]{64}$/u.test(expectedSha256))
throw new TypeError(
"Linux Hopper launch requires an authenticated launcher SHA-256",
);
const helperArguments = [
options.demoHelperPath,
"--owner-stdin",
@@ -466,6 +526,8 @@ export const linuxDemoLaunch = (
...(strategy === "user-mount-namespace" ? ["--mount-private-x11"] : []),
"--hopper",
options.launcherPath,
"--expected-launcher-sha256",
expectedSha256,
"--socket",
session.socketPath,
"--",
@@ -501,7 +563,7 @@ const hopperLaunchFailure = (
? new HopperCancelledError()
: new HopperStartError({ cause });
/** Select the version-pinned adapter from the caller's explicit launch mode. */
/** Select the publisher-verified demo adapter from the caller's explicit launch mode. */
export const usesLinuxDemo = (
options: HopperApplicationLauncherOptions,
): options is HopperApplicationLauncherOptions & {
+27 -3
View File
@@ -29,6 +29,7 @@ import {
type ProviderOperationRequest,
} from "../domain/providerOperationHealth.js";
import { err, ok, type Result } from "../domain/result.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { ProgressReporter } from "../application/ProgressReporter.js";
import { silentLogger } from "../logger.js";
@@ -328,7 +329,8 @@ export class HopperClient {
if (
this.#socket !== undefined ||
this.#resources.launch !== undefined ||
this.#resources.runtimeRoot !== undefined
this.#resources.runtimeRoot !== undefined ||
this.#startupFailureResult?.cleanupIncomplete === true
) {
return err(
this.#startupFailureResult ??
@@ -563,7 +565,9 @@ export class HopperClient {
this.#releaseTransport(),
);
await this.#reconnectForCleanup();
return await this.#cleanup(options);
const closed = await this.#cleanup(options);
if (closed.ok) this.#startupFailureResult = undefined;
return closed;
} finally {
if (this.#startPromise === starting) this.#startPromise = undefined;
if (this.#startupController === controller) {
@@ -641,7 +645,7 @@ export class HopperClient {
this.#closing = true;
this.#ready = false;
try {
return await cleanupHopperSession({
const closed = await cleanupHopperSession({
socket: this.#socket,
resources: this.#resources,
activeRequest: this.#requests.activity(),
@@ -653,6 +657,26 @@ export class HopperClient {
retainDocument: options.retainDocument === true,
releaseTransport: () => this.#releaseTransport(),
});
const launcherClosed = await this.#options.launcher
.cleanup?.()
.catch((cause: unknown) =>
err(
new ProviderCleanupError(
"hopper",
["hopper-launcher"],
{
reason: cause instanceof Error ? cause.message : String(cause),
},
{ cause },
),
),
);
if (launcherClosed === undefined || launcherClosed.ok) return closed;
return closed.ok
? launcherClosed
: err(
analysisErrorWithCleanupFailure(closed.error, launcherClosed.error),
);
} finally {
if (this.#socket?.destroyed === true) this.#socket = undefined;
+1 -1
View File
@@ -20,7 +20,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
acquireHopperTargetLease,
acquireLinuxHopperApplicationLease,
} from "./HopperTargetLease.js";
} from "../process/HopperLease.js";
const directories: string[] = [];
+128
View File
@@ -0,0 +1,128 @@
import { beforeAll, describe, expect, it } from "vitest";
import { createMessage, generateKey, readPrivateKey, sign } from "openpgp";
import { verifyDetachedPgpSignature } from "./DetachedPgpSignature.js";
const bytes = Buffer.from("binary package fixture\0\xff", "latin1");
let publicKey: string;
let fingerprint: string;
let signatures: Uint8Array[];
let signingFingerprints: string[];
beforeAll(async () => {
const generated = await generateKey({
type: "ecc",
curve: "curve25519Legacy",
userIDs: [{ name: "Fixture publisher" }],
subkeys: [{ sign: true }, { sign: true }],
format: "armored",
});
const privateKey = await readPrivateKey({ armoredKey: generated.privateKey });
publicKey = generated.publicKey;
fingerprint = privateKey.getFingerprint().toUpperCase();
signingFingerprints = privateKey.subkeys.map((subkey) =>
subkey.getFingerprint().toUpperCase(),
);
signatures = [];
for (const subkey of privateKey.subkeys)
signatures.push(
await sign({
message: await createMessage({ binary: bytes }),
signingKeys: privateKey,
signingKeyIDs: [subkey.getKeyID()],
detached: true,
format: "binary",
}),
);
});
describe("detached binary PGP publisher verification", () => {
it("accepts independently selected signing subkeys through one pinned primary", async () => {
for (const [index, signature] of signatures.entries())
expect(
await verifyDetachedPgpSignature(
bytes,
signature,
publicKey,
fingerprint,
),
).toEqual({
ok: true,
value: {
primaryFingerprint: fingerprint,
signingFingerprint: signingFingerprints[index],
},
});
});
it("rejects a valid signature belonging to another primary", async () => {
expect(
await verifyDetachedPgpSignature(
bytes,
signatures[0]!,
publicKey,
"0".repeat(40),
),
).toMatchObject({ ok: false, error: { reason: "wrong_publisher" } });
});
it("rejects tampered payload bytes", async () => {
const tampered = Buffer.from(bytes);
tampered[0] = (tampered[0] ?? 0) ^ 1;
expect(
await verifyDetachedPgpSignature(
tampered,
signatures[0]!,
publicKey,
fingerprint,
),
).toMatchObject({ ok: false, error: { reason: "invalid_signature" } });
});
it("rejects malformed signatures and public keys", async () => {
expect(
await verifyDetachedPgpSignature(
bytes,
new Uint8Array([1, 2, 3]),
publicKey,
fingerprint,
),
).toMatchObject({ ok: false, error: { reason: "invalid_signature" } });
expect(
await verifyDetachedPgpSignature(
bytes,
signatures[0]!,
"malformed",
fingerprint,
),
).toMatchObject({ ok: false, error: { reason: "invalid_signature" } });
});
it("preserves cancellation", async () => {
expect(
await verifyDetachedPgpSignature(
bytes,
signatures[0]!,
publicKey,
fingerprint,
AbortSignal.abort(),
),
).toMatchObject({ ok: false, error: { reason: "cancelled" } });
});
it("preserves cancellation while loading the publisher key, before classifying its identity", async () => {
const controller = new AbortController();
const verification = verifyDetachedPgpSignature(
bytes,
signatures[0]!,
publicKey,
"0".repeat(40),
controller.signal,
);
queueMicrotask(() => controller.abort());
expect(await verification).toMatchObject({
ok: false,
error: { reason: "cancelled" },
});
});
});
+101
View File
@@ -0,0 +1,101 @@
import { err, ok, type Result } from "../domain/result.js";
export class DetachedPgpSignatureError extends Error {
readonly _tag = "DetachedPgpSignatureError";
constructor(
readonly reason: "invalid_signature" | "wrong_publisher" | "cancelled",
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = this._tag;
}
}
/** Verify the complete binary payload and its signing subkey's primary identity. */
export const verifyDetachedPgpSignature = async (
bytes: Uint8Array,
detachedSignature: Uint8Array,
armoredPublicKey: string,
primaryFingerprint: string,
signal?: AbortSignal,
): Promise<
Result<
{
readonly primaryFingerprint: string;
readonly signingFingerprint: string;
},
DetachedPgpSignatureError
>
> => {
const cancelled = () =>
err(
new DetachedPgpSignatureError(
"cancelled",
"Package verification was cancelled.",
),
);
const isAborted = () => signal?.aborted === true;
if (isAborted()) return cancelled();
try {
// Keep the cryptographic implementation out of unrelated CLI cold starts.
const pgp = await import("openpgp");
const key = await pgp.readKey({ armoredKey: armoredPublicKey });
if (isAborted()) return cancelled();
const fingerprint = key.getFingerprint().toUpperCase();
if (fingerprint !== primaryFingerprint)
return err(
new DetachedPgpSignatureError(
"wrong_publisher",
"The package signing key does not belong to the pinned publisher.",
),
);
const signature = await pgp.readSignature({
binarySignature: detachedSignature,
});
if (isAborted()) return cancelled();
if (
signature.packets.length !== 1 ||
signature.packets[0]?.signatureType !== pgp.enums.signature.binary
)
return err(
new DetachedPgpSignatureError(
"invalid_signature",
"Expected one detached binary package signature.",
),
);
const verified = await pgp.verify({
message: await pgp.createMessage({ binary: bytes }),
signature,
verificationKeys: key,
format: "binary",
});
const signed = verified.signatures[0];
if (signed === undefined || verified.signatures.length !== 1)
return err(
new DetachedPgpSignatureError(
"invalid_signature",
"The package has no unique verified signature.",
),
);
await signed.verified;
const signingKey = await key.getSigningKey(
signed.keyID,
signature.packets[0].created,
);
if (isAborted()) return cancelled();
return ok({
primaryFingerprint: fingerprint,
signingFingerprint: signingKey.getFingerprint().toUpperCase(),
});
} catch (cause: unknown) {
if (isAborted()) return cancelled();
return err(
new DetachedPgpSignatureError(
"invalid_signature",
"The package signature could not be verified.",
{ cause },
),
);
}
};
@@ -17,6 +17,8 @@ interface HopperTargetLeaseOwner {
}
export interface HopperTargetLease {
/** Bound socket identity for cleanup diagnostics; omission remains unknown. */
readonly socketPath?: string;
readonly release: () => Promise<void>;
}
@@ -66,6 +68,21 @@ export const acquireLinuxHopperApplicationLease = (input: {
}): Promise<HopperTargetLeaseAcquisition> =>
acquireLease({ ...input, identity: { application: "linux-hopper-demo" } });
/** Serialize approved publication/repair of one canonical signed-package entry. */
export const acquireHopperReleaseCacheLease = async (input: {
readonly cacheDirectory: string;
readonly launcherSha256: string;
readonly runId: string;
}): Promise<HopperTargetLeaseAcquisition> =>
acquireLease({
runId: input.runId,
identity: {
cacheDirectory: await realpath(input.cacheDirectory),
launcherSha256: input.launcherSha256,
operation: "signed-package-retention",
},
});
const acquireLease = async (input: {
readonly identity: Readonly<Record<string, unknown>>;
readonly runId: string;
@@ -153,6 +170,7 @@ const tryAcquireLease = async (
return {
acquired: true,
lease: {
socketPath,
release: async () => {
if (released) return;
// Node removes a bound Unix socket on close. A second unlink here
@@ -10,7 +10,7 @@ import { z } from "zod";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js";
import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js";
const statusSchema = z.object({ result: z.object({ open: z.boolean() }) });
const test = workspaceCliTest.skipIf(process.platform !== "darwin");
@@ -45,6 +45,7 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
? {
PATH: [join(home, "bin"), process.env.PATH ?? ""].join(delimiter),
REA_HOPPER_LDD_TEST_MARKER: loaderMarker,
NODE_OPTIONS: `--import=${new URL("../../fixtures/hopperRejectionFeed.mjs", import.meta.url).href}`,
}
: {}),
HOME: home,
@@ -5,7 +5,7 @@ import { z } from "zod";
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js";
import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js";
const FAILED_LAUNCHER = `#!/bin/sh
printf 'Cannot launch Hopper!\\nDocument state is unknown.\\n' >&2
@@ -9,7 +9,7 @@ import type {
BridgeSession,
} from "../../../src/hopper/BridgeLauncher.js";
import { HopperClient } from "../../../src/hopper/HopperClient.js";
import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js";
import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js";
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import { hopperFixturePath } from "../providers/hopper/hopperClient.fixture.js";
@@ -5,7 +5,7 @@ import { join } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
@@ -17,8 +17,38 @@ import {
} from "../../../../src/hopper/BridgeLauncher.js";
import { ProviderProcessSupervisor } from "../../../../src/process/ProviderProcess.js";
import { err, ok } from "../../../../src/domain/result.js";
import { HopperReleaseError } from "../../../../src/application/HopperReleaseFeed.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js";
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
import { HopperClient } from "../../../../src/hopper/HopperClient.js";
const readyDirectDisplay = async () =>
({
ok: true,
strategy: "direct",
diagnostic: {
component: "hopper_private_display",
operation: "probe",
status: "ready",
failure_code: null,
reason: "ready",
socket_directory: "/tmp/.X11-unix",
socket_directory_mode: "1777",
mount_read_only: false,
effective_socket_directory_mode: "1777",
effective_mount_read_only: false,
wsl: false,
strategy: "direct",
fallback_reason: null,
xvfb_stderr_bytes: 0,
},
}) as const;
const execFileAsync = promisify(execFile);
const verifiedSha256 = "0".repeat(64);
const demoHelperPath = fileURLToPath(
new URL("../../../../scripts/hopper-demo-x11.py", import.meta.url),
);
@@ -34,13 +64,93 @@ const options = (launcherPath: string): HopperApplicationLauncherOptions => ({
demoHelperPath: "/rea/hopper-demo-x11.py",
});
it("retains cancelled verification cleanup through client shutdown and retries it", async () => {
const directory = await createTestTempDirectory("rea-publisher-cancel-");
const targetPath = join(directory, "target");
await writeFile(targetPath, "source-owned target");
const root = await PrivateRuntimeRoot.create({
prefix: "rea-publisher-pending-",
});
const resources = new ArtifactResourceScope();
let allowCleanup = false;
expect(
await resources.release({
kind: "private-runtime-root",
resource: root.path,
root: {
path: root.path,
close: async () => {
if (!allowCleanup)
throw new Error("Fixture publisher cleanup denied");
await root.close();
},
},
}),
).toMatchObject({ kind: "failed" });
const controller = new AbortController();
const verify = vi.fn(async () => {
controller.abort();
return err(
new HopperReleaseError(
"cleanup_failed",
"Publisher cleanup remains owned",
),
);
});
const launcher = new HopperApplicationLauncher(
{
...options(process.execPath),
targetPath,
},
{
verifyLinuxLauncher: verify,
retryPublisherCleanup: () => resources.close(),
selectPrivateDisplay: readyDirectDisplay,
},
);
const client = new HopperClient({ launcher, startupTimeoutMs: 5_000 });
try {
const started = await client.start(controller.signal);
expect(started).toMatchObject({
ok: false,
error: { cleanupIncomplete: true, cleanupResources: [root.path] },
});
if (started.ok)
throw new Error("Expected cancelled startup with pending cleanup");
expect(projectAnalysisError(started.error)).toMatchObject({
code: "cleanup_incomplete",
details: {
diagnostics: { primary_error: { code: "cancelled" } },
resources: [root.path],
},
});
expect(await client.start()).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
expect(verify).toHaveBeenCalledOnce();
expect(await client.close()).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
await access(root.path);
allowCleanup = true;
expect(await client.close()).toEqual({ ok: true, value: null });
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
allowCleanup = true;
await client.close();
await resources.close();
}
});
describe("Hopper bridge launcher selection", () => {
it.each([
"/opt/hopper/bin/Hopper",
"/usr/local/bin/hopper",
"/workspace/bin/hopper-wrapper",
])(
"routes launcher spelling %s through the explicitly selected pinned adapter",
"routes launcher spelling %s through the explicitly selected demo adapter",
(path) => {
expect(usesLinuxDemo(options(path))).toBe(true);
},
@@ -71,6 +181,7 @@ describe("Hopper bridge launcher selection", () => {
},
["--analysis"],
"user-mount-namespace",
verifiedSha256,
);
expect(launch).toMatchObject({
@@ -90,6 +201,8 @@ describe("Hopper bridge launcher selection", () => {
"--mount-private-x11",
"--hopper",
"/opt/hopper/bin/Hopper",
"--expected-launcher-sha256",
verifiedSha256,
"--socket",
"/tmp/rea-fixture/bridge.sock",
"--",
@@ -113,6 +226,7 @@ describe("Hopper bridge launcher selection", () => {
},
[],
"direct",
verifiedSha256,
),
).toMatchObject({
command: "/usr/bin/python3",
@@ -123,6 +237,8 @@ describe("Hopper bridge launcher selection", () => {
"direct",
"--hopper",
"/opt/hopper/bin/Hopper",
"--expected-launcher-sha256",
verifiedSha256,
"--socket",
"/tmp/rea-fixture/bridge.sock",
"--",
@@ -131,7 +247,7 @@ describe("Hopper bridge launcher selection", () => {
});
});
it("rejects an unpinned wrapper before executing it", async () => {
it("rejects launcher bytes changed since publisher verification before executing them", async () => {
const directory = await createTestTempDirectory("rea-hopper-wrapper-");
const wrapper = join(directory, "hopper-wrapper");
const marker = join(directory, "executed");
@@ -145,6 +261,8 @@ describe("Hopper bridge launcher selection", () => {
demoHelperPath,
"--hopper",
wrapper,
"--expected-launcher-sha256",
verifiedSha256,
"--socket",
join(directory, "bridge.sock"),
"--",
@@ -218,26 +336,15 @@ it.skipIf(process.platform !== "linux")(
),
},
{
selectPrivateDisplay: async () => ({
ok: true,
strategy: "direct",
diagnostic: {
component: "hopper_private_display",
operation: "probe",
status: "ready",
failure_code: null,
reason: "ready",
socket_directory: "/tmp/.X11-unix",
socket_directory_mode: "1777",
mount_read_only: false,
effective_socket_directory_mode: "1777",
effective_mount_read_only: false,
wsl: false,
strategy: "direct",
fallback_reason: null,
xvfb_stderr_bytes: 0,
},
}),
verifyLinuxLauncher: async () =>
ok({
sha256: verifiedSha256,
version: "fixture",
primaryFingerprint: "0".repeat(40),
signingFingerprint: "1".repeat(40),
source: "signed_package_cache",
}),
selectPrivateDisplay: readyDirectDisplay,
},
);
const launched = await launcher.launch({
@@ -260,3 +367,43 @@ it.skipIf(process.platform !== "linux")(
}
},
);
it.skipIf(process.platform !== "linux")(
"preserves a publisher rejection through the public error without inventing a helper exit",
async () => {
const directory = await createTestTempDirectory("rea-publisher-rejection-");
const targetPath = join(directory, "target");
await writeFile(targetPath, "fixture target");
const rejected = new HopperReleaseError(
"unsupported_hopper_build",
"Fixture launcher is outside verified releases",
);
const launcher = new HopperApplicationLauncher(
{ ...options("/unused/Hopper"), targetPath },
{
selectPrivateDisplay: readyDirectDisplay,
verifyLinuxLauncher: async () => err(rejected),
},
);
const result = await launcher.launch({
directory,
socketPath: join(directory, "bridge.sock"),
token: "fixture-token",
runId: randomUUID(),
});
if (result.ok) throw new Error("Unverified launcher started");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "provider_unavailable",
details: {
stage: "launch",
launch_rejection: {
boundary: "publisher_verification",
reason: rejected.reason,
},
},
});
expect(projected.details).not.toHaveProperty("exit_code");
expect(projected.details).not.toHaveProperty("launcher");
},
);
@@ -15,7 +15,7 @@ import type {
} from "../../../../src/hopper/BridgeLauncher.js";
import { HopperApplicationLauncher } from "../../../../src/hopper/BridgeLauncher.js";
import { HopperClient } from "../../../../src/hopper/HopperClient.js";
import { acquireHopperTargetLease } from "../../../../src/hopper/HopperTargetLease.js";
import { acquireHopperTargetLease } from "../../../../src/process/HopperLease.js";
import { HopperFixtureLauncher } from "./hopperClient.fixture.js";
// Failure assertions keep the external document unknown. Teardown then publishes
@@ -3,7 +3,7 @@ import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { snapshotHopperRuntime } from "../../../../scripts/lib/real-hopper-cleanup.mjs";
import { acquireHopperTargetLease } from "../../../../src/hopper/HopperTargetLease.js";
import { acquireHopperTargetLease } from "../../../../src/process/HopperLease.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
it.skipIf(process.platform === "win32")(
@@ -0,0 +1,765 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { once } from "node:events";
import { promises as filesystem } from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import {
access,
copyFile,
cp,
mkdir,
readFile,
readdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { createMessage, generateKey, readPrivateKey, sign } from "openpgp";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
LinuxHopperPublisher,
linuxHopperReleaseCacheDirectory,
retryLinuxHopperPublisherCleanup,
} from "../../../src/application/LinuxHopperPublisher.js";
import {
HOPPER_LINUX_PRIMARY_FINGERPRINT,
type LinuxHopperRelease,
} from "../../../src/application/HopperReleaseFeed.js";
import * as signatures from "../../../src/process/DetachedPgpSignature.js";
import { downloadHopperPackage } from "../../../src/application/HopperDownload.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
import * as leases from "../../../src/process/HopperLease.js";
import * as nativeCommands from "../../../src/process/ExecFileOutput.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createTestProcessRegistry } from "../../support/process/processFixture.js";
const run = promisify(execFile);
const verifySignature = signatures.verifyDetachedPgpSignature;
const sha256 = (bytes: Uint8Array) =>
createHash("sha256").update(bytes).digest("hex");
const linux = it.skipIf(process.platform !== "linux");
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
/** Real native archives and cryptography; substitute only the test publisher identity. */
const fixture = async (family: "deb" | "arch" = "deb") => {
const directory = await createTestTempDirectory("rea-linux-publisher-");
const packageRoot = join(directory, "package-root");
const launcher = join(packageRoot, "opt/hopper/bin/Hopper");
await mkdir(join(packageRoot, "opt/hopper/bin"), { recursive: true });
await copyFile("/usr/bin/true", launcher);
const archive = join(directory, "archive");
if (family === "deb") {
await mkdir(join(packageRoot, "DEBIAN"));
await writeFile(
join(packageRoot, "DEBIAN/control"),
"Package: hopper-fixture\nVersion: 1.0\nArchitecture: amd64\nMaintainer: Fixture\nDescription: Signature boundary fixture\n",
);
await run("dpkg-deb", ["--build", packageRoot, archive]);
} else await run("tar", ["-cJf", archive, "-C", packageRoot, "opt"]);
const bytes = await readFile(archive);
const generated = await generateKey({
type: "ecc",
curve: "curve25519Legacy",
userIDs: [{ name: "Fixture publisher" }],
format: "armored",
});
const key = await readPrivateKey({ armoredKey: generated.privateKey });
const signature = await sign({
message: await createMessage({ binary: bytes }),
signingKeys: key,
detached: true,
format: "binary",
});
const release: LinuxHopperRelease = {
version: "1.0",
filename: `https://www.hopperapp.com:443/downloader/public/Hopper-1.0-Linux-demo.${family === "deb" ? "deb" : "pkg.tar.xz"}`,
file_length: String(bytes.length),
file_hash: createHash("sha1").update(bytes).digest("hex"),
main_exe_hash: sha256(await readFile(launcher)),
signature: Buffer.from(signature).toString("base64"),
public: "1",
beta: "0",
};
const publisher = new LinuxHopperPublisher(
{ ...process.env, XDG_CACHE_HOME: join(directory, "cache") },
async () => family,
);
const cache = join(publisher.cacheRoot, release.main_exe_hash);
return {
publisher,
archive,
launcher,
release,
cache,
publicKey: generated.publicKey,
fingerprint: key.getFingerprint().toUpperCase(),
};
};
const admitFixturePublisher = (
fingerprint: string,
module: typeof signatures = signatures,
) =>
vi
.spyOn(module, "verifyDetachedPgpSignature")
.mockImplementation((bytes, signature, key, primary, signal) => {
expect(primary).toBe(HOPPER_LINUX_PRIMARY_FINGERPRINT);
return verifySignature(bytes, signature, key, fingerprint, signal);
});
it.each([undefined, "", "/selected/cache", "relative/cache"])(
"resolves the selected XDG cache setting %j once for every consumer",
(setting) => {
const selected = linuxHopperReleaseCacheDirectory({
HOME: "/caller/home",
...(setting === undefined ? {} : { XDG_CACHE_HOME: setting }),
});
if (setting === "relative/cache")
expect(selected).toMatchObject({ ok: false });
else
expect(selected).toEqual({
ok: true,
value: join(setting || "/caller/home/.cache", "rea/hopper-releases"),
});
},
);
describe("Linux signed package retention boundary", () => {
linux(
"rejects a cryptographically valid package signed by another publisher",
async () => {
const f = await fixture();
try {
expect(
await f.publisher.verifyArchive(
"deb",
f.release,
f.archive,
undefined,
f.publicKey,
),
).toMatchObject({
ok: false,
error: { reason: "publisher_verification_failed" },
});
await expect(access(f.publisher.cacheRoot)).rejects.toMatchObject({
code: "ENOENT",
});
} finally {
await f.publisher.close();
}
},
);
linux.each(["deb", "arch"] as const)(
"authenticates a real %s payload and verifies a retained install without network",
async (family) => {
const f = await fixture(family);
admitFixturePublisher(f.fingerprint);
const network = vi.fn(() => {
throw new Error("Offline");
});
vi.stubGlobal("fetch", network);
try {
const verified = await f.publisher.verifyArchive(
family,
f.release,
f.archive,
undefined,
f.publicKey,
);
if (!verified.ok) throw verified.error;
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
).toEqual({ ok: true, value: undefined });
await f.publisher.close();
// A new instance alone still shares process-local cryptographic proofs.
// Reload the owner and assert actual PGP and native payload inspection.
vi.resetModules();
const coldSignatures =
await import("../../../src/process/DetachedPgpSignature.js");
const signature = admitFixturePublisher(f.fingerprint, coldSignatures);
const coldCommands =
await import("../../../src/process/ExecFileOutput.js");
const extraction = vi.spyOn(coldCommands, "execFileOutput");
const { LinuxHopperPublisher: ColdPublisher } =
await import("../../../src/application/LinuxHopperPublisher.js");
const fresh = new ColdPublisher(
f.publisher.environment,
async () => family,
);
try {
expect(await fresh.inspectLauncher(f.launcher)).toMatchObject({
ok: true,
value: {
sha256: f.release.main_exe_hash,
primaryFingerprint: f.fingerprint,
source: "signed_package_cache",
},
});
expect(network).not.toHaveBeenCalled();
expect(signature).toHaveBeenCalledOnce();
expect(extraction).toHaveBeenCalledWith(
family === "deb" ? "dpkg-deb" : "tar",
expect.any(Array),
expect.any(Object),
);
} finally {
await fresh.close();
}
} finally {
await f.publisher.close();
}
},
);
});
linux.each([
"archive",
"metadata",
"missing metadata",
"missing key",
"malformed key",
"missing archive",
"malformed JSON",
"empty directory",
"symlink archive",
])("fails closed on retained %s tampering", async (kind) => {
const f = await fixture();
admitFixturePublisher(f.fingerprint);
try {
const verified = await f.publisher.verifyArchive(
"deb",
f.release,
f.archive,
undefined,
f.publicKey,
);
if (!verified.ok) throw verified.error;
const retained = await f.publisher.retainVerifiedArchive(
verified.value,
f.archive,
);
if (!retained.ok) throw retained.error;
if (kind === "archive") {
const tampered = Buffer.from("tampered");
await writeFile(join(f.cache, "archive"), tampered);
const metadata = JSON.parse(
await readFile(join(f.cache, "release.json"), "utf8"),
);
metadata.release.file_length = String(tampered.length);
metadata.release.file_hash = createHash("sha1")
.update(tampered)
.digest("hex");
await writeFile(join(f.cache, "release.json"), JSON.stringify(metadata));
}
if (kind === "metadata") {
const metadata = JSON.parse(
await readFile(join(f.cache, "release.json"), "utf8"),
);
metadata.release.main_exe_hash = "a".repeat(64);
await writeFile(join(f.cache, "release.json"), JSON.stringify(metadata));
}
if (kind === "missing metadata") await rm(join(f.cache, "release.json"));
if (kind === "missing key") await rm(join(f.cache, "public-key.asc"));
if (kind === "malformed key")
await writeFile(join(f.cache, "public-key.asc"), Buffer.from([0xff]));
if (kind === "missing archive") await rm(join(f.cache, "archive"));
if (kind === "malformed JSON")
await writeFile(join(f.cache, "release.json"), "{broken JSON");
if (kind === "empty directory")
await Promise.all(
["release.json", "public-key.asc", "archive"].map((name) =>
rm(join(f.cache, name)),
),
);
if (kind === "symlink archive") {
await rm(join(f.cache, "archive"));
await symlink(f.archive, join(f.cache, "archive"));
}
const network = vi.fn(() => {
throw new Error("Must preserve the cache failure");
});
vi.stubGlobal("fetch", network);
expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({
ok: false,
});
expect(network).not.toHaveBeenCalled();
// An explicitly approved fresh package can repair this entry while
// preserving the original evidence and keeping ordinary reads strict.
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
).toEqual({ ok: true, value: undefined });
const backups = (await readdir(f.publisher.cacheRoot)).filter((name) =>
name.startsWith(`${f.release.main_exe_hash}.backup-`),
);
expect(backups).toHaveLength(1);
if (kind === "missing metadata" || kind === "empty directory")
await expect(
access(join(f.publisher.cacheRoot, backups[0]!, "release.json")),
).rejects.toMatchObject({ code: "ENOENT" });
else
expect(
await readFile(
join(f.publisher.cacheRoot, backups[0]!, "release.json"),
"utf8",
),
).toBeTruthy();
expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({
ok: true,
});
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
).toEqual({ ok: true, value: undefined });
expect(
(await readdir(f.publisher.cacheRoot)).filter((name) =>
name.includes(".backup-"),
),
).toEqual(backups);
expect(network).not.toHaveBeenCalled();
} finally {
await f.publisher.close();
}
});
linux.each([false, true])(
"serializes real cross-process repair and cancellation: %s",
async (cancel) => {
const f = await fixture();
admitFixturePublisher(f.fingerprint);
const processes = createTestProcessRegistry();
const controller = new AbortController();
let retention:
| ReturnType<LinuxHopperPublisher["retainVerifiedArchive"]>
| undefined;
try {
const verified = await f.publisher.verifyArchive(
"deb",
f.release,
f.archive,
undefined,
f.publicKey,
);
if (!verified.ok) throw verified.error;
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
).toEqual({ ok: true, value: undefined });
const staged = join(f.publisher.cacheRoot, "authenticated-fixture-stage");
await cp(f.cache, staged, { recursive: true });
const broken = "{interrupted cache record";
await writeFile(join(f.cache, "release.json"), broken);
const child = processes.spawn(process.execPath, [
fileURLToPath(
new URL("../../fixtures/hopperCachePublisher.mjs", import.meta.url),
),
f.publisher.cacheRoot,
f.release.main_exe_hash,
staged,
]);
let stderr = "";
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
const exited = once(child, "close");
const ready = await Promise.race([
once(child.stdout, "data").then(([bytes]) => {
if (!Buffer.isBuffer(bytes))
throw new Error("Expected fixture readiness bytes");
return JSON.parse(bytes.toString());
}),
exited.then(([code]) => {
throw new Error(
`Fixture exited before acquiring its lease: ${String(code)} ${stderr}`,
);
}),
]);
expect(ready).toEqual({ held: true });
let notifyBusy: (() => void) | undefined;
const busy = new Promise<void>((resolve) => {
notifyBusy = resolve;
});
const acquire = leases.acquireHopperReleaseCacheLease;
vi.spyOn(leases, "acquireHopperReleaseCacheLease").mockImplementation(
async (input) => {
const observed = await acquire(input);
if (!observed.acquired) notifyBusy?.();
return observed;
},
);
retention = f.publisher.retainVerifiedArchive(
verified.value,
f.archive,
controller.signal,
);
await Promise.race([
busy,
retention.then(() => {
throw new Error("Retention bypassed the other process's cache lease");
}),
]);
expect(await readFile(join(f.cache, "release.json"), "utf8")).toBe(
broken,
);
expect(
(await readdir(f.publisher.cacheRoot)).some((name) =>
name.includes(".backup-"),
),
).toBe(false);
if (cancel) {
controller.abort();
expect(await retention).toMatchObject({
ok: false,
error: { reason: "cancelled" },
});
child.stdin.end("release\n");
} else {
child.stdin.end("publish\n");
expect(await retention).toEqual({ ok: true, value: undefined });
expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({
ok: true,
});
expect(
(await readdir(f.publisher.cacheRoot)).filter((name) =>
name.includes(".backup-"),
),
).toHaveLength(1);
}
expect(await exited).toEqual([0, null]);
} finally {
controller.abort();
await processes.shutdown();
await retention;
await f.publisher.close();
}
},
);
linux("restores old evidence when replacement publication fails", async () => {
const f = await fixture();
admitFixturePublisher(f.fingerprint);
try {
const verified = await f.publisher.verifyArchive(
"deb",
f.release,
f.archive,
undefined,
f.publicKey,
);
if (!verified.ok) throw verified.error;
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
).toEqual({ ok: true, value: undefined });
const broken = "{interrupted cache metadata";
await writeFile(join(f.cache, "release.json"), broken);
const rename = filesystem.rename;
const denied = Object.assign(new Error("Fixture publication failure"), {
code: "EIO",
});
let backup: string | undefined;
const publication = vi
.spyOn(filesystem, "rename")
.mockImplementation(async (from, to) => {
if (backup !== undefined && to === f.cache && from !== backup)
throw denied;
await rename(from, to);
if (from === f.cache && typeof to === "string") backup = to;
});
syncBuiltinESMExports();
try {
const result = await f.publisher.retainVerifiedArchive(
verified.value,
f.archive,
);
expect(result).toMatchObject({
ok: false,
error: {
reason: "verification_unavailable",
message: expect.stringContaining(f.cache),
},
});
if (result.ok) throw new Error("Expected publication failure");
expect(result.error.cause).toBeInstanceOf(AggregateError);
if (result.error.cause instanceof AggregateError)
expect(result.error.cause.errors).toContain(denied);
expect(await readFile(join(f.cache, "release.json"), "utf8")).toBe(
broken,
);
expect(
(await readdir(f.publisher.cacheRoot)).some((name) =>
name.includes(".backup-"),
),
).toBe(false);
} finally {
publication.mockRestore();
syncBuiltinESMExports();
}
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
).toEqual({ ok: true, value: undefined });
expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({
ok: true,
});
} finally {
await f.publisher.close();
}
});
linux(
"preserves existing evidence when native cache inspection is unavailable",
async () => {
const f = await fixture();
admitFixturePublisher(f.fingerprint);
try {
const verified = await f.publisher.verifyArchive(
"deb",
f.release,
f.archive,
undefined,
f.publicKey,
);
if (!verified.ok) throw verified.error;
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
).toEqual({ ok: true, value: undefined });
await writeFile(join(f.cache, "public-key.asc"), f.publicKey + "\n");
const metadata = await readFile(join(f.cache, "release.json"));
const unavailable = vi
.spyOn(nativeCommands, "execFileOutput")
.mockRejectedValue(new Error("Fixture native inspection unavailable"));
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
).toMatchObject({
ok: false,
error: { reason: "verification_unavailable" },
});
expect(await readFile(join(f.cache, "release.json"))).toEqual(metadata);
expect(
(await readdir(f.publisher.cacheRoot)).some((name) =>
name.includes(".backup-"),
),
).toBe(false);
unavailable.mockRestore();
expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({
ok: true,
});
} finally {
await f.publisher.close();
}
},
);
linux(
"rejects a feed digest not represented in the signed payload",
async () => {
const f = await fixture();
admitFixturePublisher(f.fingerprint);
try {
expect(
await f.publisher.verifyArchive(
"deb",
{ ...f.release, main_exe_hash: "a".repeat(64) },
f.archive,
undefined,
f.publicKey,
),
).toMatchObject({
ok: false,
error: {
reason: "integrity",
message: expect.stringContaining(f.release.main_exe_hash),
},
});
expect(
await f.publisher.verifyArchive(
"deb",
f.release,
f.archive,
AbortSignal.abort(),
f.publicKey,
),
).toMatchObject({ ok: false, error: { reason: "cancelled" } });
} finally {
await f.publisher.close();
}
},
);
linux(
"retains a failed publication lease until its real socket owner closes",
async () => {
const f = await fixture();
admitFixturePublisher(f.fingerprint);
const acquire = leases.acquireHopperReleaseCacheLease;
let allowCleanup = false;
vi.spyOn(leases, "acquireHopperReleaseCacheLease").mockImplementation(
async (input) => {
const acquired = await acquire(input);
if (!acquired.acquired) return acquired;
return {
acquired: true as const,
lease: {
...acquired.lease,
release: async () => {
if (!allowCleanup)
throw new Error("Fixture lease close unavailable");
await acquired.lease.release();
},
},
};
},
);
try {
const verified = await f.publisher.verifyArchive(
"deb",
f.release,
f.archive,
undefined,
f.publicKey,
);
if (!verified.ok) throw verified.error;
expect(
await f.publisher.retainVerifiedArchive(verified.value, f.archive),
).toMatchObject({ ok: false, error: { reason: "cleanup_failed" } });
const input = {
cacheDirectory: f.publisher.cacheRoot,
launcherSha256: f.release.main_exe_hash,
runId: "fixture-competing-writer",
};
expect(await acquire(input)).toMatchObject({ acquired: false });
await expect(f.publisher.close()).rejects.toMatchObject({
cleanup: { resources: [expect.stringContaining(".sock")] },
});
allowCleanup = true;
await f.publisher.close();
const recovered = await acquire(input);
if (!recovered.acquired)
throw new Error("Expected the released real cache lease");
await recovered.lease.release();
} finally {
allowCleanup = true;
await f.publisher.close();
}
},
);
it("bounds a stalled metadata fetch and preserves caller cancellation", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
(_url, options: RequestInit) =>
new Promise((_resolve, reject) => {
const signal = options.signal;
signal?.addEventListener("abort", () => reject(signal.reason), {
once: true,
});
signal?.throwIfAborted();
}),
),
);
await expect(
downloadHopperPackage("https://www.hopperapp.com/fixture", {
timeoutMs: 10,
}),
).rejects.toMatchObject({ name: "TimeoutError" });
const controller = new AbortController();
const reason = new Error("Caller cancelled metadata inspection");
const pending = downloadHopperPackage("https://www.hopperapp.com/fixture", {
signal: controller.signal,
});
controller.abort(reason);
await expect(pending).rejects.toBe(reason);
});
it("cancels an oversized chunked metadata response before retaining it", async () => {
const cancelled = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(4));
},
cancel: cancelled,
}),
),
),
);
expect(
await downloadHopperPackage("https://www.hopperapp.com/fixture", {
maxBytes: 3,
}),
).toEqual({ ok: false, bytes: new Uint8Array() });
expect(cancelled).toHaveBeenCalledOnce();
});
linux("retains and retries failed verification cleanup", async () => {
const f = await fixture();
admitFixturePublisher(f.fingerprint);
const cleanup = vi
.spyOn(PrivateRuntimeRoot.prototype, "close")
.mockRejectedValueOnce(new Error("Fixture cleanup unavailable"));
try {
expect(
await f.publisher.verifyArchive(
"deb",
f.release,
f.archive,
undefined,
f.publicKey,
),
).toMatchObject({ ok: false, error: { reason: "cleanup_failed" } });
expect(cleanup).toHaveBeenCalledOnce();
await f.publisher.close();
expect(cleanup).toHaveBeenCalledTimes(2);
} finally {
await f.publisher.close();
}
});
linux(
"preserves cleanup ownership when verification is cancelled",
async () => {
const f = await fixture();
admitFixturePublisher(f.fingerprint);
const controller = new AbortController();
const cleanup = vi
.spyOn(PrivateRuntimeRoot.prototype, "close")
.mockImplementation(async () => {
controller.abort();
throw new Error("Fixture cleanup unavailable after cancellation");
});
try {
expect(
await f.publisher.verifyArchive(
"deb",
f.release,
f.archive,
controller.signal,
f.publicKey,
),
).toMatchObject({
ok: false,
error: { reason: "cleanup_failed" },
});
await expect(retryLinuxHopperPublisherCleanup()).rejects.toMatchObject({
cleanup: {
resources: [expect.stringContaining("rea-hopper-publisher-")],
},
});
} finally {
cleanup.mockRestore();
await f.publisher.close();
await retryLinuxHopperPublisherCleanup();
}
},
);
@@ -72,9 +72,54 @@ describe("packaged Linux failure verification", () => {
}),
),
).not.toThrow();
expect(() =>
assertLinuxPackageProviderFailure(
execution({
code: "provider_unavailable",
details: {
stage: "launch",
launch_rejection: {
boundary: "publisher_verification",
reason: "unsupported_hopper_build",
},
},
}),
),
).not.toThrow();
});
it.each([
execution({
code: "provider_unavailable",
details: {
stage: "launch",
launch_rejection: {
boundary: "publisher_verification",
reason: "download",
},
},
}),
execution({
code: "provider_unavailable",
details: {
stage: "analysis",
launch_rejection: {
boundary: "publisher_verification",
reason: "unsupported_hopper_build",
},
},
}),
execution({
code: "provider_unavailable",
details: {
stage: "launch",
launch_rejection: {
boundary: "publisher_verification",
reason: "unsupported_hopper_build",
},
exit_code: 72,
},
}),
execution(missingDisplay, 0),
execution({ code: "provider_unavailable" }),
execution({
+31
View File
@@ -0,0 +1,31 @@
import { once } from "node:events";
import { randomUUID } from "node:crypto";
import { rename } from "node:fs/promises";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { acquireHopperReleaseCacheLease } from "../../dist/process/HopperLease.js";
// The parent prepares authenticated fixture evidence. Coordinate a real second
// process publishing it while another publisher is waiting for this lease.
const [cacheDirectory, launcherSha256, staged] = process.argv.slice(2);
const commands = createInterface({ input: process.stdin });
const command = once(commands, "line");
const acquired = await acquireHopperReleaseCacheLease({
cacheDirectory,
launcherSha256,
runId: randomUUID(),
});
if (!acquired.acquired) throw new Error("Fixture cache lease was already held");
try {
console.log(JSON.stringify({ held: true }));
const [action] = await command;
if (action === "publish") {
const target = join(cacheDirectory, launcherSha256);
await rename(target, `${target}.backup-${randomUUID()}`);
await rename(staged, target);
} else if (action !== "release") throw new Error("Unknown fixture action");
} finally {
await acquired.lease.release();
commands.close();
}
+2 -1
View File
@@ -64,7 +64,7 @@ if len(sys.argv) > 3:
)
return child, ":123", directory / "Xauthority", helper.CapturedStderr(child.stderr)
helper.digest = lambda _: next(iter(helper.SUPPORTED_HOPPER_SHA256))
helper.digest = lambda _: "0" * 64
helper.start_xvfb = start_xvfb
helper.ctypes.CDLL = lambda _: X11()
helper.configure_x11 = lambda *_: None
@@ -75,6 +75,7 @@ if len(sys.argv) > 3:
raise SystemExit(helper.run_launch(argparse.Namespace(
owner_stdin=True,
hopper=sys.executable,
expected_launcher_sha256="0" * 64,
socket=str(socket),
command=child_command(directory / "hopper.pid"),
)))
+36
View File
@@ -0,0 +1,36 @@
/** Deterministic feed boundary for the package verifier's deliberately foreign launcher.
* This fixture never authorizes a Hopper build or simulates successful cryptography.
*/
import { HOPPER_RELEASES_URL } from "../../dist/application/HopperReleaseFeed.js";
const systemFetch = globalThis.fetch;
const row = (suffix) => ({
version: "fixture-current",
filename: `https://www.hopperapp.com:443/downloader/public/Hopper-fixture-current-Linux-demo.${suffix}`,
file_length: "1",
file_hash: "0".repeat(40),
main_exe_hash: "0".repeat(64),
signature: "AQ==",
public: "1",
beta: "0",
});
globalThis.fetch = (input, options) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
if (url !== HOPPER_RELEASES_URL) return systemFetch(input, options);
options?.signal?.throwIfAborted();
return Promise.resolve(
new Response(
JSON.stringify({
"Ubuntu / Mint": row("deb"),
Fedora: row("rpm"),
Arch: row("pkg.tar.xz"),
}),
),
);
};
@@ -32,7 +32,7 @@ vi.mock("node:fs/promises", async (importOriginal) => {
};
});
import { acquireLinuxHopperApplicationLease } from "../../src/hopper/HopperTargetLease.js";
import { acquireLinuxHopperApplicationLease } from "../../src/process/HopperLease.js";
afterEach(() => {
replacement.selected = "";