fix(hopper): keep installed apt dependencies at their versions during Linux setup (#6974)

* fix(hopper): keep installed apt dependencies at their versions during Linux setup

apt-get install upgrades every listed package that is already installed, so Linux setup could upgrade the system python3 and X11 libraries while installing Hopper. Runtime dependencies are now installed with --no-upgrade in a separate transaction before the Hopper archive, which may still upgrade. The setup approval plan lists both commands.

* fix(hopper): narrow the apt upgrade claim and cover split transaction failures

* fix(evidence): preserve JSON ownership through metadata updates

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Adolaniumandmorluto authored and GitHub committed 2026-10-25 03:37:20 +08:00
1 parent ce4e48ccae
commit a5bfd04401
4 files changed
+116 -20

No files matched your search

+6
View File
@@ -681,6 +681,12 @@ 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.
On apt systems, Hopper's runtime dependencies are installed with
`--no-upgrade` in their own transaction, so setup no longer names an
already-installed dependency as an upgrade target. apt can still upgrade a
package that the Hopper package or the Python library repair requires at a
newer version. Arch's `pacman -S --needed` still updates an outdated
dependency.
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
+26
View File
@@ -277,6 +277,32 @@ describe("Linux Hopper installation", () => {
);
},
);
it.each([true, false])(
"keeps installed apt dependencies at their versions while Hopper may upgrade (root: %s)",
(isRoot) => {
const prefix = isRoot ? [] : ["pkexec"];
expect(
linuxPackageManagerCommands("deb", "/tmp/Hopper package", isRoot).map(
({ executable, args }) => [executable, ...args],
),
).toEqual([
[
...prefix,
"apt-get",
"install",
"-y",
"--no-upgrade",
"xvfb",
"xauth",
"python3",
"libx11-6",
"libxtst6",
],
[...prefix, "apt-get", "install", "-y", "/tmp/Hopper package"],
]);
},
);
});
describe("Linux Hopper package installation", () => {
+6 -1
View File
@@ -818,7 +818,12 @@ export const linuxPackageManagerCommands = (
: ["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"];
const commands =
family === "deb"
? [["apt-get", "install", "-y", archive, ...runtimeDependencies]]
? [
// apt-get install upgrades listed packages that are already
// installed, so dependencies are not named as upgrade targets.
["apt-get", "install", "-y", "--no-upgrade", ...runtimeDependencies],
["apt-get", "install", "-y", archive],
]
: family === "rpm"
? [["dnf", "install", "-y", archive, ...runtimeDependencies]]
: [
@@ -6,9 +6,17 @@ import { err, ok } from "../domain/result.js";
import * as pythonRuntime from "./LinuxHopperPython.js";
import {
linuxHopperInstallDisclosure,
linuxPackageManagerCommands,
systemLinuxHopperInstallHost,
} from "./LinuxHopper.js";
/** Package transactions that install Hopper before any runtime library repair. */
const debCommands = linuxPackageManagerCommands(
"deb",
"/tmp/verified-hopper.deb",
false,
).length;
const missingPython = {
interpreter: "/usr/bin/python3",
version: [3, 14],
@@ -37,13 +45,13 @@ describe("Linux Hopper package runtime", () => {
{
name: "installs and verifies the matching future Python library",
inspections: [ok(missingPython), ok(readyPython)],
packageCalls: 2,
packageCalls: debCommands + 1,
status: "installed",
},
{
name: "skips the extra APT command for an already loadable library",
inspections: [ok(readyPython)],
packageCalls: 1,
packageCalls: debCommands,
status: "installed",
},
{
@@ -54,13 +62,13 @@ describe("Linux Hopper package runtime", () => {
library: { status: "unknown", reason: "No INSTSONAME" },
} satisfies pythonRuntime.LinuxHopperPythonRuntime),
],
packageCalls: 1,
packageCalls: debCommands,
status: "runtime_unavailable",
},
{
name: "rejects APT success when the library still cannot load",
inspections: [ok(missingPython), ok(missingPython)],
packageCalls: 2,
packageCalls: debCommands + 1,
status: "runtime_unavailable",
},
{
@@ -73,7 +81,7 @@ describe("Linux Hopper package runtime", () => {
),
),
],
packageCalls: 1,
packageCalls: debCommands,
status: "runtime_unavailable",
},
])("$name", async ({ inspections, packageCalls, status }) => {
@@ -97,9 +105,9 @@ describe("Linux Hopper package runtime", () => {
).toMatchObject({ status });
expect(command).toHaveBeenCalledTimes(packageCalls);
expect(inspect).toHaveBeenCalledTimes(inspections.length);
if (packageCalls === 2) {
if (packageCalls > debCommands) {
const isRoot = process.getuid?.() === 0;
expect(command.mock.calls[1]?.slice(0, 2)).toEqual([
expect(command.mock.calls[debCommands]?.slice(0, 2)).toEqual([
isRoot ? "apt-get" : "pkexec",
[...(isRoot ? [] : ["apt-get"]), "install", "-y", "libpython3.14"],
]);
@@ -128,10 +136,10 @@ describe("Linux Hopper package runtime", () => {
stderr: "original package error",
outputTruncated: true,
};
const command = vi
.spyOn(boundedCommands, "execFileBoundedOutput")
.mockResolvedValueOnce(commandSuccess)
.mockResolvedValueOnce(failed);
const command = vi.spyOn(boundedCommands, "execFileBoundedOutput");
for (let index = 0; index < debCommands; index++)
command.mockResolvedValueOnce(commandSuccess);
command.mockResolvedValueOnce(failed);
const inspect = vi
.spyOn(pythonRuntime, "inspectLinuxHopperPythonRuntime")
.mockResolvedValue(ok(missingPython));
@@ -139,7 +147,7 @@ describe("Linux Hopper package runtime", () => {
expect(
await host.installPackage("deb", "/tmp/verified-hopper.deb"),
).toMatchObject({ status: "failed", failure: failed });
expect(command).toHaveBeenCalledTimes(2);
expect(command).toHaveBeenCalledTimes(debCommands + 1);
expect(inspect).toHaveBeenCalledTimes(1);
} finally {
try {
@@ -184,7 +192,7 @@ describe("Linux Hopper package mapping", () => {
message: expect.stringContaining(path),
},
});
expect(command).toHaveBeenCalledTimes(1);
expect(command).toHaveBeenCalledTimes(debCommands);
expect(inspect).toHaveBeenCalledTimes(1);
} finally {
try {
@@ -197,13 +205,62 @@ describe("Linux Hopper package mapping", () => {
});
});
describe("Linux Hopper package transaction failures", () => {
it.each(Array.from({ length: debCommands }, (_, index) => index + 1))(
"stops after failed install transaction %i without inspecting Python",
async (failing) => {
const directory = await createTestTempDirectory(
"rea-linux-install-failure-",
);
const host = systemLinuxHopperInstallHost(
{ XDG_CACHE_HOME: directory },
async () => undefined,
);
const failed = {
exitCode: 100,
stdout: "",
stderr: "transaction " + String(failing) + " failed",
outputTruncated: false,
};
let calls = 0;
const command = vi
.spyOn(boundedCommands, "execFileBoundedOutput")
.mockImplementation(async () =>
++calls === failing ? failed : commandSuccess,
);
const inspect = vi.spyOn(
pythonRuntime,
"inspectLinuxHopperPythonRuntime",
);
try {
expect(
await host.installPackage("deb", "/tmp/verified-hopper.deb"),
).toMatchObject({
status: "failed",
failure: { exitCode: 100, stderr: failed.stderr },
});
expect(command).toHaveBeenCalledTimes(failing);
expect(inspect).not.toHaveBeenCalled();
} finally {
try {
await host.close?.();
} finally {
inspect.mockRestore();
command.mockRestore();
}
}
},
);
});
describe("Linux Hopper package cancellation and disclosure", () => {
it.each([
{ phase: "before", commands: 0, inspections: 0 },
{ phase: "first transaction", commands: 1, inspections: 0 },
{ phase: "query", commands: 1, inspections: 1 },
{ phase: "runtime transaction", commands: 2, inspections: 1 },
{ phase: "verification", commands: 2, inspections: 2 },
{ phase: "archive transaction", commands: debCommands, inspections: 0 },
{ phase: "query", commands: debCommands, inspections: 1 },
{ phase: "runtime transaction", commands: debCommands + 1, inspections: 1 },
{ phase: "verification", commands: debCommands + 1, inspections: 2 },
])(
"honors cancellation at $phase without starting the next stage",
async ({ phase, commands, inspections }) => {
@@ -224,7 +281,9 @@ describe("Linux Hopper package cancellation and disclosure", () => {
commandCount++;
if (
(phase === "first transaction" && commandCount === 1) ||
(phase === "runtime transaction" && commandCount === 2)
(phase === "archive transaction" && commandCount === debCommands) ||
(phase === "runtime transaction" &&
commandCount === debCommands + 1)
)
controller.abort(cause);
return commandSuccess;
@@ -267,8 +326,8 @@ describe("Linux Hopper package cancellation and disclosure", () => {
it("discloses the conditional privileged library install without returning probe code", () => {
const disclosure = linuxHopperInstallDisclosure("deb", false);
expect(disclosure.commands).toHaveLength(2);
expect(disclosure.commands[1]).toBe(
expect(disclosure.commands).toHaveLength(debCommands + 1);
expect(disclosure.commands[debCommands]).toBe(
'pkexec apt-get install -y "<libpython matching /usr/bin/python3, if not loadable>"',
);
expect(linuxHopperInstallDisclosure("rpm", false).commands).toHaveLength(1);