deploy/tmp-fixtures.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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<string>()
/** 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<T>(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 <file>` 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<string, string> = {}
): Record<string, string> {
const runDir = join(root, 'run')
const env: Record<string, string> = {
...(process.env as Record<string, string | undefined>),
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<string, string>
}
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 <file>`.
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 }