import { chmodSync, copyFileSync, existsSync, mkdirSync, renameSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { locateBinary } from '@@/plugins/os-deps'; // The iris sidecar: a builder wearing a supervisor's clothes. // // The install pipeline installs packages and fetches release binaries, but deliberately runs no build // step — so compiling a Rust crate is this plugin's own job, and the sidecar is the only process the // platform starts for a plugin. First start pays for the build (minutes — watch `pm2 logs officer-iris`); // every later start finds the binary and settles into a heartbeat. // // Everything lands where the platform already looks: `~/.local/bin` is first on the probe path in // os-deps.ts, so `iris` and `iris-browser` are found by whoever asks, without anyone's PATH changing. const name = 'officer-iris'; const CRATE = 'iris-screenshot'; // Upstream's declared rust-version. Distro cargo lags real Rust by a year or more, so this is checked // BEFORE building — a clear refusal with instructions beats cargo's own MSRV error buried in build spam. const MSRV: readonly [number, number] = [1, 88]; const home = homedir(); const localBin = join(home, '.local', 'bin'); // The platform's own resolver, not a copy of it. It asks the OWNER's login shell where a binary is, // which is the only source that knows about rustup's `~/.cargo/bin`, or anything else a per-user // installer added — this file used to guess that list and disagreed with the manifest probe, so a // rustup-only machine was refused the install and then built fine. const which = (binary: string): string | null => locateBinary(binary); const log = (message: string): void => console.log(`[${name}] ${message}`); /** * The wrapper ships in this repository; agents need it on a path they can name. Overwrite = upgrade. * * Written to a temporary name and RENAMED over the target, because `copyFileSync` truncates in place: a * restart during a capture would have the running shell read a half-written script, which fails in * whatever way the truncation happens to produce. Rename is atomic within a filesystem, so a reader * holds either the old file or the new one. Same reasoning as `installRelease` in os-deps.ts. */ function installWrapper(): void { const source = join(import.meta.dir, '..', 'bin', 'iris-browser'); const target = join(localBin, 'iris-browser'); const staging = `${target}.new`; mkdirSync(localBin, { recursive: true }); copyFileSync(source, staging); chmodSync(staging, 0o755); renameSync(staging, target); log(`iris-browser installed to ${target}`); } /** `cargo 1.75.0 (…)` → [1, 75], or null when it will not even say. */ function cargoVersion(cargo: string): [number, number] | null { try { const out = Bun.spawnSync([cargo, '--version'], { stdout: 'pipe', stderr: 'pipe' }); const match = new TextDecoder().decode(out.stdout).match(/cargo (\d+)\.(\d+)/); return match ? [Number(match[1]), Number(match[2])] : null; } catch { return null; } } const meetsMsrv = ([major, minor]: [number, number]): boolean => major > MSRV[0] || (major === MSRV[0] && minor >= MSRV[1]); /** * Build iris into ~/.local/bin. Streams cargo's output straight to PM2's log, because a silent * multi-minute compile is indistinguishable from a hang. * * `--locked` builds against upstream's committed lockfile rather than re-resolving dependencies at * install time — the platform's frozen-lockfile rule, honoured one language over. */ let building: Bun.Subprocess | null = null; async function build(cargo: string): Promise { log(`building ${CRATE} — first start only, takes a few minutes`); const proc = Bun.spawn([cargo, 'install', CRATE, '--locked', '--root', join(home, '.local')], { stdout: 'inherit', stderr: 'inherit', }); // Remembered so `shutdown` can kill it. A cargo build spawns rustc processes that keep going long after // this process exits — `pm2 restart` during a build would otherwise leave the old compile running and // start a second one on top of it, both writing the same target directory. building = proc; const code = await proc.exited; building = null; if (code !== 0) { log(`build failed (cargo exited ${code}) — read the output above; restart this process to retry`); return false; } return true; } /** * Is the binary named `iris` actually ours? * * Presence is not identity. `iris` is a common enough name for a program, and a half-written file from a * cargo install that was killed mid-copy is also present — both make `which` succeed and both make this * sidecar report ready and never rebuild. The platform learned this once already: `os-deps.ts` grew * `identify` for exactly it, with the note that "a namesake on PATH is not the dependency". * * Cheap, and only on start. */ function isOurIris(path: string): boolean { try { const out = Bun.spawnSync([path, '--version'], { stdout: 'pipe', stderr: 'pipe', timeout: 5_000 }); const text = `${new TextDecoder().decode(out.stdout)}${new TextDecoder().decode(out.stderr)}`; return text.toLowerCase().includes('iris'); } catch { // Will not execute at all — a truncated or wrong-architecture file. No more useful than a missing one. return false; } } /** True when the binary is usable. Says why not, with the fix, when it is not. */ async function ensureIris(): Promise { const present = which('iris'); if (present && isOurIris(present)) { log(`iris ready at ${present}`); return true; } if (present) { log(`${present} does not identify as iris — rebuilding over it`); } const cargo = which('cargo'); if (!cargo) { log('cargo is not on PATH — the install should have provided it; install rust and restart this process'); return false; } const version = cargoVersion(cargo); if (!version || !meetsMsrv(version)) { log(`${cargo} is ${version ? `cargo ${version.join('.')}` : 'unidentifiable'}, and ${CRATE} needs ${MSRV.join('.')}+`); log('your distribution\'s rust is too old. Install rustup as the user officer runs as:'); log(" curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal"); log('then restart this process: pm2 restart officer-iris'); return false; } if (!(await build(cargo))) return false; const built = which('iris'); if (!built) { log('cargo reported success but iris is not in ~/.local/bin — read the build output above'); return false; } log(`iris built and ready at ${built}`); return true; } log(`started (pid ${process.pid})`); // ── Signals FIRST ── // // `ensureIris()` below can take minutes, and until these are registered a SIGTERM is handled by Bun's // default, which kills this process and leaves the cargo build running as an orphan. `pm2 restart` during // a first start is not an unusual thing to do — it is what somebody does when they think it is stuck. let beat: ReturnType | null = null; const shutdown = (signal: string): void => { log(`${signal} — exiting`); if (beat) clearInterval(beat); if (building) { log('killing the in-progress cargo build'); building.kill(); } process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); // Guarded, in a file whose every other failure logs and continues. An unwritable `~/.local/bin` — wrong // ownership after an install run as the wrong user is the way this happens — threw here at top level and // crash-looped the sidecar under PM2, which reports "errored" and says nothing about a directory. try { installWrapper(); } catch (err) { log(`could not install iris-browser: ${err instanceof Error ? err.message : err}`); log(`check that ${localBin} exists and is writable; captures will fail until it is`); } let ready = await ensureIris(); // The heartbeat re-probes rather than repeating a remembered answer: a binary deleted out from under us // shows up here, and a rustup installed after a refusal turns the next restart into a build — which the // log has already told the owner. Building inside the interval is deliberately avoided: a periodic // compile that nobody asked for is not a heartbeat. beat = setInterval(() => { const present = which('iris'); if (present && !ready) { ready = true; log(`iris appeared at ${present}`); } else if (!present && ready) { ready = false; log('iris is GONE from PATH — restart this process to rebuild'); } else { log(`alive — iris ${present ? 'ready' : 'missing'}`); } }, 300_000);