@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.
- package/README.md +26 -37
- package/dist/caddy/config-lifecycle.d.ts +49 -3
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +6 -2
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +11 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +2 -1
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +71 -13
- package/dist/caddy/index.js.map +9 -9
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +50 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +18 -9
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +342 -8
- package/dist/launchd/index.js.map +15 -11
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +56 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +8 -2
- package/dist/openbao/fake-engines.d.ts.map +1 -1
- package/dist/openbao/fake-families.d.ts +49 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +359 -280
- package/dist/openbao/index.js.map +21 -19
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -3
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +13 -12
- package/dist/openbao/rename.d.ts.map +1 -1
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/peers.md +39 -0
- package/package.json +1 -1
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-lifecycle.test.ts +31 -53
- package/src/caddy/config-lifecycle.ts +98 -7
- package/src/caddy/config.ts +48 -11
- package/src/caddy/fake-caddy.ts +19 -0
- package/src/caddy/index.ts +2 -1
- package/src/caddy/providers.test.ts +8 -23
- package/src/caddy/with-file.ts +7 -3
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +33 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +2 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-preflight.ts +3 -2
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +7 -5
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +151 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +235 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +8 -2
- package/src/openbao/REPLACE.md +65 -37
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +35 -14
- package/src/openbao/cloudflare-role.ts +6 -14
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +26 -5
- package/src/openbao/jwt-role.ts +24 -8
- package/src/openbao/kubernetes-role.ts +20 -8
- package/src/openbao/mfa-enforcement.ts +23 -8
- package/src/openbao/mfa-totp.ts +21 -8
- package/src/openbao/pki-role.ts +19 -7
- package/src/openbao/plugin.ts +41 -15
- package/src/openbao/policy.ts +7 -7
- package/src/openbao/proxmox-role.ts +7 -16
- package/src/openbao/rename-adoption.test.ts +82 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename.ts +19 -15
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
*
|
|
11
11
|
* ★ REPLACE SEMANTICS (REPLACE.md): `mount` changed → `replace`: the new mount gets the config;
|
|
12
12
|
* the old one keeps its own until that mount is disabled. There is no identity but the mount.
|
|
13
|
+
* ⛔ A MOVE ONTO A MOUNT WHOSE CONFIG EXISTS FAILS THE PLAN, like every other family's move onto an
|
|
14
|
+
* occupied path (rename-identity.ts). With no delete, nothing is lost to a swap, which MEASURED
|
|
15
|
+
* 2026-09-21 ended with each config where it was declared. But the new generation writes over
|
|
16
|
+
* the config it lands on, which changes who can log in on that mount: an issuer or key set
|
|
17
|
+
* someone else manages, silently replaced. Move through a mount with no config instead.
|
|
13
18
|
*/
|
|
14
19
|
import { Resource } from 'alchemy';
|
|
15
20
|
import * as Provider from 'alchemy/Provider';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"jwt-config.d.ts","sourceRoot":"","sources":["../../src/openbao/jwt-config.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"jwt-config.d.ts","sourceRoot":"","sources":["../../src/openbao/jwt-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAO3B,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EAAE,KAAK,QAAQ,EAAuC,MAAM,qBAAqB,CAAC;AAEzF,YAAY,EAAE,0BAA0B,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAE9F,MAAM,WAAW,gBAAiB,SAAQ,QAAQ,CAChD,mBAAmB,EACnB,qBAAqB,EACrB,0BAA0B,EAC1B,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,gBAAgB,mDAE3B,CAAC;AAYH,eAAO,MAAM,aAAa,UACjB,qBAAqB,KAC3B,QAAQ,CAAC,0BAA0B,CAQpC,CAAC;AAEH,eAAO,MAAM,wBAAwB,uGAgClC,CAAC"}
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
*
|
|
15
15
|
* ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first. The new
|
|
16
16
|
* path cannot collide with the old, so no `deleteFirst`. Under the default `retain` the old role
|
|
17
|
-
* stays live, and still admits logins, until removed by hand.
|
|
17
|
+
* stays live, and still admits logins, until removed by hand. ⛔ A move onto a role that already
|
|
18
|
+
* exists fails the plan (rename-identity.ts).
|
|
18
19
|
* ★ `defaultRemovalPolicy: 'retain'`, like every Bao.* family.
|
|
19
20
|
*/
|
|
20
21
|
import { Resource } from 'alchemy';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"jwt-role.d.ts","sourceRoot":"","sources":["../../src/openbao/jwt-role.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"jwt-role.d.ts","sourceRoot":"","sources":["../../src/openbao/jwt-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,eAAe,EAMrB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EAAE,KAAK,QAAQ,EAAuC,MAAM,qBAAqB,CAAC;AAEzF,YAAY,EACV,kBAAkB,EAClB,oBAAoB,EACpB,eAAe,EACf,cAAc,GACf,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAcH,eAAO,MAAM,WAAW,UAAW,eAAe,KAAG,QAAQ,CAAC,oBAAoB,CAOhF,CAAC;AAEH,eAAO,MAAM,kBAAkB,iGAuC5B,CAAC"}
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* and nothing else (path_role.go:239-256).
|
|
10
10
|
*
|
|
11
11
|
* ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first (the new
|
|
12
|
-
* path cannot collide). Under the default `retain` the old role stays live until
|
|
12
|
+
* path cannot collide with the old). Under the default `retain` the old role stays live until
|
|
13
|
+
* removed by hand. ⛔ A move onto a role that already exists fails the plan (rename-identity.ts).
|
|
13
14
|
*/
|
|
14
15
|
import { Resource } from 'alchemy';
|
|
15
16
|
import * as Provider from 'alchemy/Provider';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"kubernetes-role.d.ts","sourceRoot":"","sources":["../../src/openbao/kubernetes-role.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"kubernetes-role.d.ts","sourceRoot":"","sources":["../../src/openbao/kubernetes-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,2BAA2B,EAChC,KAAK,sBAAsB,EAM5B,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EAAE,KAAK,QAAQ,EAAuC,MAAM,qBAAqB,CAAC;AAEzF,YAAY,EACV,wBAAwB,EACxB,2BAA2B,EAC3B,sBAAsB,GACvB,MAAM,2BAA2B,CAAC;AAEnC,MAAM,WAAW,iBAAkB,SAAQ,QAAQ,CACjD,oBAAoB,EACpB,sBAAsB,EACtB,2BAA2B,EAC3B,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,iBAAiB,oDAE5B,CAAC;AAUH,eAAO,MAAM,kBAAkB,UACtB,sBAAsB,KAC5B,QAAQ,CAAC,2BAA2B,CAOrC,CAAC;AAEH,eAAO,MAAM,yBAAyB,wGAoCnC,CAAC"}
|
|
@@ -19,9 +19,12 @@
|
|
|
19
19
|
* default `retain` it never runs; with `destroy`, set `retain` to drop it from state and leave it
|
|
20
20
|
* live — which is what the server would do anyway.
|
|
21
21
|
*
|
|
22
|
-
* ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first. Under `retain` BOTH
|
|
23
|
+
* enforcements then apply — fail closed. Under `destroy` the old one's delete is refused, so the
|
|
24
|
+
* deploy stops after creating. ⛔ A rename onto an enforcement that exists fails the plan
|
|
25
|
+
* (rename-identity.ts): the upsert by name would otherwise rewrite, in place, what someone else's
|
|
26
|
+
* enforcement demands. MEASURED 2026-09-21: a swap of two under `destroy` wrote each over the
|
|
27
|
+
* other, then stopped on the two refused deletes.
|
|
25
28
|
*/
|
|
26
29
|
import { Resource } from 'alchemy';
|
|
27
30
|
import * as Provider from 'alchemy/Provider';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mfa-enforcement.d.ts","sourceRoot":"","sources":["../../src/openbao/mfa-enforcement.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"mfa-enforcement.d.ts","sourceRoot":"","sources":["../../src/openbao/mfa-enforcement.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EACL,KAAK,gCAAgC,EACrC,KAAK,2BAA2B,EAMjC,MAAM,2BAA2B,CAAC;AAInC,YAAY,EACV,gCAAgC,EAChC,2BAA2B,GAC5B,MAAM,2BAA2B,CAAC;AAEnC,MAAM,WAAW,sBAAuB,SAAQ,QAAQ,CACtD,yBAAyB,EACzB,2BAA2B,EAC3B,gCAAgC,EAChC,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,sBAAsB,yDAEjC,CAAC;AAEH,KAAK,GAAG,GAAG,UAAU,CAAC,UAAU,CAAC;AAyBjC,eAAO,MAAM,eAAe,UACnB,2BAA2B,KACjC,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,QAAQ,EAAE,QAAQ,EAAE,GAAG,CAM7C,CAAC;AAEL,8DAA8D;AAC9D,eAAO,MAAM,oBAAoB,UACxB,2BAA2B,KACjC,MAAM,CAAC,MAAM,CAAC,gCAAgC,EAAE,QAAQ,EAAE,GAAG,CAe5D,CAAC;AAEL,eAAO,MAAM,8BAA8B,6GAuCxC,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mfa-totp.d.ts","sourceRoot":"","sources":["../../src/openbao/mfa-totp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAM3B,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"mfa-totp.d.ts","sourceRoot":"","sources":["../../src/openbao/mfa-totp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAM3B,MAAM,oBAAoB,CAAC;AAI5B,YAAY,EACV,0BAA0B,EAC1B,qBAAqB,EACrB,gBAAgB,GACjB,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,gBAAiB,SAAQ,QAAQ,CAChD,mBAAmB,EACnB,qBAAqB,EACrB,0BAA0B,EAC1B,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,gBAAgB,mDAE3B,CAAC;AAKH,eAAO,MAAM,QAAQ,UACZ,qBAAqB,KAC3B,MAAM,CAAC,MAAM,CAAC,0BAA0B,GAAG,SAAS,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAGrF,CAAC;AAEJ,eAAO,MAAM,QAAQ,UACZ,qBAAqB,KAC3B,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,QAAQ,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAK/D,CAAC;AAEL,uEAAuE;AACvE,eAAO,MAAM,aAAa,UACjB,qBAAqB,KAC3B,MAAM,CAAC,MAAM,CAAC,0BAA0B,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAWxE,CAAC;AAEL,eAAO,MAAM,wBAAwB,uGA0ClC,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pki-role.d.ts","sourceRoot":"","sources":["../../src/openbao/pki-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;
|
|
1
|
+
{"version":3,"file":"pki-role.d.ts","sourceRoot":"","sources":["../../src/openbao/pki-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,eAAe,EAMrB,MAAM,oBAAoB,CAAC;AAG5B,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,CAAC;AAEtD,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAgBH,eAAO,MAAM,kBAAkB,iGAiH5B,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/openbao/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/openbao/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,cAAc,EAKpB,MAAM,kBAAkB,CAAC;AAM1B,YAAY,EAAE,mBAAmB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAE3F,MAAM,WAAW,SAAU,SAAQ,QAAQ,CACzC,YAAY,EACZ,cAAc,EACd,mBAAmB,EACnB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,SAAS,4CAEpB,CAAC;AAoBH,eAAO,MAAM,iBAAiB,gGAsD3B,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/openbao/policy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,UAAU,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,IAAI,MAAM,aAAa,CAAC;AACpC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;
|
|
1
|
+
{"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/openbao/policy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,UAAU,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,IAAI,MAAM,aAAa,CAAC;AACpC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAOnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,WAAW,cAAc;IAC7B,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,mBAAmB;IAClC,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,SAAU,SAAQ,QAAQ,CACzC,YAAY,EACZ,cAAc,EACd,mBAAmB,EACnB,KAAK,EACL,UAAU,CAAC,UAAU,GAAG,UAAU,CAAC,UAAU,GAAG,IAAI,CAAC,IAAI,CAC1D;CAAG;AAEJ;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,SAAS,4CAEpB,CAAC;AAeH,eAAO,MAAM,iBAAiB,oIAuH3B,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"proxmox-role.d.ts","sourceRoot":"","sources":["../../src/openbao/proxmox-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EAKzB,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"proxmox-role.d.ts","sourceRoot":"","sources":["../../src/openbao/proxmox-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EAKzB,MAAM,wBAAwB,CAAC;AAKhC,YAAY,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,CAAC;AAE9D,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,mBAAmB,EACnB,wBAAwB,EACxB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,cAAc,iDAEzB,CAAC;AAKH,eAAO,MAAM,sBAAsB,qGA6JhC,CAAC"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rename guard every Bao.* family whose identity is a name, a mount, or a catalog key shares:
|
|
3
|
+
* one `Identity` per family, and two steps built from rename.ts, `judgeRename` at the top of `diff`
|
|
4
|
+
* and `guardRename` at the top of `reconcile`. REPLACE.md has the audit behind both.
|
|
5
|
+
*
|
|
6
|
+
* ★ ONE DESCRIPTION OF "WHICH OBJECT", NOT TWELVE COPIES OF THE CHECK. The rename-safety fix went
|
|
7
|
+
* in family by family, and the families it skipped were the ones that kept the bug: nine of them
|
|
8
|
+
* judged the identity only after `isResolved(news)` and never asked whether the new path was
|
|
9
|
+
* taken. 🔴 MEASURED 2026-09-21 through the engine (rename-families.test.ts): two Bao.AuthRole,
|
|
10
|
+
* Bao.PkiRole, Bao.JwtRole, Bao.KubernetesRole, Bao.SshRole or Bao.Plugin resources that swapped
|
|
11
|
+
* names under `RemovalPolicy.destroy()` both planned `replace`, each wrote over the other, and
|
|
12
|
+
* both old deletes then removed what had just been written: a green deploy, both objects gone.
|
|
13
|
+
* With any other prop still an Output, all nine planned `update` and left the old object live
|
|
14
|
+
* under no state record; for Bao.MfaTotpMethod that wrote a second method and stranded every
|
|
15
|
+
* enrolment.
|
|
16
|
+
*/
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
/** How one family names the object it manages — the thing a rename changes. */
|
|
19
|
+
export interface Identity<A> {
|
|
20
|
+
/** `Bao.JwtRole` etc., for messages. */
|
|
21
|
+
readonly family: string;
|
|
22
|
+
/** The identity a set of props declares; undefined while any part of it is still an Output. */
|
|
23
|
+
readonly declared: (props: unknown) => string | undefined;
|
|
24
|
+
/** The identity a generation's attributes record. */
|
|
25
|
+
readonly recorded: (output: A) => string;
|
|
26
|
+
/** The API path an identity is read at, for the occupied check. Default: the identity. */
|
|
27
|
+
readonly pathOf?: ((identity: string) => string) | undefined;
|
|
28
|
+
/** How the server keys it (`policyKey`). Default: exact. */
|
|
29
|
+
readonly key?: ((identity: string) => string) | undefined;
|
|
30
|
+
}
|
|
31
|
+
/** A family keyed by `name` alone, read at `pathOf(name)`. */
|
|
32
|
+
export declare const nameIdentity: <A extends {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
}>(family: string, pathOf: (name: string) => string, key?: (name: string) => string) => Identity<A>;
|
|
35
|
+
/**
|
|
36
|
+
* A role at `rolePath(mount, name)`; the identity is that path. `defaultMount` is what an absent
|
|
37
|
+
* `mount` prop means — undefined when `mount` is required.
|
|
38
|
+
*/
|
|
39
|
+
export declare const roleIdentity: <A extends {
|
|
40
|
+
readonly mount: string;
|
|
41
|
+
readonly name: string;
|
|
42
|
+
}>(family: string, rolePath: (mount: string, name: string) => string, defaultMount?: string, key?: (path: string) => string) => Identity<A>;
|
|
43
|
+
/**
|
|
44
|
+
* ⚠️ THE LAST SEGMENT LOWERCASED: a role store that folds the name and not the mount. openbao v2.6.2
|
|
45
|
+
* AppRole (builtin/credential/approle/path_role.go:1485, :1537) and Kubernetes
|
|
46
|
+
* (builtin/credential/kubernetes/backend.go:362, path_role.go:396) both store `role/<lowercased
|
|
47
|
+
* name>`, and JWT reads its `name` as a TypeLowerCaseString (builtin/credential/jwt/path_role.go:77),
|
|
48
|
+
* while the router folds no case in a mount path. So `Host` → `host` is the same role, never a
|
|
49
|
+
* replace that would delete, under `destroy`, the role it had just written.
|
|
50
|
+
*/
|
|
51
|
+
export declare const foldName: (path: string) => string;
|
|
52
|
+
/**
|
|
53
|
+
* `diff`'s first step, before `isResolved(news)`: the move from the identity this resource last
|
|
54
|
+
* wrote or tried to the declared one, or undefined when both name the same object or either is not
|
|
55
|
+
* known yet. ⛔ Fails the plan when the new identity is already taken (`judgeMove`).
|
|
56
|
+
*
|
|
57
|
+
* ★ `olds` STANDS IN FOR `output` WHILE A CREATE OR A REPLACEMENT IS UNFINISHED, which is what
|
|
58
|
+
* catches reverting a move whose new generation failed (rename.ts, `judgeMove`).
|
|
59
|
+
*/
|
|
60
|
+
export declare const judgeRename: <A>(identity: Identity<A>, olds: unknown, news: unknown, output: A | undefined) => Effect.Effect<{
|
|
61
|
+
from: string;
|
|
62
|
+
to: string;
|
|
63
|
+
} | undefined, import("./bao-status.ts").BaoError, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
64
|
+
/**
|
|
65
|
+
* `reconcile`'s first step: an `update` whose prior attributes name another object dies before any
|
|
66
|
+
* read or write (`refuseMovedUpdate`). Only reached when the diff could not see the new identity.
|
|
67
|
+
*/
|
|
68
|
+
export declare const guardRename: <A>(identity: Identity<A>, news: unknown, output: A | undefined) => Effect.Effect<void>;
|
|
69
|
+
//# sourceMappingURL=rename-identity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rename-identity.d.ts","sourceRoot":"","sources":["../../src/openbao/rename-identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AASxC,+EAA+E;AAC/E,MAAM,WAAW,QAAQ,CAAC,CAAC;IACzB,wCAAwC;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,+FAA+F;IAC/F,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;IAC1D,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC;IACzC,0FAA0F;IAC1F,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC,GAAG,SAAS,CAAC;IAC7D,4DAA4D;IAC5D,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC,GAAG,SAAS,CAAC;CAC3D;AAED,8DAA8D;AAC9D,eAAO,MAAM,YAAY,GAAI,CAAC,SAAS;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,UACtD,MAAM,UACN,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,QAC1B,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,KAC7B,QAAQ,CAAC,CAAC,CAMX,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,YAAY,GAAI,CAAC,SAAS;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,UAC9E,MAAM,YACJ,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,iBAClC,MAAM,QACf,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,KAC7B,QAAQ,CAAC,CAAC,CAKX,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ,SAAU,MAAM,KAAG,MACc,CAAC;AAEvD;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,GAAI,CAAC,YACjB,QAAQ,CAAC,CAAC,CAAC,QACf,OAAO,QACP,OAAO,UACL,CAAC,GAAG,SAAS;;;wGAQpB,CAAC;AAEJ;;;GAGG;AACH,eAAO,MAAM,WAAW,GAAI,CAAC,YACjB,QAAQ,CAAC,CAAC,CAAC,QACf,OAAO,UACL,CAAC,GAAG,SAAS,KACpB,MAAM,CAAC,MAAM,CAAC,IAAI,CAOpB,CAAC"}
|
package/dist/openbao/rename.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rename safety for the families whose identity is a name,
|
|
3
|
-
*
|
|
2
|
+
* Rename safety for the families whose identity is a name, a mount and a name, or a catalog key:
|
|
3
|
+
* the primitives. rename-identity.ts builds each family's diff and reconcile step from them, and
|
|
4
|
+
* REPLACE.md has the audit and what each answer does.
|
|
4
5
|
*
|
|
5
6
|
* ⛔ THE BUG THIS CLOSES, REPRODUCED 2026-09-21 AGAINST alchemy@2.0.0-beta.79's OWN PLAN AND APPLY
|
|
6
7
|
* (fake-stack.ts): a Bao.Policy deployed as `old`, then redeclared as `new` under
|
|
@@ -32,8 +33,16 @@ import * as Effect from 'effect/Effect';
|
|
|
32
33
|
export declare const declaredString: (news: unknown, key: string) => string | undefined;
|
|
33
34
|
/** True while an optional prop is declared but is not yet a value: an Output of a changing upstream. */
|
|
34
35
|
export declare const isPendingProp: (news: unknown, key: string) => boolean;
|
|
35
|
-
/**
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* An optional string prop: `fallback` when it is absent, undefined while it is still an Output.
|
|
38
|
+
* ⚠️ Not `declaredString(…) ?? fallback`, which would read a pending mount as the default mount.
|
|
39
|
+
*/
|
|
40
|
+
export declare const declaredOr: (news: unknown, key: string, fallback: string) => string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* A role's API path from a diff's `news`, once both `mount` and `name` are resolved. `defaultMount`
|
|
43
|
+
* is what an absent `mount` means; without one, an absent mount is unknown.
|
|
44
|
+
*/
|
|
45
|
+
export declare const declaredRolePath: (news: unknown, rolePath: (mount: string, name: string) => string, defaultMount?: string) => string | undefined;
|
|
37
46
|
/**
|
|
38
47
|
* How OpenBao keys an ACL policy: trimmed and lowercased. openbao v2.6.2
|
|
39
48
|
* vault/policy/policy_store.go:698-700 (`sanitizeName`), applied by SetPolicy (:268), GetPolicy
|
|
@@ -57,14 +66,6 @@ export declare const policyKey: (name: string) => string;
|
|
|
57
66
|
* `refuseMovedUpdate` stops reconcile before it writes anything.
|
|
58
67
|
*/
|
|
59
68
|
export declare const isMoved: (stored: string, declared: string | undefined, key?: (identity: string) => string) => boolean | undefined;
|
|
60
|
-
/**
|
|
61
|
-
* The role path a resource last wrote, or last tried to: its attributes once a write finished, else
|
|
62
|
-
* the props of the create or replacement that did not (`output` is undefined for both).
|
|
63
|
-
*/
|
|
64
|
-
export declare const triedRolePath: (output: {
|
|
65
|
-
readonly mount: string;
|
|
66
|
-
readonly name: string;
|
|
67
|
-
} | undefined, olds: unknown, rolePath: (mount: string, name: string) => string) => string | undefined;
|
|
68
69
|
/**
|
|
69
70
|
* The diff's first question, asked before `isResolved(news)`: the move from the identity this
|
|
70
71
|
* resource last wrote or tried (`tried`) to the declared one, or undefined when both name the same
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rename.d.ts","sourceRoot":"","sources":["../../src/openbao/rename.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"rename.d.ts","sourceRoot":"","sources":["../../src/openbao/rename.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAKxC;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,SAAU,OAAO,OAAO,MAAM,KAAG,MAAM,GAAG,SAIpE,CAAC;AAEF,wGAAwG;AACxG,eAAO,MAAM,aAAa,SAAU,OAAO,OAAO,MAAM,KAAG,OAI1D,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,UAAU,SAAU,OAAO,OAAO,MAAM,YAAY,MAAM,KAAG,MAAM,GAAG,SACH,CAAC;AAEjF;;;GAGG;AACH,eAAO,MAAM,gBAAgB,SACrB,OAAO,YACH,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,iBAClC,MAAM,KACpB,MAAM,GAAG,SAOX,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,SAAS,SAAU,MAAM,KAAG,MAAmC,CAAC;AAE7E;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,WACV,MAAM,YACJ,MAAM,GAAG,SAAS,QACvB,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,KAChC,OAAO,GAAG,SAAiF,CAAC;AAE/F;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,SAAS,WACZ,MAAM,SACP,MAAM,GAAG,SAAS,YACf,MAAM,GAAG,SAAS,UACpB,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,QAC/B,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM;;;wGAgB/B,CAAC;AAEL;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB,WAAY,MAAM,QAAQ,MAAM,MAAM,MAAM,uCAOvE,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⛔ THE FIELDS A WRITE WOULD SILENTLY DESTROY. Because the write is a full replace, a role
|
|
3
|
+
* someone tuned by hand — `allowed_user_key_lengths` (a minimum RSA size), a
|
|
4
|
+
* `key_id_format` the audit trail is grepped by, a `not_before_duration` widened to
|
|
5
|
+
* survive clock skew, a non-default issuer — loses all of it the moment this resource
|
|
6
|
+
* decides to update. A vanished key-length floor is a security regression that no plan
|
|
7
|
+
* output would ever mention, and a vanished backdating window breaks signing on exactly
|
|
8
|
+
* the skewed host it was added for. So reconcile refuses instead of writing.
|
|
9
|
+
*/
|
|
10
|
+
export declare const wouldErase: (live: Record<string, unknown>) => readonly string[];
|
|
11
|
+
//# sourceMappingURL=ssh-role-erase.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssh-role-erase.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role-erase.ts"],"names":[],"mappings":"AAkCA;;;;;;;;GAQG;AACH,eAAO,MAAM,UAAU,SAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,SAAS,MAAM,EAMzE,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssh-role.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,
|
|
1
|
+
{"version":3,"file":"ssh-role.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,EAOrB,MAAM,oBAAoB,CAAC;AAI5B,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,CAAC;AAEtD,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAeH,eAAO,MAAM,kBAAkB,iGAwH5B,CAAC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Caddy's admin endpoint — `@homeflare/alchemy/caddy`
|
|
2
|
+
|
|
3
|
+
How the provider reaches Caddy's admin API, and what it refuses so a Caddyfile cannot cut it off.
|
|
4
|
+
The overview is [caddy.md](./caddy.md).
|
|
5
|
+
|
|
6
|
+
⛔ The admin API has no authentication. `localCaddyAdmin()` accepts only `http://` loopback
|
|
7
|
+
(`127.0.0.0/8`, `localhost`, `[::1]`) or `unix:///path`; the default is `http://127.0.0.1:2019`.
|
|
8
|
+
|
|
9
|
+
- **Host and Origin.** Caddy refuses a request whose `Host` is not an allowed origin (DNS-rebinding
|
|
10
|
+
guard; measured on Caddy 2.11.4: `403 host not allowed`), and checks `Origin` when one is sent or
|
|
11
|
+
`enforce_origin` is on. The transport sends both as the Caddy CLI does. A unix socket skips the
|
|
12
|
+
Host check entirely.
|
|
13
|
+
- **`admin { origins … }`** narrowed, or **an SSH-forwarded port** (Caddy checks the Host against
|
|
14
|
+
ITS port): pass `hostHeader: 'localhost:2019'`.
|
|
15
|
+
- **Another host's Caddy** (SSH later): forward its socket or port to loopback here and point a
|
|
16
|
+
`localCaddyAdmin()` at it, or implement `CaddyAdmin` over your own route. Never expose :2019.
|
|
17
|
+
- **A Caddyfile cannot strand the provider.** Before loading, the adapted `admin` block is refused
|
|
18
|
+
if it turns the API off, listens off loopback or somewhere the transport does not reach (another
|
|
19
|
+
port, socket or loopback address: `[::1]` is not `127.0.0.1`, and `localhost` binds IPv4), allows
|
|
20
|
+
no Host the transport sends (its `origins`, or Caddy's loopback defaults when there are none), sets
|
|
21
|
+
`enforce_origin` over a unix socket (no Origin is sent there), enables `remote`, or pulls config.
|
|
22
|
+
- ⛔ **No `admin` address means Caddy's default** (`localhost:2019`, or `$CADDY_ADMIN`) after the
|
|
23
|
+
load. That is refused unless the transport is at that default: a Caddy reached on a socket or
|
|
24
|
+
another port declares `admin <address>` in its Caddyfile.
|
package/docs/caddy.md
CHANGED
|
@@ -24,10 +24,12 @@ export const edge = Effect.gen(function* () {
|
|
|
24
24
|
yield* LaunchdJob('caddy-daemon', {
|
|
25
25
|
label: 'com.example.caddy',
|
|
26
26
|
domain: 'system',
|
|
27
|
+
// --resume: a restart runs the last config Caddy accepted (see Managed Caddies below).
|
|
27
28
|
// --envfile: a file a secret renderer (openbao-agent) writes; {env.CF_API_TOKEN} reads it.
|
|
28
29
|
programArguments: [
|
|
29
30
|
'/usr/local/bin/caddy',
|
|
30
31
|
'run',
|
|
32
|
+
'--resume',
|
|
31
33
|
'--config',
|
|
32
34
|
file.path,
|
|
33
35
|
'--adapter',
|
|
@@ -35,6 +37,8 @@ export const edge = Effect.gen(function* () {
|
|
|
35
37
|
'--envfile',
|
|
36
38
|
'/usr/local/etc/caddy.env',
|
|
37
39
|
],
|
|
40
|
+
// ⛔ Here, not in the envfile: Caddy fixes its autosave path before it reads --envfile.
|
|
41
|
+
environment: { XDG_CONFIG_HOME: '/usr/local/var/caddy-edge' },
|
|
38
42
|
runAtLoad: true,
|
|
39
43
|
keepAlive: true,
|
|
40
44
|
});
|
|
@@ -56,15 +60,15 @@ export const providers = Layer.mergeAll(caddyProviders(), launchdProviders());
|
|
|
56
60
|
| `endpoint` | where the admin API was reached |
|
|
57
61
|
| `sourceFile` | as applied |
|
|
58
62
|
|
|
59
|
-
| step | what happens
|
|
60
|
-
| ------- |
|
|
61
|
-
| plan | `POST /adapt` the Caddyfile (side-effect free): a syntax error, a literal secret, no apps or an unsafe `admin` block fails the **plan**
|
|
62
|
-
| apply | `POST /load` with `text/caddyfile`, then `GET /config/` must hash to what `/adapt` produced; skipped when Caddy already runs it
|
|
63
|
-
| refused | Caddy keeps the old config (it rolls back itself); the deploy fails with Caddy's reason and says whether the old config is still running
|
|
64
|
-
| read | the digest of `GET /config
|
|
65
|
-
| drift | live ≠ stored → `update`: a hand `curl` to the API, or a restart that loaded a different file
|
|
66
|
-
| replace | never — a new Caddyfile is a reload of the same Caddy
|
|
67
|
-
| delete | ⛔ nothing. See [Removing it](#removing-it)
|
|
63
|
+
| step | what happens |
|
|
64
|
+
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| plan | `POST /adapt` the Caddyfile (side-effect free): a syntax error, a literal secret, no apps or an unsafe `admin` block fails the **plan** |
|
|
66
|
+
| apply | `POST /load` with `text/caddyfile`, then `GET /config/` must hash to what `/adapt` produced; skipped when Caddy already runs it |
|
|
67
|
+
| refused | Caddy keeps the old config (it rolls back itself); the deploy fails with Caddy's reason and says whether the old config is still running |
|
|
68
|
+
| read | the digest of `GET /config/`; with no state, the adoption probe — ⛔ a config that is not the declared one needs `--adopt` ([Adoption](#adoption)) |
|
|
69
|
+
| drift | live ≠ stored → `update`: a hand `curl` to the API, or a restart that loaded a different file |
|
|
70
|
+
| replace | never — a new Caddyfile is a reload of the same Caddy |
|
|
71
|
+
| delete | ⛔ nothing. See [Removing it](#removing-it) |
|
|
68
72
|
|
|
69
73
|
The digests compare adapted JSON, not bytes: `/adapt` answers Go struct order, `GET /config/` sorted
|
|
70
74
|
keys with `<>&` escaped and a trailing newline. `digest.ts` erases all three. ★ Measured 2026-09-21
|
|
@@ -86,31 +90,42 @@ before any admin API exists to load into. What bounds the cost:
|
|
|
86
90
|
`/adapt` fails the plan. On the very first deploy the path is still an Output, Alchemy skips the
|
|
87
91
|
adoption probe, and the check runs at apply instead.
|
|
88
92
|
- ⚠️ **A Caddyfile that adapts but that Caddy refuses** (a port in use, a missing cert file) is on
|
|
89
|
-
disk when `/load` fails. Caddy keeps serving the old config
|
|
90
|
-
the refused file
|
|
93
|
+
disk when `/load` fails. Caddy keeps serving the old config, and a managed Caddy's restart runs its
|
|
94
|
+
autosave, not the refused file (below). Without `--resume` a **restart before the fix loads the
|
|
95
|
+
refused file**: fix and redeploy.
|
|
91
96
|
- ⛔ Secrets are refused at declaration, before the HostFile exists (its `content` is a prop too).
|
|
92
97
|
- ★ Both resources retain: removing them from a stack leaves the file a restart needs.
|
|
93
98
|
|
|
94
|
-
##
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
- **
|
|
103
|
-
|
|
104
|
-
- **
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
99
|
+
## Managed Caddies: `--resume` and their own config dir
|
|
100
|
+
|
|
101
|
+
★ **Decision, 2026-09-21.** A Caddy this package manages runs `caddy run --resume --config <file>`
|
|
102
|
+
with `XDG_CONFIG_HOME` set to a directory of its own, so a restart runs the last config Caddy
|
|
103
|
+
**accepted**. After every successful load (ours included) Caddy writes the config JSON to
|
|
104
|
+
`$XDG_CONFIG_HOME/caddy/autosave.json`; `--resume` starts from it, and from `--config` only when no
|
|
105
|
+
autosave exists (cmd/commandfuncs.go cmdRun). Read in caddyserver/caddy v2.11.4:
|
|
106
|
+
|
|
107
|
+
- ★ **A refused Caddyfile on disk cannot take sites down.** The file is the first-boot fallback: a
|
|
108
|
+
new host, or an autosave someone deleted.
|
|
109
|
+
- ★ **Its own directory,** so nothing else (a Homebrew service, a person's `caddy run`) writes the
|
|
110
|
+
autosave it resumes from. With neither `HOME` nor `XDG_CONFIG_HOME` a daemon would use `./caddy/`
|
|
111
|
+
under its working directory (storage.go AppConfigDir).
|
|
112
|
+
- ⛔ **Set `XDG_CONFIG_HOME` in the job's `environment`, not the envfile.** The autosave path is
|
|
113
|
+
fixed when the binary starts (storage.go `var ConfigAutosavePath`), before `--envfile` is read.
|
|
114
|
+
- ⛔ **No `persist_config off`** in a managed Caddyfile. It stops the autosave, so `--resume` starts
|
|
115
|
+
from a stale one (the last config before it) or, with none, from the file.
|
|
116
|
+
- ⚠️ **A hand change through the API survives a restart.** The next plan reports it as drift, and the
|
|
117
|
+
deploy puts the declaration back.
|
|
118
|
+
- ⚠️ **SIGUSR1 is not a way to change config.** After a resumed start Caddy records no source file
|
|
119
|
+
(cmdRun calls `SetLastConfig` only after loading `--config`), so SIGUSR1 is ignored with the log
|
|
120
|
+
line `last config unknown`. Our `Caddy-Config-Source-File` header keeps a source Caddy already
|
|
121
|
+
has (caddy.go ClearLastConfigIfDifferent) and never sets one. After a first boot from the file,
|
|
122
|
+
SIGUSR1 still reloads it: the text the deploy wrote. Change config by deploying. Editing the file
|
|
123
|
+
and running `caddy reload` is a hand `/load`, which the next plan reports as drift.
|
|
124
|
+
- ⛔ **No `--watch`.** After a resumed start it has no file to watch, and falls back to a `Caddyfile`
|
|
125
|
+
in the working directory (cmd/main.go watchConfigFile).
|
|
126
|
+
|
|
127
|
+
A Caddy not yet moved over (no `--resume`) restarts from `--config`: see the ⚠️ in
|
|
128
|
+
[Order](#order-file-then-load).
|
|
114
129
|
|
|
115
130
|
## Secrets
|
|
116
131
|
|
|
@@ -133,31 +148,33 @@ bcrypt/argon2 hashes). It never echoes what it found. It is a tripwire, not a sc
|
|
|
133
148
|
|
|
134
149
|
## The admin endpoint
|
|
135
150
|
|
|
136
|
-
⛔ The admin API has no authentication
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
guard; measured on Caddy 2.11.4: `403 host not allowed`), and checks `Origin` when one is sent or
|
|
141
|
-
`enforce_origin` is on. The transport sends both as the Caddy CLI does. A unix socket skips the
|
|
142
|
-
Host check entirely.
|
|
143
|
-
- **`admin { origins … }`** narrowed, or **an SSH-forwarded port** (Caddy checks the Host against
|
|
144
|
-
ITS port): pass `hostHeader: 'localhost:2019'`.
|
|
145
|
-
- **Another host's Caddy** (SSH later): forward its socket or port to loopback here and point a
|
|
146
|
-
`localCaddyAdmin()` at it, or implement `CaddyAdmin` over your own route. Never expose :2019.
|
|
147
|
-
- **A Caddyfile cannot strand the provider.** Before loading, the adapted `admin` block is refused
|
|
148
|
-
if it turns the API off, listens off loopback or somewhere the transport does not reach (another
|
|
149
|
-
port, socket or loopback address: `[::1]` is not `127.0.0.1`, and `localhost` binds IPv4), allows
|
|
150
|
-
no Host the transport sends (its `origins`, or Caddy's loopback defaults when there are none), sets
|
|
151
|
-
`enforce_origin` over a unix socket (no Origin is sent there), enables `remote`, or pulls config.
|
|
152
|
-
- ⛔ **No `admin` address means Caddy's default** (`localhost:2019`, or `$CADDY_ADMIN`) after the
|
|
153
|
-
load. That is refused unless the transport is at that default: a Caddy reached on a socket or
|
|
154
|
-
another port declares `admin <address>` in its Caddyfile.
|
|
151
|
+
⛔ The admin API has no authentication: `localCaddyAdmin()` accepts only `http://` loopback or
|
|
152
|
+
`unix:///path` (default `http://127.0.0.1:2019`), and a Caddyfile whose `admin` block would move,
|
|
153
|
+
expose or lock out the API is refused before it loads. Host/Origin checks, narrowed `origins`, SSH
|
|
154
|
+
forwards and every refusal: [caddy-admin.md](./caddy-admin.md).
|
|
155
155
|
|
|
156
156
|
## Adoption
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
★ **Nothing is adopted silently** (decision, 2026-09-21) — the rule `HostFile` and `LaunchdJob` keep.
|
|
159
|
+
With no state, `read` is Alchemy's adoption probe, and what the Caddy runs decides:
|
|
160
|
+
|
|
161
|
+
| running | read | plan |
|
|
162
|
+
| ----------------------------------- | --------- | ---------------------------------------------------------- |
|
|
163
|
+
| the declared config (same digest) | ours | adopted; the forced update loads nothing |
|
|
164
|
+
| nothing (`null`, or no apps) | absent | create — nothing to take over, as an empty R2 lock is none |
|
|
165
|
+
| anything else | `Unowned` | refused unless the deploy runs with `--adopt` |
|
|
166
|
+
| a Caddyfile that cannot be compared | `Unowned` | the same, with a warning saying why |
|
|
167
|
+
| no Caddy answering | absent | create, with a warning; the apply checks again |
|
|
168
|
+
|
|
169
|
+
- ⚠️ **Alchemy skips the probe while the props hold an Output** — always on `caddyWithFile()`'s first
|
|
170
|
+
deploy, whose `sourceFile` is the HostFile's path. So the apply of a create checks too, and fails
|
|
171
|
+
before any `/load` unless the deploy runs with `--adopt`. The HostFile is written by then (a file
|
|
172
|
+
already at the path is the HostFile's own `Unowned`); under `--resume` a restart still runs the
|
|
173
|
+
autosave.
|
|
174
|
+
- ★ **That apply-time check resolves adoption as the planner does:** a resource-scoped `adopt(…)`,
|
|
175
|
+
else `--adopt`. So `.pipe(adopt(false))` still refuses under `--adopt`, and `adopt(true)` works.
|
|
176
|
+
- ★ **An uncomparable Caddyfile is `Unowned`, not an error**: the same read recovers an interrupted
|
|
177
|
+
create with that deploy's props, and a throw would fail every later plan, the fixed one included.
|
|
161
178
|
|
|
162
179
|
## Removing it
|
|
163
180
|
|
|
@@ -177,4 +194,6 @@ it by hand at its admin API.
|
|
|
177
194
|
its launchd job has Caddy listening. The transport retries a refused connection (`retries`,
|
|
178
195
|
`retryDelayMs`; default 2 × 500 ms) — raise them, or rerun the deploy.
|
|
179
196
|
- ⚠️ One `CaddyAdmin` per stack: the transport picks the Caddy, not a prop. Pointing it at another
|
|
180
|
-
Caddy plans an update
|
|
197
|
+
Caddy plans an update; the old one keeps what it had. ⛔ State vouches only for the Caddy it was
|
|
198
|
+
applied to, so the apply loads there only if it serves nothing, runs the declared config or the
|
|
199
|
+
one last stored — anything else needs `--adopt`.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Deploying a Mac host as yourself — `sudoRunner()`
|
|
2
|
+
|
|
3
|
+
A host stack that declares system daemons needs root for a handful of calls. `sudoRunner()` lets
|
|
4
|
+
the deploy run as you (the operator) and sends **only those calls** through `sudo -n`, in fixed
|
|
5
|
+
argv shapes, logging each one. It is an explicit opt-in: nothing falls back to it, and
|
|
6
|
+
`localRunner()` stays the default and never elevates.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { launchdProviders, sudoRunner } from '@homeflare/alchemy/launchd';
|
|
10
|
+
|
|
11
|
+
const runner = sudoRunner({
|
|
12
|
+
// ⛔ Required: the directories root may write. Each must be a real directory that root owns
|
|
13
|
+
// and only root may write; that is checked at every privileged call.
|
|
14
|
+
prefixes: ['/Library/LaunchDaemons', '/opt/example'],
|
|
15
|
+
// log: (line) => …, // default: one line on stderr per privileged call
|
|
16
|
+
});
|
|
17
|
+
// Provide launchdProviders(runner) alongside the stack's other provider layers.
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## What runs as root: the whole list
|
|
21
|
+
|
|
22
|
+
Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path, never through a shell.
|
|
23
|
+
|
|
24
|
+
| when | argv |
|
|
25
|
+
| -------------------------------- | ---------------------------------------------------------------------- |
|
|
26
|
+
| a system job is bootstrapped | `/bin/launchctl bootstrap system /Library/LaunchDaemons/<label>.plist` |
|
|
27
|
+
| a system job is booted out | `/bin/launchctl bootout system/<label>` |
|
|
28
|
+
| (your own code, via `exec`) | `/bin/launchctl kickstart [-k] [-p] system/<label>` |
|
|
29
|
+
| a file under a prefix is written | `/usr/bin/install -S -m <0644> [-o <uid>] [-g <gid>] <staged> <path>` |
|
|
30
|
+
| a file under a prefix is removed | `/bin/rm -f -- <path>` |
|
|
31
|
+
|
|
32
|
+
- A file's bytes never reach argv or the log: they are written, as you, to a `0600` file in a
|
|
33
|
+
fresh `0700` temp directory. `install` copies that file (and only that file) into place, and
|
|
34
|
+
the temp directory is removed afterwards, whether or not the install succeeded.
|
|
35
|
+
- `install` writes a temp file beside the target and renames it (install(1) on macOS 27.2), so the
|
|
36
|
+
target is never torn. `-S` adds the fsync.
|
|
37
|
+
- Owner and group are numeric ids, resolved by the provider before the call.
|
|
38
|
+
|
|
39
|
+
## What stays as you
|
|
40
|
+
|
|
41
|
+
- `launchctl print` and `print-disabled`. launchctl(1): "Anyone may read or query the system
|
|
42
|
+
domain"; measured on macOS 27.2, too (see `src/launchd/launchctl.ts`).
|
|
43
|
+
- User and group lookups, `id -G`, every file read and `lstat`.
|
|
44
|
+
- Files outside every prefix, and your own `gui/<uid>` jobs.
|
|
45
|
+
|
|
46
|
+
★ So **a plan never calls sudo.** `read` and `diff` are reads; only an apply elevates.
|
|
47
|
+
|
|
48
|
+
## Refused before sudo is asked
|
|
49
|
+
|
|
50
|
+
- Any other argv. That includes a bare `bootout system` (which removes the whole system domain),
|
|
51
|
+
`bootstrap system <directory>` (which loads every plist in it), a plist anywhere but
|
|
52
|
+
`/Library/LaunchDaemons/<label>.plist` (the only place launchd loads daemons from at boot),
|
|
53
|
+
`rm -r`, `install -d`, a label under `org.nixos.`, `com.apple.` or `homebrew.mxcl.`, and any
|
|
54
|
+
argv whose program is `sudo`.
|
|
55
|
+
- A system job's `bootstrap` or `bootout` when `/Library/LaunchDaemons` is not a prefix. ⚠️ Without
|
|
56
|
+
it a delete would boot the job out, then fail to remove its plist, which launchd loads again at
|
|
57
|
+
the next boot.
|
|
58
|
+
- A prefix that is missing, a symlink, not owned by root, or writable by group or other. ⛔ A
|
|
59
|
+
symlinked prefix makes root write wherever it points, a path the log never names.
|
|
60
|
+
- A path outside every prefix that needs root: another user as the owner, say.
|
|
61
|
+
- A symlink or missing directory between the prefix and the file, or anything but a regular file
|
|
62
|
+
at the path. ⚠️ `install src <directory>` copies _into_ the directory, and a symlink to one does
|
|
63
|
+
the same.
|
|
64
|
+
- A file you could not read back. Both providers read every write back, and read again at every
|
|
65
|
+
plan, as you. A root-only `0600` would be installed, then fail with `EACCES` and stay on disk
|
|
66
|
+
with no state. Membership comes from `id -G`, because macOS caps `getgroups()` at 16 (measured
|
|
67
|
+
2026-09-21: 16 against 18).
|
|
68
|
+
- Another user's `gui/<uid>` or `user/<uid>` domain. Only the system domain is elevated.
|
|
69
|
+
|
|
70
|
+
Every refusal above throws `SudoRefusedError`: nothing ran as root. A privileged command that ran
|
|
71
|
+
and failed throws a plain `Error` with its exit code.
|
|
72
|
+
|
|
73
|
+
## When sudo wants a password
|
|
74
|
+
|
|
75
|
+
`sudo -n` never prompts. It fails at once, and the error says so: `a password is required, and
|
|
76
|
+
this runner never prompts`. The command did not run. You have two options.
|
|
77
|
+
|
|
78
|
+
1. **`sudo -v` just before the deploy.** This caches a ticket. By default it lasts 5 minutes and
|
|
79
|
+
is tied to one terminal (sudoers(5): `timestamp_timeout`, `timestamp_type`).
|
|
80
|
+
⚠️ A deploy that outlives the ticket fails at its next privileged call. Every step is
|
|
81
|
+
idempotent (Alchemy requires `delete` to be), so `sudo -v` and deploying again converges. The
|
|
82
|
+
exception is a `LaunchdJob` label or domain change: it is delete-first, so if it fails between
|
|
83
|
+
the delete and the create, the job is down until you redeploy.
|
|
84
|
+
2. **A `NOPASSWD` sudoers rule for exactly the shapes above.**
|
|
85
|
+
- ⛔ **That rule is root-equivalent.** Anyone who may `install` into `/Library/LaunchDaemons`
|
|
86
|
+
and `launchctl bootstrap system` can run any program as root. The allowlist protects you
|
|
87
|
+
from mistakes, not from the account that holds the rule.
|
|
88
|
+
- ⛔ **Never use wildcards in the arguments.** sudoers(5): in arguments, `*` also matches spaces
|
|
89
|
+
and `/`, so `/bin/rm -f -- /opt/example/*` matches `../../etc/x` and two paths at once. Use
|
|
90
|
+
`^…$` regular expressions (sudo 1.9.10 and later; macOS 27.2 ships 1.9.17p2) and forbid
|
|
91
|
+
`..`. Check the file with `visudo -c -f <file>` before you install it.
|
|
92
|
+
- ⚠️ The kit ships no tested rule. Nothing here has run sudo.
|
|
93
|
+
|
|
94
|
+
⚠️ The error text sudo prints is **reasoned, not measured**. It comes from sudoers(5) and sudo(8)
|
|
95
|
+
for sudo 1.9.17p2. A sudo refusal the runner does not recognise comes back as a failed command,
|
|
96
|
+
with sudo's own stderr.
|
|
97
|
+
|
|
98
|
+
## The log
|
|
99
|
+
|
|
100
|
+
Each privileged call logs one line before it runs:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
homeflare/launchd sudo -n ["/bin/launchctl","bootstrap","system","/Library/LaunchDaemons/com.example.exporter.plist"]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The argv is written as JSON, so a space in a path cannot hide a second argument. The line holds
|
|
107
|
+
the argv only, never file content. ⛔ A custom `log` should keep every line: the log is the only
|
|
108
|
+
record of what ran as root.
|
|
109
|
+
|
|
110
|
+
## Limits
|
|
111
|
+
|
|
112
|
+
- ⚠️ **Moving a job from `system` to another user's `gui/<uid>` passes the plan and fails at
|
|
113
|
+
apply**, after the old job was already removed. The runner reports `privileged: true`, so the
|
|
114
|
+
plan-time check lets it through, and the refusal only comes at the write. Do that move as a
|
|
115
|
+
remove, then an add.
|
|
116
|
+
- ⚠️ **Under a prefix, the runner writes as root.** An omitted owner is root, and an omitted group
|
|
117
|
+
is the directory's group (a new file's group on macOS).
|
|
118
|
+
- ⚠️ **The checks guard against a mistaken declaration, not against a hostile one.** They run as
|
|
119
|
+
you, just before the call. The prefix itself must be root-only, but a directory _below_ it that
|
|
120
|
+
another user owns is not checked for that, and that user could swap what lies under it in
|
|
121
|
+
between. Keep every directory under a prefix root-owned, too. ACLs are not read, only mode bits.
|
|
122
|
+
- ⛔ **macOS hosts.** Every argv shape was checked against macOS 27.2 man pages.
|