@homeflare/alchemy 0.7.0 → 0.10.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 (312) hide show
  1. package/README.md +45 -45
  2. package/dist/caddy/config-lifecycle.d.ts +49 -3
  3. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  4. package/dist/caddy/config.d.ts +6 -2
  5. package/dist/caddy/config.d.ts.map +1 -1
  6. package/dist/caddy/fake-caddy.d.ts +11 -0
  7. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  8. package/dist/caddy/index.d.ts +2 -1
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +270 -44
  11. package/dist/caddy/index.js.map +15 -11
  12. package/dist/caddy/with-file.d.ts +7 -3
  13. package/dist/caddy/with-file.d.ts.map +1 -1
  14. package/dist/launchd/fake-runner.d.ts +1 -0
  15. package/dist/launchd/fake-runner.d.ts.map +1 -1
  16. package/dist/launchd/fake-sudo.d.ts +51 -0
  17. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  18. package/dist/launchd/host-effect.d.ts +1 -1
  19. package/dist/launchd/host-file-form.d.ts +5 -0
  20. package/dist/launchd/host-file-form.d.ts.map +1 -1
  21. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  22. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  23. package/dist/launchd/host-file.d.ts +2 -1
  24. package/dist/launchd/host-file.d.ts.map +1 -1
  25. package/dist/launchd/index.d.ts +2 -0
  26. package/dist/launchd/index.d.ts.map +1 -1
  27. package/dist/launchd/index.js +642 -49
  28. package/dist/launchd/index.js.map +24 -14
  29. package/dist/launchd/job-lifecycle.d.ts +8 -1
  30. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  31. package/dist/launchd/job-preflight.d.ts +13 -1
  32. package/dist/launchd/job-preflight.d.ts.map +1 -1
  33. package/dist/launchd/job-validate.d.ts +2 -0
  34. package/dist/launchd/job-validate.d.ts.map +1 -1
  35. package/dist/launchd/job.d.ts +2 -1
  36. package/dist/launchd/job.d.ts.map +1 -1
  37. package/dist/launchd/local-runner.d.ts +2 -0
  38. package/dist/launchd/local-runner.d.ts.map +1 -1
  39. package/dist/launchd/providers.d.ts +2 -1
  40. package/dist/launchd/providers.d.ts.map +1 -1
  41. package/dist/launchd/runner.d.ts +14 -5
  42. package/dist/launchd/runner.d.ts.map +1 -1
  43. package/dist/launchd/sudo-acl.d.ts +25 -0
  44. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  45. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  46. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  47. package/dist/launchd/sudo-guard.d.ts +83 -0
  48. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  49. package/dist/launchd/sudo-runner.d.ts +60 -0
  50. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  51. package/dist/launchd/sudo-said.d.ts +10 -0
  52. package/dist/launchd/sudo-said.d.ts.map +1 -0
  53. package/dist/launchd/sudo-stage.d.ts +7 -0
  54. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  55. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  56. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  57. package/dist/openbao/auth-method.d.ts.map +1 -1
  58. package/dist/openbao/auth-role-form.d.ts +0 -9
  59. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  60. package/dist/openbao/auth-role.d.ts +3 -2
  61. package/dist/openbao/auth-role.d.ts.map +1 -1
  62. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  63. package/dist/openbao/fake-bao.d.ts +18 -0
  64. package/dist/openbao/fake-bao.d.ts.map +1 -1
  65. package/dist/openbao/fake-core.d.ts +39 -0
  66. package/dist/openbao/fake-core.d.ts.map +1 -0
  67. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  68. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  69. package/dist/openbao/fake-engines.d.ts +8 -2
  70. package/dist/openbao/fake-engines.d.ts.map +1 -1
  71. package/dist/openbao/fake-families.d.ts +52 -0
  72. package/dist/openbao/fake-families.d.ts.map +1 -0
  73. package/dist/openbao/fake-mounts.d.ts +16 -0
  74. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  75. package/dist/openbao/fake-stack.d.ts +5 -1
  76. package/dist/openbao/fake-stack.d.ts.map +1 -1
  77. package/dist/openbao/host-approles.d.ts.map +1 -1
  78. package/dist/openbao/index.js +709 -359
  79. package/dist/openbao/index.js.map +32 -25
  80. package/dist/openbao/jwt-config.d.ts +5 -0
  81. package/dist/openbao/jwt-config.d.ts.map +1 -1
  82. package/dist/openbao/jwt-role.d.ts +2 -1
  83. package/dist/openbao/jwt-role.d.ts.map +1 -1
  84. package/dist/openbao/kubernetes-role.d.ts +2 -1
  85. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  86. package/dist/openbao/mfa-enforcement.d.ts +12 -5
  87. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  88. package/dist/openbao/mfa-totp.d.ts +6 -2
  89. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  90. package/dist/openbao/mount-reconcile.d.ts +5 -1
  91. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  92. package/dist/openbao/mount.d.ts.map +1 -1
  93. package/dist/openbao/pki-role.d.ts.map +1 -1
  94. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  95. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  96. package/dist/openbao/plugin.d.ts.map +1 -1
  97. package/dist/openbao/policy.d.ts.map +1 -1
  98. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  99. package/dist/openbao/rename-identity.d.ts +69 -0
  100. package/dist/openbao/rename-identity.d.ts.map +1 -0
  101. package/dist/openbao/rename.d.ts +13 -12
  102. package/dist/openbao/rename.d.ts.map +1 -1
  103. package/dist/openbao/role-reconcile.d.ts +11 -1
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  105. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  106. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  107. package/dist/openbao/ssh-role.d.ts.map +1 -1
  108. package/dist/ownership/adopt.d.ts +52 -0
  109. package/dist/ownership/adopt.d.ts.map +1 -0
  110. package/dist/ownership/probe.d.ts +13 -0
  111. package/dist/ownership/probe.d.ts.map +1 -0
  112. package/dist/ownership/resume.d.ts +40 -0
  113. package/dist/ownership/resume.d.ts.map +1 -0
  114. package/dist/ownership/rows.d.ts +33 -0
  115. package/dist/ownership/rows.d.ts.map +1 -0
  116. package/dist/ownership/whole.d.ts +14 -0
  117. package/dist/ownership/whole.d.ts.map +1 -0
  118. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  119. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  120. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  121. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  122. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  123. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  124. package/dist/proxmox/fake-pve.d.ts +38 -0
  125. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  126. package/dist/proxmox/index.d.ts +6 -2
  127. package/dist/proxmox/index.d.ts.map +1 -1
  128. package/dist/proxmox/index.js +970 -186
  129. package/dist/proxmox/index.js.map +22 -9
  130. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  131. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  132. package/dist/proxmox/lxc-harness.d.ts +34 -0
  133. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  134. package/dist/proxmox/lxc-identity.d.ts +18 -0
  135. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  136. package/dist/proxmox/lxc-judge.d.ts +46 -0
  137. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  138. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  139. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  140. package/dist/proxmox/lxc-props.d.ts +139 -0
  141. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  142. package/dist/proxmox/lxc-volume.d.ts +29 -0
  143. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  144. package/dist/proxmox/lxc-wire.d.ts +51 -0
  145. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  146. package/dist/proxmox/lxc.d.ts +27 -47
  147. package/dist/proxmox/lxc.d.ts.map +1 -1
  148. package/dist/proxmox/network-apply-read.d.ts +1 -1
  149. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  150. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  151. package/dist/proxmox/resource.d.ts.map +1 -1
  152. package/dist/proxmox/update-guard.d.ts +27 -0
  153. package/dist/proxmox/update-guard.d.ts.map +1 -0
  154. package/dist/verify/args.d.ts +16 -0
  155. package/dist/verify/args.d.ts.map +1 -0
  156. package/dist/verify/cli.d.ts +3 -0
  157. package/dist/verify/cli.d.ts.map +1 -0
  158. package/dist/verify/cli.js +320 -0
  159. package/dist/verify/cli.js.map +15 -0
  160. package/dist/verify/fake-engine.d.ts +57 -0
  161. package/dist/verify/fake-engine.d.ts.map +1 -0
  162. package/dist/verify/index.d.ts +16 -0
  163. package/dist/verify/index.d.ts.map +1 -0
  164. package/dist/verify/index.js +297 -0
  165. package/dist/verify/index.js.map +14 -0
  166. package/dist/verify/report.d.ts +6 -0
  167. package/dist/verify/report.d.ts.map +1 -0
  168. package/dist/verify/rows.d.ts +57 -0
  169. package/dist/verify/rows.d.ts.map +1 -0
  170. package/dist/verify/spy.d.ts +31 -0
  171. package/dist/verify/spy.d.ts.map +1 -0
  172. package/dist/verify/verify.d.ts +43 -0
  173. package/dist/verify/verify.d.ts.map +1 -0
  174. package/docs/adopt-verify.md +108 -0
  175. package/docs/adopted-deploys.md +85 -0
  176. package/docs/caddy-admin.md +24 -0
  177. package/docs/caddy.md +73 -54
  178. package/docs/credentials.md +15 -0
  179. package/docs/launchd-sudo.md +147 -0
  180. package/docs/launchd.md +14 -7
  181. package/docs/ownership.md +113 -0
  182. package/docs/peers.md +39 -0
  183. package/docs/proxmox-lxc.md +196 -0
  184. package/docs/proxmox.md +12 -0
  185. package/package.json +9 -2
  186. package/src/caddy/adopt-scope.test.ts +82 -0
  187. package/src/caddy/config-adopt.test.ts +235 -0
  188. package/src/caddy/config-lifecycle.test.ts +31 -53
  189. package/src/caddy/config-lifecycle.ts +98 -7
  190. package/src/caddy/config.ts +48 -10
  191. package/src/caddy/fake-caddy.ts +19 -0
  192. package/src/caddy/index.ts +2 -1
  193. package/src/caddy/providers.test.ts +8 -23
  194. package/src/caddy/with-file.ts +7 -3
  195. package/src/launchd/adopt-apply.test.ts +166 -0
  196. package/src/launchd/fake-runner.ts +26 -4
  197. package/src/launchd/fake-sudo.ts +136 -0
  198. package/src/launchd/host-effect.ts +1 -1
  199. package/src/launchd/host-file-form.ts +10 -2
  200. package/src/launchd/host-file-lifecycle.ts +41 -15
  201. package/src/launchd/host-file.ts +13 -4
  202. package/src/launchd/index.ts +2 -0
  203. package/src/launchd/job-lifecycle.ts +22 -14
  204. package/src/launchd/job-preflight.ts +21 -3
  205. package/src/launchd/job-validate.ts +1 -1
  206. package/src/launchd/job.ts +13 -4
  207. package/src/launchd/local-runner.ts +6 -3
  208. package/src/launchd/providers.test.ts +3 -0
  209. package/src/launchd/providers.ts +2 -1
  210. package/src/launchd/runner.ts +14 -5
  211. package/src/launchd/sudo-acl.ts +68 -0
  212. package/src/launchd/sudo-allowlist.test.ts +191 -0
  213. package/src/launchd/sudo-allowlist.ts +213 -0
  214. package/src/launchd/sudo-chain.test.ts +121 -0
  215. package/src/launchd/sudo-guard.test.ts +100 -0
  216. package/src/launchd/sudo-guard.ts +230 -0
  217. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  218. package/src/launchd/sudo-modes.test.ts +135 -0
  219. package/src/launchd/sudo-runner-files.test.ts +240 -0
  220. package/src/launchd/sudo-runner.test.ts +126 -0
  221. package/src/launchd/sudo-runner.ts +216 -0
  222. package/src/launchd/sudo-said.ts +58 -0
  223. package/src/launchd/sudo-stage.ts +28 -0
  224. package/src/openbao/README.md +11 -2
  225. package/src/openbao/REPLACE.md +67 -37
  226. package/src/openbao/adopt-core.test.ts +119 -0
  227. package/src/openbao/adopt-families.test.ts +97 -0
  228. package/src/openbao/adopt-holes.test.ts +168 -0
  229. package/src/openbao/adopt-recovery.test.ts +71 -0
  230. package/src/openbao/auth-method-reconcile.ts +15 -3
  231. package/src/openbao/auth-method.ts +15 -6
  232. package/src/openbao/auth-role-form.test.ts +2 -16
  233. package/src/openbao/auth-role-form.ts +0 -11
  234. package/src/openbao/auth-role.ts +48 -17
  235. package/src/openbao/cloudflare-role.ts +23 -18
  236. package/src/openbao/fake-bao.ts +52 -0
  237. package/src/openbao/fake-core.ts +141 -0
  238. package/src/openbao/fake-engines-roles.ts +114 -0
  239. package/src/openbao/fake-engines.ts +18 -11
  240. package/src/openbao/fake-families.ts +188 -0
  241. package/src/openbao/fake-mounts.ts +74 -0
  242. package/src/openbao/fake-stack.ts +17 -4
  243. package/src/openbao/host-approles.test.ts +15 -2
  244. package/src/openbao/host-approles.ts +19 -4
  245. package/src/openbao/jwt-config.ts +35 -11
  246. package/src/openbao/jwt-role.ts +33 -14
  247. package/src/openbao/kubernetes-role.ts +32 -14
  248. package/src/openbao/mfa-enforcement.ts +42 -13
  249. package/src/openbao/mfa-totp.ts +42 -14
  250. package/src/openbao/mount-move.test.ts +2 -35
  251. package/src/openbao/mount-reconcile.ts +13 -2
  252. package/src/openbao/mount.ts +15 -6
  253. package/src/openbao/pki-role.ts +33 -10
  254. package/src/openbao/plugin-reconcile.ts +16 -5
  255. package/src/openbao/plugin.ts +57 -20
  256. package/src/openbao/policy.ts +24 -11
  257. package/src/openbao/proxmox-role.ts +20 -20
  258. package/src/openbao/rename-adoption.test.ts +95 -0
  259. package/src/openbao/rename-families.test.ts +122 -0
  260. package/src/openbao/rename-identity.test.ts +129 -0
  261. package/src/openbao/rename-identity.ts +117 -0
  262. package/src/openbao/rename.ts +19 -15
  263. package/src/openbao/role-reconcile.ts +23 -0
  264. package/src/openbao/ssh-role-erase.ts +50 -0
  265. package/src/openbao/ssh-role.ts +29 -57
  266. package/src/ownership/adopt.ts +122 -0
  267. package/src/ownership/ownership.test.ts +200 -0
  268. package/src/ownership/probe.ts +65 -0
  269. package/src/ownership/resume.ts +118 -0
  270. package/src/ownership/rows.ts +117 -0
  271. package/src/ownership/whole.test.ts +39 -0
  272. package/src/ownership/whole.ts +60 -0
  273. package/src/proxmox/adopt-noop.test.ts +112 -0
  274. package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
  275. package/src/proxmox/ceph-pool-form.ts +3 -3
  276. package/src/proxmox/ceph-pool.ts +10 -3
  277. package/src/proxmox/fake-bao-env.ts +28 -0
  278. package/src/proxmox/fake-pve-lxc.ts +210 -0
  279. package/src/proxmox/fake-pve.ts +75 -0
  280. package/src/proxmox/index.test.ts +6 -6
  281. package/src/proxmox/index.ts +6 -2
  282. package/src/proxmox/lxc-adopt.test.ts +170 -0
  283. package/src/proxmox/lxc-create-form.ts +76 -0
  284. package/src/proxmox/lxc-create.test.ts +126 -0
  285. package/src/proxmox/lxc-guards.test.ts +158 -0
  286. package/src/proxmox/lxc-harness.ts +150 -0
  287. package/src/proxmox/lxc-identity.ts +43 -0
  288. package/src/proxmox/lxc-judge.test.ts +175 -0
  289. package/src/proxmox/lxc-judge.ts +196 -0
  290. package/src/proxmox/lxc-lifecycle.ts +200 -0
  291. package/src/proxmox/lxc-ownership.test.ts +159 -0
  292. package/src/proxmox/lxc-props.ts +215 -0
  293. package/src/proxmox/lxc-read.test.ts +60 -0
  294. package/src/proxmox/lxc-volume.test.ts +96 -0
  295. package/src/proxmox/lxc-volume.ts +153 -0
  296. package/src/proxmox/lxc-wire.ts +210 -0
  297. package/src/proxmox/lxc.ts +216 -93
  298. package/src/proxmox/network-apply-read.ts +3 -1
  299. package/src/proxmox/pbs-datastore.ts +4 -3
  300. package/src/proxmox/resource.ts +7 -7
  301. package/src/proxmox/update-guard.ts +31 -0
  302. package/src/verify/args.ts +82 -0
  303. package/src/verify/cli.test.ts +86 -0
  304. package/src/verify/cli.ts +49 -0
  305. package/src/verify/fake-engine.ts +158 -0
  306. package/src/verify/index.ts +36 -0
  307. package/src/verify/report.ts +52 -0
  308. package/src/verify/rows.ts +169 -0
  309. package/src/verify/spy.test.ts +141 -0
  310. package/src/verify/spy.ts +149 -0
  311. package/src/verify/verify.test.ts +124 -0
  312. package/src/verify/verify.ts +172 -0
@@ -5,8 +5,7 @@
5
5
  * - create / update — `POST /adapt` (validate), `POST /load` with `text/caddyfile` (a graceful
6
6
  * reload; Caddy keeps the old config if it refuses the new one, and the error names why), then
7
7
  * `GET /config/` and insist it matches. Skipped when Caddy already runs this config.
8
- * - read — the digest of `GET /config/`. ★ WITH NO STATE, A RUNNING CADDY IS ADOPTED: its live
9
- * config becomes the starting point, and the deploy's forced update loads the declared one.
8
+ * - read — the digest of `GET /config/`. With no state it is the adoption probe (below).
10
9
  * - diff — declared, live and stored digests (config-lifecycle.ts). Never `replace`.
11
10
  * - delete — ⛔ NOTHING. See below.
12
11
  *
@@ -15,17 +14,24 @@
15
14
  * servers. `retain` is the default (the engine never calls delete); with `destroy` the handler
16
15
  * still only forgets. It never calls `/stop` (which exits the process) or `DELETE /config/`.
17
16
  * To empty a Caddy, do it by hand at its admin API.
17
+ * ⛔ NOTHING IS ADOPTED SILENTLY (decision, 2026-09-21 — the house rule HostFile and LaunchdJob
18
+ * keep). With no state, a running Caddy whose live config IS the declared one reads as ours (the
19
+ * adoption changes nothing); any other config reads as `Unowned`, and the plan refuses it unless
20
+ * the deploy runs with `--adopt`. A Caddy serving nothing reads as absent: a create. Where the
21
+ * engine skips that probe, reconcile refuses the same takeover (config-lifecycle.ts).
18
22
  * ⛔ `caddyfile` IS NEVER A SECRET — see config-form.ts; refused before anything is sent.
19
23
  * ⚠️ WHICH CADDY is the transport's (providers.ts), not a prop: one CaddyAdmin per stack.
20
24
  */
21
25
  import { Resource } from 'alchemy';
26
+ import { Unowned } from 'alchemy/AdoptPolicy';
22
27
  import { isResolved } from 'alchemy/Diff';
23
28
  import * as Provider from 'alchemy/Provider';
24
29
  import * as Effect from 'effect/Effect';
25
30
  import { lift, resolvedString } from '../launchd/host-effect.ts';
31
+ import { adoptEnabled } from '../ownership/adopt.ts';
26
32
  import { CaddyAdminService, CaddyUnreachableError } from './admin.ts';
27
33
  import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
28
- import { diffConfig, readLive, reconcileConfig } from './config-lifecycle.ts';
34
+ import { diffConfig, probeLive, readLive, reconcileConfig } from './config-lifecycle.ts';
29
35
 
30
36
  export type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
31
37
 
@@ -64,17 +70,40 @@ export const CaddyConfigProvider = () =>
64
70
  CaddyConfig,
65
71
  Effect.gen(function* () {
66
72
  const admin = yield* CaddyAdminService;
73
+ /** The probe, branded for the engine: plain when ours, `Unowned` otherwise. */
74
+ const probe = (caddyfile: string | undefined, sourceFile: string | undefined) =>
75
+ Effect.flatMap(
76
+ lift(() => probeLive(admin, caddyfile, sourceFile)),
77
+ (found) => {
78
+ if (found === undefined) return Effect.succeed(undefined);
79
+ if (found.ours) return Effect.succeed(found.attributes);
80
+ const unowned = Unowned(found.attributes);
81
+ return found.unchecked === undefined
82
+ ? Effect.succeed(unowned)
83
+ : Effect.as(
84
+ Effect.logWarning(
85
+ `Caddy.Config at ${admin.endpoint}: the running config could not be compared ` +
86
+ `with the declared Caddyfile (${found.unchecked}), so it is not treated as ours`,
87
+ ),
88
+ unowned,
89
+ );
90
+ },
91
+ );
67
92
  return CaddyConfig.Provider.of({
68
93
  /** ⛔ A Caddy has one config; there is nothing to enumerate, and nothing for nuke to delete. */
69
94
  list: () => Effect.succeed([]),
70
95
 
71
96
  /**
72
- * ★ Plain attributes, never `Unowned`: a Caddy on this host's loopback admin API is the one
73
- * the stack means (the transport refuses anything else), and adopting reads — it changes
74
- * nothing until reconcile loads the declared Caddyfile.
97
+ * With state: the live attributes, which diff compares. With none, the ADOPTION PROBE —
98
+ * `olds` is the declaration (Plan.ts passes `news`), and only a live config equal to it is
99
+ * ours. ⚠️ A running Caddy is still the one the stack means (the transport reaches only
100
+ * loopback), but WHOSE CONFIG it runs is not: a person, another tool or another stack.
75
101
  */
76
- read: ({ olds }) =>
77
- lift(() => readLive(admin, resolvedString(olds, 'sourceFile'))).pipe(
102
+ read: ({ olds, output }) =>
103
+ (output === undefined
104
+ ? probe(resolvedString(olds, 'caddyfile'), resolvedString(olds, 'sourceFile'))
105
+ : lift(() => readLive(admin, resolvedString(olds, 'sourceFile')))
106
+ ).pipe(
78
107
  // ★ Nothing to adopt from a Caddy that is down: plan a create, which is the same load.
79
108
  Effect.catchIf(unreachable, planWithoutCaddy(admin, undefined)),
80
109
  ),
@@ -101,9 +130,18 @@ export const CaddyConfigProvider = () =>
101
130
  }).pipe(Effect.catchIf(unreachable, update));
102
131
  },
103
132
 
104
- reconcile: ({ news }) =>
133
+ reconcile: ({ fqn, news, output }) =>
105
134
  Effect.gen(function* () {
106
- const applied = yield* lift(() => reconcileConfig(admin, news));
135
+ // ⛔ State is authority over the Caddy it was applied to, and no other. With none (the
136
+ // engine may not have probed: see reconcileConfig), or with the transport now at
137
+ // another endpoint, only adoption — `--adopt`, or this resource's own `adopt(…)`,
138
+ // as the planner resolves it — loads over a config the stack cannot claim.
139
+ const sameCaddy = output !== undefined && output.endpoint === admin.endpoint;
140
+ const takeOver = sameCaddy || (yield* adoptEnabled(fqn));
141
+ const stored = output === undefined ? {} : { stored: output.configSha256 };
142
+ const applied = yield* lift(() =>
143
+ reconcileConfig(admin, news, { takeOver, ...stored }),
144
+ );
107
145
  for (const warning of applied.warnings) {
108
146
  yield* Effect.logWarning(`Caddy.Config at ${admin.endpoint}: ${warning}`);
109
147
  }
@@ -17,6 +17,9 @@
17
17
  * ★ `listenPort` is the port Caddy BELIEVES it listens on — its Host check uses it — while the
18
18
  * fake really listens on an ephemeral one: a Caddy on its default :2019 behind a forward.
19
19
  */
20
+ import type { CaddyAdmin } from './admin.ts';
21
+ import { localCaddyAdmin } from './local-admin.ts';
22
+
20
23
  export type Seen = {
21
24
  readonly method: string;
22
25
  readonly path: string;
@@ -158,3 +161,19 @@ export const fakeCaddy = (
158
161
  stop: () => void server.stop(true),
159
162
  };
160
163
  };
164
+
165
+ /**
166
+ * A fake Caddy on its DEFAULT :2019 behind a forward (`hostHeader`), and the transport to it.
167
+ * ★ Addressed that way so a Caddyfile with no `admin` line — the common case — passes the guard.
168
+ * A Caddy reached on any other port needs `admin <address>` declared, or the first load would
169
+ * move it (admin-guard.ts).
170
+ */
171
+ export const fakeDefaultCaddy = (running?: unknown): { admin: CaddyAdmin; caddy: FakeCaddy } => {
172
+ const caddy = fakeCaddy({ listenPort: 2019, ...(running === undefined ? {} : { running }) });
173
+ const admin = localCaddyAdmin({
174
+ address: caddy.address,
175
+ hostHeader: '127.0.0.1:2019',
176
+ retries: 0,
177
+ });
178
+ return { admin, caddy };
179
+ };
@@ -6,7 +6,8 @@
6
6
  * the digest, the guards and the admin calls are internals; an `export *` would publish them.
7
7
  * ⛔ THE ADMIN API STAYS ON LOOPBACK OR A UNIX SOCKET. It has no authentication; localCaddyAdmin()
8
8
  * refuses any other address, and a Caddyfile that would move or expose it is refused before load.
9
- * Guide, order of file and load, `--resume` and secrets: docs/caddy.md.
9
+ * ⛔ NOTHING IS ADOPTED SILENTLY: a running config that is not the declared one needs `--adopt`.
10
+ * Guide, order of file and load, adoption, `--resume` and secrets: docs/caddy.md.
10
11
  */
11
12
  export type {
12
13
  CaddyAdmin,
@@ -3,7 +3,6 @@
3
3
  * way the engine calls them; caddyWithFile's registration; and the barrel, the public API.
4
4
  */
5
5
  import { afterEach, describe, expect, test } from 'bun:test';
6
- import { Unowned } from 'alchemy/AdoptPolicy';
7
6
  import * as Output from 'alchemy/Output';
8
7
  import { Stack } from 'alchemy/Stack';
9
8
  import * as Effect from 'effect/Effect';
@@ -52,37 +51,23 @@ const withProvider = <A>(
52
51
  };
53
52
 
54
53
  describe('CaddyConfigProvider', () => {
55
- test('read with no state ADOPTS: plain attributes of the live config, never Unowned', async () => {
56
- const probe = await withProvider((provider) =>
57
- handler('read', provider.read)({ ...ids, olds: { caddyfile: SITE }, output: undefined }),
58
- );
59
- expect(Unowned.is(probe)).toBe(false);
60
- expect(probe).toMatchObject({ endpoint: fake?.address });
61
- });
62
-
63
- test('reconcile applies; delete then sends nothing at all', async () => {
54
+ test('reconcile applies over its own state; delete then sends nothing at all', async () => {
64
55
  await withProvider((provider) =>
65
56
  Effect.gen(function* () {
66
- yield* provider.reconcile({
57
+ const output = yield* provider.reconcile({
67
58
  ...ids,
68
59
  bindings: [] as never,
69
60
  news: { caddyfile: SITE },
70
- olds: undefined,
71
- output: undefined,
61
+ olds: { caddyfile: SITE },
62
+ // ★ State from an earlier deploy: the running `legacy` config is this stack's to replace.
63
+ output: { configSha256: 'earlier', endpoint: fake?.address ?? '' },
72
64
  session: undefined as never,
73
65
  });
66
+ expect(fake?.seen.some((call) => call.path === '/load')).toBe(true);
74
67
  const before = fake?.seen.length;
75
- const output = yield* handler(
76
- 'read',
77
- provider.read,
78
- )({
79
- ...ids,
80
- olds: { caddyfile: SITE },
81
- output: undefined,
82
- });
83
68
  yield* provider.delete({ ...ids, olds: { caddyfile: SITE }, output } as never);
84
- // ★ Only the read above talked to Caddy: delete never unloads, never stops.
85
- expect(fake?.seen.length).toBe((before ?? 0) + 1);
69
+ // ★ Delete never unloads, never stops: not one more call reached Caddy.
70
+ expect(fake?.seen.length).toBe(before);
86
71
  }),
87
72
  );
88
73
  });
@@ -14,9 +14,13 @@
14
14
  * still an Output, Alchemy skips the adoption probe, and the check runs at apply instead.)
15
15
  * · A Caddyfile that adapts but that Caddy then REFUSES (a port in use, a missing cert file) is
16
16
  * on disk when `/load` fails. Caddy keeps serving the old config, the deploy fails with Caddy's
17
- * reason — and a RESTART before the fix would load the refused file. Fix and redeploy, or run
18
- * Caddy with `--resume` (docs/caddy.md): it restarts from `autosave.json`, the last config Caddy
19
- * ACCEPTED, and the file is only the first-boot fallback.
17
+ * reason — and a Caddy started without `--resume` would load the refused file on its next
18
+ * restart. Managed Caddies run with `--resume` (decision, 2026-09-21; docs/caddy.md): they
19
+ * restart from `autosave.json`, the last config Caddy ACCEPTED, and the file is only the
20
+ * first-boot fallback.
21
+ * · The skipped probe is also why the ADOPTION check of a first deploy happens at apply: a Caddy
22
+ * running a config this stack did not load is refused before `/load` unless the deploy runs
23
+ * with `--adopt` (config-lifecycle.ts reconcileConfig) — after this file is written.
20
24
  * ⛔ SECRETS ARE REFUSED HERE, AT DECLARATION, before the HostFile exists: its `content` is a prop
21
25
  * too, and would carry a literal into state even if the CaddyConfig then refused it.
22
26
  * ★ BOTH RETAIN. Removing this from a stack must not delete the file a restart needs.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `--adopt` and `adopt(…)` at APPLY, for HostFile and LaunchdJob — the create Alchemy never probed
3
+ * (a prop still an Output at plan time). Before 2026-09-21 these reconciles refused a foreign file
4
+ * or job whatever the deploy said, so the flag the refusal told you to use could not work there.
5
+ * They now resolve adoption the way the planner does (ownership/adopt.ts adoptEnabled), BOTH ways —
6
+ * for a CREATE only: the row Apply committed for this instance is `creating`. ⛔ A replace's new
7
+ * generation (a `replacing` row, `output` undefined all the same) is never adoptable at apply.
8
+ * ★ Registered through the REAL `Resource` under a Stack, as caddy/adopt-scope.test.ts is, over a
9
+ * state store holding the row Apply commits before it calls reconcile.
10
+ */
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { AdoptPolicy, adopt } from 'alchemy/AdoptPolicy';
13
+ import { Artifacts, makeScopedArtifacts } from 'alchemy/Artifacts';
14
+ import { InMemoryService } from 'alchemy/State/InMemoryState';
15
+ import type { ResourceState } from 'alchemy/State/ResourceState';
16
+ import { State } from 'alchemy/State/State';
17
+ import { Stack } from 'alchemy/Stack';
18
+ import * as Effect from 'effect/Effect';
19
+ import * as Layer from 'effect/Layer';
20
+ import { noteUnfinished } from '../ownership/resume.ts';
21
+ import { fakeRunner } from './fake-runner.ts';
22
+ import { HostFile, HostFileProvider } from './host-file.ts';
23
+ import { LaunchdJob, LaunchdJobProvider } from './job.ts';
24
+ import type { LaunchdJobAttributes } from './job-form.ts';
25
+ import { hostRunnerLayer } from './runner.ts';
26
+
27
+ type Scope = 'adopt(true)' | 'adopt(false)' | 'none';
28
+
29
+ const FOREIGN = new TextEncoder().encode('someone else wrote this\n');
30
+ const PATH = '/etc/example/a.conf';
31
+ const PLIST = '/Library/LaunchDaemons/com.example.job.plist';
32
+ const JOB = { domain: 'system', label: 'com.example.job', programArguments: ['/bin/job'] } as const;
33
+
34
+ const host = () => {
35
+ const fake = fakeRunner({ dirs: { '/Library/LaunchDaemons': 0, '/etc/example': 0 }, euid: 0 });
36
+ for (const path of [PATH, PLIST]) {
37
+ fake.files.set(path, { bytes: FOREIGN, gid: 0, kind: 'file', mode: 0o644, uid: 0 });
38
+ }
39
+ return fake;
40
+ };
41
+
42
+ const scoped = <A extends Effect.Effect<unknown, unknown, unknown>>(declared: A, scope: Scope) =>
43
+ scope === 'none' ? declared : (declared.pipe(adopt(scope === 'adopt(true)')) as A);
44
+
45
+ /** The row Apply commits before reconcile: a create's, or a replace's new generation's. */
46
+ type Generation = 'creating' | 'replacing';
47
+
48
+ /** Register `family` with `scope` under a deploy whose `--adopt` is `flag`; reconcile it statelessly. */
49
+ const firstApply = (
50
+ family: 'file' | 'job',
51
+ scope: Scope,
52
+ flag: boolean,
53
+ output?: unknown,
54
+ generation: Generation = 'creating',
55
+ /** The plan's diff saw this generation's row with no attributes (ownership/resume.ts). */
56
+ unfinished = false,
57
+ ) => {
58
+ const fake = host();
59
+ const fqn = family === 'file' ? 'conf' : 'job';
60
+ const row = { fqn, instanceId: 'i-1', status: generation } as unknown as ResourceState;
61
+ const spec = { actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' };
62
+ const call = {
63
+ bindings: [] as never,
64
+ instanceId: 'i-1',
65
+ olds: undefined,
66
+ session: undefined as never,
67
+ };
68
+ const program: Effect.Effect<unknown, unknown, AdoptPolicy | Stack> =
69
+ family === 'file'
70
+ ? Effect.gen(function* () {
71
+ yield* scoped(HostFile('conf', { content: 'x', path: PATH }), scope);
72
+ const provider = yield* HostFile.Provider;
73
+ const news = { content: 'x', path: PATH };
74
+ return yield* provider.reconcile({
75
+ ...call,
76
+ fqn: 'conf',
77
+ id: 'conf',
78
+ news,
79
+ output: undefined,
80
+ });
81
+ }).pipe(
82
+ Effect.provide(HostFileProvider().pipe(Layer.provide(hostRunnerLayer(fake.runner)))),
83
+ )
84
+ : Effect.gen(function* () {
85
+ yield* scoped(LaunchdJob('job', JOB), scope);
86
+ const provider = yield* LaunchdJob.Provider;
87
+ const prior = output as LaunchdJobAttributes | undefined;
88
+ return yield* provider.reconcile({
89
+ ...call,
90
+ fqn: 'job',
91
+ id: 'job',
92
+ news: JOB,
93
+ output: prior,
94
+ });
95
+ }).pipe(
96
+ Effect.provide(LaunchdJobProvider().pipe(Layer.provide(hostRunnerLayer(fake.runner)))),
97
+ );
98
+ const bag = makeScopedArtifacts(new Map(), fqn);
99
+ const noted = unfinished ? noteUnfinished('i-1') : Effect.void;
100
+ const applying = Effect.runPromise(
101
+ Effect.andThen(noted, program).pipe(
102
+ Effect.provideService(Artifacts, bag),
103
+ Effect.provideService(AdoptPolicy, flag),
104
+ Effect.provideService(State, InMemoryService({ test: { test: { [fqn]: row } } })),
105
+ Effect.provideService(Stack, spec as never),
106
+ ),
107
+ );
108
+ return { applying, fake };
109
+ };
110
+
111
+ const REFUSED = /already exists and is not this resource|is already on this host/;
112
+
113
+ describe.each(['file', 'job'] as const)('%s: a first apply over a foreign object', (family) => {
114
+ test.each([
115
+ ['adopt(false) under --adopt', 'adopt(false)', true, false],
116
+ ['adopt(true) without --adopt', 'adopt(true)', false, true],
117
+ ['no scope under --adopt', 'none', true, true],
118
+ ['no scope without --adopt', 'none', false, false],
119
+ ] as const)('with %s', async (_, scope, flag, takesOver) => {
120
+ const { applying, fake } = firstApply(family, scope, flag);
121
+ const path = family === 'file' ? PATH : PLIST;
122
+ if (takesOver) {
123
+ await applying;
124
+ expect(fake.files.get(path)?.bytes).not.toEqual(FOREIGN);
125
+ } else {
126
+ await expect(applying).rejects.toThrow(REFUSED);
127
+ expect(fake.files.get(path)?.bytes).toEqual(FOREIGN);
128
+ }
129
+ });
130
+ });
131
+
132
+ test('a LaunchdJob rename onto a foreign job stays refused, --adopt or not', async () => {
133
+ const old = {
134
+ domain: 'system',
135
+ label: 'com.example.old',
136
+ loaded: false,
137
+ plistPath: '/Library/LaunchDaemons/com.example.old.plist',
138
+ plistSha256: '',
139
+ serviceTarget: 'system/com.example.old',
140
+ };
141
+ const { applying, fake } = firstApply('job', 'adopt(true)', true, old);
142
+ await expect(applying).rejects.toThrow(REFUSED);
143
+ expect(fake.files.get(PLIST)?.bytes).toEqual(FOREIGN);
144
+ });
145
+
146
+ describe.each(['file', 'job'] as const)('%s: a replace onto a foreign object', (family) => {
147
+ test.each([
148
+ ['--adopt', 'none', true],
149
+ ['adopt(true)', 'adopt(true)', false],
150
+ ] as const)(
151
+ 'stays refused under %s: the plan never offered its new generation adoption',
152
+ async (_, scope, flag) => {
153
+ const { applying, fake } = firstApply(family, scope, flag, undefined, 'replacing');
154
+ await expect(applying).rejects.toThrow(REFUSED);
155
+ expect(fake.files.get(family === 'file' ? PATH : PLIST)?.bytes).toEqual(FOREIGN);
156
+ },
157
+ );
158
+
159
+ test('an UNFINISHED replace (its row seen by the plan) resumes under --adopt, as a create does', async () => {
160
+ const refused = firstApply(family, 'none', false, undefined, 'replacing', true);
161
+ await expect(refused.applying).rejects.toThrow(REFUSED);
162
+ const { applying, fake } = firstApply(family, 'none', true, undefined, 'replacing', true);
163
+ await applying;
164
+ expect(fake.files.get(family === 'file' ? PATH : PLIST)?.bytes).not.toEqual(FOREIGN);
165
+ });
166
+ });
@@ -9,6 +9,8 @@
9
9
  * job exits non-zero; a job can linger after bootout (`lingerPrints`); a gui uid with no login
10
10
  * session answers 112 (`loggedOut`); writes into a root-owned directory, or a chown to another
11
11
  * uid, need root.
12
+ * ★ Every directory ABOVE a declared one exists, root's and `0755`, as `/`, `/Library` and `/opt`
13
+ * are on a Mac; `acls` holds the `ls -lden` entry lines a directory prints (sudo-acl.ts).
12
14
  */
13
15
  import { LAUNCHCTL, NOT_FOUND, NO_DOMAIN } from './launchctl.ts';
14
16
  import type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
@@ -54,6 +56,16 @@ export const fakeRunner = (options: FakeOptions = {}) => {
54
56
  const root = privileged || euid === 0;
55
57
  const dirs = new Map(Object.entries(options.dirs ?? {}));
56
58
  const files = new Map<string, Entry>();
59
+ /** Directory → the ACL entry lines `ls -lden` prints under it. */
60
+ const acls = new Map<string, string[]>();
61
+ /** The directories above the declared ones, as the host starts: root's, `0755`. */
62
+ const above = new Set<string>(['/']);
63
+ for (const dir of dirs.keys()) {
64
+ const parts = dir.split('/').slice(1, -1);
65
+ for (const index of parts.keys()) above.add(`/${parts.slice(0, index + 1).join('/')}`);
66
+ }
67
+ for (const dir of dirs.keys()) above.delete(dir);
68
+ const implied = (path: string) => above.has(path);
57
69
  const calls: string[][] = [];
58
70
  /** target → pid (undefined = loaded, not running). */
59
71
  const loaded = new Map<string, number | undefined>();
@@ -128,6 +140,16 @@ export const fakeRunner = (options: FakeOptions = {}) => {
128
140
  exec: async (argv) => {
129
141
  calls.push([...argv]);
130
142
  if (argv[0] === LAUNCHCTL) return launchctl(argv.slice(1));
143
+ if (argv[0] === '/bin/ls' && argv[1] === '-lden' && argv[2] === '--') {
144
+ const paths = argv.slice(3);
145
+ if (paths.some((path) => !dirs.has(path) && !files.has(path) && !implied(path)))
146
+ return fail(1, 'ls: No such file or directory');
147
+ const lines = paths.flatMap((path) => [
148
+ `drwxr-xr-x 2 0 0 64 Sep 21 10:00 ${path}`,
149
+ ...(acls.get(path) ?? []),
150
+ ]);
151
+ return ok(lines.join('\n'));
152
+ }
131
153
  return fail(127, `fake: no program ${String(argv[0])}`);
132
154
  },
133
155
  lookupGroup: async (nameOrId) => options.groups?.[nameOrId],
@@ -153,9 +175,9 @@ export const fakeRunner = (options: FakeOptions = {}) => {
153
175
  size: entry.bytes.length,
154
176
  uid: entry.uid,
155
177
  };
156
- return dirs.has(path)
157
- ? { gid: 0, kind: 'directory', mode: 0o755, size: 0, uid: dirs.get(path) ?? 0 }
158
- : undefined;
178
+ if (dirs.has(path) || implied(path))
179
+ return { gid: 0, kind: 'directory', mode: 0o755, size: 0, uid: dirs.get(path) ?? 0 };
180
+ return undefined;
159
181
  },
160
182
  writeFileAtomic: async (path: string, bytes: Uint8Array, write: WriteOptions) => {
161
183
  calls.push(['write', path]);
@@ -172,5 +194,5 @@ export const fakeRunner = (options: FakeOptions = {}) => {
172
194
  });
173
195
  },
174
196
  };
175
- return { calls, disabled, dirs, files, loaded, runner, state };
197
+ return { acls, calls, disabled, dirs, files, loaded, runner, state };
176
198
  };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * A sudo runner over fake-runner.ts, for the tests beside it: the deploying user is uid 501 and
3
+ * unprivileged, and `/usr/bin/sudo` is a tiny interpreter that runs the three allowlisted programs
4
+ * against the same in-memory host, as root.
5
+ *
6
+ * ⛔ TEST-ONLY, like fake-runner.ts: no provider imports it, it is not on the barrel, and it never
7
+ * runs a real program. No test anywhere runs a real sudo.
8
+ * ★ THE INTERPRETER READS ONLY WHAT THE ARGV SAYS — mode, owner, group, source, destination — so a
9
+ * runner that dropped `-o` or wrote the wrong path shows up as the wrong file, not a passing test.
10
+ * ★ THE BASE STAYS UNPRIVILEGED, so a write under /Library/LaunchDaemons that skipped sudo fails
11
+ * with the fake's EACCES instead of quietly succeeding.
12
+ * ★ SUDO REFUSES A CALL THAT WAS NOT LOGGED FIRST, so every test holds the runner to "logged
13
+ * before it runs", not merely "logged at some point".
14
+ */
15
+ import { fakeRunner } from './fake-runner.ts';
16
+ import { LAUNCHCTL } from './launchctl.ts';
17
+ import type { ExecResult, HostRunner } from './runner.ts';
18
+ import { INSTALL, RM, SUDO } from './sudo-allowlist.ts';
19
+ import { makeSudoRunner } from './sudo-runner.ts';
20
+
21
+ export const OPERATOR = 501;
22
+ export const PREFIXES = ['/Library/LaunchDaemons', '/opt/example'];
23
+
24
+ const ok: ExecResult = { exitCode: 0, stderr: '', stdout: '' };
25
+
26
+ /** What `sudo -n` prints (exit 1) when it will not run the command. Reasoned; see sudo-runner.ts. */
27
+ export const SUDO_SAYS = {
28
+ denied:
29
+ "Sorry, user someone is not allowed to execute '/bin/launchctl bootout system/x' as root on example.\n",
30
+ 'not-in-sudoers': 'someone is not in the sudoers file.\n',
31
+ 'not-on-host': 'someone is not allowed to run sudo on example.\n',
32
+ password: 'sudo: a password is required\n',
33
+ } as const;
34
+ const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
35
+
36
+ /** Parse `install -S -m MODE [-o UID] [-g GID] SRC DEST` the way install(1) would. */
37
+ const parseInstall = (args: readonly string[]) => {
38
+ const mode = Number.parseInt(args[2] ?? '', 8);
39
+ const rest = args.slice(3);
40
+ const flag = (name: string) => {
41
+ const at = rest.indexOf(name);
42
+ return at === -1 ? undefined : Number(rest[at + 1]);
43
+ };
44
+ return {
45
+ dest: rest[rest.length - 1] ?? '',
46
+ gid: flag('-g'),
47
+ mode,
48
+ source: rest[rest.length - 2] ?? '',
49
+ uid: flag('-o'),
50
+ };
51
+ };
52
+
53
+ /** `groups` is the operator's `id -G`; `'unknown'` models that lookup failing. */
54
+ export const fakeSudoHost = (
55
+ groups: readonly number[] | 'unknown' = [20, 80],
56
+ prefixes: readonly string[] = PREFIXES,
57
+ ) => {
58
+ const fake = fakeRunner({
59
+ dirs: {
60
+ '/Library/LaunchDaemons': 0,
61
+ '/opt/example': 0,
62
+ '/opt/example/app': 0,
63
+ '/Users/someone/Library/LaunchAgents': OPERATOR,
64
+ '/Users/other/Library/LaunchAgents': 502,
65
+ // Root-owned, and deliberately NOT a prefix: the forgotten-prefix case.
66
+ '/etc/example': 0,
67
+ },
68
+ euid: OPERATOR,
69
+ users: {
70
+ other: { gid: 20, home: '/Users/other', uid: 502 },
71
+ someone: { gid: 20, home: '/Users/someone', uid: OPERATOR },
72
+ },
73
+ });
74
+ const staged = new Map<string, Uint8Array>();
75
+ const logs: string[] = [];
76
+ const sudoCalls: string[][] = [];
77
+ const state = {
78
+ /** What sudo itself answers: runs the command, or refuses it with one of SUDO_SAYS. */
79
+ sudo: 'ok' as 'ok' | keyof typeof SUDO_SAYS,
80
+ /** Returned by `install` in place of success; nothing is written. */
81
+ installFailure: undefined as ExecResult | undefined,
82
+ stagedCount: 0,
83
+ /** The staged bytes as `install` saw them, by destination. */
84
+ installedFrom: new Map<string, string>(),
85
+ };
86
+
87
+ const asRoot = async (argv: readonly string[]): Promise<ExecResult> => {
88
+ const [program, ...args] = argv;
89
+ if (program === LAUNCHCTL) return fake.runner.exec(argv);
90
+ if (program === INSTALL) {
91
+ if (state.installFailure !== undefined) return state.installFailure;
92
+ const { dest, gid, mode, source, uid } = parseInstall(args);
93
+ const bytes = staged.get(source);
94
+ if (bytes === undefined) return fail(71, `install: ${source}: No such file or directory`);
95
+ state.installedFrom.set(dest, new TextDecoder().decode(bytes));
96
+ // ★ No -g: the new file takes its directory's group, as a BSD file create does.
97
+ const dirGid = (await fake.runner.stat(dest.slice(0, dest.lastIndexOf('/'))))?.gid ?? 0;
98
+ fake.files.set(dest, { bytes, gid: gid ?? dirGid, kind: 'file', mode, uid: uid ?? 0 });
99
+ return ok;
100
+ }
101
+ if (program === RM) {
102
+ fake.files.delete(args[2] ?? '');
103
+ return ok;
104
+ }
105
+ return fail(1, `sudo: ${String(program)}: command not found`);
106
+ };
107
+
108
+ const base: HostRunner = {
109
+ ...fake.runner,
110
+ exec: async (argv) => {
111
+ if (argv[0] !== SUDO) return fake.runner.exec(argv);
112
+ sudoCalls.push([...argv]);
113
+ if (logs[logs.length - 1] !== `homeflare/launchd sudo -n ${JSON.stringify(argv.slice(3))}`) {
114
+ throw new Error(`fake sudo: ${JSON.stringify(argv)} was not logged before it ran`);
115
+ }
116
+ if (state.sudo !== 'ok') return fail(1, SUDO_SAYS[state.sudo]);
117
+ // argv is [sudo, -n, --, ...command]
118
+ return asRoot(argv.slice(3));
119
+ },
120
+ };
121
+
122
+ const runner = makeSudoRunner(prefixes, {
123
+ base,
124
+ groups: async () => (groups === 'unknown' ? undefined : groups),
125
+ log: (line) => logs.push(line),
126
+ stage: async (bytes) => {
127
+ state.stagedCount += 1;
128
+ const path = `/private/tmp/hf-sudo-${String(state.stagedCount)}/staged`;
129
+ staged.set(path, bytes);
130
+ return { dispose: async () => void staged.delete(path), path };
131
+ },
132
+ });
133
+ /** The privileged commands run, without the `sudo -n --` in front. */
134
+ const privileged = () => sudoCalls.map((call) => call.slice(3));
135
+ return { fake, logs, privileged, runner, staged, state, sudoCalls };
136
+ };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The one place a HostRunner's promises become Effects — plus the one probe of an unresolved
3
- * `news` both providers' diffs share.
3
+ * `news` both providers' diffs share. (The engine's adopt setting lives in ownership/adopt.ts.)
4
4
  *
5
5
  * ★ The lifecycle files are plain async functions over a HostRunner (see runner.ts for why the
6
6
  * runner is promise-based), so each provider handler is a single `lift(() => step(runner, …))`.
@@ -46,9 +46,12 @@ const identityProblems = (name: string, value: string | number | undefined): str
46
46
  : [`${name} ${JSON.stringify(value)} is not a valid user or group name`];
47
47
  };
48
48
 
49
- export const fileProblems = (props: HostFileProps): string[] => {
49
+ /**
50
+ * An absolute, normalised path, or the reasons it is not. ★ Shared with sudo-allowlist.ts, so a
51
+ * path the sudo runner may touch as root obeys exactly the rule a declared file does.
52
+ */
53
+ export const pathProblems = (path: string): string[] => {
50
54
  const found: string[] = [];
51
- const { path } = props;
52
55
  if (!path.startsWith('/')) found.push('path must be absolute');
53
56
  // ★ One spelling per file: `a/./b`, `a//b` and `a/../b` would each be a second resource for the
54
57
  // same inode, and `..` is how a derived path walks out of the directory it was meant for.
@@ -62,6 +65,11 @@ export const fileProblems = (props: HostFileProps): string[] => {
62
65
  found.push('path must be normalised (no trailing slash, no empty, "." or ".." segments)');
63
66
  }
64
67
  if (path.includes('\x00')) found.push('path contains NUL');
68
+ return found;
69
+ };
70
+
71
+ export const fileProblems = (props: HostFileProps): string[] => {
72
+ const found = pathProblems(props.path);
65
73
  const mode = props.mode ?? DEFAULT_MODE;
66
74
  if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o7777) found.push('mode must be 0–0o7777');
67
75
  found.push(...identityProblems('owner', props.owner));