@homeflare/alchemy 0.7.0 → 0.8.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 (137) hide show
  1. package/README.md +26 -37
  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 +71 -13
  11. package/dist/caddy/index.js.map +9 -9
  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-sudo.d.ts +50 -0
  15. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  16. package/dist/launchd/host-effect.d.ts +18 -9
  17. package/dist/launchd/host-effect.d.ts.map +1 -1
  18. package/dist/launchd/host-file-form.d.ts +5 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  21. package/dist/launchd/index.d.ts +2 -0
  22. package/dist/launchd/index.d.ts.map +1 -1
  23. package/dist/launchd/index.js +342 -8
  24. package/dist/launchd/index.js.map +15 -11
  25. package/dist/launchd/job-preflight.d.ts.map +1 -1
  26. package/dist/launchd/job-validate.d.ts +2 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -1
  28. package/dist/launchd/local-runner.d.ts +2 -0
  29. package/dist/launchd/local-runner.d.ts.map +1 -1
  30. package/dist/launchd/providers.d.ts +2 -1
  31. package/dist/launchd/providers.d.ts.map +1 -1
  32. package/dist/launchd/runner.d.ts +7 -5
  33. package/dist/launchd/runner.d.ts.map +1 -1
  34. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  35. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  36. package/dist/launchd/sudo-guard.d.ts +56 -0
  37. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  38. package/dist/launchd/sudo-runner.d.ts +60 -0
  39. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  40. package/dist/launchd/sudo-stage.d.ts +7 -0
  41. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  42. package/dist/openbao/auth-role-form.d.ts +0 -9
  43. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-role.d.ts +3 -2
  45. package/dist/openbao/auth-role.d.ts.map +1 -1
  46. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  47. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  48. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  49. package/dist/openbao/fake-engines.d.ts +8 -2
  50. package/dist/openbao/fake-engines.d.ts.map +1 -1
  51. package/dist/openbao/fake-families.d.ts +49 -0
  52. package/dist/openbao/fake-families.d.ts.map +1 -0
  53. package/dist/openbao/host-approles.d.ts.map +1 -1
  54. package/dist/openbao/index.js +359 -280
  55. package/dist/openbao/index.js.map +21 -19
  56. package/dist/openbao/jwt-config.d.ts +5 -0
  57. package/dist/openbao/jwt-config.d.ts.map +1 -1
  58. package/dist/openbao/jwt-role.d.ts +2 -1
  59. package/dist/openbao/jwt-role.d.ts.map +1 -1
  60. package/dist/openbao/kubernetes-role.d.ts +2 -1
  61. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  62. package/dist/openbao/mfa-enforcement.d.ts +6 -3
  63. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  64. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  65. package/dist/openbao/pki-role.d.ts.map +1 -1
  66. package/dist/openbao/plugin.d.ts.map +1 -1
  67. package/dist/openbao/policy.d.ts.map +1 -1
  68. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  69. package/dist/openbao/rename-identity.d.ts +69 -0
  70. package/dist/openbao/rename-identity.d.ts.map +1 -0
  71. package/dist/openbao/rename.d.ts +13 -12
  72. package/dist/openbao/rename.d.ts.map +1 -1
  73. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  74. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  75. package/dist/openbao/ssh-role.d.ts.map +1 -1
  76. package/docs/caddy-admin.md +24 -0
  77. package/docs/caddy.md +73 -54
  78. package/docs/launchd-sudo.md +122 -0
  79. package/docs/launchd.md +7 -4
  80. package/docs/peers.md +39 -0
  81. package/package.json +1 -1
  82. package/src/caddy/adopt-scope.test.ts +82 -0
  83. package/src/caddy/config-adopt.test.ts +235 -0
  84. package/src/caddy/config-lifecycle.test.ts +31 -53
  85. package/src/caddy/config-lifecycle.ts +98 -7
  86. package/src/caddy/config.ts +48 -11
  87. package/src/caddy/fake-caddy.ts +19 -0
  88. package/src/caddy/index.ts +2 -1
  89. package/src/caddy/providers.test.ts +8 -23
  90. package/src/caddy/with-file.ts +7 -3
  91. package/src/launchd/fake-sudo.ts +136 -0
  92. package/src/launchd/host-effect.ts +33 -1
  93. package/src/launchd/host-file-form.ts +10 -2
  94. package/src/launchd/host-file-lifecycle.ts +2 -1
  95. package/src/launchd/index.ts +2 -0
  96. package/src/launchd/job-preflight.ts +3 -2
  97. package/src/launchd/job-validate.ts +1 -1
  98. package/src/launchd/local-runner.ts +6 -3
  99. package/src/launchd/providers.test.ts +3 -0
  100. package/src/launchd/providers.ts +2 -1
  101. package/src/launchd/runner.ts +7 -5
  102. package/src/launchd/sudo-allowlist.test.ts +191 -0
  103. package/src/launchd/sudo-allowlist.ts +213 -0
  104. package/src/launchd/sudo-guard.test.ts +100 -0
  105. package/src/launchd/sudo-guard.ts +151 -0
  106. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  107. package/src/launchd/sudo-runner-files.test.ts +240 -0
  108. package/src/launchd/sudo-runner.test.ts +126 -0
  109. package/src/launchd/sudo-runner.ts +235 -0
  110. package/src/launchd/sudo-stage.ts +28 -0
  111. package/src/openbao/README.md +8 -2
  112. package/src/openbao/REPLACE.md +65 -37
  113. package/src/openbao/auth-role-form.test.ts +2 -16
  114. package/src/openbao/auth-role-form.ts +0 -11
  115. package/src/openbao/auth-role.ts +35 -14
  116. package/src/openbao/cloudflare-role.ts +6 -14
  117. package/src/openbao/fake-engines-roles.ts +114 -0
  118. package/src/openbao/fake-engines.ts +18 -11
  119. package/src/openbao/fake-families.ts +182 -0
  120. package/src/openbao/host-approles.test.ts +15 -2
  121. package/src/openbao/host-approles.ts +19 -4
  122. package/src/openbao/jwt-config.ts +26 -5
  123. package/src/openbao/jwt-role.ts +24 -8
  124. package/src/openbao/kubernetes-role.ts +20 -8
  125. package/src/openbao/mfa-enforcement.ts +23 -8
  126. package/src/openbao/mfa-totp.ts +21 -8
  127. package/src/openbao/pki-role.ts +19 -7
  128. package/src/openbao/plugin.ts +41 -15
  129. package/src/openbao/policy.ts +7 -7
  130. package/src/openbao/proxmox-role.ts +7 -16
  131. package/src/openbao/rename-adoption.test.ts +82 -0
  132. package/src/openbao/rename-families.test.ts +122 -0
  133. package/src/openbao/rename-identity.test.ts +129 -0
  134. package/src/openbao/rename-identity.ts +117 -0
  135. package/src/openbao/rename.ts +19 -15
  136. package/src/openbao/ssh-role-erase.ts +50 -0
  137. package/src/openbao/ssh-role.ts +16 -54
@@ -9,9 +9,11 @@
9
9
  * · live ≠ stored — DRIFT: a hand `curl` to the API, or a restart that loaded a different file
10
10
  * (or an `autosave.json` holding someone else's change, under `--resume`).
11
11
  * · declared = live — nothing to load, whatever the state says.
12
+ * With NO state there is no stored digest, and the question is whose config is running: see
13
+ * `claimable` below, which both the adoption probe and the apply ask.
12
14
  */
13
15
  import type { Diff } from 'alchemy/Diff';
14
- import type { CaddyAdmin } from './admin.ts';
16
+ import { type CaddyAdmin, CaddyUnreachableError } from './admin.ts';
15
17
  import {
16
18
  CaddyAdminError,
17
19
  adaptCaddyfile,
@@ -66,16 +68,75 @@ export const desiredConfig = async (
66
68
  return { digest: configDigest(adapted.config), warnings: adapted.warnings };
67
69
  };
68
70
 
69
- /** What is running now, as attributes. `sourceFile` is carried over, never discovered. */
70
- export const readLive = async (
71
+ const attributesOf = (
71
72
  admin: CaddyAdmin,
73
+ running: unknown,
72
74
  sourceFile?: string,
73
- ): Promise<CaddyConfigAttributes> => ({
74
- configSha256: configDigest(await readRunningConfig(admin)),
75
+ ): CaddyConfigAttributes => ({
76
+ configSha256: configDigest(running),
75
77
  endpoint: admin.endpoint,
76
78
  ...(sourceFile === undefined ? {} : { sourceFile }),
77
79
  });
78
80
 
81
+ /** What is running now, as attributes. `sourceFile` is carried over, never discovered. */
82
+ export const readLive = async (
83
+ admin: CaddyAdmin,
84
+ sourceFile?: string,
85
+ ): Promise<CaddyConfigAttributes> =>
86
+ attributesOf(admin, await readRunningConfig(admin), sourceFile);
87
+
88
+ /**
89
+ * ⛔ WITH NO STATE, A RUNNING CONFIG IS THIS STACK'S ONLY IF IT IS THE DECLARED ONE (decision,
90
+ * 2026-09-21: CaddyConfig must not adopt silently — the house rule HostFile and LaunchdJob keep).
91
+ * Anything else was put there by a person, another tool or another stack, and loading over it
92
+ * replaces every site it serves. ★ A config that serves nothing (`null`, or no apps) is claimable
93
+ * too: there is nothing to take over, as an empty R2 lock rule set reads as no lock.
94
+ */
95
+ export const claimable = (running: unknown, declaredDigest: string): boolean =>
96
+ servesNothing(running) || configDigest(running) === declaredDigest;
97
+
98
+ export type Probe = {
99
+ readonly attributes: CaddyConfigAttributes;
100
+ /** The live config IS the declared one: adopting it changes nothing. */
101
+ readonly ours: boolean;
102
+ /** Why the declaration could not be compared, when it could not — the Caddy is then not ours. */
103
+ readonly unchecked?: string;
104
+ };
105
+
106
+ /**
107
+ * The adoption probe — `read` with no state: `undefined` when Caddy serves nothing (plan a create),
108
+ * else the live attributes and whether they are provably ours (the provider brands the rest
109
+ * `Unowned`, and the plan refuses them without `--adopt`).
110
+ * ⛔ IT NEVER THROWS OVER THE DECLARATION. The engine also runs this read to recover an interrupted
111
+ * create, with THAT deploy's props (Plan.ts, `status: "creating"`) — so a Caddyfile that failed to
112
+ * adapt then would fail every later plan, the one carrying the fix included. What cannot be
113
+ * compared is "not proven ours", with the reason; the next diff or apply validates the Caddyfile.
114
+ */
115
+ export const probeLive = async (
116
+ admin: CaddyAdmin,
117
+ caddyfile: string | undefined,
118
+ sourceFile?: string,
119
+ ): Promise<Probe | undefined> => {
120
+ const running = await readRunningConfig(admin);
121
+ if (servesNothing(running)) return undefined;
122
+ const attributes = attributesOf(admin, running, sourceFile);
123
+ if (caddyfile === undefined) {
124
+ return { attributes, ours: false, unchecked: 'the stored Caddyfile is not plain text' };
125
+ }
126
+ try {
127
+ const want = await desiredConfig(admin, { caddyfile });
128
+ return { attributes, ours: claimable(running, want.digest) };
129
+ } catch (cause) {
130
+ // ⚠️ Unreachable mid-probe is still "no Caddy" (config.ts plans without it), not "not ours".
131
+ if (cause instanceof CaddyUnreachableError) throw cause;
132
+ return {
133
+ attributes,
134
+ ours: false,
135
+ unchecked: cause instanceof Error ? cause.message : String(cause),
136
+ };
137
+ }
138
+ };
139
+
79
140
  /**
80
141
  * ★ NEVER `replace`. Nothing in the props names a different object: a new Caddyfile is a reload of
81
142
  * the same Caddy, and `sourceFile` is a header. (Which Caddy is the transport's business — see
@@ -94,6 +155,7 @@ export const diffConfig = async (
94
155
  news.sourceFile === output.sourceFile &&
95
156
  // ⚠️ A stack whose transport now reaches ANOTHER Caddy (a new address) must load there; the
96
157
  // old one keeps what it has — the same as a delete, which never unloads (see config.ts).
158
+ // Planned as an update, but the state does not vouch for that Caddy: see Authority.
97
159
  admin.endpoint === output.endpoint;
98
160
  return converged ? { action: 'noop' } : { action: 'update' };
99
161
  };
@@ -106,13 +168,42 @@ export type Applied = {
106
168
  readonly loaded: boolean;
107
169
  };
108
170
 
109
- /** Load the Caddyfile unless Caddy already runs it, then read it back and insist it matches. */
171
+ /**
172
+ * What this apply may load over — config.ts decides from the state and the adopt setting:
173
+ * · `takeOver` — any running config: the state was applied to THIS Caddy (an update, drift
174
+ * correction, or a create the engine adopted), or adoption is on (`--adopt`, or the
175
+ * resource's own `adopt(true)` — host-effect.ts adoptEnabled).
176
+ * · otherwise only a claimable one — plus, as `stored`, the digest the state last recorded: a
177
+ * Caddy reached at a NEW endpoint that runs exactly that is the same config under another name.
178
+ */
179
+ export type Authority = { readonly takeOver: boolean; readonly stored?: string };
180
+
181
+ /**
182
+ * Load the Caddyfile unless Caddy already runs it, then read it back and insist it matches.
183
+ * ⛔ WITHOUT `takeOver`, A CONFIG THE STACK CANNOT CLAIM IS NEVER LOADED OVER. The engine's adoption
184
+ * probe cannot guard every create: it is skipped while `news` holds an Output — ALWAYS on
185
+ * caddyWithFile's first deploy (`sourceFile` is its HostFile's path) — and a Caddy that was down at
186
+ * plan time read as nothing. Nor does state vouch for a Caddy the transport NOW reaches at another
187
+ * endpoint (diff plans that as an update). Refused here, before any `/load`, like HostFile's.
188
+ */
110
189
  export const reconcileConfig = async (
111
190
  admin: CaddyAdmin,
112
191
  props: CaddyConfigProps,
192
+ authority: Authority,
113
193
  ): Promise<Applied> => {
114
194
  const want = await desiredConfig(admin, props);
115
- const before = configDigest(await readRunningConfig(admin));
195
+ const running = await readRunningConfig(admin);
196
+ const before = configDigest(running);
197
+ const known = before === authority.stored || claimable(running, want.digest);
198
+ if (!authority.takeOver && !known) {
199
+ throw refuse(
200
+ admin,
201
+ `Caddy runs config ${short(before)}, which this stack did not load, and the Caddyfile adapts ` +
202
+ `to ${short(want.digest)} — loading it would replace every site that config serves. ` +
203
+ 'Deploy with --adopt (or wrap the resource in adopt(true)) to take this Caddy over, or ' +
204
+ 'point caddyProviders() at the Caddy you meant.',
205
+ );
206
+ }
116
207
  let warnings = want.warnings;
117
208
  const loaded = before !== want.digest;
118
209
  if (loaded) {
@@ -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,23 @@
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
- import { lift, resolvedString } from '../launchd/host-effect.ts';
30
+ import { adoptEnabled, lift, resolvedString } from '../launchd/host-effect.ts';
26
31
  import { CaddyAdminService, CaddyUnreachableError } from './admin.ts';
27
32
  import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
28
- import { diffConfig, readLive, reconcileConfig } from './config-lifecycle.ts';
33
+ import { diffConfig, probeLive, readLive, reconcileConfig } from './config-lifecycle.ts';
29
34
 
30
35
  export type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
31
36
 
@@ -64,17 +69,40 @@ export const CaddyConfigProvider = () =>
64
69
  CaddyConfig,
65
70
  Effect.gen(function* () {
66
71
  const admin = yield* CaddyAdminService;
72
+ /** The probe, branded for the engine: plain when ours, `Unowned` otherwise. */
73
+ const probe = (caddyfile: string | undefined, sourceFile: string | undefined) =>
74
+ Effect.flatMap(
75
+ lift(() => probeLive(admin, caddyfile, sourceFile)),
76
+ (found) => {
77
+ if (found === undefined) return Effect.succeed(undefined);
78
+ if (found.ours) return Effect.succeed(found.attributes);
79
+ const unowned = Unowned(found.attributes);
80
+ return found.unchecked === undefined
81
+ ? Effect.succeed(unowned)
82
+ : Effect.as(
83
+ Effect.logWarning(
84
+ `Caddy.Config at ${admin.endpoint}: the running config could not be compared ` +
85
+ `with the declared Caddyfile (${found.unchecked}), so it is not treated as ours`,
86
+ ),
87
+ unowned,
88
+ );
89
+ },
90
+ );
67
91
  return CaddyConfig.Provider.of({
68
92
  /** ⛔ A Caddy has one config; there is nothing to enumerate, and nothing for nuke to delete. */
69
93
  list: () => Effect.succeed([]),
70
94
 
71
95
  /**
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.
96
+ * With state: the live attributes, which diff compares. With none, the ADOPTION PROBE —
97
+ * `olds` is the declaration (Plan.ts passes `news`), and only a live config equal to it is
98
+ * ours. ⚠️ A running Caddy is still the one the stack means (the transport reaches only
99
+ * loopback), but WHOSE CONFIG it runs is not: a person, another tool or another stack.
75
100
  */
76
- read: ({ olds }) =>
77
- lift(() => readLive(admin, resolvedString(olds, 'sourceFile'))).pipe(
101
+ read: ({ olds, output }) =>
102
+ (output === undefined
103
+ ? probe(resolvedString(olds, 'caddyfile'), resolvedString(olds, 'sourceFile'))
104
+ : lift(() => readLive(admin, resolvedString(olds, 'sourceFile')))
105
+ ).pipe(
78
106
  // ★ Nothing to adopt from a Caddy that is down: plan a create, which is the same load.
79
107
  Effect.catchIf(unreachable, planWithoutCaddy(admin, undefined)),
80
108
  ),
@@ -101,9 +129,18 @@ export const CaddyConfigProvider = () =>
101
129
  }).pipe(Effect.catchIf(unreachable, update));
102
130
  },
103
131
 
104
- reconcile: ({ news }) =>
132
+ reconcile: ({ fqn, news, output }) =>
105
133
  Effect.gen(function* () {
106
- const applied = yield* lift(() => reconcileConfig(admin, news));
134
+ // ⛔ State is authority over the Caddy it was applied to, and no other. With none (the
135
+ // engine may not have probed: see reconcileConfig), or with the transport now at
136
+ // another endpoint, only adoption — `--adopt`, or this resource's own `adopt(…)`,
137
+ // as the planner resolves it — loads over a config the stack cannot claim.
138
+ const sameCaddy = output !== undefined && output.endpoint === admin.endpoint;
139
+ const takeOver = sameCaddy || (yield* adoptEnabled(fqn));
140
+ const stored = output === undefined ? {} : { stored: output.configSha256 };
141
+ const applied = yield* lift(() =>
142
+ reconcileConfig(admin, news, { takeOver, ...stored }),
143
+ );
107
144
  for (const warning of applied.warnings) {
108
145
  yield* Effect.logWarning(`Caddy.Config at ${admin.endpoint}: ${warning}`);
109
146
  }
@@ -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,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,13 +1,17 @@
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, and the engine's adopt setting for a check at apply.
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, …))`.
7
7
  * ⚠️ A thrown Error stays that Error — its message is the refusal a deploy prints. Anything else
8
8
  * thrown is wrapped, so the failure channel is always an Error with a readable message.
9
9
  */
10
+ import { AdoptPolicy } from 'alchemy/AdoptPolicy';
11
+ import { AlchemyContext } from 'alchemy/AlchemyContext';
12
+ import { Stack } from 'alchemy/Stack';
10
13
  import * as Effect from 'effect/Effect';
14
+ import * as Option from 'effect/Option';
11
15
 
12
16
  export const lift = <A>(step: () => Promise<A>): Effect.Effect<A, Error> =>
13
17
  Effect.tryPromise({
@@ -26,3 +30,31 @@ export const resolvedString = (news: unknown, key: string): string | undefined =
26
30
  const value = (news as Record<string, unknown>)[key];
27
31
  return typeof value === 'string' ? value : undefined;
28
32
  };
33
+
34
+ /**
35
+ * Whether the resource at `fqn` may be adopted, resolved exactly as the planner resolves it (alchemy
36
+ * beta.79 Plan.ts: `resource.Adopt ?? shouldAdopt`): a resource-scoped `adopt(…)`, else the
37
+ * `AdoptPolicy` service, else `AlchemyContext.adopt`, else off.
38
+ * ★ FOR A CREATE THE ENGINE NEVER PROBED. Alchemy skips the adoption probe while `news` holds an
39
+ * Output, so an ownership check that must still honour `--adopt` can only run at apply. All three
40
+ * reach the apply: Alchemist runs plan AND apply under the session context, which provides the
41
+ * services from the CLI flag and the Stack the resources registered on (Alchemist/Session.ts,
42
+ * routes/stack.ts; Apply.ts reads the same Stack).
43
+ * ⛔ THE RESOURCE-SCOPED SETTING WINS, BOTH WAYS. The engine captures `adopt(…)` on the resource at
44
+ * registration (Resource.ts `Adopt`, into `Stack.resources[fqn]`) and never hands it to a
45
+ * provider. Reading only the deploy-wide flag let `--adopt` take over a resource declared
46
+ * `.pipe(adopt(false))` — which the planner refuses — and refused one declared `adopt(true)`.
47
+ * ⚠️ `resources` carries an `@internal` comment in Alchemy's source, but it is in the published types
48
+ * and the planner reads `Adopt` from it. caddy/adopt-scope.test.ts registers through the real
49
+ * `Resource`, so an upgrade that stops recording it there fails the suite.
50
+ */
51
+ export const adoptEnabled = (fqn: string): Effect.Effect<boolean> =>
52
+ Effect.gen(function* () {
53
+ const stack = yield* Effect.serviceOption(Stack);
54
+ const scoped = Option.isSome(stack) ? stack.value.resources[fqn]?.Adopt : undefined;
55
+ if (scoped !== undefined) return scoped;
56
+ const policy = yield* Effect.serviceOption(AdoptPolicy);
57
+ if (Option.isSome(policy)) return policy.value;
58
+ const context = yield* Effect.serviceOption(AlchemyContext);
59
+ return Option.isSome(context) ? context.value.adopt : false;
60
+ });
@@ -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));
@@ -102,7 +102,8 @@ export const reconcileFile = async (
102
102
  throw refuse(
103
103
  props.path,
104
104
  `owner ${String(props.owner)} is not the deploying user; only root may chown. Deploy as root ` +
105
- 'or through a privileged HostRunner (privileged: true). This provider never calls sudo.',
105
+ 'or through a privileged HostRunner (sudoRunner() with this path under a prefix). This ' +
106
+ 'provider never calls sudo itself.',
106
107
  );
107
108
  }
108
109
  const stat = await runner.stat(props.path);
@@ -24,3 +24,5 @@ export { PlistError, renderPlist } from './plist.ts';
24
24
  export { launchdProviders } from './providers.ts';
25
25
  export type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
26
26
  export { HostRunnerService, canActAsRoot, hostRunnerLayer } from './runner.ts';
27
+ export type { SudoRunnerOptions } from './sudo-runner.ts';
28
+ export { SudoRefusedError, sudoRunner } from './sudo-runner.ts';
@@ -61,8 +61,9 @@ export const assertMayWrite = (runner: HostRunner, label: string, domain: Parsed
61
61
  if (domain.kind === 'system') {
62
62
  throw refuse(
63
63
  label,
64
- 'the system domain needs root. Run the deploy as root, or provide a HostRunner that is ' +
65
- 'deliberately privileged (privileged: true). This provider never calls sudo.',
64
+ 'the system domain needs root. Run the deploy as root, or pass a deliberately privileged ' +
65
+ 'HostRunner: sudoRunner() elevates exactly the calls this needs. This provider never ' +
66
+ 'calls sudo itself.',
66
67
  );
67
68
  }
68
69
  if (domain.uid !== runner.effectiveUid()) {
@@ -15,7 +15,7 @@ import {
15
15
  import { jobSecretProblems } from './secret-tripwire.ts';
16
16
 
17
17
  /** ★ Labels become file names: no `/`, no leading dot, nothing a path could re-interpret. */
18
- const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
18
+ export const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
19
19
 
20
20
  /**
21
21
  * ⛔ LABELS OTHER TOOLS OWN ARE NEVER DECLARED HERE. nix-darwin (`org.nixos.*`) rewrites its plists
@@ -5,7 +5,8 @@
5
5
  * the published dist also loads under node (the package's smoke test and the kit's AGENTS.md:
6
6
  * consumers run dist/, not src/), and both runtimes implement these modules.
7
7
  * ⛔ NO ELEVATION, EVER. `privileged` is false; a system-domain write succeeds only when the whole
8
- * deploy was started as root. See the ⛔ on HostRunner.privileged for why nothing here calls sudo.
8
+ * deploy was started as root. See the ⛔ on HostRunner.privileged for why nothing here calls sudo;
9
+ * the opt-in elevating runner is sudo-runner.ts, which wraps this one.
9
10
  * ⛔ NO SHELL. `exec` spawns argv directly, so a label, path or argument can never be re-parsed
10
11
  * as shell syntax.
11
12
  */
@@ -21,13 +22,15 @@ export type LocalRunnerOptions = {
21
22
  readonly execTimeoutMs?: number;
22
23
  };
23
24
 
24
- const code = (cause: unknown): string | undefined =>
25
+ /** The errno code of a thrown fs error (`ENOENT`, `EACCES`, …), if it has one. */
26
+ export const errnoCode = (cause: unknown): string | undefined =>
25
27
  typeof cause === 'object' && cause !== null && 'code' in cause
26
28
  ? String((cause as { code: unknown }).code)
27
29
  : undefined;
28
30
 
29
31
  /** ENOENT and ENOTDIR both mean "nothing is at this path". */
30
- const absent = (cause: unknown): boolean => code(cause) === 'ENOENT' || code(cause) === 'ENOTDIR';
32
+ const absent = (cause: unknown): boolean =>
33
+ errnoCode(cause) === 'ENOENT' || errnoCode(cause) === 'ENOTDIR';
31
34
 
32
35
  const run = (argv: readonly string[], timeoutMs: number): Promise<ExecResult> =>
33
36
  new Promise((resolve, reject) => {