@homeflare/alchemy 0.37.7 → 0.38.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 (51) hide show
  1. package/dist/caddy/admin.d.ts +42 -8
  2. package/dist/caddy/admin.d.ts.map +1 -1
  3. package/dist/caddy/config.d.ts +9 -3
  4. package/dist/caddy/config.d.ts.map +1 -1
  5. package/dist/caddy/fake-caddy.d.ts +5 -0
  6. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  7. package/dist/caddy/index.js +10 -6
  8. package/dist/caddy/index.js.map +5 -5
  9. package/dist/caddy/providers.d.ts +19 -12
  10. package/dist/caddy/providers.d.ts.map +1 -1
  11. package/dist/linux/container-form.d.ts +1 -1
  12. package/dist/linux/container-generator.d.ts +28 -0
  13. package/dist/linux/container-generator.d.ts.map +1 -1
  14. package/dist/linux/container-lifecycle.d.ts +18 -1
  15. package/dist/linux/container-lifecycle.d.ts.map +1 -1
  16. package/dist/linux/container-preflight.d.ts +34 -0
  17. package/dist/linux/container-preflight.d.ts.map +1 -1
  18. package/dist/linux/container-settle.d.ts.map +1 -1
  19. package/dist/linux/fake-sudo.d.ts +14 -2
  20. package/dist/linux/fake-sudo.d.ts.map +1 -1
  21. package/dist/linux/index.js +76 -29
  22. package/dist/linux/index.js.map +11 -10
  23. package/dist/linux/sudo-exec.d.ts +16 -0
  24. package/dist/linux/sudo-exec.d.ts.map +1 -1
  25. package/dist/linux/unit-form.d.ts.map +1 -1
  26. package/dist/linux/unit-text.d.ts +35 -0
  27. package/dist/linux/unit-text.d.ts.map +1 -0
  28. package/docs/linux-sudo.md +18 -7
  29. package/package.json +1 -1
  30. package/src/caddy/admin-transport-scope.test.ts +127 -0
  31. package/src/caddy/admin.ts +45 -9
  32. package/src/caddy/config-adopt-provider.test.ts +2 -2
  33. package/src/caddy/config-format-warning.test.ts +9 -3
  34. package/src/caddy/config.ts +20 -4
  35. package/src/caddy/fake-caddy.ts +10 -1
  36. package/src/caddy/providers.test.ts +3 -2
  37. package/src/caddy/providers.ts +18 -11
  38. package/src/linux/container-form.ts +1 -1
  39. package/src/linux/container-generator.test.ts +33 -0
  40. package/src/linux/container-generator.ts +72 -0
  41. package/src/linux/container-lifecycle.test.ts +6 -3
  42. package/src/linux/container-lifecycle.ts +23 -2
  43. package/src/linux/container-preflight.ts +47 -0
  44. package/src/linux/container-settle.ts +12 -1
  45. package/src/linux/container-shadow.test.ts +131 -0
  46. package/src/linux/fake-sudo.ts +21 -5
  47. package/src/linux/sudo-exec.ts +60 -10
  48. package/src/linux/sudo-lifecycle.test.ts +64 -3
  49. package/src/linux/unit-form.ts +4 -18
  50. package/src/linux/unit-text.test.ts +67 -0
  51. package/src/linux/unit-text.ts +58 -0
@@ -23,6 +23,22 @@
23
23
  * is a valid type in `unit-form.ts`'s `TYPES` set) would also have elevated. Grepped: no code
24
24
  * in this package ever calls `enable`/`start`/`restart` on a unit it has not itself just
25
25
  * written a file for, so narrowing the exemption to `stop`/`disable` costs nothing legitimate.
26
+ * 🔴 MEASURED, CT100 deploy 2026-09-26 00:11Z: a THIRD gap, this one refusing a call that should
27
+ * have been allowed. `Podman.Container caddy`'s generated `caddy.service` has
28
+ * `FragmentPath=/run/systemd/generator/caddy.service` — Quadlet's generator output, never a
29
+ * path this runner would ever declare as a prefix (nothing writes there directly) — so `restart`
30
+ * was refused outright, even though the `.container` FILE THAT GENERATED IT lives at
31
+ * `/etc/containers/systemd/caddy.container`, squarely under this runner's own
32
+ * `/etc/containers/systemd` prefix, and this SAME runner had just written it. `SourcePath` is
33
+ * the generator's own record of that file (systemctl.ts's header, MEASURED on CT100). So a
34
+ * generated unit is now also reachable when its `FragmentPath` is the generator's OWN output
35
+ * directory (`GENERATOR_DIRECTORIES` below) AND its `SourcePath` is under a declared prefix —
36
+ * the thing this runner actually owns is the `.container` file, and the generated unit is
37
+ * systemd's own derivative of it, not a foreign unit. Everything the check refused before still
38
+ * refuses: a vendor unit's `FragmentPath` (`/usr/lib/systemd/system/...`) is not under a
39
+ * generator directory at all; a generated unit with no `SourcePath`, or one outside every
40
+ * prefix (a `.container` file elsewhere in Quadlet's OWN search path —
41
+ * `podman-systemd.unit(5)` — that this runner was never told to own), still refuses.
26
42
  */
27
43
  import type { ExecResult, HostRunner } from '../launchd/runner.ts';
28
44
  import type { Elevate } from './sudo-write.ts';
@@ -1 +1 @@
1
- {"version":3,"file":"sudo-exec.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAGnE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAuC/C,iGAAiG;AACjG,eAAO,MAAM,YAAY,SACjB,UAAU,WACP,OAAO,YACN,SAAS,MAAM,EAAE,QACrB,SAAS,MAAM,EAAE,KACtB,OAAO,CAAC,UAAU,CA2CpB,CAAC"}
1
+ {"version":3,"file":"sudo-exec.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAInE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AA4D/C,iGAAiG;AACjG,eAAO,MAAM,YAAY,SACjB,UAAU,WACP,OAAO,YACN,SAAS,MAAM,EAAE,QACrB,SAAS,MAAM,EAAE,KACtB,OAAO,CAAC,UAAU,CAuDpB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"unit-form.d.ts","sourceRoot":"","sources":["../../src/linux/unit-form.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,WAAW,GAAG;IACxB,yFAAyF;IACzF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;CACpE,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,qFAAqF;IACrF,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iGAAiG;IACjG,QAAQ,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IAClC,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gDAAgD;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,iGAAiG;AACjG,eAAO,MAAM,sBAAsB,wBAAwB,CAAC;AAE5D;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,YAClC,qBAAqB,EACrB,qBAAqB,EACrB,+BAA+B,EAC/B,yBAAyB,EACzB,qBAAqB,CACb,CAAC;AAEX,mGAAmG;AACnG,eAAO,MAAM,UAAU;aAAK,GAAG,EAAE,CAAC;aAAE,IAAI,EAAE,GAAK;aAAE,GAAG,EAAE,CAAC;CAAW,CAAC;AAEnE,eAAO,MAAM,QAAQ,SAAU,MAAM,KAAG,MAAmD,CAAC;AAE5F,4FAA4F;AAC5F,eAAO,MAAM,YAAY,cAAe,SAAS,MAAM,EAAE,GAAG,SAAS,KAAG,MAChC,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,eAAO,MAAM,aAAa,gBACX,MAAM,YACT,MAAM,GAAG,SAAS,WACnB,OAAO,KACf,OAKF,CAAC;AAEF,kGAAkG;AAClG,eAAO,MAAM,UAAU,aAAc,SAAS,WAAW,EAAE,KAAG,MAKzC,CAAC;AAEtB,eAAO,MAAM,QAAQ,UAAW,gBAAgB,KAAG,MACA,CAAC;AAEpD,eAAO,MAAM,WAAW,UAAW,gBAAgB,KAAG,MACQ,CAAC;AAE/D,mGAAmG;AACnG,eAAO,MAAM,IAAI,QAA6C,CAAC;AAkB/D;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,UAAW,gBAAgB,WAAW,MAAM,KAAG,MAAM,EAmC7E,CAAC"}
1
+ {"version":3,"file":"unit-form.d.ts","sourceRoot":"","sources":["../../src/linux/unit-form.ts"],"names":[],"mappings":"AAoBA,MAAM,MAAM,WAAW,GAAG;IACxB,yFAAyF;IACzF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;CACpE,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,qFAAqF;IACrF,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iGAAiG;IACjG,QAAQ,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IAClC,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gDAAgD;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,iGAAiG;AACjG,eAAO,MAAM,sBAAsB,wBAAwB,CAAC;AAE5D;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,YAClC,qBAAqB,EACrB,qBAAqB,EACrB,+BAA+B,EAC/B,yBAAyB,EACzB,qBAAqB,CACb,CAAC;AAEX,mGAAmG;AACnG,eAAO,MAAM,UAAU;aAAK,GAAG,EAAE,CAAC;aAAE,IAAI,EAAE,GAAK;aAAE,GAAG,EAAE,CAAC;CAAW,CAAC;AAEnE,eAAO,MAAM,QAAQ,SAAU,MAAM,KAAG,MAAmD,CAAC;AAE5F,4FAA4F;AAC5F,eAAO,MAAM,YAAY,cAAe,SAAS,MAAM,EAAE,GAAG,SAAS,KAAG,MAChC,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,eAAO,MAAM,aAAa,gBACX,MAAM,YACT,MAAM,GAAG,SAAS,WACnB,OAAO,KACf,OAKF,CAAC;AAEF,kGAAkG;AAClG,eAAO,MAAM,UAAU,aAAc,SAAS,WAAW,EAAE,KAAG,MAKzC,CAAC;AAEtB,eAAO,MAAM,QAAQ,UAAW,gBAAgB,KAAG,MACA,CAAC;AAEpD,eAAO,MAAM,WAAW,UAAW,gBAAgB,KAAG,MACQ,CAAC;AAE/D,mGAAmG;AACnG,eAAO,MAAM,IAAI,QAA6C,CAAC;AAgB/D;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,UAAW,gBAAgB,WAAW,MAAM,KAAG,MAAM,EAmC7E,CAAC"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The parts of `unit-form.ts`'s validation that inspect the unit file's TEXT rather than its
3
+ * props struct — split out so unit-form.ts stays under the file cap, and because these are one
4
+ * coherent concern: is this INI shape something systemd would actually load. Takes plain strings,
5
+ * never `SystemdUnitProps`, so it never needs to import back from unit-form.ts.
6
+ *
7
+ * ⛔ `[Install]` KEYS ARE `unit-form.ts`'s OWN LIST, NOT SYSTEMD'S FULL VOCABULARY — WantedBy,
8
+ * RequiredBy, UpsertedBy, Also, Alias, per `systemd.unit(5)` §[Install]. `container-form.ts`'s
9
+ * own header notes it deliberately does NOT reuse this set: a `.container` file's `[Install]`
10
+ * supports only Alias/WantedBy/RequiredBy (`podman-systemd.unit(5)`), narrower than this.
11
+ */
12
+ export declare const INSTALL_KEYS: Set<string>;
13
+ /** Whether `text` has an `[Install]` section carrying at least one key systemd actually links on. */
14
+ export declare const hasInstallSection: (text: string) => boolean;
15
+ /**
16
+ * The first line that is neither blank nor a comment — the line whose shape actually decides
17
+ * whether the file "begins with a [Section]", per `textProblems` below.
18
+ * ★ systemd.syntax(7): "Empty lines and lines starting with '#' or ';' are ignored", with no
19
+ * carve-out for the position before the first section — a comment header is ordinary content
20
+ * there, same as anywhere else in the file.
21
+ */
22
+ export declare const firstSubstantiveLine: (text: string) => string | undefined;
23
+ /**
24
+ * 🔴 MEASURED FALSE, CT100 deploy 2026-09-26 00:11Z: the openbao-agent.service asset's first eight
25
+ * lines are `#`-comments recording where the file was recovered from (verbatim from the live
26
+ * host — homeflare-ct100's `src/openbao-agent/files/openbao-agent.service`), and the file is
27
+ * byte-identical to what systemd already loads there. The old check tested the WHOLE text
28
+ * against `/^\s*\[[A-Za-z]+]/` — `\s*` skips blank lines and indentation, never a `#`/`;`
29
+ * comment — so a leading comment line failed it outright. This checks only the first
30
+ * SUBSTANTIVE line, so any number of leading blank lines and comments are fine and a genuine
31
+ * `key=value` before any section — the one real error this exists to catch — still fails, since
32
+ * that line is neither blank nor a comment either.
33
+ */
34
+ export declare const textProblems: (text: string) => string[];
35
+ //# sourceMappingURL=unit-text.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unit-text.d.ts","sourceRoot":"","sources":["../../src/linux/unit-text.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,eAAO,MAAM,YAAY,aAAqE,CAAC;AAE/F,qGAAqG;AACrG,eAAO,MAAM,iBAAiB,SAAU,MAAM,KAAG,OAIhD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,SAAU,MAAM,KAAG,MAAM,GAAG,SAG6B,CAAC;AAE3F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,SAAU,MAAM,KAAG,MAAM,EAajD,CAAC"}
@@ -76,15 +76,26 @@ Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path.
76
76
  `enable`/`disable`/`start`/`stop`/`restart` elevate only when the unit is not
77
77
  **masked** (`LoadState`, read alongside `FragmentPath` in the same call —
78
78
  masking is someone's decision, never overridden here) and its **own
79
- `FragmentPath`** is either under a declared prefix, or empty AND the verb is
79
+ `FragmentPath`** is either under a declared prefix, empty AND the verb is
80
80
  `stop`/`disable` — the only two `unit-lifecycle.ts` `deleteUnit` ever sends
81
81
  against a unit whose file it may just have removed, so a second, idempotent
82
- delete pass still works. `enable`/`start`/`restart` with an empty
83
- `FragmentPath` are refused, closing a kernel-generated pseudo-unit
84
- (`init.scope`, a `session-N.scope`) as a target. A vendor unit like
85
- `pveproxy.service`, whose `FragmentPath` is `/usr/lib/systemd/system/…`, is
86
- refused before sudo ever runs: this runner cannot touch what it did not
87
- declare. `--user`, `--global`, `-H`, `-M` and `--root` are refused outright,
82
+ delete pass still works — **or a systemd GENERATOR's own output** (measured,
83
+ CT100 deploy 2026-09-26: `/run/systemd/generator`, and `.late`) **whose
84
+ `SourcePath` is under a declared prefix.** That is exactly `Podman.Container`:
85
+ Quadlet's generator writes `caddy.service`'s `FragmentPath` under
86
+ `/run/systemd/generator`, never a path this runner would declare, but stamps
87
+ `SourcePath` with the `.container` file that produced it — the thing this
88
+ runner actually wrote, under `/etc/containers/systemd`. `SourcePath` is read
89
+ in the same `systemctl show` as `FragmentPath`/`LoadState`, so no extra host
90
+ round trip. A generated unit with no `SourcePath`, or one outside every
91
+ prefix (a `.container` file elsewhere in Quadlet's OWN search path this
92
+ runner was never told to own), still refuses. `enable`/`start`/`restart`
93
+ with an empty `FragmentPath` are refused, closing a kernel-generated
94
+ pseudo-unit (`init.scope`, a `session-N.scope`) as a target. A vendor unit
95
+ like `pveproxy.service`, whose `FragmentPath` is `/usr/lib/systemd/system/…`
96
+ — not a generator directory — is refused before sudo ever runs: this runner
97
+ cannot touch what it did not declare, directly or through a generator.
98
+ `--user`, `--global`, `-H`, `-M` and `--root` are refused outright,
88
99
  anywhere in the argv. `directory-lifecycle.ts`'s own `chown` argv is bare
89
100
  (`0`, `:0`, `0:0`); `canonicalize()` rewrites it to the `+`-forced form above
90
101
  before the allowlist ever sees it, so the unprivileged caller never has to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.37.7",
3
+ "version": "0.38.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The leak this fix closes: `caddyProviders()` used to merge Caddy's admin `HttpClient.HttpClient`
3
+ * (and `Credentials`) straight into ITS OWN Layer output (providers.ts's `provideMerge`). Once Caddy
4
+ * sits in a stack's `Layer.mergeAll(…the stack's providers…)` (the exact shape homeflare-mini's
5
+ * `alchemy.run.ts` uses), its leaked client can shadow the stack's own ambient one for every sibling
6
+ * fetch-based provider merged alongside it — homeflare-mini's Forgejo and LiteLLM rows, per the PR
7
+ * evidence. Split from providers.test.ts at the 250-line cap.
8
+ *
9
+ * ⛔ A HAND-BUILT `Effect.provide(Layer.mergeAll(ambient, caddyProviders(admin), someProvider))` CANNOT
10
+ * REPRODUCE THIS — measured 2026-09-26 (red-team review), correcting an earlier version of this file
11
+ * that claimed it did. `Layer.mergeAll` does not thread one member's output into another member's
12
+ * requirement, so making a sibling provider that requires `HttpClient.HttpClient` typecheck inside
13
+ * the same `mergeAll` as `ambient` needs `someProvider.pipe(Layer.provideMerge(ambient))` — but that
14
+ * `provideMerge` also re-outputs `ambient` as the LAST layer effect's own build, which then wins any
15
+ * later flat-context lookup regardless of whether Caddy leaked, defeating the setup whether the bug
16
+ * is present or not (0 fail either way — confirmed by re-swapping in the pre-fix admin.ts/config.ts/
17
+ * providers.ts and re-running: still 2 pass, 0 fail, with or without `--rerun-each 3`).
18
+ * ★ THE REAL SHAPE INSTEAD: `verify/fake-engine.ts`'s `engineOver` runs the ACTUAL `Alchemy.Stack` +
19
+ * `Plan.make` + `apply` over an in-memory state store — the same merge Stack.ts itself performs
20
+ * (providers' combined context over the platform's ambient `FetchHttpClient`), not a hand-assembled
21
+ * stand-in for it. `Layer.mergeAll(caddyProviders(admin), ProbeProvider()).pipe(Layer.provideMerge
22
+ * (ambient))` mirrors a real stack's `providers` option with `ambient` standing in for the platform
23
+ * layer Stack.ts itself provides underneath — CAPTURED FAILURE on pre-fix admin.ts/config.ts/
24
+ * providers.ts (bun test, this file):
25
+ * (fail) ENGINE: Alchemy.Stack + Plan + apply, ambient under the merged providers
26
+ * error: expect(received).toEqual(expected)
27
+ * Expected: Set {"ambient"}
28
+ * Received: Set {"{\"error\":\"host not allowed: 127.0.0.1:<port>\"}\n"}
29
+ * (the sibling's `client.get('http://sibling.invalid/probe')` dialled Caddy's OWN admin transport —
30
+ * a real loopback connect to the fake Caddy server, which answered its Host check (admin-guard.ts's
31
+ * own concern, at the wire) rather than ever reaching `sibling.invalid`); passes post-fix.
32
+ */
33
+ import { afterEach, describe, expect, test } from 'bun:test';
34
+ import { Resource } from 'alchemy';
35
+ import * as Provider from 'alchemy/Provider';
36
+ import * as Effect from 'effect/Effect';
37
+ import * as Layer from 'effect/Layer';
38
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
39
+ import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
40
+ import { engineOver } from '../verify/fake-engine.ts';
41
+ import { CaddyConfig } from './config.ts';
42
+ import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
43
+ import { caddyProviders } from './providers.ts';
44
+
45
+ const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
46
+ const ids = { fqn: 'stack/caddy', id: 'caddy', instanceId: 'i-1' };
47
+
48
+ /** Stands in for a real fetch-based sibling (Forgejo, LiteLLM): a `Provider` whose handlers do
49
+ * exactly what a distilled SDK's generated operation does — `yield* HttpClient.HttpClient`. */
50
+ interface Probe extends Resource<'Test.Probe', { name: string }, { via: string }> {}
51
+ const Probe = Resource<Probe>('Test.Probe');
52
+
53
+ const ProbeProvider = (vias: string[]) => {
54
+ const hit = Effect.gen(function* () {
55
+ const client = yield* HttpClient.HttpClient;
56
+ const response = yield* client.get('http://sibling.invalid/probe');
57
+ const via = yield* response.text;
58
+ vias.push(via);
59
+ return { via };
60
+ });
61
+ return Provider.succeed(Probe, {
62
+ read: () => Effect.as(hit, undefined),
63
+ reconcile: () => hit,
64
+ delete: () => Effect.void,
65
+ });
66
+ };
67
+
68
+ /** Answers instantly and records that IT (not some other client) was the one asked. */
69
+ const labeledClient = (label: string, seen: string[]): Layer.Layer<HttpClient.HttpClient> =>
70
+ Layer.succeed(
71
+ HttpClient.HttpClient,
72
+ HttpClient.make((request) => {
73
+ seen.push(label);
74
+ return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(label)));
75
+ }),
76
+ );
77
+
78
+ let fake: FakeCaddy | undefined;
79
+ afterEach(() => {
80
+ fake?.stop();
81
+ fake = undefined;
82
+ });
83
+
84
+ describe('caddyProviders() admin-transport scoping', () => {
85
+ test('a sibling provider merged alongside it gets the AMBIENT client, never the admin one', async () => {
86
+ // ★ The real engine (Alchemy.Stack + Plan + apply, see the file header's ★): `ambient` stands in
87
+ // for the platform's own `FetchHttpClient`, `Layer.provideMerge`d under the stack's merged
88
+ // providers exactly as Stack.ts does. This never starts the fake Caddy's own traffic — it only
89
+ // proves WHICH client the sibling resolves, so the admin transport can point anywhere;
90
+ // `fakeDefaultCaddy()` gives it a real one.
91
+ const { admin, caddy } = fakeDefaultCaddy();
92
+ fake = caddy;
93
+ const vias: string[] = [];
94
+ const ambient = labeledClient('ambient', vias);
95
+ const engine = engineOver(
96
+ Layer.mergeAll(caddyProviders(admin), ProbeProvider(vias)).pipe(Layer.provideMerge(ambient)),
97
+ );
98
+ await engine.deploy(Probe('p', { name: 'sibling' }));
99
+ expect(vias.length).toBeGreaterThan(0);
100
+ // ★ Every hit the sibling made must read the ambient client's own label, never an error body from
101
+ // Caddy's admin transport (admin-guard.ts's Host check, reached only by a wrong client).
102
+ expect(new Set(vias)).toEqual(new Set(['ambient']));
103
+ });
104
+
105
+ test("Caddy's own admin calls still go through ITS transport, never the stack's ambient client", async () => {
106
+ const { admin, caddy } = fakeDefaultCaddy();
107
+ fake = caddy;
108
+ const seen: string[] = [];
109
+ const stack = Layer.mergeAll(labeledClient('ambient', seen), caddyProviders(admin));
110
+ await Effect.runPromise(
111
+ Effect.gen(function* () {
112
+ const provider = yield* CaddyConfig.Provider;
113
+ return yield* provider.reconcile({
114
+ ...ids,
115
+ bindings: [] as never,
116
+ news: { caddyfile: SITE },
117
+ olds: { caddyfile: SITE },
118
+ output: { configSha256: 'earlier', endpoint: caddy.address },
119
+ session: undefined as never,
120
+ });
121
+ }).pipe(Effect.provide(stack)),
122
+ );
123
+ expect(caddy.seen.some((call) => call.path === '/load')).toBe(true);
124
+ // ★ The ambient stub is never asked anything: every admin call reached `admin`'s own transport.
125
+ expect(seen).toEqual([]);
126
+ });
127
+ });
@@ -10,12 +10,25 @@
10
10
  * caddyconfig/load.go and admin.go, and in the API docs (caddyserver.com/docs/api).
11
11
  * ★ `CaddyAdminService` CARRIES ONLY MESSAGE/GUARD METADATA (endpoint, listener) — never a
12
12
  * credential, and never the transport itself. The transport is `@distilled.cloud/caddy`'s own
13
- * `Credentials` and `HttpClient.HttpClient` services, provided alongside it by `caddyAdminLayer`,
14
- * so admin-calls.ts calls the SDK's operations directly rather than through a house-specific
15
- * `.request()` seam. A consumer plugs in another route to the API (an SSH-forwarded socket today;
16
- * a remote runner later) by providing a different `CaddyTransport` to `caddyAdminLayer`.
13
+ * `Credentials` and `HttpClient.HttpClient` services — admin-calls.ts calls the SDK's operations
14
+ * directly rather than through a house-specific `.request()` seam. A consumer plugs in another
15
+ * route to the API (an SSH-forwarded socket today; a remote runner later) by providing a different
16
+ * `CaddyTransport` to `caddyAdminLayer`.
17
17
  * ⛔ THE ADMIN API HAS NO AUTHENTICATION. Anyone who reaches it can replace every site. It stays on
18
18
  * loopback or a unix socket; localCaddyAdmin() refuses anything else (local-admin.ts).
19
+ * ⛔ `caddyAdminLayer`'s OWN OUTPUT NEVER CARRIES `Credentials`/`HttpClient.HttpClient` DIRECTLY —
20
+ * MEASURED 2026-09-26: it used to (`Layer.mergeAll(CaddyAdminService-layer, transport.layer)`,
21
+ * what `caddyProviders()` then `provideMerge`d into a stack), and a stack that applies its own
22
+ * ambient client OUTSIDE the providers' merge — `Layer.mergeAll(providers…).pipe(Layer.provide
23
+ * (ambient))`, the shape `argocd`/`discord`/`opnsense`/`unifi`'s own `providers.ts` document —
24
+ * never got it back: `Layer.provide(A, B)`'s result outputs only `A`'s own services, so once `A`
25
+ * (the merged providers) already carries `HttpClient.HttpClient` from Caddy, no outer `B` overrides
26
+ * it. Every OTHER fetch-based provider merged alongside `caddyProviders()` (homeflare-mini's
27
+ * Forgejo, LiteLLM) then dialled Caddy's admin API instead of its own target — reproduced in
28
+ * admin-transport-scope.test.ts. `caddyAdminLayer` now stores `transport.layer` itself as a plain
29
+ * VALUE, under `CaddyAdminTransport` — a house-only tag nothing outside caddy/ ever reads — and
30
+ * config.ts is the one place that `Effect.provide`s it, scoped to exactly the effects that call
31
+ * `@distilled.cloud/caddy`'s operations.
19
32
  */
20
33
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
21
34
  import * as Context from 'effect/Context';
@@ -43,7 +56,16 @@ export interface CaddyTarget {
43
56
 
44
57
  /** What `localCaddyAdmin()` (or a consumer's own transport) hands `caddyAdminLayer`. */
45
58
  export interface CaddyTransport extends CaddyTarget {
46
- /** Provides the SDK's own `Credentials` and `HttpClient.HttpClient` — see local-admin.ts. */
59
+ /**
60
+ * Provides the SDK's own `Credentials` and `HttpClient.HttpClient` — see local-admin.ts.
61
+ * ⚠️ BUILT AND TORN DOWN ON EVERY `read`/`diff`/`reconcile` CALL — config.ts's `Effect.provide
62
+ * (transportLayer)` runs fresh each time, scoped to that one call (the scoping fix this file's
63
+ * own ⛔ documents). `localCaddyAdmin()`'s layer is three plain `Layer.succeed` values, so this
64
+ * costs nothing; a transport that opens something stateful (an SSH forward, a connection pool)
65
+ * would open and close it per call instead of once per stack. No such transport exists in the
66
+ * tray today (2026-09-26) — keep `layer` cheap and stateless, or build once and hand config.ts a
67
+ * pre-built `Context` instead, if that changes.
68
+ */
47
69
  readonly layer: Layer.Layer<Credentials | HttpClient.HttpClient>;
48
70
  }
49
71
 
@@ -52,19 +74,33 @@ export class CaddyAdminService extends Context.Service<CaddyAdminService, CaddyT
52
74
  'homeflare/caddy/CaddyAdmin',
53
75
  ) {}
54
76
 
77
+ /**
78
+ * `transport.layer` ITSELF, carried as a plain value rather than merged into the ambient context —
79
+ * the scoping fix (see the file header's ⛔). Only config.ts reads this tag, and only to
80
+ * `Effect.provide` it locally around the handful of effects that actually call
81
+ * `@distilled.cloud/caddy`'s operations; nothing else in this directory, and nothing outside it,
82
+ * has a reason to.
83
+ */
84
+ export class CaddyAdminTransport extends Context.Service<
85
+ CaddyAdminTransport,
86
+ Layer.Layer<Credentials | HttpClient.HttpClient>
87
+ >()('homeflare/caddy/CaddyAdminTransport') {}
88
+
55
89
  /**
56
90
  * Provide a transport to the provider: `CaddyConfigProvider().pipe(Layer.provideMerge(caddyAdminLayer(localCaddyAdmin())))`
57
91
  * — never plain `Layer.provide` here (see providers.ts's own ⚠️): `CaddyConfigProvider()`'s handlers
58
- * keep needing these services every time the engine calls them, not just once while it is built. Merges
59
- * the target metadata with the SDK's `Credentials`/`HttpClient` layers `transport.layer` carries.
92
+ * keep needing `CaddyAdminService`/`CaddyAdminTransport` every time the engine calls them, not just
93
+ * once while it is built. Neither tag is a generic platform service, so merging them into a stack's
94
+ * ambient context (unlike the raw `Credentials`/`HttpClient.HttpClient` this used to carry — the
95
+ * file header's ⛔) is safe: nothing outside this directory looks either up.
60
96
  */
61
97
  export const caddyAdminLayer = (
62
98
  transport: CaddyTransport,
63
- ): Layer.Layer<CaddyAdminService | Credentials | HttpClient.HttpClient> =>
99
+ ): Layer.Layer<CaddyAdminService | CaddyAdminTransport> =>
64
100
  Layer.mergeAll(
65
101
  Layer.succeed(CaddyAdminService, {
66
102
  endpoint: transport.endpoint,
67
103
  listener: transport.listener,
68
104
  }),
69
- transport.layer,
105
+ Layer.succeed(CaddyAdminTransport, transport.layer),
70
106
  );
@@ -9,7 +9,6 @@ import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
9
9
  import { AlchemyContext } from 'alchemy/AlchemyContext';
10
10
  import * as Effect from 'effect/Effect';
11
11
  import * as Layer from 'effect/Layer';
12
- import type * as Caddy from '@distilled.cloud/caddy';
13
12
  import { type CaddyAdminService, type CaddyTransport, caddyAdminLayer } from './admin.ts';
14
13
  import { CaddyConfig, CaddyConfigProvider } from './config.ts';
15
14
  import { configDigest } from './digest.ts';
@@ -40,9 +39,10 @@ type Deploy = { readonly adopt?: boolean; readonly contextAdopt?: boolean };
40
39
  const withProvider = <A>(
41
40
  admin: CaddyTransport,
42
41
  deploy: Deploy,
42
+ // ★ `CaddyAdminService` ONLY — see providers.test.ts's own `withProvider` for why.
43
43
  use: (
44
44
  p: Effect.Success<typeof CaddyConfig.Provider>,
45
- ) => Effect.Effect<A, unknown, CaddyAdminService | Caddy.CaddyOpContext>,
45
+ ) => Effect.Effect<A, unknown, CaddyAdminService>,
46
46
  ) => {
47
47
  // ⚠️ `Layer.provideMerge`, not `Layer.provide` — providers.ts's own doc on `caddyAdminLayer` says
48
48
  // why: `CaddyConfigProvider()`'s handlers still need these services every time they run, not
@@ -13,7 +13,7 @@ import { AdoptPolicy } from 'alchemy/AdoptPolicy';
13
13
  import * as Effect from 'effect/Effect';
14
14
  import * as Layer from 'effect/Layer';
15
15
  import * as Logger from 'effect/Logger';
16
- import { caddyAdminLayer } from './admin.ts';
16
+ import { type CaddyTransport, caddyAdminLayer } from './admin.ts';
17
17
  import { CaddyConfig, CaddyConfigProvider } from './config.ts';
18
18
  import { diffConfig } from './config-reconcile.ts';
19
19
  import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
@@ -34,6 +34,12 @@ const setup = () => {
34
34
  return made;
35
35
  };
36
36
 
37
+ /** `diffConfig` still needs the raw `Credentials`/`HttpClient` directly — `caddyAdminLayer` alone
38
+ * (admin.ts's own scoping fix) only carries `CaddyAdminService`/`CaddyAdminTransport`; merge
39
+ * `admin.layer` in too, the same fix `fake-caddy.ts`'s `runCaddy` makes for this exact shape. */
40
+ const withTransport = (admin: CaddyTransport) =>
41
+ Layer.mergeAll(caddyAdminLayer(admin), admin.layer);
42
+
37
43
  /** Runs `effect` and returns its result plus every `Effect.logWarning` line it emitted. */
38
44
  const withWarnings = async <A>(effect: Effect.Effect<A, unknown, never>) => {
39
45
  const warnings: string[] = [];
@@ -49,7 +55,7 @@ describe('the formatting warning is surfaced distinctly', () => {
49
55
  const { admin } = setup();
50
56
  const output = { configSha256: 'stub', endpoint: admin.endpoint };
51
57
  const { value, warnings } = await withWarnings(
52
- diffConfig({ caddyfile: UNFORMATTED }, output).pipe(Effect.provide(caddyAdminLayer(admin))),
58
+ diffConfig({ caddyfile: UNFORMATTED }, output).pipe(Effect.provide(withTransport(admin))),
53
59
  );
54
60
  expect(value).toEqual({ action: 'update' });
55
61
  expect(warnings).toHaveLength(1);
@@ -61,7 +67,7 @@ describe('the formatting warning is surfaced distinctly', () => {
61
67
  const { admin } = setup();
62
68
  const output = { configSha256: 'stub', endpoint: admin.endpoint };
63
69
  const { warnings } = await withWarnings(
64
- diffConfig({ caddyfile: SITE }, output).pipe(Effect.provide(caddyAdminLayer(admin))),
70
+ diffConfig({ caddyfile: SITE }, output).pipe(Effect.provide(withTransport(admin))),
65
71
  );
66
72
  expect(warnings).toEqual([]);
67
73
  });
@@ -21,7 +21,13 @@
21
21
  * engine skips that probe, reconcile refuses the same takeover (config-lifecycle.ts).
22
22
  * ⛔ `caddyfile` IS NEVER A SECRET — see config-form.ts; refused before anything is sent.
23
23
  * ⚠️ WHICH CADDY is the transport's (providers.ts): `CaddyAdminService` plus the `Credentials`/
24
- * `HttpClient` layers `caddyAdminLayer` merges — one per stack, not a prop.
24
+ * `HttpClient` layer `CaddyAdminTransport` carries — one per stack, not a prop.
25
+ * ⚠️ `transportLayer` IS `Effect.provide`D LOCALLY IN EACH HANDLER BELOW, NEVER MERGED AHEAD OF
26
+ * TIME. `caddyAdminLayer` (admin.ts) stores it under `CaddyAdminTransport` rather than merging its
27
+ * `Credentials`/`HttpClient.HttpClient` into the ambient context precisely so this file is the ONE
28
+ * place that discharges them — scoped to the handful of effects that actually call
29
+ * `@distilled.cloud/caddy`'s operations, never leaking into a sibling fetch-based provider merged
30
+ * into the same stack (admin.ts's own ⛔; MEASURED 2026-09-26, admin-transport-scope.test.ts).
25
31
  */
26
32
  import { Resource } from 'alchemy';
27
33
  import { Unowned } from 'alchemy/AdoptPolicy';
@@ -30,7 +36,7 @@ import * as Provider from 'alchemy/Provider';
30
36
  import * as Effect from 'effect/Effect';
31
37
  import { resolvedString } from '../launchd/host-effect.ts';
32
38
  import { adoptEnabled } from '../ownership/adopt.ts';
33
- import { CaddyAdminService } from './admin.ts';
39
+ import { CaddyAdminService, CaddyAdminTransport } from './admin.ts';
34
40
  import { isUnreachable } from './caddy-http-client.ts';
35
41
  import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
36
42
  import { probeLive, readLive } from './config-lifecycle.ts';
@@ -72,6 +78,7 @@ export const CaddyConfigProvider = () =>
72
78
  CaddyConfig,
73
79
  Effect.gen(function* () {
74
80
  const admin = yield* CaddyAdminService;
81
+ const transportLayer = yield* CaddyAdminTransport;
75
82
  /** The probe, branded for the engine: plain when ours, `Unowned` otherwise. */
76
83
  const probe = (caddyfile: string | undefined, sourceFile: string | undefined) =>
77
84
  Effect.flatMap(probeLive(caddyfile, sourceFile), (found) => {
@@ -104,6 +111,9 @@ export const CaddyConfigProvider = () =>
104
111
  ? yield* probe(resolvedString(olds, 'caddyfile'), resolvedString(olds, 'sourceFile'))
105
112
  : yield* readLive(resolvedString(olds, 'sourceFile'));
106
113
  }).pipe(
114
+ // ★ Scoped to Caddy's own transport — see the file header's ⚠️ — before the catch below,
115
+ // so `isUnreachable` still narrows the SAME raw error this always raised.
116
+ Effect.provide(transportLayer),
107
117
  // ★ Nothing to adopt from a Caddy that is down: plan a create, which is the same load.
108
118
  Effect.catchIf(isUnreachable, planWithoutCaddy(admin, undefined)),
109
119
  ),
@@ -112,7 +122,10 @@ export const CaddyConfigProvider = () =>
112
122
  if (output === undefined) return Effect.succeed(undefined);
113
123
  const update = planWithoutCaddy(admin, { action: 'update' as const });
114
124
  if (isResolved(news)) {
115
- return diffConfig(news, output).pipe(Effect.catchIf(isUnreachable, update));
125
+ return diffConfig(news, output).pipe(
126
+ Effect.provide(transportLayer),
127
+ Effect.catchIf(isUnreachable, update),
128
+ );
116
129
  }
117
130
  /**
118
131
  * ⚠️ `sourceFile` is an Output while its HostFile changes in the same deploy
@@ -123,6 +136,7 @@ export const CaddyConfigProvider = () =>
123
136
  const caddyfile = resolvedString(news, 'caddyfile');
124
137
  if (caddyfile === undefined) return Effect.succeed(undefined);
125
138
  return Effect.as(diffConfig({ caddyfile }, output), { action: 'update' as const }).pipe(
139
+ Effect.provide(transportLayer),
126
140
  Effect.catchIf(isUnreachable, update),
127
141
  );
128
142
  },
@@ -136,7 +150,9 @@ export const CaddyConfigProvider = () =>
136
150
  const sameCaddy = output !== undefined && output.endpoint === admin.endpoint;
137
151
  const takeOver = sameCaddy || (yield* adoptEnabled(fqn));
138
152
  const stored = output === undefined ? {} : { stored: output.configSha256 };
139
- const applied = yield* reconcileConfig(news, { takeOver, ...stored });
153
+ const applied = yield* reconcileConfig(news, { takeOver, ...stored }).pipe(
154
+ Effect.provide(transportLayer),
155
+ );
140
156
  // ★ THE FORMATTING WARNING GETS ITS OWN LINE, NAMING THE FIX — separate from the rest,
141
157
  // which keep logging exactly as before (format-warnings.ts).
142
158
  const { formatting, rest } = splitFormattingWarning(applied.warnings);
@@ -18,6 +18,7 @@
18
18
  * fake really listens on an ephemeral one: a Caddy on its default :2019 behind a forward.
19
19
  */
20
20
  import * as Effect from 'effect/Effect';
21
+ import * as Layer from 'effect/Layer';
21
22
  import type * as Caddy from '@distilled.cloud/caddy';
22
23
  import { type CaddyAdminService, type CaddyTransport, caddyAdminLayer } from './admin.ts';
23
24
  import { localCaddyAdmin } from './local-admin.ts';
@@ -190,8 +191,16 @@ export const fakeDefaultCaddy = (
190
191
  * Runs an Effect that needs `CaddyAdminService` and `@distilled.cloud/caddy`'s own `Credentials`/
191
192
  * `HttpClient` against one transport — the one line every test in this directory used to spend on
192
193
  * `Effect.provide(caddyAdminLayer(admin))` before this helper existed.
194
+ * ⚠️ TWO LAYERS, NOT ONE: `caddyAdminLayer(admin)` only carries `CaddyAdminService` and the
195
+ * `CaddyAdminTransport`-wrapped VALUE of `admin.layer` (admin.ts's own scoping fix) — the
196
+ * lifecycle functions this helper drives (`probeLive`, `reconcileConfig`, …) still need the raw
197
+ * `Credentials`/`HttpClient.HttpClient` directly in context, so `admin.layer` is provided again,
198
+ * built this time, alongside it.
193
199
  */
194
200
  export const runCaddy = <A, E>(
195
201
  effect: Effect.Effect<A, E, CaddyAdminService | Caddy.CaddyOpContext>,
196
202
  admin: CaddyTransport,
197
- ): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(caddyAdminLayer(admin))));
203
+ ): Promise<A> =>
204
+ Effect.runPromise(
205
+ effect.pipe(Effect.provide(Layer.mergeAll(caddyAdminLayer(admin), admin.layer))),
206
+ );
@@ -7,7 +7,6 @@ import * as Output from 'alchemy/Output';
7
7
  import { Stack } from 'alchemy/Stack';
8
8
  import * as Effect from 'effect/Effect';
9
9
  import * as Layer from 'effect/Layer';
10
- import type * as Caddy from '@distilled.cloud/caddy';
11
10
  import { type CaddyAdminService, caddyAdminLayer } from './admin.ts';
12
11
  import { CaddyConfig, CaddyConfigProvider } from './config.ts';
13
12
  import { type FakeCaddy, fakeCaddy } from './fake-caddy.ts';
@@ -31,9 +30,11 @@ const handler = <F>(name: string, fn: F | undefined): F => {
31
30
  };
32
31
 
33
32
  const withProvider = <A>(
33
+ // ★ `CaddyAdminService` ONLY — `read`/`diff`/`reconcile` no longer need the admin transport from
34
+ // OUTSIDE post-fix: config.ts's handlers `Effect.provide` it locally now (admin.ts's own ⛔).
34
35
  use: (
35
36
  p: Effect.Success<typeof CaddyConfig.Provider>,
36
- ) => Effect.Effect<A, unknown, CaddyAdminService | Caddy.CaddyOpContext>,
37
+ ) => Effect.Effect<A, unknown, CaddyAdminService>,
37
38
  address?: string,
38
39
  ) => {
39
40
  // ★ A Caddy on its default :2019 behind a forward, as in config-lifecycle.test.ts.
@@ -12,17 +12,24 @@
12
12
  * and `HttpClient.HttpClient`.
13
13
  *
14
14
  * ⚠️ `Layer.provideMerge`, NEVER PLAIN `Layer.provide`, for `caddyAdminLayer`. `CaddyConfigProvider()`'s
15
- * `read`/`diff`/`reconcile` handlers each still need `CaddyAdminService` and
16
- * `@distilled.cloud/caddy`'s `Credentials`/`HttpClient` — Alchemy's own `Provider.effect` types
17
- * those as part of the LAYER's overall requirement (`ProviderService`'s per-handler `Req` type
18
- * parameters, `alchemy/Provider.ts`), for exactly this reason. Plain `Layer.provide` SEALS a
19
- * layer — it satisfies `CaddyConfigProvider()`'s requirement only for BUILDING the `Provider`
20
- * value (the `admin` in its outer `Effect.gen`), then hides those services from anything that
21
- * runs LATER, when the returned `read`/`diff`/`reconcile` FUNCTIONS actually execute. MEASURED
22
- * 2026-09-23: with plain `Layer.provide`, `caddyProviders()`'s own `reconcile` handler dies with
23
- * "Service not found: homeflare/caddy/CaddyAdmin" the moment the ENGINE calls it. `provideMerge`
24
- * feeds `caddyAdminLayer`'s output into `CaddyConfigProvider()`'s requirement AND keeps that same
25
- * output live in the result, so every later handler call still runs with it in context.
15
+ * `read`/`diff`/`reconcile` handlers each still need `CaddyAdminService` and `CaddyAdminTransport`
16
+ * — Alchemy's own `Provider.effect` types those as part of the LAYER's overall requirement
17
+ * (`ProviderService`'s per-handler `Req` type parameters, `alchemy/Provider.ts`), for exactly this
18
+ * reason. Plain `Layer.provide` SEALS a layer — it satisfies `CaddyConfigProvider()`'s requirement
19
+ * only for BUILDING the `Provider` value (the `admin`/`transportLayer` in its outer `Effect.gen`),
20
+ * then hides those services from anything that runs LATER, when the returned `read`/`diff`/
21
+ * `reconcile` FUNCTIONS actually execute. MEASURED 2026-09-23: with plain `Layer.provide`,
22
+ * `caddyProviders()`'s own `reconcile` handler dies with "Service not found: homeflare/caddy/
23
+ * CaddyAdmin" the moment the ENGINE calls it. `provideMerge` feeds `caddyAdminLayer`'s output into
24
+ * `CaddyConfigProvider()`'s requirement AND keeps that same output live in the result, so every
25
+ * later handler call still runs with it in context.
26
+ * ⛔ THIS `caddyProviders()` LAYER NEVER CARRIES `Credentials`/`HttpClient.HttpClient` ITSELF (fixed
27
+ * 2026-09-26 — admin.ts's own ⛔ has the mechanism and the measured leak). `caddyAdminLayer`'s
28
+ * output is `CaddyAdminService | CaddyAdminTransport`, both house-only tags safe to `provideMerge`
29
+ * into any host stack; the SDK's own `Credentials`/`HttpClient.HttpClient` stay inside
30
+ * `CaddyAdminTransport`'s stored value, `Effect.provide`d locally by config.ts around exactly the
31
+ * effects that need them. A stack merging `caddyProviders()` with any other fetch-based provider
32
+ * (`forgejoProviders()`, `litellmProviders()`, …) is safe from this file's own composition alone.
26
33
  */
27
34
  import * as Layer from 'effect/Layer';
28
35
  import { type CaddyTransport, caddyAdminLayer } from './admin.ts';
@@ -18,7 +18,7 @@
18
18
  * or renders that file's contents.
19
19
  * ⛔ `[Install]` SUPPORTS ONLY `Alias=`/`WantedBy=`/`RequiredBy=` FOR A `.container` FILE (same doc,
20
20
  * "Currently, only the Alias, WantedBy and RequiredBy keys are supported") — NOT `Also=`, which
21
- * plain `systemd.unit(5)` allows and unit-form.ts's `INSTALL_KEYS` therefore lists. See
21
+ * plain `systemd.unit(5)` allows and unit-text.ts's `INSTALL_KEYS` therefore lists. See
22
22
  * container-generator.ts's header for why there is no `enabled` prop at all.
23
23
  */
24
24
  import { type UnitSection, configDigest, digestOf, renderUnit } from './unit-form.ts';
@@ -192,4 +192,37 @@ describe('verifyGenerated, directly', () => {
192
192
  }),
193
193
  ).toThrow(/earlier in Quadlet/);
194
194
  });
195
+
196
+ /**
197
+ * ⛔ REGRESSION — found on adversarial review. Before the FragmentPath check was added, a real,
198
+ * already-loaded PLAIN unit (e.g. an admin's own `/etc/systemd/system/…` sharing this
199
+ * container's service name) reported `LoadState=loaded` (it is a genuinely valid unit) and no
200
+ * `SourcePath` (hand-written units never carry one) — both existing checks passed it silently,
201
+ * so `verifyGenerated` was not the backstop its own doc comments claimed: a shadow that slipped
202
+ * past `assertUnshadowed` (container-preflight.ts) would misapply silently, not throw loudly.
203
+ */
204
+ test('a loaded plain unit with no SourcePath is still caught, via FragmentPath', () => {
205
+ expect(() =>
206
+ verifyGenerated(NAME, PATH, SERVICE, {
207
+ activeState: 'active',
208
+ fragmentPath: '/etc/systemd/system/hf-example.service',
209
+ known: true,
210
+ loadState: 'loaded',
211
+ needDaemonReload: false,
212
+ }),
213
+ ).toThrow(/not from Quadlet.s generator/);
214
+ });
215
+
216
+ test('a real generated FragmentPath is not mistaken for a shadow', () => {
217
+ expect(() =>
218
+ verifyGenerated(NAME, PATH, SERVICE, {
219
+ activeState: 'active',
220
+ fragmentPath: `/run/systemd/generator/${SERVICE}`,
221
+ known: true,
222
+ loadState: 'loaded',
223
+ needDaemonReload: false,
224
+ sourcePath: PATH,
225
+ }),
226
+ ).not.toThrow();
227
+ });
195
228
  });