a plugin can depend on a release binary, and prove it is the right one

osDependencies could only say "this package, from this manager". Gitea's `tea`
is not in any distribution — it is a Go binary from dl.gitea.com — and Debian
has an UNRELATED package of that name: a graphical text editor. So the one
dependency gitea actually wants could not be expressed, and its marketplace
page showed no dependencies at all while music's showed two.

`release: { url, identify }` covers it. {os} and {arch} are filled from the
host in the spelling Go releases use, and the binary is fetched to
~/.local/bin — no root, and nothing a plugin downloaded from the internet
sitting beside what the distribution installed. Written to a temporary name and
moved into place, so a half-downloaded file is never on PATH under the real
one.

`identify` is the part that matters. Bun.which('tea') cannot tell Gitea's CLI
from the text editor, so presence was never the same question as identity —
and reporting the wrong program as satisfied is worse than reporting it
missing, because the install succeeds and every call afterwards fails
strangely. A binary that fails to identify is now `present: false, imposter:
true`.

Found while testing: Bun.which() with no options reads the PATH the PROCESS
started with, which under PM2 frequently excludes ~/.local/bin entirely. A
release binary fetched during an install would have been reported missing
seconds later by stillMissing, failing an install that had just succeeded. The
search now passes PATH explicitly, with ~/.local/bin first.

Verified against the real binaries on this machine: the genuine tea identifies,
a fake that prints text-editor output is caught as an imposter, and both
dl.gitea.com URLs (linux-amd64, darwin-arm64) answer 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 08:48:18 +00:00
co-authored by Claude Opus 5
parent 51afa40a19
commit 09f2cfa267
5 changed files with 277 additions and 9 deletions
+19 -3
View File
@@ -20,7 +20,7 @@ import { refreshPluginMounts, snapshotPlugins } from './mount';
import { generatePluginSchemas, pushPluginSchema } from './schema';
import { declaredTables, dropTables, existingTables } from './tables';
import type { OnLog } from './run-logged';
import { installDependencies, manualInstallHint, reportDependencies, stillMissing } from './os-deps';
import { installDependencies, installRelease, manualInstallHint, reportDependencies, stillMissing } from './os-deps';
import { mountPrefix, type DiscoveredPlugin } from './manifest';
// The install runner: the four verbs, each as a short ordered list of effects.
@@ -219,8 +219,24 @@ export async function installPlugin(
};
}
await step(steps, onStep, `dependencies: installing ${deps.packagesToInstall.join(', ')} with ${deps.manager}`);
const installed = await installDependencies(deps.manager!, deps.packagesToInstall);
// Release binaries first, and separately: they need no package manager, which is the reason they
// exist. `tea` is the motivating case — Gitea publishes it from dl.gitea.com, and the Debian
// package of that name is an unrelated text editor.
for (const dep of deps.missing.filter((d) => d.releaseUrl)) {
await step(steps, onStep, `dependencies: fetching ${dep.binary} from ${dep.releaseUrl}`);
const got = await installRelease(dep);
if (!got.ok) {
return { ok: false, appName, steps, error: `could not fetch ${dep.binary}: ${got.error}` };
}
await step(steps, onStep, `dependencies: ${dep.binary} installed to ${got.path}`);
}
if (deps.packagesToInstall.length) {
await step(steps, onStep, `dependencies: installing ${deps.packagesToInstall.join(', ')} with ${deps.manager}`);
}
const installed = deps.packagesToInstall.length
? await installDependencies(deps.manager!, deps.packagesToInstall)
: { ok: true as const, installed: [], output: '' };
if (!installed.ok) {
return {
ok: false,
+28
View File
@@ -86,6 +86,34 @@ export type OsDependency = {
* to the owner rather than a guess.
*/
packages: Partial<Record<PackageManager, string>>;
/**
* A release binary, for a dependency no package manager carries.
*
* `packages` covers the ordinary case: the thing is in the distribution and `apt install` fetches it.
* Some tools are not, and the motivating one is Gitea's `tea` — distributed as a Go binary from
* dl.gitea.com, and **Debian has an unrelated package of the same name**, a graphical text editor.
* Declaring `apt: 'tea'` would install the wrong program, report success, and leave every call failing
* strangely.
*
* `{os}` and `{arch}` are substituted from the host: `linux`/`darwin`, `amd64`/`arm64` — the spelling
* Go releases use, since that is what publishes binaries this way.
*
* Downloaded to `~/.local/bin`, not a system directory: this needs no root, and a binary a plugin
* fetched from the internet has no business in `/usr/local/bin` beside things the distribution put
* there.
*/
release?: {
/** e.g. `https://dl.gitea.com/tea/0.9.2/tea-0.9.2-{os}-{arch}` */
url: string;
/**
* How to prove the binary on PATH is THIS one and not a namesake.
*
* Presence is not identity. `Bun.which('tea')` cannot tell Gitea's CLI from the text editor, so
* without this the platform would find a `tea`, report the dependency satisfied, and be wrong. The
* command is run with these arguments and its output must contain `contains`.
*/
identify?: { args: string[]; contains: string };
};
};
export type PluginManifest = {
+101 -3
View File
@@ -1,6 +1,9 @@
import { describe, expect, test } from 'bun:test';
import { manifestProblems, type PluginManifest } from './manifest';
import { installCommand, manualInstallHint } from './os-deps';
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { manifestProblems, type DiscoveredPlugin, type OsDependency, type PluginManifest } from './manifest';
import { hostTarget, installCommand, manualInstallHint, reportDependencies } from './os-deps';
// The manifest field that reaches a ROOT command line, and the argv it turns into.
//
@@ -130,3 +133,98 @@ describe('manualInstallHint', () => {
expect(manualInstallHint(null, ['ffmpeg'])).toBeNull();
});
});
// ── Release binaries, and the namesake problem ──
//
// The motivating case is real: Gitea's `tea` is a Go binary from dl.gitea.com, and Debian has an
// unrelated package called `tea` that is a graphical text editor. `Bun.which('tea')` cannot tell them
// apart, so a platform that treats "on PATH" as "satisfied" installs the wrong program, reports success,
// and leaves every call failing in a way nobody traces back to here.
describe('a dependency that no package manager carries', () => {
const bin = join(tmpdir(), `dep-test-${process.pid}`);
const plugin = (dep: Partial<OsDependency>): DiscoveredPlugin =>
({
appName: 'test',
dir: '/tmp',
manifest: {
osDependencies: [{ binary: 'dep-test-fake', reason: 'testing', packages: {}, ...dep }],
},
}) as DiscoveredPlugin;
afterAll(() => rmSync(bin, { recursive: true, force: true }));
test('{os} and {arch} are filled from the host', () => {
const { os, arch } = hostTarget();
const report = reportDependencies(plugin({ release: { url: 'https://x/y-{os}-{arch}' } }));
expect(report.dependencies[0]!.releaseUrl).toBe(`https://x/y-${os}-${arch}`);
});
test('a release makes it resolvable even with no package manager entry', () => {
const report = reportDependencies(plugin({ release: { url: 'https://x/y-{os}-{arch}' } }));
// The whole point: `packages: {}` would otherwise be unresolvable and block the install.
expect(report.unresolvable).toEqual([]);
});
test('with neither a package nor a release it is still unresolvable', () => {
expect(reportDependencies(plugin({})).unresolvable.length).toBe(1);
});
});
describe('identity — presence is not the same as being the right program', () => {
const dir = join(tmpdir(), `dep-id-${process.pid}`);
const name = 'officer-dep-probe';
// Written to `~/.local/bin` because that is where `searchPath` looks — `Bun.which` reads the PATH the
// process STARTED with, so a temp directory added to `process.env.PATH` here would be invisible. That
// is the same reason the production code passes PATH explicitly.
const localBin = join(homedir(), '.local', 'bin');
const writeFake = (body: string) => {
mkdirSync(localBin, { recursive: true });
writeFileSync(join(localBin, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 });
};
afterAll(() => {
rmSync(join(localBin, name), { force: true });
rmSync(dir, { recursive: true, force: true });
});
const plugin = (identify?: { args: string[]; contains: string }): DiscoveredPlugin =>
({
appName: 'test',
dir: '/tmp',
manifest: {
osDependencies: [
{
binary: name,
reason: 'testing',
packages: {},
release: { url: 'https://x/y-{os}-{arch}', ...(identify ? { identify } : {}) },
},
],
},
}) as DiscoveredPlugin;
test('a namesake that does not identify is NOT present, and is named an imposter', () => {
writeFake('echo "graphical text editor 62.0.2"');
const status = reportDependencies(plugin({ args: ['--version'], contains: 'go-sdk' })).dependencies[0]!;
expect(status.path).not.toBeNull(); // it IS on PATH
expect(status.present).toBe(false); // and that is not enough
expect(status.imposter).toBe(true);
});
test('the right program identifies and is present', () => {
writeFake('echo "Version: 0.15.1 golang: 1.26.5 go-sdk: v1.2.0"');
const status = reportDependencies(plugin({ args: ['--version'], contains: 'go-sdk' })).dependencies[0]!;
expect(status.present).toBe(true);
expect(status.imposter).toBe(false);
});
test('without an identify rule, presence is taken at face value — running an unknown binary has a cost', () => {
writeFake('echo "anything at all"');
const status = reportDependencies(plugin()).dependencies[0]!;
expect(status.present).toBe(true);
expect(status.imposter).toBe(false);
});
});
+113 -3
View File
@@ -1,3 +1,6 @@
import { chmodSync, mkdirSync, renameSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { PACKAGE_MANAGERS, type DiscoveredPlugin, type OsDependency, type PackageManager } from './manifest';
// Host binaries a plugin needs, and getting them onto the machine.
@@ -56,17 +59,81 @@ export type DependencyStatus = {
present: boolean;
/** What would be installed on THIS machine, or null when this manager has no entry for it. */
packageName: string | null;
/** Resolved download URL when the plugin ships a release binary, or null. */
releaseUrl: string | null;
/**
* A binary of the right NAME that is the wrong PROGRAM.
*
* The motivating case: Debian's `tea` is a graphical text editor and Gitea's `tea` is a CLI. Presence
* is not identity, and reporting "satisfied" here would be worse than reporting missing — the install
* succeeds and every call afterwards fails strangely.
*/
imposter: boolean;
};
/** `linux`/`darwin` and `amd64`/`arm64` — the spelling Go releases use, which is who publishes this way. */
export function hostTarget(): { os: string; arch: string } {
const os = process.platform === 'darwin' ? 'darwin' : 'linux';
const arch = process.arch === 'arm64' ? 'arm64' : 'amd64';
return { os, arch };
}
const releaseUrlFor = (dep: OsDependency): string | null => {
if (!dep.release) return null;
const { os, arch } = hostTarget();
return dep.release.url.replaceAll('{os}', os).replaceAll('{arch}', arch);
};
/**
* Is the binary on PATH actually the one meant?
*
* Only asked when the plugin says how to tell. Running an unknown binary to identify it is a real cost,
* so it is opt-in — and the answer is only interesting for dependencies whose name is contested.
*/
function identifies(dep: OsDependency, path: string): boolean {
if (!dep.release?.identify) return true;
try {
const { args, contains } = dep.release.identify;
const out = Bun.spawnSync([path, ...args], { stdout: 'pipe', stderr: 'pipe' });
const text = `${new TextDecoder().decode(out.stdout)}${new TextDecoder().decode(out.stderr)}`;
return text.toLowerCase().includes(contains.toLowerCase());
} catch {
// Could not run it at all: treat as not-ours rather than assume. A binary that will not execute is
// no more useful than a missing one.
return false;
}
}
/**
* Where to look for a binary.
*
* `~/.local/bin` FIRST, and explicitly, for two reasons that are both bugs otherwise:
*
* `Bun.which()` with no options reads the PATH this PROCESS started with. Under PM2 that is a minimal
* environment which frequently does not include `~/.local/bin` at all — so a release binary fetched
* during an install would be reported missing seconds later by `stillMissing`, and the install would
* fail having just succeeded.
*
* It is also where `installRelease` puts things, so preferring it means the copy the platform installed
* wins over a namesake the distribution supplied. `identify` catches the wrong one either way; this
* decides which is found first.
*/
const searchPath = (): string => `${join(homedir(), '.local', 'bin')}:${process.env.PATH ?? ''}`;
/** Probe one dependency against the live PATH. */
function probe(dep: OsDependency, manager: PackageManager | null): DependencyStatus {
const path = Bun.which(dep.binary);
const path = Bun.which(dep.binary, { PATH: searchPath() });
const genuine = path ? identifies(dep, path) : false;
return {
binary: dep.binary,
reason: dep.reason,
path,
present: !!path,
// Present means USABLE. A namesake on PATH is not the dependency, and calling it present is how the
// wrong program gets used for a week before anyone works out why.
present: !!path && genuine,
imposter: !!path && !genuine,
packageName: (manager && dep.packages[manager]) || null,
releaseUrl: releaseUrlFor(dep),
};
}
@@ -112,7 +179,9 @@ export function reportDependencies(plugin: DiscoveredPlugin): DependencyReport {
const manager = detectPackageManager();
const dependencies = declared.map((dep) => probe(dep, manager));
const missing = dependencies.filter((d) => !d.present);
const unresolvable = missing.filter((d) => !d.packageName);
// A release binary resolves it too, and without a package manager at all — which is the whole point:
// the dependencies that need this are the ones no distribution carries.
const unresolvable = missing.filter((d) => !d.packageName && !d.releaseUrl);
// Deduped and sorted: ffmpeg and ffprobe are one apt package, and naming it twice on the command line
// is the sort of thing that reads as a bug in a log even though apt does not care.
@@ -176,6 +245,47 @@ export function manualInstallHint(manager: PackageManager | null, packages: stri
return argv.join(' ');
}
/**
* Fetch a release binary into `~/.local/bin`.
*
* For the dependency no package manager carries. Not root, not `/usr/local/bin`: a binary a plugin
* fetched from the internet has no business sitting beside what the distribution installed, and needing
* sudo for it would be a worse trade than needing PATH to include `~/.local/bin` — which it already does
* on every machine this platform sets up.
*
* ── What is checked, and what is not ──
*
* The download is verified by RUNNING it, through the same `identify` the probe uses. That catches an
* HTML error page saved as a binary, a truncated download, and a wrong-architecture build, because none
* of them will print what the plugin said to look for.
*
* It is NOT verified by checksum, and that is a real gap rather than an oversight: a checksum per
* os/arch is a table that goes stale on every upstream release, and a plugin that pinned one would stop
* installing the day upstream re-cut a tag. HTTPS and a URL the owner can read in the install dialog are
* what stands behind this today. Recorded in TODO.md.
*/
export async function installRelease(dep: DependencyStatus): Promise<{ ok: boolean; path?: string; error?: string }> {
if (!dep.releaseUrl) return { ok: false, error: `${dep.binary} ships no release binary` };
const dir = join(homedir(), '.local', 'bin');
const target = join(dir, dep.binary);
try {
mkdirSync(dir, { recursive: true });
const res = await fetch(dep.releaseUrl, { redirect: 'follow', signal: AbortSignal.timeout(120_000) });
if (!res.ok) return { ok: false, error: `${dep.releaseUrl} answered ${res.status}` };
// Written to a temporary name and moved into place, so a half-downloaded file is never on PATH under
// the real name — the failure that leaves a machine looking installed and behaving broken.
const tmp = `${target}.download`;
await Bun.write(tmp, res);
chmodSync(tmp, 0o755);
renameSync(tmp, target);
return { ok: true, path: target };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export type InstallDependenciesResult = { ok: boolean; installed: string[]; output: string; error?: string };
/**