/** * Temporary-tree ownership for the deploy suites. * * WHY THIS EXISTS. /tmp is NOT a scratch space on this host — it is the SAME * filesystem that holds production's SQLite database (`/var/lib/synaptomind`). * On 2026-09-30 that filesystem hit 100% with zero bytes free, which broke * other agents' tool output and put production's own writes at risk (AGENTS.md * §8). Every `mkdtempSync(join(tmpdir(), 'synapto-*'))` that outlives its test * is therefore a production-risk defect, not untidiness: these suites create * ~28 such directories PER RUN, and each `bun test ./deploy/` is cheap enough * that they are run many times a day. * * WHY A REGISTRY AND NOT PER-SITE `rmSync`. The previous shape was one * `mkdtempSync` per fixture helper plus one `rmSync` per CALL SITE — 15 * `setupBootstrap()` callers in updater.sh.test.ts alone, each of which had to * remember to clean up, and none of which did. A per-site cleanup call is * exactly the failure mode: the next test added to the describe forgets it and * the leak comes straight back. Here the CREATION function owns the path: it * registers on the way in and the sweep removes it afterwards, so adding a test * cannot reintroduce the leak. * * WHY `installCleanup()` IS CALLED BY EVERY FILE, WITH NO IDEMPOTENCE FLAG. * bun runs each test FILE in the same process with one shared module registry, * so a module-level `let installed` guard makes the SECOND file a no-op: it * calls `installCleanup()`, gets a cached module back, registers no hook, and * every tree it creates leaks. Measured on this repo: with a guard, file 2 * finished with 1 of 2 trees surviving; without one, 0 of 5 across five files. * Each file therefore installs its own hook and every hook sweeps the same * registry — repeated `afterEach` registrations are harmless and idempotent * because the sweep empties the set it walks. * * NEVER removes a path this module did not create: `owned` only ever receives * the return value of our own `mkdtempSync`. The host's /tmp also holds other * sessions' scratch, so there is no glob, no `rm -rf /tmp/*`, and no * age-based sweep anywhere in this file. There is deliberately no "clean up * anything older than N" convenience — that is how a test harness deletes * somebody else's work. */ import { afterAll, afterEach } from 'bun:test' import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' /** * Every tree this process created through `mkTempTree`, and therefore every * tree this process is allowed to remove. */ const owned = new Set() /** Trees created but not yet swept. Exported for the ownership tests only. */ export function pendingTempTrees(): string[] { return [...owned] } /** * Remove every tree created by `mkTempTree` in this process. * * Safe to call when nothing is pending, and safe to call twice: `owned` is * emptied as it is walked, so the second call is a no-op rather than a second * `rm -rf` of a path someone else may have taken over in the meantime. */ export function sweepTempTrees(): number { const paths = [...owned] for (const path of paths) { owned.delete(path) // `force` covers the case where the test itself already removed the tree, // so a suite that cleans up eagerly does not start failing on ENOENT. rmSync(path, { recursive: true, force: true }) } return paths.length } /** * A private temp directory owned by the caller. * * This is the ONLY supported way to create a scratch tree in the deploy * suites — the bare `mkdtempSync` is what let trees escape in the first place. * The path is returned for the caller to populate and assert against; removal * is the sweep's job and is not the caller's to remember. */ export function mkTempTree(prefix: string): string { const path = mkdtempSync(join(tmpdir(), prefix)) owned.add(path) return path } /** * `mkTempTree` plus an immediate removal — for the fixture helpers whose whole * job is to produce a value (a rendered unit, a resolved tag) that the test * asserts on afterwards. The tree is released before returning so it cannot * outlive the helper, and the result is still returned intact. * * The `finally` is load-bearing: `seedBinaryUpdate` and friends assert while * they build, so a failure mid-construction must still release the tree. */ export function withTempTree(prefix: string, build: (path: string) => T): T { const path = mkTempTree(prefix) try { return build(path) } finally { owned.delete(path) rmSync(path, { recursive: true, force: true }) } } /** * Register the per-test sweep. Call once at the top level of EVERY deploy test * file — see the note above on why this cannot be made idempotent. * * Three hooks, because they fail differently: * - `afterEach` is the normal path: it runs after every test, including one * that threw, and so covers the per-test leak. * - `afterAll` catches the case `afterEach` cannot — the file threw during * COLLECTION, before any test body ran, so no `afterEach` was ever reached * and every tree that file built stayed on disk. * - `process.on('exit')` is the backstop for a run that dies outside the test * lifecycle entirely (a kill, an unhandled error between files). It is `once` * because one sweep at exit is enough. * * WHY `afterAll` WAS ADDED, AND WHY IT MATTERS MORE THAN IT LOOKS. Measured on * bun 1.4.2 while writing the app-env suites (task #1112): under `bun test`, * `process.on('exit')` handlers DO NOT RUN — not at module scope, not from a test * body. A probe that only sets `process.exitCode = 7` in an exit handler exits 0, * while the identical handler registered through `afterAll` exits 7. So this * function's exit-time sweep — and `guardRealStateDir`'s tripwire, which used the * same mechanism — was unreachable in every `bun test` run that had ever * reported them as installed. `afterAll` is the hook bun's runner actually calls; * `process.on('exit')` is kept only for a plain `bun run ` invocation. */ export function installCleanup(): void { afterEach(() => { sweepTempTrees() }) afterAll(() => { sweepTempTrees() }) process.once('exit', () => { sweepTempTrees() }) } // ── Operator state-dir containment (task #1104) ────────────────────────────── // THE REAL INCIDENT, AND WHY `HOME` IS NOT A FIX. // // On 2026-10-01 a deploy fixture overwrote /home/opencode/.synaptomind/scripts/ // app.env — the file the whole delivery mechanism reads — with INSTALL_DIR // pointing into the fixture's own deleted /tmp tree, PORT=3999, and // HEALTH_URL=http://127.0.0.1:1/health. It was caught by hand minutes before a // production cutover, by no test at all. // // The obvious remedy is WRONG, and understanding why is the whole point. // resolve_target_user() (deploy/lib/common.sh:142-164) resolves TARGET_HOME via // `getent passwd "$TARGET_USER"` FIRST and falls back to ${HOME} only when // getent is missing or silent. So substituting HOME in a fixture does NOT // isolate anything: on any host with getent installed — which is every host — // RUN_DIR still resolves to the target user's REAL ~/.synaptomind, and // install_helper_scripts()/install_hook_scripts() copy app.env and the helper // scripts straight over the operator's own files. Measured on this host with // HOME pointed at an empty scratch dir: the run reported // `State: /home/opencode/.synaptomind` and wrote a non-identical app.env there. // // WHY THIS IS ENFORCEMENT AND NOT A CONVENTION. Option (a) — "every fixture // must remember to set RUN_DIR" — is precisely what failed: seedBinaryTree set // it, three other paths did not, and the omission was invisible until it cost a // production file. A rule a fixture can forget is not a fix. So the SAFE DEFAULT // moves into the constructor: `isolatedEnv()` below is the ONLY sanctioned way // to build the environment for a spawn of install.sh / update.sh / updater.sh / // uninstall.sh, and it pins RUN_DIR (plus the state-derived HOOKS_DIR, UNIT_FILE // and DATA_DIR) inside the fixture's own tree. A fixture that forgets cannot // reach the operator's state dir, because it never gets to choose RUN_DIR. // // And because "the constructor is used everywhere" is itself a claim, the // end-of-run tripwire below verifies it from the OUTSIDE: it fingerprints the // real state dir and fails the run if it moved. That is the property that would // have caught #1101, and it holds even for a fixture that bypasses the // constructor entirely. /** * The operator's REAL state directory, resolved the way `resolve_target_user` * resolves it — from the passwd database, not from $HOME. * * `getent passwd $(id -un)` is exactly the lookup that defeats a substituted * HOME, so using anything else here would be checking the wrong directory and * the tripwire would be the second vacuous assertion in this file's history. * Returns null when the host has no state dir at all (a fresh container), which * is a legitimate "nothing to protect" rather than a failure. */ export function realStateDir(): string | null { const probe = spawnSync('getent', ['passwd', String(process.getuid?.() ?? '')], { encoding: 'utf8' }) // getent keyed by uid is the portable form; fall back to the username. const passwd = probe.stdout.trim() || lookupByName() const home = passwd.split(':')[5] if (!home) return null const dir = join(home, '.synaptomind') return existsSync(dir) ? dir : null } function lookupByName(): string { const res = spawnSync('getent', ['passwd', process.env.USER ?? ''], { encoding: 'utf8' }) return res.stdout.trim() } /** * A content fingerprint of a state dir: every path with its size, mtime and * sha256. Both halves are deliberate — a fixture that rewrites app.env with * identical bytes would still move the mtime, and one that appends a comment * keeps the size. A listing alone would miss a rewrite; a hash alone would miss * a pure touch. */ export function fingerprint(dir: string): string { const hash = createHash('sha256') const walk = (rel: string) => { const abs = join(dir, rel) for (const entry of readdirSync(abs, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1 )) { const childRel = rel ? `${rel}/${entry.name}` : entry.name if (entry.isDirectory()) { walk(childRel) continue } const st = statSync(join(dir, childRel)) hash.update(`${childRel} ${st.size} ${st.mtimeMs} `) if (st.isFile()) hash.update(readFileSync(join(dir, childRel))) } } walk('') return hash.digest('hex') } /** * Build the environment for a spawn of a deploy SCRIPT (install.sh, update.sh, * updater.sh, uninstall.sh) inside `root`. * * This is the safe default made structural: RUN_DIR is pinned inside `root`, so * the getent fallback in resolve_target_user() never becomes reachable, because * RUN_DIR is already non-empty and line 163 only derives a default when it is * not. `extra` may override anything — that is deliberate, so a test can still * exercise a pathological RUN_DIR on purpose — but the value is checked FIRST, * so an override that points back at the operator's real state dir fails the * fixture at BUILD time instead of destroying the file it aimed at. */ export function isolatedEnv( root: string, extra: Record = {} ): Record { const runDir = join(root, 'run') const env: Record = { ...(process.env as Record), RUN_DIR: runDir, // install.sh:616 derives the hook dir from RUN_DIR, and these two are the // other state-derived paths that would otherwise follow the real home. HOOKS_DIR: join(runDir, 'hooks'), UNIT_FILE: join(root, 'unit', 'synaptomind.service'), DATA_DIR: join(root, 'data'), ...extra } assertInsideRoot(env.RUN_DIR, root, 'RUN_DIR') assertInsideRoot(env.HOOKS_DIR, root, 'HOOKS_DIR') assertInsideRoot(env.UNIT_FILE, root, 'UNIT_FILE') assertInsideRoot(env.DATA_DIR, root, 'DATA_DIR') return env as Record } function assertInsideRoot(value: string, root: string, key: string): void { const real = realStateDir() // The check that matters is against the operator's state dir specifically. // A fixture legitimately writes elsewhere in /tmp; it must never write THERE. if (real && (value === real || value.startsWith(`${real}/`))) { throw new Error( `isolatedEnv: ${key}=${value} points at the operator's REAL state dir ${real}. ` + `A fixture must never target it — put the path inside the fixture root (${root}).` ) } } /** * Fingerprint the operator's real state dir now and fail if it differs later. * * Call ONCE at the top level of a deploy test file, beside installCleanup(). * The fingerprint is taken at import time — before any fixture has run — and * compared when the FILE's tests are done, because the damage a fixture does is * not visible in any assertion the fixture itself makes: the fixture asserts on * its own scratch tree, and the operator's file is somewhere else entirely. * That asymmetry is precisely why #1101 produced a green suite over a * destroyed production config. * * The comparison runs in `afterAll`, NOT only in `process.on('exit')`. Measured on * bun 1.4.2 (task #1112): `bun test` does not run `process.on('exit')` handlers, * so the exit-only version of this tripwire could never fail a run — it was * documentation of an intent rather than a check. Proven by a deliberate * overwrite of a watched production path in a throwaway suite: with the exit hook * alone the run reported `1 pass, 0 fail` and exited 0. * * Skipped (with the reason recorded) when the host has no state dir, so a fresh * container reports `skip` rather than a vacuous pass. */ export function guardRealStateDir(): void { const dir = realStateDir() if (!dir) { guarded = { dir: null, hash: null } return } const hash = fingerprint(dir) guarded = { dir, hash } const verify = () => { if (!guarded?.dir || !guarded.hash) return let after: string try { after = fingerprint(guarded.dir) } catch (err) { report(`${guarded.dir} became unreadable during the run: ${(err as Error).message}`) return } if (after !== guarded.hash) report(`${guarded.dir} was MODIFIED by a deploy fixture`) } // afterAll is the hook bun's test runner actually calls; process.on('exit') is // unreachable under `bun test` (measured) and is kept only for `bun run `. afterAll(verify) process.on('exit', verify) } let guarded: { dir: string | null; hash: string | null } | null = null function report(message: string): void { process.stderr.write( `\n!!! DEPLOY STATE-DIR VIOLATION: ${message}\n` + ` A deploy fixture wrote outside its own temp tree. This is the #1101\n` + ` failure mode: the operator's app.env is the file the whole delivery\n` + ` mechanism reads. Use isolatedEnv() from ./tmp-fixtures for every spawn\n` + ` of a deploy script.\n\n` ) // A non-zero exit is what turns this into a FAILED run rather than a warning // someone scrolls past; the suite's own result stays visible either way. process.exitCode = 1 } /** Exported for the ownership tests: does a path still exist? */ export { existsSync }