@celilo/cli 1.13.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +31 -5
  3. package/README.md +0 -2
  4. package/drizzle/0030_drop_module_builds_environment.sql +8 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/public-web-helpers.test.ts +12 -6
  8. package/src/capabilities/public-web-publish.test.ts +42 -13
  9. package/src/capabilities/validation.test.ts +31 -0
  10. package/src/cli/commands/alerts-sweep.ts +3 -0
  11. package/src/cli/commands/console-get-chain.test.ts +96 -0
  12. package/src/cli/commands/console.ts +13 -5
  13. package/src/cli/commands/monitor.ts +15 -2
  14. package/src/cli/commands/notify-config.test.ts +79 -0
  15. package/src/cli/commands/notify-config.ts +13 -2
  16. package/src/cli/commands/system-doctor.test.ts +121 -1
  17. package/src/cli/commands/system-doctor.ts +151 -1
  18. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  19. package/src/cli/completion.ts +10 -2
  20. package/src/cli/index.ts +7 -1
  21. package/src/console/closure.test.ts +76 -0
  22. package/src/console/closure.ts +87 -1
  23. package/src/console/control-plane-boundary.test.ts +82 -4
  24. package/src/console/projection.test.ts +63 -1
  25. package/src/console/projection.ts +39 -2
  26. package/src/db/schema.ts +0 -1
  27. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  28. package/src/hooks/capability-loader.ts +81 -10
  29. package/src/hooks/executor.ts +110 -17
  30. package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
  31. package/src/hooks/hook-jail-unreachability.test.ts +28 -2
  32. package/src/hooks/hook-protocol.ts +44 -0
  33. package/src/hooks/hook-runner-entry.ts +23 -0
  34. package/src/hooks/hook-runner.ts +10 -0
  35. package/src/hooks/hook-trespass.test.ts +9 -3
  36. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  37. package/src/hooks/jail.test.ts +92 -0
  38. package/src/hooks/jail.ts +128 -11
  39. package/src/hooks/mount-set.test.ts +28 -6
  40. package/src/hooks/mount-set.ts +34 -20
  41. package/src/hooks/remote-broker.test.ts +350 -0
  42. package/src/hooks/remote-broker.ts +404 -0
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  45. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  46. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  47. package/src/hooks/unjailed-lint.test.ts +251 -0
  48. package/src/hooks/unjailed-lint.ts +395 -0
  49. package/src/manifest/contracts/v1.ts +22 -1
  50. package/src/manifest/validate.ts +25 -4
  51. package/src/module/web-root.ts +35 -0
  52. package/src/policy/module-business-baseline.ts +27 -3
  53. package/src/policy/module-script-scan.test.ts +22 -0
  54. package/src/policy/module-script-scan.ts +92 -1
  55. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  56. package/src/policy/no-module-business-in-core.test.ts +1 -1
  57. package/src/services/alerting/hook-jail.test.ts +66 -0
  58. package/src/services/alerting/hook-jail.ts +70 -0
  59. package/src/services/alerting/run-monitor.test.ts +62 -0
  60. package/src/services/alerting/run-monitor.ts +12 -0
  61. package/src/services/alerting/sweep-runner.test.ts +1 -0
  62. package/src/services/api-principal-enrolment.test.ts +73 -0
  63. package/src/services/api-principal-enrolment.ts +55 -0
  64. package/src/services/backup-create.ts +36 -7
  65. package/src/services/backup-restore.ts +2 -0
  66. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  67. package/src/services/deploy-ansible.ts +9 -1
  68. package/src/services/fleet-key.test.ts +47 -0
  69. package/src/services/fleet-key.ts +75 -0
  70. package/src/services/health-runner.ts +2 -0
  71. package/src/services/module-build.test.ts +1 -64
  72. package/src/services/module-build.ts +10 -86
  73. package/src/services/module-deploy.ts +20 -0
  74. package/src/services/remote-access.test.ts +139 -0
  75. package/src/services/remote-access.ts +98 -0
  76. package/src/services/restore-from-file.ts +12 -6
  77. package/src/services/static-content-converge.test.ts +338 -0
  78. package/src/services/static-content-converge.ts +299 -0
  79. package/src/services/system-state-stage.test.ts +165 -0
  80. package/src/services/system-state-stage.ts +196 -0
@@ -59,7 +59,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
59
59
  - **celilo-apt-repo** — Debian apt repository (reprepro + Bun HTTP server) serving the celilo `.deb` at apt.celilo.computer. **provides:** `apt_publish`. **requires:** `public_web`, `dns_registrar`.
60
60
  - **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public) and **`--receive-mode=manual`**, which is load-bearing: signal-cli's default (`on-start`) leaves the daemon permanently receiving, so it drains every reply into an SSE stream nothing is attached to and REFUSES celilo's `receive` call — replies arrive and are unreadable, while `send` and every health check keep passing. Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:** no capabilities — a transport that depended on the proxy, registrar or firewall could not tell you those were broken — and a system in the **`secure-mgmt`** zone: it holds a linked Signal account (the operator's own messaging identity and keys), and its job is to observe every tier while depending on none, which is what the control-plane zone is for. See `openspec/changes/add-alerting/`.
61
61
  - **celilo-website** — public docs site (static Astro) served via Caddy on celilo.computer. **requires:** `public_web`, `dns_registrar`.
62
- - **celilo-web-console** — the read-mostly operator console: the fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. **Not yet deployed** — the manifest, the SPA, the console server and the read verbs exist; the `control_plane_api` enrolment and the e2e suite do not. It holds **no database handle**: every fact it displays arrives over the SSH remote API as a principal whose grants derive from the `COMMANDS` registry's read-only classifier, because an in-process console would hold the whole database including the encrypted secret store and be constrained only by its own code. **⚠️ `secure-mgmt`, and the placement is a security boundary rather than a preference:** celilo reaches into every data-plane zone by trust and no data-plane zone reaches back, so the ONLY client path in is the control-plane VPN. It registers no web route, requests no port forward, takes no `natIp` or ingress address, and gets no DNS record resolvable from a data-plane zone. `apps/celilo/src/console/control-plane-boundary.test.ts` asserts that on every pull request rather than only in e2e, because the way it erodes is a one-line manifest edit adding `private_web` so someone can reach it without bringing up the VPN. It offers no deploy, uninstall, pause, restore or config control: those can raise an interview question, a browser has no responder, and they are ABSENT rather than disabled. **provides:** nothing. **requires:** `idp`, `control_plane_api`. See `openspec/changes/web-ui-console/`.
62
+ - **celilo-web-console** — the read-mostly operator console: the fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. **Deployable, not yet servable.** The manifest, the SPA, the console server, the read verbs, the `control_plane_api` capability, the `on_install` that enrols the principal, and a full deployable body (`terraform/`, `ansible/` with a role, config variables, and a `build:` step compiling a per-architecture bun binary plus the SPA) all exist. What does not exist is an OIDC client: the console refuses to serve without an identity provider, and nothing creates one yet, so a deployed unit starts, fails its start limit and sits in `failed` with the reason in its journal. That is deliberate rather than hidden — leaving the service disabled would make an unfinished module look like a clean converge. The e2e suite does not exist either. It holds **no database handle**: every fact it displays arrives over the SSH remote API as a principal whose grants derive from the `COMMANDS` registry's read-only classifier, because an in-process console would hold the whole database including the encrypted secret store and be constrained only by its own code. **⚠️ `secure-mgmt`, and the placement is a security boundary rather than a preference:** celilo reaches into every data-plane zone by trust and no data-plane zone reaches back, so the ONLY client path in is the control-plane VPN. It registers no web route, requests no port forward, takes no `natIp` or ingress address, and gets no DNS record resolvable from a data-plane zone. `apps/celilo/src/console/control-plane-boundary.test.ts` asserts that on every pull request rather than only in e2e, because the way it erodes is a one-line manifest edit adding `private_web` so someone can reach it without bringing up the VPN. It offers no deploy, uninstall, pause, restore or config control: those can raise an interview question, a browser has no responder, and they are ABSENT rather than disabled. **provides:** nothing. **requires:** `idp`, `control_plane_api`. See `openspec/changes/web-ui-console/`.
63
63
  - **celilo-canary** — a deliberately minimal nginx serving one static page in **`dmz`**, permanently deployed, whose health check IS the assertion that the deploy path still works. Nothing about it is interesting except that it is *always there*: modules already deployed keep running when the pipeline breaks, so without a canary a regression in IPAM allocation, Terraform provisioning, Ansible convergence or capability wiring stays invisible until the next real deploy — which is exactly when nobody wants to discover it. One deploy exercises all four, plus a live cross-module capability call. **Fleet-only, and the absence is the design**: it registers one route through `private_web` and requires nothing that reaches the perimeter, so there is no public record, no ACME certificate and no port forward. Its `health_check` probes nginx over systemd and its `/healthz` endpoint with **`probeHttp`, from the management server** rather than by SSHing in and running `curl` — the production `ubuntu-22.04-standard` LXC ships no curl, so the old form could not tell a missing binary from a dead service, and reaching the canary at its own address additionally catches a service bound only to loopback. **requires:** `private_web`.
64
64
 
65
65
  ## Git forge & CI pipeline
@@ -93,6 +93,26 @@ content module targets either with
93
93
  `capabilities.external_web ?? capabilities.public_web`. Both are
94
94
  **authoritative**: a publish that doesn't reach clients throws (#328).
95
95
 
96
+ - **Static-content converge (`/srv/www`, design D10)** —
97
+ `apps/celilo/src/services/static-content-converge.ts` (pure
98
+ `planStaticContent`/`buildStaticContentPlan` + `writeStaticContentVars` + the
99
+ `executeAnsible` caller `convergeStaticContent`). The declared `web_routes`
100
+ release set (slug, content hash, hostnames, the source dir core derives from
101
+ `modules.source_path`) is the desired state; the provider's Ansible converge
102
+ makes `/srv/www` match: content-hashed release dirs `/srv/www/<slug>-<hash>`,
103
+ an atomic symlink swap of `/srv/www/<slug>`, pruning beyond
104
+ `static_release_retention` (caddy manifest variable, operator config with
105
+ default 5 — the role never carries the number). Two callers, one plan: the
106
+ `public_web` capability's `convergeStaticContent` callback (tag-scoped run
107
+ through `executeAnsible`, so a publish returns only once the host matches)
108
+ and the provider's own deploy (vars written pre-Ansible in
109
+ `module-deploy.ts`, so a rebuilt host recovers with no consumer). The
110
+ content hash lands on EVERY route row sharing the slug. Replaced the
111
+ hand-built ssh tar pipe in `upload_static_assets` (celilo#1014); the
112
+ no-hand-built-SSH gate now scans `@celilo/capabilities` everywhere except
113
+ its primitive file (`module-script-scan.ts`, `scanCapabilityPackageSource`).
114
+ e2e: `modules/caddy/e2e/static-content-converge.test.ts`.
115
+
96
116
  ## Remote-ops primitives (the SSH seam — modules never hand-build SSH)
97
117
 
98
118
  Module hooks reach a remote box ONLY through these typed primitives
@@ -180,8 +200,10 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
180
200
  ## Hooks & deploy
181
201
 
182
202
  - **Hook executor / ABI** — `apps/celilo/src/hooks/executor.ts` (`invokeHook`, `executeHookScript`, `checkRequiredCapabilities`), types in `apps/celilo/src/hooks/types.ts` (`HookContext`, `HookDefinition`, `HookName`). Named-hook runner: `apps/celilo/src/hooks/run-named-hook.ts`. Manifest hook config: `apps/celilo/src/hooks/load-hook-config.ts`.
183
- - **Hook process boundary (a hook is a program celilo RUNS)** — `apps/celilo/src/hooks/hook-protocol.ts` (the NDJSON frame union, `HOOK_PROTOCOL_VERSION`, `serializeError`/`deserializeError`), `apps/celilo/src/hooks/broker.ts` (`startBroker`, `capabilityShape`), `apps/celilo/src/hooks/hook-runner.ts` (the spawned shim — the ONLY thing that `import()`s module code). `executeHookScript` spawns `bun hook-runner.ts` over a Unix socket instead of importing; the nine `invokeHook` call sites and `defineHook` are unchanged. **The broker does not know what a capability is**: it sends a shape descriptor built by the same own-string-key walk `wrapWithLogging` does (functions → `methods`, everything else → `data`, which is where `stampProvider`'s `providerModuleId` lives), and the shim rebuilds forwarding proxies from it — so an optional method a provider did not implement is absent rather than present-and-throwing, and `if (cap.registerTrustedSource)` keeps answering correctly. A socket rather than stdout because module scripts spawn subprocesses and a grandchild writing to fd 1 would corrupt the frame stream. Two consequences worth knowing: the child's environment is an **allow-list** (`hookChildEnv` — `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`, `CELILO_HOOK_*`, `CELILO_DEBUG`), so a hook reading any other operator variable now gets `undefined`; and a timeout is a real SIGTERM-then-SIGKILL with the broker refusing further capability calls, replacing a `Promise.race` that cancelled nothing and let a "timed out" hook go on writing DNS and firewall state (celilo#1003). Capability PROVIDER factories still load in-process — they ARE the broker's implementation. Stages 1 and 2 of `openspec/changes/hook-process-boundary`; the filesystem is claimed by the jail below, the network is not claimed at all (stage 3, D12).
184
- - **Hook jail (a hook sees the paths it was given, and nothing else)** — `apps/celilo/src/hooks/mount-set.ts` (`deriveMountSet`, `toBwrapArgs`, `forbiddenPaths` — PURE, computes a filesystem view and touches nothing) and `apps/celilo/src/hooks/jail.ts` (`detectJailBackend`, `planJailedSpawn`, `realpathRequest`, `runtimeModulePathsFor`, `recordJailMode` — the half that touches the machine). `executeHookScript` spawns the shim under `bwrap` with the module's tree read-only, `state/` + `generated/` + this run's `screenshots/<run>` read-write on top of it, each contract-declared path input at its declared access, the broker's socket directory, the runtime and the `node_modules` the shim resolves through, `~/.ssh` read-only (stage 2 only — stage 3 drops it, D12), and `/tmp` a fresh tmpfs FIRST so it cannot erase the socket or a staged input. **The acceptance criterion is absence, not a check**: the module store and the data directory are simply not bound, so `master.key` and a sibling module give `ENOENT`. The set is DERIVED — a module cannot ask for more — and `bwrap` itself is never in it, because the AppArmor profile grants `userns` to `/usr/bin/bwrap` for anyone on the box (design D9). Backend detection RUNS bubblewrap rather than looking for it (four different denials all leave the binary in place), and the resulting mode is written to `hook-jail-mode.json` beside celilo's other per-machine state rather than logged, so a host that stops jailing is readable. `CELILO_HOOK_JAIL` = `auto` (default) / `required` / `off`. No backend on macOS yet (task 4.8), and the hook then runs unjailed with the mode recorded.
203
+ - **Hook process boundary (a hook is a program celilo RUNS)** — `apps/celilo/src/hooks/hook-protocol.ts` (the NDJSON frame union, `HOOK_PROTOCOL_VERSION`, `serializeError`/`deserializeError`), `apps/celilo/src/hooks/broker.ts` (`startBroker`, `capabilityShape`), `apps/celilo/src/hooks/hook-runner-entry.ts` + `apps/celilo/src/hooks/hook-runner.ts` (the spawned shim — the ONLY thing that `import()`s module code; the entry exists to install the advisory lint before the shim's import graph can ESM-load `node:fs`, see `unjailed-lint.ts`). `executeHookScript` spawns `bun hook-runner-entry.ts` over a Unix socket instead of importing; the nine `invokeHook` call sites and `defineHook` are unchanged. **The broker does not know what a capability is**: it sends a shape descriptor built by the same own-string-key walk `wrapWithLogging` does (functions → `methods`, everything else → `data`, which is where `stampProvider`'s `providerModuleId` lives), and the shim rebuilds forwarding proxies from it — so an optional method a provider did not implement is absent rather than present-and-throwing, and `if (cap.registerTrustedSource)` keeps answering correctly. A socket rather than stdout because module scripts spawn subprocesses and a grandchild writing to fd 1 would corrupt the frame stream. Two consequences worth knowing: the child's environment is an **allow-list** (`hookChildEnv` — how to run: `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`; whom to trust: `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `SSL_CERT_DIR`; the proxy variables; the `CELILO_HOOK_*` channels; `CELILO_DEBUG` — `FORWARDED_ENV` in executor.ts is the source of truth), so a hook reading any other operator variable now gets `undefined`; and a timeout is a real SIGTERM-then-SIGKILL with the broker refusing further capability calls, replacing a `Promise.race` that cancelled nothing and let a "timed out" hook go on writing DNS and firewall state (celilo#1003). Capability PROVIDER factories still load in-process — they ARE the broker's implementation. Stages 1 and 2 of `openspec/changes/hook-process-boundary`; the filesystem is claimed by the jail below, and SSH reachability by the remote-ops broker next.
204
+ - **Unjailed advisory lint (task 4.7, NOT a security boundary)** — `apps/celilo/src/hooks/unjailed-lint.ts`. Where there is no jail backend the executor passes the run's derived mount set to the shim in the environment (`CELILO_HOOK_MOUNT_SET`), and the shim wraps the path-taking `node:fs` / `node:fs/promises` functions so an access outside the set (or a write to a read-only row) warns through the hook's own logger: "on a jailed host this would fail", and that it is advisory. It is the mount-set derivation's SECOND consumer, so it cannot drift from what the jail enforces. It observes JS-level `node:fs` calls only — module code bypasses it trivially — and it must never be described as a boundary, in code or output.
205
+ - **Remote-ops broker (reachability scoped by the credential, stage 3 / D12)** — `apps/celilo/src/hooks/remote-broker.ts` (`startRemoteBroker`, `RemoteAccessPolicy`) answering a SECOND socket beside the capability one, with the policy in `apps/celilo/src/services/remote-access.ts` (`remoteAccessPolicy`) and the asking half inside `@celilo/capabilities`' own remote primitives (`packages/capabilities/src/remote.ts`, "The hook remote-ops bridge"). The jail binds no `~/.ssh`, so a hand-built `ssh` cannot authenticate; the primitives detect `CELILO_HOOK_REMOTE_SOCKET`, send each operation as a STRUCTURED request (never a shell string — the broker rebuilds the ssh line itself), and the broker checks the target against `ownedSystemModuleIds` + `getModuleSystems` before running anything, refusing with the module, the target, and the capability route named. Requests carrying the module's OWN credential (an `identityFile` crossing as content and materialised per call, or `installAuthorizedKey`'s password — the cPanel case) are scoped by that credential instead; an explicit non-root user likewise, because the fleet key's authority is root on fleet systems. Stream primitives' LOCAL paths are confined to the run's granted roots (stateDir, screenshots, generated/, declared path inputs), or `streamBackup` would be a write-as-celilo oracle. Attribution is by the module that PERFORMS the operation: a hook's request to the hook's module (the nine `invokeHook` sites build the policy), a provider's transport to the provider's module — providers run in-process and do not cross this socket, and `public_web`'s hand-built upload was replaced by the Ansible static-content converge (capability-owned-tables stage 4, celilo#1014). Residual, recorded rather than papered over: a hook can still `fetch()` any HTTP endpoint directly; only `probeHttp` consults the target check.
206
+ - **Hook jail (a hook sees the paths it was given, and nothing else)** — `apps/celilo/src/hooks/mount-set.ts` (`deriveMountSet`, `toBwrapArgs`, `forbiddenPaths` — PURE, computes a filesystem view and touches nothing) and `apps/celilo/src/hooks/jail.ts` (`detectJailBackend`, `planJailedSpawn`, `realpathRequest`, `runtimeModulePathsFor`, `recordJailMode` — the half that touches the machine). `executeHookScript` spawns the shim under `bwrap` with the module's tree read-only, `state/` + `generated/` + this run's `screenshots/<run>` read-write on top of it, each contract-declared path input at its declared access, the broker's socket directory, the runtime and the `node_modules` the shim resolves through, and `/tmp` a fresh tmpfs FIRST so it cannot erase the socket or a staged input. `~/.ssh` is deliberately NOT bound (stage 3, D12): withholding the credential is what makes the remote-ops broker's target check a boundary. **The acceptance criterion is absence, not a check**: the module store and the data directory are simply not bound, so `master.key` and a sibling module give `ENOENT`. The set is DERIVED — a module cannot ask for more — and `bwrap` itself is never in it, because the AppArmor profile grants `userns` to `/usr/bin/bwrap` for anyone on the box (design D9). Backend detection RUNS bubblewrap rather than looking for it (four different denials all leave the binary in place), and the resulting mode is written to `hook-jail-mode.json` beside celilo's other per-machine state rather than logged, so a host that stops jailing is readable. `CELILO_HOOK_JAIL` = `auto` (default) / `required` / `off`. macOS has a `sandbox-exec` backend in the union, but `auto` defers on it (ce-29z: sandbox-exec DENIES undeclared writes where bubblewrap masks them, and D14's declared-path mechanism is not built), so the hook runs unjailed with the mode recorded; `required` bypasses the deferral for an operator who opts in. An `unjailed` record carries `lastJailed` (the jailed record it replaced on the same host), which is what the `hook_jail` self-monitor reads (`services/alerting/hook-jail.ts`, created unsuppressible by `celilo monitor add hook_jail`) to alert on a host that used to jail and has stopped. `celilo system doctor`'s "Hook execution" section (`renderHookExecutionSection`) reports the live mode and, when unjailed, why.
185
207
  - **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
186
208
  - **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
187
209
  - **Base-module aspects (fan-out across the fleet)** — `apps/celilo/src/services/aspect-runner.ts` — `planAspectFanOut`, `runAspectFanOut`, `maybeRunAspectForTrigger`. Aspect content lives in `modules/<m>/base-module-aspect/` (e.g. knot-unbound-internal, technitium). Two directions, with DELIBERATELY OPPOSITE failure semantics:
@@ -263,7 +285,7 @@ is currently wrong, and routes carry the message to a person's phone. Design:
263
285
 
264
286
  - **Alert identity** — `apps/celilo/src/services/alerting/keys.ts` — the key grammar (`module:<id>[/check:<name>]`, `builtin:<check>[/<kind>:<target>]`) that makes "the same problem" the same alert across runs. `moduleAlertKey`, `moduleCheckAlertKey`, `builtinAlertKey`, `parseAlertKey`.
265
287
  - **Reconciliation** — `apps/celilo/src/services/alerting/reconcile.ts` (`reconcile`) — a successful run's failing-key set is authoritative and resolution is by SET DIFFERENCE (absent ⇒ resolved). A run whose outcome is `error` resolves NOTHING and fires a module-level alert instead: the false-all-clear guard.
266
- - **Monitor execution** — `apps/celilo/src/services/alerting/run-monitor.ts` (`runOneMonitor`) + `sweep.ts` (`selectDueMonitors`) + `builtin-monitors.ts` / `health-coverage.ts` (the built-in checks and the "module with no health check" coverage check).
288
+ - **Monitor execution** — `apps/celilo/src/services/alerting/run-monitor.ts` (`runOneMonitor`) + `sweep.ts` (`selectDueMonitors`) + `builtin-monitors.ts` / `health-coverage.ts` / `hook-jail.ts` (the built-in checks, the "module with no health check" coverage check, and the hook-jail regression self-monitor).
267
289
  - **Health-check cadence (one accessor)** — `apps/celilo/src/services/alerting/health-cadence.ts` — `effectiveHealthCheckCadence(manifest, override)`, `isScheduled`, `loadModuleHealthCadences`, `reconcileModuleWatchState`. The manifest's `hooks.health_check.interval` SUGGESTS; the operator's `health_check_interval` decides; both resolve at read time. `null` means nobody named a cadence (a coverage gap); `'manual'` means the operator opted out (a decision — raises no coverage finding, and resolves the module's live alerts on the way down, since nothing will report on them again).
268
290
  - **⚠️ `monitors.intervalMinutes` and `monitors.enabled` are `builtin_check`-only.** A `module_hook` row carries severity, escalation policy and `lastRunAt`; its cadence and whether it is watched resolve through the accessor above. The columns' meaning depending on `kind` is a named smell (design.md D8) — the alternatives are a cached resolved value that rots, or splitting the table, which needs a synthetic monitor identity for `alerts.monitorId`. Gates: `sweep-runner.test.ts` asserts a module row's stored values are NOT consulted; `cadence-migration.test.ts` asserts the same through `loadModuleHealthCadences`.
269
291
  - **Carrying an existing fleet over** — `apps/celilo/src/services/alerting/cadence-migration.ts` (`migrateMonitorCadences`), run from `celilo system migrate` (the `.deb` postinst runs it on every apt upgrade). A monitor row whose cadence diverges from its manifest gets that cadence written as an override; a disabled one gets `manual`. Not bookkeeping: without it the upgrade that ships read-time resolution silently reverts every hand-set cadence to the author's suggestion and resumes watching modules an operator deliberately disabled. Idempotent — writes only where no override exists.
@@ -290,6 +312,8 @@ SUGGESTS a `backup.schedule`; the operator's override decides; celilo runs it on
290
312
  the resolved cadence and alerts when it stops.
291
313
 
292
314
  - **Creation** — `apps/celilo/src/services/backup-create.ts` — `createModuleBackup` (invokes the module's `on_backup` hook into an encrypted envelope), `createSystemStateBackup` (celilo.db), `findBackupEligibleModules` (returns each module's operator config alongside its manifest, because every caller has to resolve a policy out of the two together), `isBackupDue`. Storage destinations: `backup-storage.ts`. Restore: `backup-restore.ts`.
315
+ - **Staging celilo's own state** — `apps/celilo/src/services/system-state-stage.ts` — `stageSystemState(rootDir)` and `snapshotDatabase(src, dest)`. Before invoking `on_backup` on a `cross_module_read` module, celilo COPIES its own state (`celilo.db` snapshot, `master.key`, the fleet `ssh/`, and `module_src/<id>/` for every module's lean source) into a directory it creates, and passes the path as the contract input `system_state_root`. ⚠️ **This is what lets celilo back ITSELF up without exempting celilo-mgmt from the hook jail** (`openspec/changes/hook-process-boundary`, design D9b): the hook never READ those bytes, it copied them into `backup_dir`, so the framework does the copying and celilo's data directory is in no hook's mount set. Same allow-list as `cross_module_root` — one privilege, one list to audit. ⚠️ **`snapshotDatabase` uses a readonly connection + `serialize()`, never `copyFileSync`**: celilo runs the DB in WAL mode, so the main file is routinely one near-empty page while all the real data sits in `celilo.db-wal` (measured: 4 KB main against 832 KB WAL), and a plain copy produces a snapshot that opens cleanly, contains NOTHING, and is installed by restore. Gate: `services/system-state-stage.test.ts` writes 200 uncheckpointed rows and asserts they survive.
316
+ - **The fleet SSH key (one accessor)** — `apps/celilo/src/services/fleet-key.ts` — `ensureFleetKey()` (idempotent mint, returns the public half) and `getFleetSshDir()`. Surfaced as `celilo system ensure-fleet-key`, which also records `ssh.public_key`. celilo-mgmt's `on_install` used to mint the keypair itself inside celilo's data directory — a WRITE into the one directory the jail exists to keep out of the mount set (design D9b), which staging does not cover because staging covers copies OUT. ⚠️ **Never re-key**: an existing key is reused, because regenerating strands every machine whose `authorized_keys` holds the old public half, and a redeploy calls this every time. `getFleetSshDir()` follows the DB (`dirname(getDbPath())/.ssh`), not `getDataDir()` — the same directory on a deb install and different when `CELILO_DB_PATH` is overridden; both the mint and `restore-from-file.ts`'s laydown read that one helper so they cannot drift apart.
293
317
  - **Cadence (one accessor)** — `apps/celilo/src/services/backup-schedule.ts` — `effectiveBackupSchedule(manifest, override)`. The manifest SUGGESTS; the operator's `backup_schedule` row in `module_configs` decides; absent from both means `daily`, NOT `manual` (opting out takes an explicit `manual`). Resolution happens at READ time — nothing is materialised at install or deploy — so a corrected manifest reaches every install that has not overridden. The one-argument form was DELETED rather than kept as an overload (Rule 3.9): it would let an un-updated reader compile clean while silently ignoring overrides. Both the freshness audit and the backup sweep must read cadence through this one function, or a module can be alerted-on but never backed up.
294
318
  - **The cadence type** — `apps/celilo/src/services/cadence.ts` — `Cadence` (`{minutes}` | `'manual'`), `parseCadence` / `formatCadence` / `cadenceMs`, and `cadenceSchema({floorMinutes})`. One spelling set for every cadence in celilo: a named period (`hourly`/`daily`/`weekly`/`monthly`), a duration (`6h`, `90m`, `3d`), or `manual`. ⚠️ **The floors are DERIVED from the sweep ticks, never written down**: this file owns `BACKUP_SWEEP_PATTERN` / `ALERTING_SWEEP_PATTERN` and computes `BACKUP_CADENCE_FLOOR_MINUTES` / `MONITOR_INTERVAL_FLOOR_MINUTES` from them (the sweeps import their pattern from here), so changing a tick moves what it can serve in the same edit. A cadence finer than its sweep's tick is REFUSED, not coerced — accepting it leaves the operator believing they configured something that can silently never happen.
295
319
  - **Retention (one accessor)** — `apps/celilo/src/services/backup-retention.ts` — `effectiveBackupRetention(manifest, configs)`, `prunesNothing`, `identifyExpiredBackups`, `pruneBackupsForModule`. Two INDEPENDENT dimensions (copies, age), each resolved override → manifest → **unbounded**. ⚠️ **An unset dimension is unbounded, never a default bound.** `backup.retention` is an optional block, so a manifest omitting it prunes nothing at all and its inner `count: 7` / `max_age_days: 30` defaults never apply; if setting one dimension let the other fall back to those, an operator asking to keep 3 copies would silently arm a 30-day deletion on a module that had been keeping everything. Unbounded is `Infinity`, which `identifyExpiredBackups` needs no special case for. Gate: `services/backup-retention.test.ts`. The four sites that used to read `manifest.backup.retention` directly (`cli/commands/backup-sweep.ts`, `backup-create.ts`, `backup-prune.ts` twice) all go through the accessor — that duplication is what let the schedule readers drift.
@@ -346,6 +370,7 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
346
370
  - **Server** — `apps/celilo/src/api/serve.ts` (`apiServeMode`); the `celilo api-serve --principal=<id>` sshd forced-command entry point (dispatched in `apps/celilo/src/cli/index.ts`). Authorizes per principal, runs the command as a protocol-mode child, streams output, audits to stderr.
347
371
  - **Client** — `packages/core/src/remote-client.ts` (`@celilo/core`) — `resolveRemote` (`--remote <dest>` / `CELILO_REMOTE`), `runRemoteClient` (`ssh -T`, renders progress via the local ProgressDisplay, answers interviews via the `@celilo/cli-display` prompts). Refuses to prompt on a non-TTY stdin, replying `unanswerable` rather than submitting a default as if a human had chosen it.
348
372
  - **Access control** — `apps/celilo/src/services/api-access.ts` — `grantPrincipal`, `isAuthorized` (deny-by-default, `command:subcommand` grants), `renderAuthorizedKeys`. Table: `api_principals` (`apps/celilo/src/db/schema.ts`). CLI: `apps/celilo/src/cli/commands/api.ts` (`api grant|list|revoke|authorized-keys|key new`).
373
+ - **Principal enrolment for a module (`control_plane_api`)** — `apps/celilo/src/services/api-principal-enrolment.ts` — `enrolControlPlanePrincipal`, `revokeControlPlanePrincipal`, and `buildControlPlaneApi`, the method table a consuming module's hooks receive. The consumer generates an ed25519 pair on its own system and presents the public half; nothing here accepts a private key. Grants are DERIVED from `readOnlyGrants(COMMANDS)` and are not a parameter, so a caller cannot ask for more, and a write verb is never granted however it is named. **Framework-granted, so no module provides it** — enrolment writes celilo's own `api_principals` row and a module script may import nothing but `@celilo/capabilities`, which rules out celilo-mgmt as much as anyone else (`web-ui-console` D7b). Injected by `capability-loader.ts` ONLY for a module whose stored manifest declares it under `requires`/`optional`, unlike every other capability the loader hands out, and scoped to that module: a caller may not name a neighbour's principal. Contract: `packages/capabilities/src/control-plane-api.ts`.
349
374
  - **Mid-run interview bridge (`kind:daemon` responder)** — `apps/celilo/src/services/remote-responder.ts` — `startRemoteResponder` bridges bus `interview.required.*` ↔ wire.
350
375
  - **Server provisioning** — the `celilo-bootstrap` deb (`packaging/celilo-bootstrap/scripts/postinst`) creates the non-root `celilo-api` landing account + sshd; membership in the `celilo` group + `/etc/sudoers.d/celilo` (`!use_pty`) gives api-serve DB access via the wrapper's sudo-drop.
351
376
  - **Self-upgrade (apt)** — `celilo apt-upgrade` (`apps/celilo/src/cli/commands/apt-upgrade.ts`) upgrades the deb-installed `celilo`/`celilo-bootstrap` packages (`apt-get update` → `--only-upgrade install`) then spawns a fresh `celilo system migrate` (ISS-0100), then `celilo events restart-daemon` so the dispatcher actually runs the code just installed — a failure there fails the whole command and names which steps DID complete, because "upgraded" while the dispatcher serves stale code is the silent state celilo#604 documents. It's the RW target behind the MCP's registry-derived `celilo_apt_upgrade` tool; the celilo user's two apt invocations are scoped-sudo'd by `/etc/sudoers.d/celilo-apt-upgrade`, shipped by `celilo-bootstrap`. **This upgrades celilo ITSELF — not the modules it manages. For those, see Module auto-upgrade below; the two are routinely confused.**
@@ -354,14 +379,15 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
354
379
 
355
380
  ## Web console (read-mostly operator UI)
356
381
 
357
- The fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. Design: `openspec/changes/web-ui-console/`. **Not yet deployed** — the SPA, its server and the console read verbs exist; the module, its `control_plane_api` enrolment and the e2e suite do not.
382
+ The fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. Design: `openspec/changes/web-ui-console/`. **Not yet deployed** — the SPA, its server, the console read verbs, the acknowledgement path and the `control_plane_api` capability exist; the module's `on_install` that CALLS that capability, and the e2e suite, do not. The capability issues only the derived read-only grants and has no parameter that could widen them (task 6.3b settled that deliberately), so acknowledgement renders a denial until an operator grants `alerts:ack` by hand.
358
383
 
359
384
  - **Console read verbs** — `apps/celilo/src/cli/commands/console.ts` — `celilo console status` (the dashboard's single poll: zone order, per-module systems, observed health, backup freshness) and `celilo console get <module-id> [--depth N]` (one module's bounded capability closure). Both classify **read-only** under `readOnlyGrants()`, so the console's principal covers them with no hand-maintained list. The verbs are named from the read-verb vocabulary (`status`, `get`) rather than for prose, because the API authorises at two levels and classifies a leaf by its SUBCOMMAND token — `console roster` would read better and classify as a WRITE.
360
385
  - **Console projection** — `apps/celilo/src/console/projection.ts` — the narrow payload, deliberately WITHOUT `manifestData`: `module list --json` returns 156 KB for 23 modules because it embeds every manifest blob, which is the wrong payload for a poll loop. Distinguishes *not deployed* from *not observed*; a module with no system is carried, not omitted.
361
386
  - **Bounded capability closure** — `apps/celilo/src/console/closure.ts` — `computeClosure()` wraps `planConsumerCleanup()`'s edge rather than forking it, adding distance, optionality and cycle termination. Takes a bindings map (`capability_bindings`, celilo#1072) so the walk follows what a module has ACTUALLY called into; without one it follows declarations, which answers "what could this reach" rather than "what does this stand on".
362
387
  - **Read-only classifier** — `packages/core/src/read-only-classifier.ts` — `READ_VERBS`, `isReadOnlyPath`, `opOf`, `flattenLeaves`, `readOnlyGrants()`. Lives beside `COMMANDS` because it is a property of the registry, and both the MCP server's `RO_GRANTS` and the console's principal derive from it. A new read verb is covered automatically; a new write verb is not.
363
388
  - **SSH transport** — `packages/core/src/ssh-transport.ts` — `childProcessTransport`, `sshArgs`, `sshTransportWith`, and the exit reaper. Shared rather than copied because the reaper exists after 656 orphaned ssh clients threw celilo-mgr into MaxStartups throttling (celilo#921), and the console server has the same restart-freely lifecycle.
364
- - **Console server** — `apps/console-server/src/upstream.ts` — holds **no database handle**; every fact arrives over the remote API as a read-only principal. An interview is a FAILURE (a browser has no responder), and an unavailable read is reported with a reason (`denied` / `unknown-verb` / `unreachable` / `interview` / `malformed`) rather than returned as an empty result. Failures are not cached, so fixing a grant recovers on the next poll.
389
+ - **Console server** — `apps/console-server/src/upstream.ts` — holds **no database handle**; every fact arrives over the remote API as a principal granted the derived read ops plus `alerts:ack`. An interview is a FAILURE (a browser has no responder), and an unavailable read is reported with a reason (`denied` / `unknown-verb` / `unreachable` / `interview` / `malformed`) rather than returned as an empty result. Failures are not cached, so fixing a grant recovers on the next poll. `Upstream.run` is the one non-read: uncached, unparsed (the ack answers with a sentence), and it drops every cached read afterwards.
390
+ - **Console acknowledgement** — `apps/console-server/src/verbs.ts` (`readSession`, `ackAlert`), `apps/celilo/src/cli/commands/notify-config.ts` (`celilo person list --json`) — the console's ONLY write. `celilo alerts ack` falls back to `people[0]` when `--as` is absent, which in a browser would credit every acknowledgement to whoever sorts first, so `ackAlert` takes a required `person` and there is no path through it that omits the flag. The person comes from `readSession`, which resolves the identity provider's subject against `person list --json` (display name, then the `sub` claim, case-insensitive); a subject matching nobody resolves to null and the console draws no control. `ackedAt` is re-read from celilo's row rather than stamped by the console server, which is a different machine. Gates: `apps/console-server/tests/ack.test.ts` records the argv, so "refused but written anyway" is visible.
365
391
  - **Protocol** — `packages/console-protocol/` — tsrpc definitions plus the generated `serviceProto`, shared by server and SPA. The repo's single documented exception to ESM-everywhere: no `type` field, because `tsrpc-cli proto` `require()`s the protocol sources. Gated by a test in an ESM directory that round-trips a call.
366
392
  - **SPA** — `apps/console/src/` — Vite + React 19 + atom.io + tsrpc-browser. Three routes (`dashboard`, `alerts`, `backups`); a module's detail is a panel, not a fourth route. Visual constraints live in `shell.css` and four are asserted against the source by `tests/constraints.test.ts`, including "no control for any operation that can raise an interview". Every read goes through one `Panel` that separates *unavailable* from *empty*.
367
393
  - **Zone topology renderer** — `packages/visualizer/src/gen/zone-topology.ts`, `topology-theme.ts`, `column-assignment.ts`, `render/topology-svg.ts` — bands by zone in trust order, columns assigned so a dependency edge falls as a vertical drop, orthogonal routing checked against every module rather than assumed clear, and a report naming anything it could not place. Tuning playground at `bun run dev` in `packages/visualizer`, route `/topology`; it exports a `TopologyTheme` JSON the console commits.
package/README.md CHANGED
@@ -286,7 +286,6 @@ bun run test:integration # All integration tests (run via bun test)
286
286
  # - VM resource defaults
287
287
 
288
288
  # What gets skipped:
289
- # - Nix builds
290
289
  # - Docker builds
291
290
  # - Slow module builds (Caddy)
292
291
  ```
@@ -296,7 +295,6 @@ bun run test:integration # All integration tests (run via bun test)
296
295
  bun run test:integration-slow # Full builds + end-to-end
297
296
 
298
297
  # Includes everything from fast tests PLUS:
299
- # - Full Nix builds (Caddy with modules)
300
298
  # - Docker builds
301
299
  # - Complete module packaging
302
300
  ```
@@ -0,0 +1,8 @@
1
+ -- The `environment` column recorded whether a build ran inside a `nix develop`
2
+ -- shell ('nix') or with system tools ('system'). That was the only information
3
+ -- it carried, and the Nix build path never shipped: no module has ever carried
4
+ -- a flake.nix, and the detection code (detectNixEnvironment / isNixAvailable /
5
+ -- the `nix develop` wrapper in module-build.ts) is deleted in the same change.
6
+ -- Every row ever written holds 'system' or NULL, so a constant column: deleted
7
+ -- with the path it described rather than kept as dead weight.
8
+ ALTER TABLE `module_builds` DROP COLUMN `environment`;
@@ -211,6 +211,13 @@
211
211
  "when": 1784400000000,
212
212
  "tag": "0029_module_instances",
213
213
  "breakpoints": true
214
+ },
215
+ {
216
+ "idx": 30,
217
+ "version": "6",
218
+ "when": 1784400000000,
219
+ "tag": "0030_drop_module_builds_environment",
220
+ "breakpoints": true
214
221
  }
215
222
  ]
216
- }
223
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "1.13.0",
3
+ "version": "2.0.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,9 +58,9 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^3.3.0",
61
+ "@celilo/capabilities": "^4.0.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.10.0",
63
+ "@celilo/core": "^0.11.0",
64
64
  "@celilo/event-bus": "^0.6.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -89,7 +89,6 @@ function basePublishRequest(
89
89
  ): PublishStaticSiteRequest {
90
90
  return {
91
91
  path: '/lunacycle',
92
- sourceDir: '/tmp/build/dist',
93
92
  ...overrides,
94
93
  };
95
94
  }
@@ -134,17 +133,24 @@ describe('validatePublishStaticSiteRequest', () => {
134
133
  expect(result.valid).toBe(true);
135
134
  });
136
135
 
137
- test('rejects empty sourceDir', () => {
138
- const result = validatePublishStaticSiteRequest(basePublishRequest({ sourceDir: '' }));
136
+ test('rejects a stale sourceDir, and says what to do instead', () => {
137
+ // Replaces 'rejects empty sourceDir'. The field is gone (D10 amendment), so
138
+ // the failure mode inverted: supplying one is now the error, and the
139
+ // message has to carry the directory move because that is not guessable
140
+ // from "unknown key".
141
+ const result = validatePublishStaticSiteRequest({
142
+ path: '/lunacycle',
143
+ sourceDir: '/tmp/site',
144
+ } as unknown as PublishStaticSiteRequest);
139
145
  expect(result.valid).toBe(false);
140
- expect(result.errors).toContain('sourceDir is required');
146
+ expect(result.errors.join(' ')).toContain('site/dist');
141
147
  });
142
148
 
143
149
  test('reports multiple errors at once', () => {
144
150
  const result = validatePublishStaticSiteRequest({
145
151
  path: '',
146
- sourceDir: '',
147
- });
152
+ sourceDir: '/tmp/site',
153
+ } as unknown as PublishStaticSiteRequest);
148
154
  expect(result.valid).toBe(false);
149
155
  expect(result.errors.length).toBeGreaterThanOrEqual(2);
150
156
  });
@@ -106,6 +106,7 @@ describe('publishStaticSite — clientConfig injection', () => {
106
106
  test('writes config.js into sourceDir before upload when clientConfig is provided', async () => {
107
107
  const { ops } = makeRouteOps();
108
108
  const cap = createPublicWeb({
109
+ webRoot: sourceDir,
109
110
  moduleId: 'lunacycle',
110
111
  logger: noopLogger,
111
112
  config: {
@@ -115,6 +116,7 @@ describe('publishStaticSite — clientConfig injection', () => {
115
116
  email: 'admin@example.com',
116
117
  },
117
118
  secrets: {},
119
+ convergeStaticContent: async () => {},
118
120
  routeOps: ops,
119
121
  hostnames: ['www.example.com'],
120
122
  caddyModuleId: 'caddy',
@@ -123,7 +125,6 @@ describe('publishStaticSite — clientConfig injection', () => {
123
125
 
124
126
  const result = await cap.publishStaticSite({
125
127
  path: '/lunacycle',
126
- sourceDir,
127
128
  clientConfig: {
128
129
  AUTHENTIK_URL: 'https://auth.example.com/application/o',
129
130
  CLIENT_ID: 'lunacycle-web',
@@ -150,6 +151,7 @@ describe('publishStaticSite — clientConfig injection', () => {
150
151
  test('does not write config.js when clientConfig is omitted', async () => {
151
152
  const { ops } = makeRouteOps();
152
153
  const cap = createPublicWeb({
154
+ webRoot: sourceDir,
153
155
  moduleId: 'lunacycle',
154
156
  logger: noopLogger,
155
157
  config: {
@@ -159,6 +161,7 @@ describe('publishStaticSite — clientConfig injection', () => {
159
161
  email: 'admin@example.com',
160
162
  },
161
163
  secrets: {},
164
+ convergeStaticContent: async () => {},
162
165
  routeOps: ops,
163
166
  hostnames: ['www.example.com'],
164
167
  caddyModuleId: 'caddy',
@@ -167,7 +170,6 @@ describe('publishStaticSite — clientConfig injection', () => {
167
170
 
168
171
  await cap.publishStaticSite({
169
172
  path: '/lunacycle',
170
- sourceDir,
171
173
  // no clientConfig
172
174
  });
173
175
 
@@ -177,6 +179,7 @@ describe('publishStaticSite — clientConfig injection', () => {
177
179
  test('registers the route as static and records moduleId/path', async () => {
178
180
  const { ops, routes } = makeRouteOps();
179
181
  const cap = createPublicWeb({
182
+ webRoot: sourceDir,
180
183
  moduleId: 'lunacycle',
181
184
  logger: noopLogger,
182
185
  config: {
@@ -186,6 +189,7 @@ describe('publishStaticSite — clientConfig injection', () => {
186
189
  email: 'admin@example.com',
187
190
  },
188
191
  secrets: {},
192
+ convergeStaticContent: async () => {},
189
193
  routeOps: ops,
190
194
  hostnames: ['www.example.com'],
191
195
  caddyModuleId: 'caddy',
@@ -194,7 +198,6 @@ describe('publishStaticSite — clientConfig injection', () => {
194
198
 
195
199
  await cap.publishStaticSite({
196
200
  path: '/lunacycle',
197
- sourceDir,
198
201
  });
199
202
 
200
203
  const stored = routes.find((r) => r.path === '/lunacycle');
@@ -204,9 +207,16 @@ describe('publishStaticSite — clientConfig injection', () => {
204
207
  expect(stored?.slug).toBe('lunacycle');
205
208
  });
206
209
 
207
- test('fails fast when sourceDir does not exist', async () => {
210
+ test('fails fast, and names the path to ship, when the module has no web root', async () => {
211
+ // The publish path's one unrecoverable input. It used to arrive on the
212
+ // request as `sourceDir`; core now resolves it (D10 amendment), so the
213
+ // missing-directory case moves onto the capability's construction.
214
+ //
215
+ // It must throw rather than upload nothing: a zero-file upload succeeds,
216
+ // writes a content hash over an empty release, and serves a blank page.
208
217
  const { ops } = makeRouteOps();
209
218
  const cap = createPublicWeb({
219
+ webRoot: '/nonexistent/path/that/should/not/exist',
210
220
  moduleId: 'lunacycle',
211
221
  logger: noopLogger,
212
222
  config: {
@@ -216,24 +226,26 @@ describe('publishStaticSite — clientConfig injection', () => {
216
226
  email: 'admin@example.com',
217
227
  },
218
228
  secrets: {},
229
+ convergeStaticContent: async () => {},
219
230
  routeOps: ops,
220
231
  hostnames: ['www.example.com'],
221
232
  caddyModuleId: 'caddy',
222
233
  dnsManagedDomains: ['www.example.com'],
223
234
  });
224
235
 
225
- await expect(
226
- cap.publishStaticSite({
227
- path: '/lunacycle',
228
- sourceDir: '/nonexistent/path/that/should/not/exist',
229
- clientConfig: { FOO: 'bar' },
230
- }),
231
- ).rejects.toThrow('sourceDir does not exist');
236
+ const attempt = cap.publishStaticSite({
237
+ path: '/lunacycle',
238
+ clientConfig: { FOO: 'bar' },
239
+ });
240
+ await expect(attempt).rejects.toThrow('no built site at');
241
+ // The operator's next move is a directory move, so the error has to name it.
242
+ await expect(attempt).rejects.toThrow('site/dist');
232
243
  });
233
244
 
234
245
  test('fails validation before touching the filesystem', async () => {
235
246
  const { ops } = makeRouteOps();
236
247
  const cap = createPublicWeb({
248
+ webRoot: sourceDir,
237
249
  moduleId: 'lunacycle',
238
250
  logger: noopLogger,
239
251
  config: {
@@ -243,6 +255,7 @@ describe('publishStaticSite — clientConfig injection', () => {
243
255
  email: 'admin@example.com',
244
256
  },
245
257
  secrets: {},
258
+ convergeStaticContent: async () => {},
246
259
  routeOps: ops,
247
260
  hostnames: ['www.example.com'],
248
261
  caddyModuleId: 'caddy',
@@ -252,7 +265,6 @@ describe('publishStaticSite — clientConfig injection', () => {
252
265
  await expect(
253
266
  cap.publishStaticSite({
254
267
  path: '', // bad — caught by validator before any side effects
255
- sourceDir,
256
268
  }),
257
269
  ).rejects.toThrow('Invalid publishStaticSite request');
258
270
 
@@ -282,6 +294,7 @@ describe('registerReverseProxy', () => {
282
294
  email: 'admin@example.com',
283
295
  },
284
296
  secrets: {},
297
+ convergeStaticContent: async () => {},
285
298
  routeOps: ops,
286
299
  hostnames: ['www.example.com'],
287
300
  caddyModuleId: 'caddy',
@@ -318,6 +331,7 @@ describe('registerReverseProxy', () => {
318
331
  email: 'admin@example.com',
319
332
  },
320
333
  secrets: {},
334
+ convergeStaticContent: async () => {},
321
335
  routeOps: ops,
322
336
  hostnames: ['www.example.com'],
323
337
  caddyModuleId: 'caddy',
@@ -355,6 +369,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
355
369
  const { ops } = makeRouteOps();
356
370
 
357
371
  const cap = createPublicWeb({
372
+ webRoot: sourceDir,
358
373
  moduleId: 'lunacycle',
359
374
  logger,
360
375
  config: {
@@ -364,6 +379,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
364
379
  email: 'admin@example.com',
365
380
  },
366
381
  secrets: {},
382
+ convergeStaticContent: async () => {},
367
383
  routeOps: ops,
368
384
  hostnames: ['www.example.com'],
369
385
  caddyModuleId: 'caddy',
@@ -389,6 +405,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
389
405
  const { ops } = makeRouteOps();
390
406
 
391
407
  const cap = createPublicWeb({
408
+ webRoot: sourceDir,
392
409
  moduleId: 'lunacycle',
393
410
  logger,
394
411
  config: {
@@ -398,6 +415,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
398
415
  email: 'admin@example.com',
399
416
  },
400
417
  secrets: {},
418
+ convergeStaticContent: async () => {},
401
419
  routeOps: ops,
402
420
  hostnames: ['www.example.com'],
403
421
  caddyModuleId: 'caddy',
@@ -424,6 +442,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
424
442
  const { ops } = makeRouteOps();
425
443
 
426
444
  const cap = createPublicWeb({
445
+ webRoot: sourceDir,
427
446
  moduleId: 'lunacycle',
428
447
  logger,
429
448
  config: {
@@ -433,13 +452,14 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
433
452
  email: 'admin@example.com',
434
453
  },
435
454
  secrets: {},
455
+ convergeStaticContent: async () => {},
436
456
  routeOps: ops,
437
457
  hostnames: ['www.example.com'],
438
458
  caddyModuleId: 'caddy',
439
459
  dnsManagedDomains: ['www.example.com'],
440
460
  });
441
461
 
442
- await cap.publishStaticSite({ path: '/lunacycle', sourceDir });
462
+ await cap.publishStaticSite({ path: '/lunacycle' });
443
463
 
444
464
  const messageTexts = messages.map((m) => m.message);
445
465
  // The high-level call itself logs.
@@ -459,6 +479,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
459
479
  const { ops } = makeRouteOps();
460
480
 
461
481
  const cap = createPublicWeb({
482
+ webRoot: sourceDir,
462
483
  moduleId: 'lunacycle',
463
484
  logger,
464
485
  config: {
@@ -468,6 +489,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
468
489
  email: 'admin@example.com',
469
490
  },
470
491
  secrets: {},
492
+ convergeStaticContent: async () => {},
471
493
  routeOps: ops,
472
494
  hostnames: ['www.example.com'],
473
495
  caddyModuleId: 'caddy',
@@ -524,6 +546,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
524
546
  logger: noopLogger,
525
547
  config: { target_ip: '10.0.10.20/24' },
526
548
  secrets: {},
549
+ convergeStaticContent: async () => {},
527
550
  routeOps: ops,
528
551
  hostnames: ['www.example.com'],
529
552
  caddyModuleId: 'caddy',
@@ -551,6 +574,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
551
574
  logger: noopLogger,
552
575
  config: { target_ip: '10.0.10.20/24' },
553
576
  secrets: {},
577
+ convergeStaticContent: async () => {},
554
578
  routeOps: ops,
555
579
  hostnames: ['www.example.com'],
556
580
  caddyModuleId: 'caddy',
@@ -582,6 +606,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
582
606
  logger: noopLogger,
583
607
  config: { target_ip: '10.0.10.20/24' },
584
608
  secrets: {},
609
+ convergeStaticContent: async () => {},
585
610
  routeOps: ops,
586
611
  hostnames: ['www.example.com'],
587
612
  caddyModuleId: 'caddy',
@@ -602,6 +627,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
602
627
  logger: noopLogger,
603
628
  config: { target_ip: '10.0.10.20/24' },
604
629
  secrets: {},
630
+ convergeStaticContent: async () => {},
605
631
  routeOps: ops,
606
632
  hostnames: ['www.example.com'],
607
633
  caddyModuleId: 'caddy',
@@ -626,6 +652,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
626
652
  logger: noopLogger,
627
653
  config: { target_ip: '10.0.10.20/24' },
628
654
  secrets: {},
655
+ convergeStaticContent: async () => {},
629
656
  routeOps: ops,
630
657
  hostnames: ['www.example.com'],
631
658
  caddyModuleId: 'caddy',
@@ -652,6 +679,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
652
679
  // No target_ip and no firewallNatIp → internalDnsIp is falsy.
653
680
  config: {},
654
681
  secrets: {},
682
+ convergeStaticContent: async () => {},
655
683
  routeOps: ops,
656
684
  hostnames: ['www.example.com'],
657
685
  caddyModuleId: 'caddy',
@@ -682,6 +710,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
682
710
  logger: noopLogger,
683
711
  config: { target_ip: '10.0.10.20/24' },
684
712
  secrets: {},
713
+ convergeStaticContent: async () => {},
685
714
  routeOps: ops,
686
715
  hostnames: ['www.example.com'],
687
716
  caddyModuleId: 'caddy',
@@ -228,6 +228,37 @@ describe('Capability Access Validation', () => {
228
228
  expect(result.success).toBe(true);
229
229
  });
230
230
 
231
+ test('a framework-granted capability needs no providing module', async () => {
232
+ // This is the breakage, not a hypothetical. `control_plane_api` has no
233
+ // provider module and never will — celilo grants it (web-ui-console D7b) —
234
+ // so the console's manifest, already merged, hit the refusal below and
235
+ // could not be imported at all. Note the db here is the SAME one that
236
+ // fails the test after this one: the difference is entirely which
237
+ // capability is asked for.
238
+ const manifest: ModuleManifest = {
239
+ celilo_contract: '1.0',
240
+ id: 'celilo-web-console',
241
+ name: 'Celilo Web Console',
242
+ version: '1.0.0',
243
+ description: 'Console',
244
+ requires: {
245
+ capabilities: [{ name: 'control_plane_api', version: '1.0.0' }],
246
+ },
247
+ provides: { capabilities: [] },
248
+ variables: { owns: [], imports: [] },
249
+ };
250
+
251
+ const noProviderDb = {
252
+ prepare: () => ({
253
+ get: () => undefined,
254
+ }),
255
+ } as unknown as Database;
256
+
257
+ const result = await validateCapabilityAccess(manifest, noProviderDb);
258
+
259
+ expect(result.success).toBe(true);
260
+ });
261
+
231
262
  test('should return error when required capability not found', async () => {
232
263
  const manifest: ModuleManifest = {
233
264
  celilo_contract: '1.0',
@@ -10,8 +10,10 @@
10
10
  * report counts.
11
11
  */
12
12
 
13
+ import { hostname } from 'node:os';
13
14
  import { getDb } from '../../db/client';
14
15
  import { modules } from '../../db/schema';
16
+ import { readJailMode } from '../../hooks/jail';
15
17
  import { runBuiltinCheckForMonitor } from '../../services/alerting/builtin-source';
16
18
  import { loadModuleCoverage } from '../../services/alerting/coverage-source';
17
19
  import { modulesInDeployWindow } from '../../services/alerting/deploy-hooks';
@@ -61,6 +63,7 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
61
63
  runModuleHealthCheck(moduleId, db, { unattended: true, noInteractive: true }),
62
64
  runBuiltinCheck: (category) => runBuiltinCheckForMonitor(category, db),
63
65
  loadModuleCoverage: () => loadModuleCoverage(db),
66
+ loadJailState: () => ({ record: readJailMode(), host: hostname() }),
64
67
  now: () => new Date(),
65
68
  graceMs: DEFAULT_GRACE_MS,
66
69
  },