/** * Where the framework suite finds the apps it has to prove itself against. * * WHY THIS EXISTS. The deploy/ framework is vendored: synaptomind is its home * repo and its test suite, and ziptask (#1110) and subagentix (#1111) each got a * `deploy/` COPY plus their own `deploy/app.env`. Neither vendored the suite — * deliberately, because those suites are hardwired to `APP="synaptomind"`, to * synaptomind's raw URL and to the `~/.synaptomind` tripwire, and dropping them * into those repos would turn a 400-test `bun test` into a ~2000-test one that * fails for reasons that have nothing to do with the app under test. * * So the conformance proof has to travel the other way: from the framework's * home suite to the OTHER repos' real app.env files. `DEPLOY_APP_ENVS` is how it * says where they are: * * DEPLOY_APP_ENVS='subagentix=/path/to/subagentix/deploy,ziptask=/path/to/ziptask/deploy' \ * bun test ./deploy/app-env-conformance.test.ts * * Unset (the default, and therefore what CI sees) means "no target": every * app-env describe reports SKIPPED, and the suite's own result is unchanged. That * is deliberate — an absolute path hardwired into the repo would either be wrong * on every other machine or, worse, resolve to some other checkout and pass * against the wrong app. * * WHY A MISSING TARGET IS A FAILURE AND NOT A SKIP. A skip is honest about "this * environment has nothing to check" and dishonest about "I named a path and it * was wrong". `unresolvedTargets()` keeps the two apart: the app-env suites are * gated on RESOLVED targets, and `checkCoverage()` — which always runs — turns * every requested-but-unresolved path into a failed assertion. A typo in * DEPLOY_APP_ENVS cannot come back green. * * WHY OVERRIDES ARE APPLIED TO THE FILE, NOT THE ENVIRONMENT. `load_app_env()` * SOURCES app.env, so a plain assignment there WINS over the process * environment — `RUN_DIR=""` in ziptask's app.env resets whatever the caller set. * Both new app.envs carry `RUN_DIR=""` (meaning "${HOME}/.app"), and * `resolve_target_user()` then falls back to `getent passwd`, i.e. the operator's * REAL ~/.ziptask / ~/.subagentix. That is precisely the #1101 incident (a * fixture overwrote the live app.env). So the sandbox lives in the app.env COPY: * every path the scripts write to is redirected into the fixture root here, and * `assertSandboxed` refuses to emit an override that points anywhere else. The * `guardRealStateDir()` tripwire in each suite verifies from the outside that * the real state dir did not move. */ import { afterAll } from 'bun:test' import { readAppEnvValues } from './app-env-targets' import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { isAbsolute, join } from 'node:path' /** One app whose real `deploy/app.env` a sandboxed install will be run against. */ export interface AppTarget { /** Short label from DEPLOY_APP_ENVS (e.g. "subagentix"). */ label: string /** Absolute path of the app's deploy/ directory. */ deployDir: string /** The app.env this target means. Always `/app.env`. */ appEnvPath: string } export interface TargetResolution { /** Targets that exist and carry both app.env and install.sh, in request order. */ resolved: AppTarget[] /** Every label named in DEPLOY_APP_ENVS, resolved or not. */ requested: string[] /** `label=path` entries that did not resolve, with the reason. */ unresolved: string[] } /** The raw DEPLOY_APP_ENVS value; '' when unset or empty. */ function appEnvsSpec(): string { return process.env.DEPLOY_APP_ENVS?.trim() ?? '' } /** * A target is only usable when it carries BOTH app.env and install.sh. * * install.sh is what a sandboxed install actually runs; app.env is what the * point of the exercise is. A deploy/ directory missing either is a partial * vendoring (which is a real state these repos have been in), and treating it as * a target would make the suite assert against a DEFAULTED app.env and pass — * the vacuous pass this file exists to prevent. */ function probe(label: string, dir: string): AppTarget | string { if (!isAbsolute(dir)) return `${label}=${dir} (not an absolute path)` if (!existsSync(join(dir, 'app.env'))) return `${label}=${dir} (no app.env)` if (!existsSync(join(dir, 'install.sh'))) return `${label}=${dir} (no install.sh)` return { label, deployDir: dir, appEnvPath: join(dir, 'app.env') } } /** * Parse DEPLOY_APP_ENVS into targets. Malformed entries become `unresolved` * rather than being dropped, so they surface through checkCoverage(). */ export function resolveTargets(): TargetResolution { const out: TargetResolution = { resolved: [], requested: [], unresolved: [] } const spec = appEnvsSpec() if (!spec) return out for (const raw of spec.split(',')) { const entry = raw.trim() if (!entry) continue const eq = entry.indexOf('=') if (eq <= 0) { out.unresolved.push(`${entry} (expected label=/absolute/path/to/deploy)`) continue } const label = entry.slice(0, eq).trim() const dir = entry.slice(eq + 1).trim() out.requested.push(label) const found = probe(label, dir) if (typeof found === 'string') out.unresolved.push(found) else out.resolved.push(found) } return out } /** * The always-on assertion that keeps an env-var-driven suite honest. * * Returns the labels that were actually exercised. Throws (i.e. fails the test) * when DEPLOY_APP_ENVS named something that did not resolve: a suite that was * ASKED to prove an app and quietly proved none is the failure mode an opt-in * cross-repo suite is most exposed to. */ export function checkCoverage(resolution: TargetResolution, exercised: string[]): string[] { const missing = resolution.requested.filter(label => !exercised.includes(label)) if (missing.length > 0) { throw new Error( `DEPLOY_APP_ENVS named ${missing.join(', ')} but no test exercised it.\n` + ` requested: ${resolution.requested.join(', ') || '(none)'}\n` + ` unresolved: ${resolution.unresolved.join('; ') || '(none)'}\n` + ` exercised: ${exercised.join(', ') || '(none)'}\n` + ` A requested target that resolves to nothing would leave this suite green\n` + ` while proving nothing about that app.` ) } const missingFiles = resolution.unresolved.filter(e => /no app\.env|no install\.sh/.test(e)) if (missingFiles.length > 0) { throw new Error( `DEPLOY_APP_ENVS pointed at an incomplete deploy/ directory: ${missingFiles.join('; ')}` ) } return exercised } // App.env parsing and sandbox helpers live in ./app-env-targets. // ── Production-path containment ────────────────────────────────────────────── /** * Fingerprint the HOST paths a sandboxed install would write if it escaped, and * fail the process if any of them moved. * * WHY NOT JUST ASSERT IN THE REPORT. Every test in these two suites already * redirects INSTALL_DIR / DATA_DIR / RUN_DIR into a temp tree, and `bun test` * passing is not evidence that the redirection held: #1101 was a green suite that * had overwritten the operator's live `app.env` (`deploy/tmp-fixtures.ts` records * the incident and the reasoning). The failure was invisible from inside because * a fixture asserts on ITS OWN scratch tree while the damage is somewhere else * entirely. So containment has to be checked from the outside, at exit. * * WHY THIS LIST AND NOT `guardRealStateDir()`. That helper guards `~/.synaptomind`, * which is this repo's app — correct for the framework suite and useless for * ziptask and subagentix, whose installs would land in `~/.ziptask` and * `~/.subagentix`. The list itself is built by productionWatchPaths() below, and * it is exactly the paths RUN_DIR, INSTALL_DIR, DATA_DIR and HOOKS_DIR resolve to * on this host for every resolved target, plus the host service tree. * * WHAT IS DELIBERATELY EXCLUDED, AND WHY IT IS NOT A CONSTANT. A path that a * LIVE, out-of-sandbox service legitimately writes cannot be fingerprinted: it * moves under the suite for reasons that have nothing to do with any fixture. * The first version of this file already knew that and excluded one such path * (`~/.ziptask/data`) by name, because the tracker these suites report through IS * the live ziptask service and its WAL changes on every task update. That * exclusion became wrong rather than useless when #1123/#1124 moved ziptask's * config home to DATA_DIR (ADR addendum R1): the database and settings.json now * live in /var/lib/ziptask, so the path that moves on every task update is * /var/lib/ziptask itself — the very entry this list watches. Measured, not * argued: a run with `DEPLOY_APP_ENVS=…ziptask…` reported * `DEPLOY SANDBOX VIOLATION: /var/lib/ziptask was MODIFIED by a deploy fixture` * over 52 passing tests, with the only writer the live service persisting the * agent's own tracker bookkeeping. * * So the exclusion is DERIVED (`liveServiceDataDirs`) rather than hardcoded: a * target's DATA_DIR is dropped from the watch list when that app's unit is * actually running on this host. Hardcoding a path would have been a second * #1124 waiting to happen — the next ADR that moves a config home would leave * the exclusion pointing at an empty directory and the false positive would come * back under a new name. * * WHAT THE GUARD KEEPS WATCHING, which is the half that matters: /opt/, the * state dir and its scripts/ and hooks/ (the paths install_helper_scripts and * install_hook_scripts write), the unit file, and the host's own deploy/app.env — * the file #1101 overwrote. Those are the paths a fixture could escape to, and * none of them is written by a running service. * * THE FAILURE DIRECTION. When a target's unit cannot be found (no systemctl, a * CI container) or its app.env cannot be sourced, the path stays WATCHED. The * blind case is a stopped unit and a false alarm; the loud case is a #1101. That * asymmetry is the whole design, so it is worth stating rather than leaving to a * reader who assumes the exclusion is unconditional. */ export function guardProductionPaths(targets: readonly AppTarget[]): void { const watched = productionWatchPaths(targets) const before = new Map() for (const path of watched) before.set(path, fingerprintOrNull(path)) const verify = () => { for (const [path, hash] of before) { const after = fingerprintOrNull(path) if (after !== hash) { process.stderr.write( `\n!!! DEPLOY SANDBOX VIOLATION: ${path} was ${after === null ? 'CREATED' : 'MODIFIED'} by a deploy fixture.\n` + ` This is the #1101 failure mode: an install escaped its temp tree and wrote\n` + ` to the host's production path. The suite's own assertions cannot see it.\n\n` ) process.exitCode = 1 } } } // `afterAll`, not `process.on('exit')`: measured on bun 1.4.2, `bun test` never // runs an exit handler, so an exit-only containment check is decoration — it // cannot fail a run. See the note on tmp-fixtures.installCleanup, which was // written the same way and had the same dead tripwire. afterAll(verify) process.on('exit', verify) } function homeDir(): string { const home = spawnSync('bash', ['-c', 'getent passwd "$(id -un)" | cut -d: -f6'], { encoding: 'utf8', timeout: 30_000 }) return home.stdout?.trim() || process.env.HOME || '/root' } /** * Is this app's unit actually running on this host? * * `systemctl is-active --quiet` answers through its exit status (0 active), which * is the interface a `--no-service`-style caller branches on. A missing systemctl * answers false, and false means "nothing is writing there", so the watch list * stays armed — the loud direction, deliberately (see the header). */ function unitIsLive(unit: string): boolean { const res = spawnSync('systemctl', ['is-active', '--quiet', unit], { encoding: 'utf8', timeout: 10_000 }) if (res.error) return false return res.status === 0 } /** * The DATA_DIR of every target whose service is live here — the directories a * running process owns and writes, which therefore cannot be tripwires. * * Read from the target's REAL app.env rather than assumed to be `/var/lib/