diff --git a/TODO.md b/TODO.md index 78b6e1d3..fe687aec 100644 --- a/TODO.md +++ b/TODO.md @@ -298,6 +298,22 @@ pane belonging to the folder you left; and the breadcrumb opted out of that last restored. Deliberate as written — a dashboard can hold two browsers and one URL cannot serve both — but worth re-deciding rather than inheriting. +## Release-binary dependencies (2026-08-19) + +Built: a plugin can declare `release: { url, identify }` for a dependency no package manager carries. +Gitea's `tea` is the motivating case and the first user. + +Two things it does NOT do, both deliberate and both worth knowing: + +- **No checksum.** The download is verified by RUNNING it through the same `identify` rule the probe + uses, which catches an HTML error page, a truncated file and a wrong-architecture build — none of them + print what the plugin said to look for. It does not catch a compromised upstream. A checksum per + os/arch is a table that goes stale every release and would stop installs the day upstream re-cuts a + tag; HTTPS and a URL the owner reads in the install dialog is what stands behind it today. +- **The version is pinned in the manifest.** `tea` is fixed at 0.15.1, so a new release needs a plugin + update. That is the right default — an install that silently fetches whatever is latest is not + reproducible — but there is no "check for a newer one" anywhere. + ## Affordances (2026-08-19) Agreed and written down in `plugins/CLAUDE.md`; **nothing is built**. An affordance is what the surface diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index ffc2d61a..f4778735 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -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, diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts index 2d1c4ca8..0910257e 100644 --- a/src/servers/plugins/manifest.ts +++ b/src/servers/plugins/manifest.ts @@ -86,6 +86,34 @@ export type OsDependency = { * to the owner rather than a guess. */ packages: Partial>; + /** + * 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 = { diff --git a/src/servers/plugins/os-deps.test.ts b/src/servers/plugins/os-deps.test.ts index ecf41cba..3ee3d967 100644 --- a/src/servers/plugins/os-deps.test.ts +++ b/src/servers/plugins/os-deps.test.ts @@ -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): 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); + }); +}); diff --git a/src/servers/plugins/os-deps.ts b/src/servers/plugins/os-deps.ts index 2e1792b1..aafd87b9 100644 --- a/src/servers/plugins/os-deps.ts +++ b/src/servers/plugins/os-deps.ts @@ -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 }; /**