plugins: a dependency can declare the architecture its package needs

Minesweeper could not be installed by the plugin flow at all. Its game is an
i386 PE, so it needs wine32:i386 — and that package does not exist on a 64-bit
host until dpkg has been told the architecture does. No package name can say
that, so the step fell outside the manifest entirely and had to be run by hand,
which meant an install that reported success and left a game dying with
`Bad EXE format` several steps from anything mentioning architectures.

Two fields, both declarative:

`multiarch: 'i386'` on an osDependency. The platform runs `dpkg
--add-architecture` and `apt-get update` before installing, apt hosts only,
skipped when already enabled. It is a closed enum rather than a command
because it runs as root: a manifest that could supply the string could run
anything as root on every machine that installs the plugin, which is a much
larger grant than "may install wine". Same principle as a plugin websocket
naming a sidecar rather than a port.

`packages.apt` may now be a LIST. One binary really can need more than one
package — `wine` provides the launcher on PATH and `wine32:i386` the 32-bit
loader, and either alone gives a wine that cannot run what the plugin came for.
Every element goes through the same regex and reaches apt as separate argv, so
a list is no more powerful than the names in it. The regex also now accepts an
architecture suffix from the same closed set.

Enabling an architecture is system-wide and permanent — nothing here ever
disables one, including plugins:delete, because by then something else may
depend on it. So it is reported before the owner consents rather than mentioned
in a log afterwards, and only for dependencies actually being installed: a
binary already present had its architecture enabled at some point in the past.

multiarch.test.ts pins the enum against shell-shaped input, the suffix against
an unknown arch, and the report against re-announcing a change that is not
happening. 1409 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-23 18:24:54 +00:00
co-authored by Claude Opus 5
parent 0f90003d15
commit 4ca4becaa8
5 changed files with 317 additions and 11 deletions
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, test } from 'bun:test';
import { DPKG_ARCHITECTURES, manifestProblems } from './manifest';
import { reportDependencies } from './os-deps';
import type { DiscoveredPlugin, OsDependency } from './manifest';
/**
* A package cannot be installed for an architecture the host does not know about, and no package name can
* say so — which is why `multiarch` is a field rather than something clever in the package string.
*
* Minesweeper is the case that produced it: `apt-get install wine` on a 64-bit host succeeds, reports a
* clean plugin install, and leaves a 32-bit game dying with `Bad EXE format`. Nothing in that chain
* mentions architectures, so it is worth pinning the two properties that keep it working.
*/
const dep = (over: Partial<OsDependency> = {}): OsDependency => ({
binary: 'definitely-not-a-real-binary-xyzzy',
reason: 'test',
packages: { apt: 'some-package' },
...over,
});
const plugin = (deps: OsDependency[]): DiscoveredPlugin =>
({ appName: 'probe', dir: '/tmp/probe', manifest: { osDependencies: deps } }) as unknown as DiscoveredPlugin;
describe('multiarch is a closed vocabulary, not a command', () => {
test('a valid architecture is accepted', () => {
for (const arch of DPKG_ARCHITECTURES) {
const problems = manifestProblems('probe', {
publisher: 'x',
version: '1.0.0',
platform: '>=1.0.0',
label: 'X',
summary: 'x',
color: '#000000',
permissions: [],
osDependencies: [dep({ multiarch: arch })],
} as never);
expect(problems.filter((p) => p.includes('multiarch'))).toEqual([]);
}
});
// The whole point of the enum. A manifest that could supply the string could run it as root on every
// machine that installs the plugin, which is a far larger grant than "may install wine".
test('anything else is refused, including something shell-shaped', () => {
for (const bad of ['i486', 'I386', 'i386; rm -rf /', '$(whoami)', '']) {
const problems = manifestProblems('probe', {
publisher: 'x',
version: '1.0.0',
platform: '>=1.0.0',
label: 'X',
summary: 'x',
color: '#000000',
permissions: [],
osDependencies: [dep({ multiarch: bad as never })],
} as never);
expect(problems.some((p) => p.includes('multiarch'))).toBe(true);
}
});
test('a package name may carry a known architecture suffix, and only a known one', () => {
const ok = manifestProblems('probe', {
publisher: 'x',
version: '1.0.0',
platform: '>=1.0.0',
label: 'X',
summary: 'x',
color: '#000000',
permissions: [],
osDependencies: [dep({ packages: { apt: 'wine32:i386' } })],
} as never);
expect(ok.filter((p) => p.includes('packages'))).toEqual([]);
// The list form, which is what wine actually needs.
const list = manifestProblems('probe', {
publisher: 'x',
version: '1.0.0',
platform: '>=1.0.0',
label: 'X',
summary: 'x',
color: '#000000',
permissions: [],
osDependencies: [dep({ packages: { apt: ['wine', 'wine32:i386'] } })],
} as never);
expect(list.filter((p) => p.includes('packages'))).toEqual([]);
const bad = manifestProblems('probe', {
publisher: 'x',
version: '1.0.0',
platform: '>=1.0.0',
label: 'X',
summary: 'x',
color: '#000000',
permissions: [],
osDependencies: [dep({ packages: { apt: 'wine32:sparc' } })],
} as never);
expect(bad.some((p) => p.includes('packages'))).toBe(true);
});
});
describe('the report tells the owner what will CHANGE', () => {
test('a missing dependency surfaces its architecture', () => {
const report = reportDependencies(plugin([dep({ multiarch: 'i386' })]));
expect(report.missing.length).toBe(1);
// Reported on every host. Whether it is ACTED on is apt-only — see `enableArchitectures` — but the
// owner is told what the plugin asks for regardless of what this machine will do about it.
expect(report.architectures).toEqual(['i386']);
});
// A binary already on PATH had its architecture enabled at some point in the past. Re-reporting it would
// tell the owner the machine is about to change when it is not.
test('a dependency that is already present does not', () => {
const report = reportDependencies(plugin([dep({ binary: 'sh', multiarch: 'i386' })]));
expect(report.missing.length).toBe(0);
expect(report.architectures).toEqual([]);
});
test('two dependencies wanting the same architecture report it once', () => {
const report = reportDependencies(
plugin([dep({ binary: 'nope-a-xyzzy', multiarch: 'i386' }), dep({ binary: 'nope-b-xyzzy', multiarch: 'i386' })]),
);
expect(report.architectures).toEqual(['i386']);
});
});