@celilo/cli 2.2.1 → 3.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 (173) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/serve.ts +13 -1
  10. package/src/api/sessions.test.ts +3 -3
  11. package/src/api-clients/proxmox.ts +10 -7
  12. package/src/cli/backup-rename.test.ts +3 -3
  13. package/src/cli/cli.test.ts +1 -1
  14. package/src/cli/commands/events.test.ts +2 -2
  15. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  16. package/src/cli/commands/machine-list.test.ts +57 -0
  17. package/src/cli/commands/machine-list.ts +35 -4
  18. package/src/cli/commands/module-health.test.ts +35 -0
  19. package/src/cli/commands/module-health.ts +12 -3
  20. package/src/cli/commands/module-import-registry.test.ts +1 -1
  21. package/src/cli/commands/module-jail.test.ts +242 -0
  22. package/src/cli/commands/module-jail.ts +227 -0
  23. package/src/cli/commands/module-list-jail.test.ts +136 -0
  24. package/src/cli/commands/module-list.ts +30 -3
  25. package/src/cli/commands/module-publish.test.ts +28 -6
  26. package/src/cli/commands/module-publish.ts +14 -12
  27. package/src/cli/commands/module-update.test.ts +22 -7
  28. package/src/cli/commands/module-update.ts +73 -18
  29. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  30. package/src/cli/commands/module-upgrade.test.ts +115 -1
  31. package/src/cli/commands/module-upgrade.ts +66 -4
  32. package/src/cli/commands/module-verify.test.ts +1 -1
  33. package/src/cli/commands/publish/execute.ts +4 -1
  34. package/src/cli/commands/publish/helpers.ts +4 -3
  35. package/src/cli/commands/publish/index.ts +23 -1
  36. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  37. package/src/cli/commands/publish/module-registry.ts +23 -4
  38. package/src/cli/commands/publish/plan.test.ts +64 -0
  39. package/src/cli/commands/publish/plan.ts +52 -19
  40. package/src/cli/commands/publish/types.ts +20 -0
  41. package/src/cli/commands/registry-owner.test.ts +1 -1
  42. package/src/cli/commands/registry-token.test.ts +1 -1
  43. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  44. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  45. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  46. package/src/cli/commands/subscribers-serve.ts +22 -4
  47. package/src/cli/commands/system-audit.ts +34 -11
  48. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  49. package/src/cli/commands/system-doctor.test.ts +76 -10
  50. package/src/cli/commands/system-doctor.ts +34 -3
  51. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  52. package/src/cli/commands/system-update.ts +16 -6
  53. package/src/cli/completion.ts +18 -3
  54. package/src/cli/flag-surface-gate.test.ts +279 -0
  55. package/src/cli/fuel-gauge.ts +12 -4
  56. package/src/cli/index.ts +80 -2
  57. package/src/cli/json-output.test.ts +81 -0
  58. package/src/cli/parser.test.ts +37 -1
  59. package/src/cli/restore-command.test.ts +3 -3
  60. package/src/cli/restore-migration-failure.test.ts +2 -2
  61. package/src/cli/tui/audit-state.ts +2 -0
  62. package/src/cli/types.ts +9 -0
  63. package/src/config/paths.test.ts +19 -19
  64. package/src/db/client.test.ts +46 -1
  65. package/src/db/client.ts +26 -0
  66. package/src/db/schema.ts +63 -0
  67. package/src/hooks/broker.test.ts +9 -3
  68. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  69. package/src/hooks/capability-loader.ts +8 -0
  70. package/src/hooks/executor.test.ts +91 -14
  71. package/src/hooks/executor.ts +31 -1
  72. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  73. package/src/hooks/hook-jail-unreachability.test.ts +27 -20
  74. package/src/hooks/hook-trespass.test.ts +22 -6
  75. package/src/hooks/jail.test.ts +105 -17
  76. package/src/hooks/jail.ts +47 -10
  77. package/src/hooks/run-named-hook.ts +19 -16
  78. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  79. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  80. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  81. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  82. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  83. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  84. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  85. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  86. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  87. package/src/manifest/contracts/v1.ts +32 -14
  88. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  89. package/src/manifest/schema.ts +66 -16
  90. package/src/manifest/validate.test.ts +47 -0
  91. package/src/policy/capability-shape-baseline.ts +63 -21
  92. package/src/policy/capability-shape-drift.test.ts +53 -1
  93. package/src/policy/capability-shape.test.ts +105 -0
  94. package/src/policy/capability-shape.ts +283 -2
  95. package/src/policy/module-script-scan.ts +52 -68
  96. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  97. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  98. package/src/registry/client.test.ts +67 -2
  99. package/src/registry/client.ts +7 -7
  100. package/src/secrets/storage.test.ts +70 -4
  101. package/src/secrets/storage.ts +71 -1
  102. package/src/services/alerting/keys.test.ts +4 -0
  103. package/src/services/alerting/keys.ts +12 -2
  104. package/src/services/audit/health.test.ts +155 -2
  105. package/src/services/audit/health.ts +76 -2
  106. package/src/services/audit/index.test.ts +23 -1
  107. package/src/services/audit/index.ts +8 -2
  108. package/src/services/audit/interface-classification.test.ts +16 -5
  109. package/src/services/audit/interface-classification.ts +25 -2
  110. package/src/services/audit/jail-exemptions.test.ts +42 -0
  111. package/src/services/audit/jail-exemptions.ts +44 -0
  112. package/src/services/audit/module-integrity.test.ts +23 -1
  113. package/src/services/audit/module-integrity.ts +7 -2
  114. package/src/services/audit/module-versions.ts +5 -1
  115. package/src/services/audit/public-dns.test.ts +20 -0
  116. package/src/services/audit/public-dns.ts +7 -2
  117. package/src/services/audit/recurrence-gate.test.ts +225 -0
  118. package/src/services/audit/trusted-sources.test.ts +17 -0
  119. package/src/services/audit/trusted-sources.ts +21 -0
  120. package/src/services/audit/types.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  123. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  124. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  125. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
  126. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  127. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  128. package/src/services/build-bus/hook-dispatch.ts +99 -121
  129. package/src/services/build-bus/hook-dispatcher.ts +143 -17
  130. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  131. package/src/services/build-bus/receiver-daemon.ts +355 -0
  132. package/src/services/build-bus/self-update.ts +156 -0
  133. package/src/services/bus-ensure-flow.test.ts +1 -1
  134. package/src/services/bus-interview-park.test.ts +2 -2
  135. package/src/services/bus-interview.test.ts +2 -2
  136. package/src/services/bus-secret-flow.test.ts +1 -1
  137. package/src/services/capability-compat.test.ts +90 -0
  138. package/src/services/capability-compat.ts +128 -0
  139. package/src/services/celilo-events.test.ts +1 -1
  140. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  141. package/src/services/container-service.test.ts +1 -1
  142. package/src/services/cross-module-read.test.ts +2 -2
  143. package/src/services/deploy-preflight.ts +7 -0
  144. package/src/services/deploy-terraform.ts +38 -1
  145. package/src/services/deployed-systems.ts +1 -1
  146. package/src/services/events-daemon.test.ts +57 -0
  147. package/src/services/events-daemon.ts +76 -0
  148. package/src/services/firewall-reach.ts +21 -8
  149. package/src/services/fleet-checks.test.ts +159 -4
  150. package/src/services/fleet-checks.ts +206 -3
  151. package/src/services/health-runner.test.ts +87 -2
  152. package/src/services/health-runner.ts +83 -16
  153. package/src/services/infrastructure-selector.test.ts +1 -1
  154. package/src/services/jail-exemptions.test.ts +125 -0
  155. package/src/services/jail-exemptions.ts +81 -0
  156. package/src/services/machine-pool.test.ts +1 -1
  157. package/src/services/module-deploy-prune.test.ts +89 -0
  158. package/src/services/module-deploy.ts +84 -129
  159. package/src/services/module-subscriptions.test.ts +2 -2
  160. package/src/services/module-types-drift.test.ts +1 -1
  161. package/src/services/module-validator/capability-versions.test.ts +13 -2
  162. package/src/services/network-discovery.test.ts +64 -1
  163. package/src/services/network-discovery.ts +30 -5
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +4 -4
  166. package/src/services/restore-preflight.test.ts +1 -1
  167. package/src/services/ssh-key-manager.test.ts +2 -2
  168. package/src/services/system-state-stage.test.ts +2 -2
  169. package/src/services/terraform-safety.test.ts +83 -0
  170. package/src/services/terraform-safety.ts +53 -0
  171. package/src/services/update/orchestrator.test.ts +3 -1
  172. package/src/test-utils/bus-responder.ts +1 -1
  173. package/tsconfig.json +2 -13
@@ -71,6 +71,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
71
71
 
72
72
  ## Applications
73
73
 
74
+ - **burner** — fleet-only ISO library and on-demand optical-disc writer. An unprivileged controller LXC owns a deliberately disposable content-addressed image store, SQLite job queue, HTTP Basic-protected dashboard, and the only inbound surface; small host workers poll it over private HTTPS and alone receive `cdrom` access to each configured `/dev/sr0`. Jobs accept only blank media with sufficient capacity, stage by SHA-256, burn through argv-only `xorriso`, read exactly the source image length back for SHA-256 verification, and never automatically retry after the laser may have started. Images are uploaded/deleted in the UI and are not covered by a Celilo backup hook; a future NAS can replace the controller store without changing the digest-addressed worker protocol. **requires:** `private_web`.
74
75
  - **homebridge** — HomeKit bridge for smart-home devices (VeSync, Leviton, Lutron, TP-Link, Tuya). No capabilities (leaf app).
75
76
 
76
77
  ## E2E fixtures & probes (not production apps)
@@ -84,4 +85,4 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
84
85
 
85
86
  ## Archived / superseded
86
87
 
87
- `modules/archive/` holds retired modules — **dns-external** (VPS authoritative DNS + WireGuard, superseded by the `dns_internal`/`dns_registrar` split), **gmail** (email-reading capability), **namecheap-api** (registrar-config via Namecheap API, superseded by the **namecheap** DDNS module). Reference only; not deployed.
88
+ `modules/__archive__/` holds retired modules — **dns-external** (VPS authoritative DNS + WireGuard, superseded by the `dns_internal`/`dns_registrar` split), **gmail** (email-reading capability), **namecheap-api** (registrar-config via Namecheap API, superseded by the **namecheap** DDNS module). Reference only; not deployed.
@@ -203,7 +203,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
203
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
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
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.
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. The policy is `auto` / `required` / `off`, resolved in a fixed precedence: the `CELILO_HOOK_JAIL` environment variable, then the stored `hooks.jail_policy` system config key (`celilo system config set hooks.jail_policy <value>`, which asks an interview question before writing `off` unless `--force`), then the default, which is **`off`** (ce-rez7). Jailing begins because an operator set a policy, never because an upgrade installed a backend — celilo-mgr's 2026-09-07 move to 1:2.2.1 installed bubblewrap and loaded the AppArmor profile and hooks kept running unjailed, which is the ruling working. `resolveJailPolicy` returns the source alongside the policy and `system doctor` renders it, so an effective value is always locatable. 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.
207
207
  - **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
208
208
  - **Control-plane bootstrap (celilo initialises its own box)** — `apps/celilo/src/services/control-plane-bootstrap.ts` (`bootstrapControlPlane`) with `apps/celilo/src/services/dns-discovery.ts` (`discoverDns`) beside `network-discovery.ts`. Called from `module-deploy.ts` at the point `on_install` runs, for `CONTROL_PLANE_MODULE_ID` (exported from `services/deployed-systems.ts`, replacing three private copies of the string). Reads the box's upstream resolvers, mints the fleet key via `ensureFleetKey`, writes `dns.*` + `ssh.public_key` in ONE `initializeSystem` call, records the network via `discoverAndRecordNetwork`, then polls `checkDispatcher` and FAILS the deploy if no dispatcher answers. **It is not a hook, and that is the point** (celilo#1225): it was `modules/celilo-mgmt/scripts/on_install.ts`, which reached all of this by spawning the `celilo` CLI — impossible inside the jail, whose mount set binds no `/usr/bin`, no `/usr/local/bin` and no shell, so the deploy ran Ansible clean and then died in its own install hook on every host with a jail backend. celilo-mgmt is never deployed to a remote box, so the host being configured is always the host celilo runs on and the hook was a process boundary with celilo on both sides. Self-registering the management host into its own pool did NOT move: celilo places a module only on a machine already in the pool. The dispatcher read is celilo's four-part `checkDispatcher`, not the "is a process up?" probe the hook used.
209
209
  - **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
package/README.md CHANGED
@@ -1536,7 +1536,7 @@ When debugging issues, check:
1536
1536
  ### Getting Help
1537
1537
 
1538
1538
  **Documentation**:
1539
- - [CLAUDE.md](../../CLAUDE.md) - Engineering standards
1539
+ - [AGENTS.md](../../AGENTS.md) - Engineering standards
1540
1540
  - [TESTING_STRATEGY.md](../../reference/TESTING_STRATEGY.md) - Testing approach
1541
1541
  - [IDENTIFIER_NAMING_CONVENTIONS.md](../../reference/IDENTIFIER_NAMING_CONVENTIONS.md) - Naming rules
1542
1542
  - [TEMPLATE_VARIABLE_SYNTAX.md](../../reference/TEMPLATE_VARIABLE_SYNTAX.md) - Variable syntax
@@ -0,0 +1,28 @@
1
+ -- Per-module hook jail policy (openspec/changes/per-module-jail-policy, task 1.1).
2
+ --
3
+ -- The operator's row in the four-step precedence, ranked between the
4
+ -- `CELILO_HOOK_JAIL` environment variable and the system-wide
5
+ -- `hooks.jail_policy` key. One row per module; an ABSENT row means "follow
6
+ -- the system", so there is no default column to get wrong and no way to
7
+ -- distinguish "not set" from "set to the same value as the system".
8
+ --
9
+ -- `module_id` is both the primary key and the FK (same shape as
10
+ -- `module_instances`): a policy cannot exist without the module row it
11
+ -- names, and dropping the module row drops this. The row must never
12
+ -- outlive the module it governs — an exemption for a module that no longer
13
+ -- exists is a finding that names a ghost.
14
+ --
15
+ -- No CHECK constraint on `policy` on purpose. The set-time validation lives
16
+ -- in the `module jail` verb, and the read-time validation lives in
17
+ -- `resolveJailPolicy`, which throws rather than coercing (peba's ruling on
18
+ -- ce-8832, applied to the system key). A restored or hand-edited junk value
19
+ -- fails loudly on the next hook invocation or doctor run; a CHECK would
20
+ -- trade that loud failure at two sites for a silent one at insert time.
21
+ -- The drizzle type keeps the union honest inside the codebase.
22
+
23
+ CREATE TABLE `module_jail_policies` (
24
+ `module_id` text PRIMARY KEY NOT NULL,
25
+ `policy` text NOT NULL,
26
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL,
27
+ FOREIGN KEY (`module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
28
+ );
@@ -0,0 +1,37 @@
1
+ -- Durable outcome record for build-bus `on_upstream_publish` hook runs.
2
+ --
3
+ -- The hook dispatcher (services/build-bus/hook-dispatcher.ts) is the only
4
+ -- component that runs a module's self-update script, and until now its
5
+ -- outcomes lived nowhere but the daemon's console output: a failed
6
+ -- self-update left no row any surface could read, so `system doctor`
7
+ -- reported the event dispatcher healthy while the CLI on the box aged in
8
+ -- place (celilo#1304). Every `on_upstream_publish` result lands here, one
9
+ -- row per (event, module, hook) execution.
10
+ --
11
+ -- Not event-bus `deliveries`: those record the SQLite bus dispatcher's own
12
+ -- subscriber handlers. The build-bus receiver emits `build-bus.publish`
13
+ -- onto that bus for the record, but hooks run from the receiver's
14
+ -- in-process dispatcher, which no delivery row was ever written for.
15
+ --
16
+ -- `exit_code` is NULLABLE on purpose: a spawn failure (bash missing, EACCES)
17
+ -- never produces an exit code, and recording 0/1 there would conflate "the
18
+ -- script ran and said no" with "the script never ran".
19
+
20
+ CREATE TABLE `build_bus_hook_runs` (
21
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
22
+ `event_id` text NOT NULL,
23
+ `package_name` text NOT NULL,
24
+ `package_version` text NOT NULL,
25
+ `tag` text NOT NULL,
26
+ `module_id` text NOT NULL,
27
+ `hook_name` text NOT NULL,
28
+ `script_path` text NOT NULL,
29
+ `exit_code` integer,
30
+ `timed_out` integer DEFAULT 0 NOT NULL,
31
+ `duration_ms` integer NOT NULL,
32
+ `stdout_tail` text,
33
+ `stderr_tail` text,
34
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL
35
+ );
36
+ --> statement-breakpoint
37
+ CREATE INDEX `build_bus_hook_runs_created_at_idx` ON `build_bus_hook_runs` (`created_at`);
@@ -225,6 +225,20 @@
225
225
  "when": 1788768000000,
226
226
  "tag": "0031_module_config_source",
227
227
  "breakpoints": true
228
+ },
229
+ {
230
+ "idx": 32,
231
+ "version": "6",
232
+ "when": 1788982143000,
233
+ "tag": "0032_module_jail_policies",
234
+ "breakpoints": true
235
+ },
236
+ {
237
+ "idx": 33,
238
+ "version": "6",
239
+ "when": 1789082481000,
240
+ "tag": "0033_build_bus_hook_runs",
241
+ "breakpoints": true
228
242
  }
229
243
  ]
230
- }
244
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "2.2.1",
3
+ "version": "3.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": "^4.2.0",
61
+ "@celilo/capabilities": "^5.0.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.11.1",
63
+ "@celilo/core": "^0.13.0",
64
64
  "@celilo/event-bus": "^0.6.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -25,7 +25,7 @@ describe('Container Services and Machine Pool CLI Integration', () => {
25
25
  });
26
26
 
27
27
  afterEach(async () => {
28
- process.env.CELILO_MASTER_KEY_PATH = undefined;
28
+ delete process.env.CELILO_MASTER_KEY_PATH;
29
29
  await ctx.cleanup();
30
30
  });
31
31
 
package/src/api/serve.ts CHANGED
@@ -139,7 +139,19 @@ async function runCommand(argv: string[], forward: (msg: ServerMessage) => void)
139
139
  stderr: 'pipe',
140
140
  });
141
141
 
142
- await Promise.all([pumpLines(child.stdout, forward), pumpLines(child.stderr, forward)]);
142
+ // Each stream is pumped separately and the log message names its origin,
143
+ // so a client can keep stderr distinguishable from the command's result
144
+ // (celilo#1362 — the two used to merge into one undifferentiated channel).
145
+ const markStream =
146
+ (stream: 'stdout' | 'stderr') =>
147
+ (msg: ServerMessage): void => {
148
+ forward(msg.type === 'log' ? { ...msg, stream } : msg);
149
+ };
150
+
151
+ await Promise.all([
152
+ pumpLines(child.stdout, markStream('stdout')),
153
+ pumpLines(child.stderr, markStream('stderr')),
154
+ ]);
143
155
  const exitCode = await child.exited;
144
156
  forward(resultMessage(exitCode === 0, exitCode));
145
157
  return exitCode;
@@ -101,9 +101,9 @@ afterEach(() => {
101
101
  server.stop(true);
102
102
  rmSync(dir, { recursive: true, force: true });
103
103
  resetTestDbPath();
104
- process.env.CELILO_DATA_DIR = undefined;
105
- process.env.CELILO_ORIGINAL_CWD = undefined;
106
- process.env.EVENT_BUS_DB = undefined;
104
+ delete process.env.CELILO_DATA_DIR;
105
+ delete process.env.CELILO_ORIGINAL_CWD;
106
+ delete process.env.EVENT_BUS_DB;
107
107
  });
108
108
 
109
109
  test('an expired session is reaped and the command reports abandoned, not declined', async () => {
@@ -38,7 +38,8 @@ async function makeProxmoxRequest<T>(
38
38
  credentials: ProxmoxCredentials,
39
39
  path: string,
40
40
  ): Promise<ProxmoxResult<T>> {
41
- return new Promise((resolve) => {
41
+ let timeout: ReturnType<typeof setTimeout> | undefined;
42
+ return new Promise<ProxmoxResult<T>>((resolve) => {
42
43
  try {
43
44
  const { api_url, api_token_id, api_token_secret } = credentials;
44
45
  const authHeader = `PVEAPIToken=${api_token_id}=${api_token_secret}`;
@@ -114,17 +115,17 @@ async function makeProxmoxRequest<T>(
114
115
  });
115
116
  });
116
117
 
117
- // Fail fast instead of hanging on an unreachable host (no implicit timeout
118
- // on https.request). Callers treat a failed result as "couldn't reach
119
- // Proxmox" and fall back accordingly.
120
- req.setTimeout(15_000, () => {
121
- req.destroy();
118
+ // Bound the entire request, including connection establishment. On Bun
119
+ // 1.4, req.setTimeout does not bound a connection to an unreachable host.
120
+ // Callers need a failed result so they can fall back instead of hanging.
121
+ timeout = setTimeout(() => {
122
122
  resolve({
123
123
  success: false,
124
124
  message: 'Request timed out',
125
125
  details: { timeoutMs: 15_000 },
126
126
  });
127
- });
127
+ req.destroy();
128
+ }, 15_000);
128
129
 
129
130
  req.end();
130
131
  } catch (error) {
@@ -134,6 +135,8 @@ async function makeProxmoxRequest<T>(
134
135
  details: { error: String(error) },
135
136
  });
136
137
  }
138
+ }).finally(() => {
139
+ if (timeout !== undefined) clearTimeout(timeout);
137
140
  });
138
141
  }
139
142
 
@@ -28,7 +28,7 @@ describe('celilo module backup (renamed surface)', () => {
28
28
  dir = mkdtempSync(join(tmpdir(), 'celilo-rename-test-'));
29
29
  process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
30
30
  process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key');
31
- process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
31
+ delete process.env.CELILO_SUPPRESS_DEPRECATION;
32
32
  await runMigrations(process.env.CELILO_DB_PATH);
33
33
 
34
34
  warnings = [];
@@ -50,8 +50,8 @@ describe('celilo module backup (renamed surface)', () => {
50
50
  }
51
51
  closeDb();
52
52
  resetTestDbPath();
53
- process.env.CELILO_MASTER_KEY_PATH = undefined;
54
- process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
53
+ delete process.env.CELILO_MASTER_KEY_PATH;
54
+ delete process.env.CELILO_SUPPRESS_DEPRECATION;
55
55
  try {
56
56
  rmSync(dir, { recursive: true, force: true });
57
57
  } catch {
@@ -147,7 +147,7 @@ description: Test module for CLI
147
147
  }
148
148
 
149
149
  // Clear environment variables
150
- process.env.CELILO_MASTER_KEY_PATH = undefined;
150
+ delete process.env.CELILO_MASTER_KEY_PATH;
151
151
  resetTestDbPath();
152
152
  });
153
153
 
@@ -28,7 +28,7 @@ describe('celilo events command handlers', () => {
28
28
  process.env.EVENT_BUS_DB = dbPath;
29
29
  });
30
30
  afterEach(() => {
31
- process.env.EVENT_BUS_DB = undefined;
31
+ delete process.env.EVENT_BUS_DB;
32
32
  try {
33
33
  rmSync(dir, { recursive: true, force: true });
34
34
  } catch {
@@ -284,7 +284,7 @@ describe('celilo events list-failed', () => {
284
284
  bus.close();
285
285
  });
286
286
  afterEach(() => {
287
- process.env.EVENT_BUS_DB = undefined;
287
+ delete process.env.EVENT_BUS_DB;
288
288
  try {
289
289
  rmSync(dir, { recursive: true, force: true });
290
290
  } catch {
@@ -69,8 +69,8 @@ afterEach(() => {
69
69
  console.log = originalLog;
70
70
  rmSync(testDir, { recursive: true, force: true });
71
71
  resetTestDbPath();
72
- process.env.CELILO_DATA_DIR = undefined;
73
- process.env.CELILO_MASTER_KEY_PATH = undefined;
72
+ delete process.env.CELILO_DATA_DIR;
73
+ delete process.env.CELILO_MASTER_KEY_PATH;
74
74
  });
75
75
 
76
76
  describe('celilo firewall interface list', () => {
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { Machine } from '../../types/infrastructure';
3
+ import { machineListJson } from './machine-list';
4
+
5
+ const AT = Date.UTC(2026, 8, 8, 22, 59, 59);
6
+
7
+ function machine(partial: Partial<Machine> = {}): Machine {
8
+ return {
9
+ id: '3f6c2a44-1f0e-4a5b-9c8d-7e6f5a4b3c2d',
10
+ hostname: 'forgejo',
11
+ zone: 'dmz',
12
+ ipAddress: '10.0.20.5',
13
+ sshUser: 'peba',
14
+ // A real row always carries one; the JSON must not.
15
+ sshKeyEncrypted: '{"v":1,"sealed":"secret"}',
16
+ hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 128, arch: 'arm64' },
17
+ role: 'host',
18
+ interfaces: [{ name: 'eth0', ipAddress: '10.0.20.5', zone: 'dmz' }],
19
+ earmarkedModule: null,
20
+ apiOnly: false,
21
+ createdAt: new Date(AT),
22
+ updatedAt: new Date(AT),
23
+ ...partial,
24
+ };
25
+ }
26
+
27
+ function parse(machines: Machine[]): unknown[] {
28
+ const result = machineListJson(machines);
29
+ if (!result.success) throw new Error(result.error);
30
+ return JSON.parse(result.message ?? 'null') as unknown[];
31
+ }
32
+
33
+ describe('machineListJson', () => {
34
+ test('output parses as a machine-readable JSON array', () => {
35
+ const rows = parse([machine()]);
36
+ expect(Array.isArray(rows)).toBe(true);
37
+ });
38
+
39
+ test('the encrypted SSH key envelope is not in the payload', () => {
40
+ // Machine-readable output gets piped into scripts and stored in
41
+ // artifacts. A secret envelope in every listing is worse than a
42
+ // missing field in a terminal table.
43
+ const [row] = parse([machine()]) as Array<Record<string, unknown>>;
44
+ expect(row).not.toHaveProperty('sshKeyEncrypted');
45
+ expect(row.sshUser).toBe('peba');
46
+ });
47
+
48
+ test('timestamps survive as exact instants, not strings', () => {
49
+ const [row] = parse([machine()]) as Array<Record<string, unknown>>;
50
+ expect(row.createdAt).toBe(AT);
51
+ expect(row.updatedAt).toBe(AT);
52
+ });
53
+
54
+ test('an empty pool is a valid answer, not an error', () => {
55
+ expect(parse([])).toEqual([]);
56
+ });
57
+ });
@@ -9,28 +9,59 @@ import {
9
9
  getModulesOnMachine,
10
10
  listMachines,
11
11
  } from '../../services/machine-pool';
12
+ import type { Machine } from '../../types/infrastructure';
12
13
  import { celiloIntro } from '../prompts';
13
14
  import type { CommandResult } from '../types';
14
15
 
16
+ /**
17
+ * Machine-readable rows for `machine list --json`. One row per machine,
18
+ * with the same fields the human table renders. The encrypted SSH key
19
+ * envelope is deliberately absent: machine-readable output travels into
20
+ * scripts, logs and artifacts, where a secret is one more copy to leak.
21
+ */
22
+ export function machineListJson(machines: Machine[]): CommandResult {
23
+ const payload = machines.map((machine) => ({
24
+ id: machine.id,
25
+ hostname: machine.hostname,
26
+ zone: machine.zone,
27
+ ipAddress: machine.ipAddress,
28
+ sshUser: machine.sshUser,
29
+ role: machine.role,
30
+ hardware: machine.hardware,
31
+ interfaces: machine.interfaces,
32
+ earmarkedModule: machine.earmarkedModule,
33
+ apiOnly: machine.apiOnly,
34
+ createdAt: machine.createdAt.getTime(),
35
+ updatedAt: machine.updatedAt.getTime(),
36
+ }));
37
+
38
+ // `rawOutput` keeps the payload out of the decorating renderer, which
39
+ // would wrap it and stop it parsing (celilo#698).
40
+ return { success: true, message: JSON.stringify(payload, null, 2), rawOutput: true };
41
+ }
42
+
15
43
  /**
16
44
  * Handle machine list command
17
45
  *
18
46
  * @param args - Command arguments (unused)
19
- * @param flags - Command flags (--zone filter)
47
+ * @param flags - Command flags (--zone filter, --json for machine-readable output)
20
48
  */
21
49
  export async function handleMachineList(
22
50
  _args: string[],
23
51
  flags: Record<string, boolean | string> = {},
24
52
  ): Promise<CommandResult> {
25
53
  try {
26
- celiloIntro('Machine Pool');
27
-
28
- // Apply zone filter if provided
29
54
  const filters: MachineFilters = {};
30
55
  if (flags.zone && typeof flags.zone === 'string') {
31
56
  filters.zone = flags.zone as NetworkZone;
32
57
  }
33
58
 
59
+ if (flags.json) {
60
+ return machineListJson(await listMachines(filters));
61
+ }
62
+
63
+ celiloIntro('Machine Pool');
64
+
34
65
  const machines = await listMachines(filters);
35
66
 
36
67
  if (machines.length === 0) {
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { formatResult } from './module-health';
3
+
4
+ describe('module health rendering — the waiver annotation', () => {
5
+ test('a waived no-checks module renders the waiver beside the verdict, not instead of it', () => {
6
+ const output = formatResult({
7
+ moduleId: 'namecheap',
8
+ status: 'no-checks',
9
+ checks: [],
10
+ waiver: {
11
+ reason: 'API-only: a real check is hard and may not be worth forcing',
12
+ by: 'peba',
13
+ at: '2026-09-09',
14
+ },
15
+ });
16
+
17
+ // The unmeasured verdict is still stated.
18
+ expect(output).toContain('no health check defined');
19
+ // The annotation names the human's words, who, and when.
20
+ expect(output).toContain(
21
+ 'waived: API-only: a real check is hard and may not be worth forcing (by peba at 2026-09-09)',
22
+ );
23
+ });
24
+
25
+ test('a no-checks module without a waiver renders no waiver line', () => {
26
+ const output = formatResult({
27
+ moduleId: 'iptables',
28
+ status: 'no-checks',
29
+ checks: [],
30
+ });
31
+
32
+ expect(output).toContain('no health check defined');
33
+ expect(output).not.toContain('waived:');
34
+ });
35
+ });
@@ -23,14 +23,23 @@ const CHECK_ICONS: Record<string, string> = {
23
23
  pass: '✓',
24
24
  warn: '⚠',
25
25
  fail: '✗',
26
+ skip: '○',
26
27
  };
27
28
 
28
- function formatResult(result: HealthCheckResult): string {
29
+ export function formatResult(result: HealthCheckResult): string {
29
30
  const icon = STATUS_ICONS[result.status] || '?';
30
31
  const lines: string[] = [];
31
32
 
32
33
  if (result.status === 'no-checks') {
33
34
  lines.push(` ${result.moduleId} ${icon} no health check defined`);
35
+ // Annotate the waiver beside the verdict, never instead of it: the
36
+ // module is still unmeasured, a human just decided it needs no check
37
+ // (openspec/changes/health-waiver-mechanism, D3).
38
+ if (result.waiver) {
39
+ lines.push(
40
+ ` waived: ${result.waiver.reason} (by ${result.waiver.by} at ${result.waiver.at})`,
41
+ );
42
+ }
34
43
  return lines.join('\n');
35
44
  }
36
45
 
@@ -69,10 +78,10 @@ export async function handleModuleHealth(
69
78
  let results: HealthCheckResult[];
70
79
 
71
80
  if (moduleId) {
72
- const result = await runModuleHealthCheck(moduleId, db, { debug });
81
+ const result = await runModuleHealthCheck(moduleId, db, { debug, quiet: jsonOutput });
73
82
  results = [result];
74
83
  } else {
75
- results = await runAllHealthChecks(db, { debug });
84
+ results = await runAllHealthChecks(db, { debug, quiet: jsonOutput });
76
85
  }
77
86
 
78
87
  if (jsonOutput) {
@@ -44,7 +44,7 @@ afterEach(() => {
44
44
  downloadSpy.mockRestore();
45
45
  rmSync(tempDir, { recursive: true, force: true });
46
46
  resetTestDbPath();
47
- process.env.CELILO_DATA_DIR = undefined;
47
+ delete process.env.CELILO_DATA_DIR;
48
48
  });
49
49
 
50
50
  describe('handlePublicRegistryImport — registry lookup', () => {