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:
49 files changed
+3195
-223
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+19
@@ -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",
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" "$@"',
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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"),
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
? {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
acquireHopperTargetLease,
|
||||
acquireLinuxHopperApplicationLease,
|
||||
} from "./HopperTargetLease.js";
|
||||
} from "../process/HopperLease.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 = "";
|
||||
|
||||
Reference in new issue
Block a user