@homeflare/alchemy 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +74 -41
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +91 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +34 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +54 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +22 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +970 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +51 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/fake-sudo.d.ts +50 -0
  53. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  54. package/dist/launchd/host-effect.d.ts +18 -9
  55. package/dist/launchd/host-effect.d.ts.map +1 -1
  56. package/dist/launchd/host-file-form.d.ts +5 -0
  57. package/dist/launchd/host-file-form.d.ts.map +1 -1
  58. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  59. package/dist/launchd/index.d.ts +2 -0
  60. package/dist/launchd/index.d.ts.map +1 -1
  61. package/dist/launchd/index.js +342 -8
  62. package/dist/launchd/index.js.map +16 -12
  63. package/dist/launchd/job-preflight.d.ts.map +1 -1
  64. package/dist/launchd/job-validate.d.ts +2 -0
  65. package/dist/launchd/job-validate.d.ts.map +1 -1
  66. package/dist/launchd/local-runner.d.ts +2 -0
  67. package/dist/launchd/local-runner.d.ts.map +1 -1
  68. package/dist/launchd/providers.d.ts +2 -1
  69. package/dist/launchd/providers.d.ts.map +1 -1
  70. package/dist/launchd/runner.d.ts +7 -5
  71. package/dist/launchd/runner.d.ts.map +1 -1
  72. package/dist/launchd/secret-tripwire.d.ts +1 -0
  73. package/dist/launchd/secret-tripwire.d.ts.map +1 -1
  74. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  75. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  76. package/dist/launchd/sudo-guard.d.ts +56 -0
  77. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  78. package/dist/launchd/sudo-runner.d.ts +60 -0
  79. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  80. package/dist/launchd/sudo-stage.d.ts +7 -0
  81. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  82. package/dist/openbao/auth-role-form.d.ts +0 -9
  83. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  84. package/dist/openbao/auth-role.d.ts +3 -2
  85. package/dist/openbao/auth-role.d.ts.map +1 -1
  86. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  87. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  88. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  89. package/dist/openbao/fake-engines.d.ts +36 -0
  90. package/dist/openbao/fake-engines.d.ts.map +1 -0
  91. package/dist/openbao/fake-families.d.ts +49 -0
  92. package/dist/openbao/fake-families.d.ts.map +1 -0
  93. package/dist/openbao/fake-stack.d.ts +29 -0
  94. package/dist/openbao/fake-stack.d.ts.map +1 -0
  95. package/dist/openbao/host-approles.d.ts.map +1 -1
  96. package/dist/openbao/index.js +393 -234
  97. package/dist/openbao/index.js.map +23 -19
  98. package/dist/openbao/jwt-config.d.ts +5 -0
  99. package/dist/openbao/jwt-config.d.ts.map +1 -1
  100. package/dist/openbao/jwt-role.d.ts +2 -1
  101. package/dist/openbao/jwt-role.d.ts.map +1 -1
  102. package/dist/openbao/kubernetes-role.d.ts +2 -1
  103. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  104. package/dist/openbao/mfa-enforcement.d.ts +6 -3
  105. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  106. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  107. package/dist/openbao/pki-role.d.ts.map +1 -1
  108. package/dist/openbao/plugin.d.ts.map +1 -1
  109. package/dist/openbao/policy.d.ts +2 -0
  110. package/dist/openbao/policy.d.ts.map +1 -1
  111. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  112. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  113. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  114. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  115. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  116. package/dist/openbao/rename-identity.d.ts +69 -0
  117. package/dist/openbao/rename-identity.d.ts.map +1 -0
  118. package/dist/openbao/rename.d.ts +107 -0
  119. package/dist/openbao/rename.d.ts.map +1 -0
  120. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  121. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  122. package/dist/openbao/ssh-role.d.ts.map +1 -1
  123. package/docs/caddy-admin.md +24 -0
  124. package/docs/caddy.md +199 -0
  125. package/docs/launchd-sudo.md +122 -0
  126. package/docs/launchd.md +7 -4
  127. package/docs/mesh-node.md +188 -0
  128. package/docs/peers.md +39 -0
  129. package/package.json +7 -2
  130. package/src/caddy/admin-calls.ts +128 -0
  131. package/src/caddy/admin-guard.ts +170 -0
  132. package/src/caddy/admin.ts +74 -0
  133. package/src/caddy/adopt-scope.test.ts +82 -0
  134. package/src/caddy/caddyfile-tripwire.ts +133 -0
  135. package/src/caddy/config-adopt.test.ts +235 -0
  136. package/src/caddy/config-form.ts +64 -0
  137. package/src/caddy/config-lifecycle.test.ts +196 -0
  138. package/src/caddy/config-lifecycle.ts +239 -0
  139. package/src/caddy/config.ts +153 -0
  140. package/src/caddy/digest.ts +44 -0
  141. package/src/caddy/fake-caddy.ts +179 -0
  142. package/src/caddy/guards.test.ts +156 -0
  143. package/src/caddy/index.ts +26 -0
  144. package/src/caddy/local-admin.test.ts +160 -0
  145. package/src/caddy/local-admin.ts +189 -0
  146. package/src/caddy/providers.test.ts +229 -0
  147. package/src/caddy/providers.ts +17 -0
  148. package/src/caddy/with-file.ts +66 -0
  149. package/src/cloudflare/fake-mesh.ts +181 -0
  150. package/src/cloudflare/index.ts +9 -3
  151. package/src/cloudflare/mesh-node-api.ts +127 -0
  152. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  153. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  154. package/src/cloudflare/mesh-node-form.ts +115 -0
  155. package/src/cloudflare/mesh-node-harness.ts +124 -0
  156. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  157. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  158. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  159. package/src/cloudflare/mesh-node-token.ts +125 -0
  160. package/src/cloudflare/mesh-node.test.ts +172 -0
  161. package/src/cloudflare/mesh-node.ts +120 -0
  162. package/src/cloudflare/providers.ts +20 -2
  163. package/src/index.ts +1 -0
  164. package/src/launchd/fake-sudo.ts +136 -0
  165. package/src/launchd/host-effect.ts +33 -1
  166. package/src/launchd/host-file-form.ts +10 -2
  167. package/src/launchd/host-file-lifecycle.ts +2 -1
  168. package/src/launchd/index.ts +2 -0
  169. package/src/launchd/job-preflight.ts +3 -2
  170. package/src/launchd/job-validate.ts +1 -1
  171. package/src/launchd/local-runner.ts +6 -3
  172. package/src/launchd/providers.test.ts +3 -0
  173. package/src/launchd/providers.ts +2 -1
  174. package/src/launchd/runner.ts +7 -5
  175. package/src/launchd/secret-tripwire.ts +1 -1
  176. package/src/launchd/sudo-allowlist.test.ts +191 -0
  177. package/src/launchd/sudo-allowlist.ts +213 -0
  178. package/src/launchd/sudo-guard.test.ts +100 -0
  179. package/src/launchd/sudo-guard.ts +151 -0
  180. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  181. package/src/launchd/sudo-runner-files.test.ts +240 -0
  182. package/src/launchd/sudo-runner.test.ts +126 -0
  183. package/src/launchd/sudo-runner.ts +235 -0
  184. package/src/launchd/sudo-stage.ts +28 -0
  185. package/src/openbao/README.md +8 -2
  186. package/src/openbao/REPLACE.md +126 -22
  187. package/src/openbao/auth-role-form.test.ts +2 -16
  188. package/src/openbao/auth-role-form.ts +0 -11
  189. package/src/openbao/auth-role.ts +35 -14
  190. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  191. package/src/openbao/cloudflare-role.ts +22 -8
  192. package/src/openbao/fake-engines-roles.ts +114 -0
  193. package/src/openbao/fake-engines.ts +126 -0
  194. package/src/openbao/fake-families.ts +182 -0
  195. package/src/openbao/fake-stack.ts +112 -0
  196. package/src/openbao/host-approles.test.ts +15 -2
  197. package/src/openbao/host-approles.ts +19 -4
  198. package/src/openbao/jwt-config.ts +26 -5
  199. package/src/openbao/jwt-role.ts +24 -8
  200. package/src/openbao/kubernetes-role.ts +20 -8
  201. package/src/openbao/mfa-enforcement.ts +23 -8
  202. package/src/openbao/mfa-totp.ts +21 -8
  203. package/src/openbao/pki-role.ts +19 -7
  204. package/src/openbao/plugin.ts +41 -15
  205. package/src/openbao/policy-rename.test.ts +112 -0
  206. package/src/openbao/policy-wire.test.ts +7 -26
  207. package/src/openbao/policy.ts +27 -4
  208. package/src/openbao/proxmox-role-form.ts +27 -8
  209. package/src/openbao/proxmox-role-wire.ts +24 -0
  210. package/src/openbao/proxmox-role.ts +52 -36
  211. package/src/openbao/rename-adoption.test.ts +82 -0
  212. package/src/openbao/rename-families.test.ts +122 -0
  213. package/src/openbao/rename-identity.test.ts +129 -0
  214. package/src/openbao/rename-identity.ts +117 -0
  215. package/src/openbao/rename-occupied.test.ts +166 -0
  216. package/src/openbao/rename.ts +166 -0
  217. package/src/openbao/role-rename.test.ts +205 -0
  218. package/src/openbao/ssh-role-erase.ts +50 -0
  219. package/src/openbao/ssh-role.ts +16 -54
@@ -0,0 +1,235 @@
1
+ /**
2
+ * sudoRunner(): the kit's one elevating HostRunner. The deploy runs as the operator, and only the
3
+ * calls that need root go through `sudo -n`, in the exact shapes sudo-allowlist.ts names:
4
+ *
5
+ * - `launchctl bootstrap | bootout | kickstart` in the system domain;
6
+ * - a file under a prefix the stack declared: staged in a private temp file as the operator, then
7
+ * `sudo -n install -S -m <mode> [-o <uid>] [-g <gid>] <staged> <path>`; removed with
8
+ * `sudo -n rm -f -- <path>`.
9
+ *
10
+ * Everything else (reads, lookups, the operator's own files and gui domain) goes to localRunner().
11
+ *
12
+ * ★ DECIDED 2026-09-21. A host stack deploys as the operator rather than as root, so a provider bug
13
+ * or a wrong declaration can do no more than this list. It is an explicit opt-in,
14
+ * `launchdProviders(sudoRunner({ prefixes }))`, and nothing ever falls back to it: localRunner()
15
+ * stays the default and never elevates.
16
+ * ⛔ `sudo -n`, NEVER A PROMPT. A deploy that stops mid-apply to ask for a password is the failure
17
+ * "no silent sudo" exists to prevent. With -n, sudo fails at once, and that failure becomes an
18
+ * error saying what to do.
19
+ * ⛔ EVERY PRIVILEGED ARGV IS LOGGED BEFORE IT RUNS, and it is only ever an argv: a file's bytes
20
+ * travel through the staged file, never through argv, so a log line cannot carry content.
21
+ * ★ `install -S`: install(1) on macOS 27.2 (read 2026-09-21) always writes a temp file in the
22
+ * target directory and renames it, so the file is never torn, and -S adds the fsync that
23
+ * localRunner's `handle.sync()` does.
24
+ * ⚠️ REASONED, NOT MEASURED: sudo's failure text. The kit never runs sudo. The strings below follow
25
+ * sudoers(5) for sudo 1.9.17p2 on macOS 27.2 ("a password is required": -n was given but a
26
+ * password was needed) and sudo(8) (sudo exits 1 when it fails itself).
27
+ */
28
+ import { type LocalRunnerOptions, errnoCode, localRunner } from './local-runner.ts';
29
+ import type { ExecResult, HostRunner } from './runner.ts';
30
+ import {
31
+ INSTALL,
32
+ RM,
33
+ SUDO,
34
+ SudoRefusedError,
35
+ checkPrefixes,
36
+ octalMode,
37
+ prefixOf,
38
+ privilegedProblem,
39
+ routeExec,
40
+ } from './sudo-allowlist.ts';
41
+ import { assertPlainPath, assertReadableBack, operatorGroups } from './sudo-guard.ts';
42
+ import { type Staged, stageFile } from './sudo-stage.ts';
43
+
44
+ export { SudoRefusedError };
45
+
46
+ export type SudoRunnerOptions = LocalRunnerOptions & {
47
+ /**
48
+ * The directories root may install into and remove from: `/Library/LaunchDaemons` for system
49
+ * jobs (bootstrap and bootout are refused without it), plus wherever the stack's root-owned
50
+ * HostFiles live. ⛔ Required: absolute, normalised, never `/`; and at every privileged call a
51
+ * real directory that root owns and only root may write (sudo-guard.ts). Outside every prefix a
52
+ * file is written as the deploying user, or refused when that needs root.
53
+ */
54
+ readonly prefixes: readonly string[];
55
+ /** Receives one line per privileged argv, before it runs. @default a line on stderr */
56
+ readonly log?: (line: string) => void;
57
+ };
58
+
59
+ /** What a sudo runner is built from. ★ Split out so the tests swap the host, never the logic. */
60
+ export type SudoDeps = {
61
+ /** Runs everything, including `sudo` itself. localRunner() for the real one. */
62
+ readonly base: HostRunner;
63
+ readonly stage: (bytes: Uint8Array) => Promise<Staged>;
64
+ /** The deploying user's groups, for the read-back check; `undefined` when unknown. */
65
+ readonly groups: () => Promise<readonly number[] | undefined>;
66
+ readonly log: (line: string) => void;
67
+ };
68
+
69
+ /**
70
+ * ⚠️ ANCHORED TO sudo'S OWN MESSAGE FORMS (`sudo: …` lines, `Sorry, user …`), so a launchctl or
71
+ * install error that happens to say "not allowed" is never reported as "the command did not run".
72
+ * The refusals are the three a sudoers denial prints: the command not allowed, the user not in
73
+ * sudoers, and the user not allowed on this host (`<user> is not allowed to run sudo on <host>.`).
74
+ * REASONED from sudo 1.9's sudoers plugin, like the rest (header).
75
+ */
76
+ const PASSWORD =
77
+ /^sudo: (?:a password is required|a terminal is required|sorry, you must have a tty)/m;
78
+ const NOT_ALLOWED =
79
+ /^(?:Sorry, user \S+ (?:is not allowed to execute|may not run sudo)|\S+ is not (?:in the sudoers file|allowed to run sudo on ))/m;
80
+
81
+ /** ★ An EACCES outside every prefix is almost always a directory the stack forgot to declare. */
82
+ const undeclared =
83
+ (path: string, prefixes: readonly string[]) =>
84
+ (cause: unknown): never => {
85
+ const code = errnoCode(cause);
86
+ if (code !== 'EACCES' && code !== 'EPERM') throw cause;
87
+ throw new Error(
88
+ `sudoRunner ${path}: ${code} as the deploying user, and the path is under no declared prefix ` +
89
+ `(${prefixes.join(', ')}). If root owns its directory, declare that directory as a prefix.`,
90
+ { cause },
91
+ );
92
+ };
93
+
94
+ export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): HostRunner => {
95
+ const prefixes = checkPrefixes(prefixList);
96
+ const { base } = deps;
97
+
98
+ /**
99
+ * ⛔ THE ONE PLACE `sudo` IS SPAWNED: allowlist, then any host check (`before`), then the log
100
+ * line, then `sudo -n -- argv`.
101
+ */
102
+ const elevate = async (
103
+ argv: readonly string[],
104
+ { before, staged }: { before?: () => Promise<unknown>; staged?: string } = {},
105
+ ): Promise<ExecResult> => {
106
+ const shown = JSON.stringify(argv);
107
+ const problem = privilegedProblem(argv, {
108
+ prefixes,
109
+ ...(staged === undefined ? {} : { staged }),
110
+ });
111
+ if (problem !== undefined)
112
+ throw new SudoRefusedError(`sudoRunner refused ${shown}: ${problem}`);
113
+ await before?.();
114
+ deps.log(`homeflare/launchd sudo -n ${shown}`);
115
+ const result = await base.exec([SUDO, '-n', '--', ...argv]);
116
+ if (result.exitCode !== 1) return result;
117
+ if (PASSWORD.test(result.stderr)) {
118
+ throw new SudoRefusedError(
119
+ `sudo -n ${shown}: a password is required, and this runner never prompts. Run \`sudo -v\` ` +
120
+ 'in the deploying terminal just before the deploy, or grant exactly these commands ' +
121
+ 'NOPASSWD (docs/launchd-sudo.md). The command did not run.',
122
+ );
123
+ }
124
+ if (NOT_ALLOWED.test(result.stderr)) {
125
+ throw new SudoRefusedError(
126
+ `sudo -n ${shown}: sudoers does not let the deploying user run this. The command did not run.`,
127
+ );
128
+ }
129
+ return result;
130
+ };
131
+
132
+ /** A privileged file operation: anything but exit 0 is an error. ⚠️ stderr names paths only. */
133
+ const mustSucceed = async (argv: readonly string[], staged?: string): Promise<void> => {
134
+ const result = await elevate(argv, staged === undefined ? {} : { staged });
135
+ if (result.exitCode !== 0) {
136
+ const stderr = result.stderr.trim().slice(0, 300);
137
+ throw new Error(`sudo -n ${JSON.stringify(argv)} -> ${String(result.exitCode)}: ${stderr}`);
138
+ }
139
+ };
140
+
141
+ return {
142
+ effectiveUid: () => base.effectiveUid(),
143
+ exec: async (argv) => {
144
+ const route = routeExec(argv, base.effectiveUid());
145
+ if ('refuse' in route) throw new SudoRefusedError(`sudoRunner: ${route.refuse}`);
146
+ if (route.as === 'operator') return base.exec(argv);
147
+ // ⛔ bootstrap makes root read the plist: it must be a plain file exactly where argv says.
148
+ // The allowlist has already put it under a prefix by the time `before` runs.
149
+ const plist = argv[1] === 'bootstrap' ? argv[3] : undefined;
150
+ const prefix = plist === undefined ? undefined : prefixOf(plist, prefixes);
151
+ return elevate(argv, {
152
+ before: async () => {
153
+ if (plist !== undefined && prefix !== undefined) {
154
+ await assertPlainPath(base, prefix, plist, 'file');
155
+ }
156
+ },
157
+ });
158
+ },
159
+ lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
160
+ lookupUser: (nameOrId) => base.lookupUser(nameOrId),
161
+ // ★ True, so the providers let a system-domain job through to this runner (canActAsRoot).
162
+ privileged: true,
163
+ readFile: (path) => base.readFile(path),
164
+ removeFile: async (path) => {
165
+ const prefix = prefixOf(path, prefixes);
166
+ if (prefix === undefined) return base.removeFile(path).catch(undeclared(path, prefixes));
167
+ /**
168
+ * ★ Nothing there is success without a privileged call, so an idempotent delete logs nothing.
169
+ * ⚠️ lstat the path FIRST: a directory on the way that is already gone means nothing is there
170
+ * (HostRunner.removeFile is idempotent, and localRunner treats ENOENT/ENOTDIR so), where the
171
+ * guard alone would refuse it as a missing directory.
172
+ */
173
+ if ((await base.stat(path)) === undefined) return;
174
+ await assertPlainPath(base, prefix, path, 'file');
175
+ await mustSucceed([RM, '-f', '--', path]);
176
+ },
177
+ sleep: (ms) => base.sleep(ms),
178
+ stat: (path) => base.stat(path),
179
+ writeFileAtomic: async (path, bytes, options) => {
180
+ const prefix = prefixOf(path, prefixes);
181
+ const operator = base.effectiveUid();
182
+ if (prefix === undefined) {
183
+ if (options.uid !== undefined && options.uid !== operator && operator !== 0) {
184
+ throw new SudoRefusedError(
185
+ `sudoRunner ${path}: owner ${String(options.uid)} needs root, and the path is under no ` +
186
+ `declared prefix (${prefixes.join(', ')}). Declare its directory, or deploy as root. ` +
187
+ 'Nothing was written.',
188
+ );
189
+ }
190
+ return base.writeFileAtomic(path, bytes, options).catch(undeclared(path, prefixes));
191
+ }
192
+ const mode = octalMode(options.mode);
193
+ if (mode === undefined)
194
+ throw new SudoRefusedError(`sudoRunner ${path}: mode must be 0–0o7777`);
195
+ await assertPlainPath(base, prefix, path, 'file-or-absent');
196
+ await assertReadableBack(base, path, options, await deps.groups());
197
+ const staged = await deps.stage(bytes);
198
+ try {
199
+ await mustSucceed(
200
+ [
201
+ INSTALL,
202
+ '-S',
203
+ '-m',
204
+ mode,
205
+ ...(options.uid === undefined ? [] : ['-o', String(options.uid)]),
206
+ ...(options.gid === undefined ? [] : ['-g', String(options.gid)]),
207
+ staged.path,
208
+ path,
209
+ ],
210
+ staged.path,
211
+ );
212
+ } finally {
213
+ await staged.dispose();
214
+ }
215
+ },
216
+ };
217
+ };
218
+
219
+ /**
220
+ * The real sudo runner: localRunner() for everything, `/usr/bin/sudo -n` for the allowlist.
221
+ * Pass it explicitly: `launchdProviders(sudoRunner({ prefixes: ['/Library/LaunchDaemons'] }))`.
222
+ */
223
+ export const sudoRunner = (options: SudoRunnerOptions): HostRunner => {
224
+ const base = localRunner(options);
225
+ return makeSudoRunner(options.prefixes, {
226
+ base,
227
+ groups: () => operatorGroups(base),
228
+ log:
229
+ options.log ??
230
+ ((line) => {
231
+ process.stderr.write(`${line}\n`);
232
+ }),
233
+ stage: stageFile,
234
+ });
235
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Where sudo-runner.ts puts a file's bytes before `install` copies them into place as root.
3
+ *
4
+ * ★ WHY STAGE AT ALL. The deploying user cannot write inside a root-owned directory, and the bytes
5
+ * must not travel through argv (they would reach the log and `ps`). So they are written, as the
6
+ * deploying user, to a private file, and `install` copies that one file.
7
+ * ⛔ A 0700 DIRECTORY, A 0600 FILE, O_EXCL: between the write and the install, nobody but the
8
+ * deploying user and root can read the bytes or swap the file.
9
+ */
10
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ /** A file staged for `install`, and how to remove it again. */
15
+ export type Staged = { readonly path: string; readonly dispose: () => Promise<void> };
16
+
17
+ export const stageFile = async (bytes: Uint8Array): Promise<Staged> => {
18
+ const dir = await mkdtemp(join(tmpdir(), 'hf-sudo-'));
19
+ const dispose = () => rm(dir, { force: true, recursive: true });
20
+ try {
21
+ const path = join(dir, 'staged');
22
+ await writeFile(path, bytes, { flag: 'wx', mode: 0o600 });
23
+ return { dispose, path };
24
+ } catch (cause) {
25
+ await dispose();
26
+ throw cause;
27
+ }
28
+ };
@@ -14,6 +14,11 @@ plus `FetchHttpClient.layer`.
14
14
  secret, a CA key, a plugin `env` or an OIDC client secret.
15
15
  - Every family defaults to `retain` on destroy. For what a rename does, read
16
16
  [REPLACE.md](./REPLACE.md) before you change any resource's path or name.
17
+ - ⛔ A rename or move onto a name or path that already exists fails the plan, before anything is
18
+ written (every family but the mounts, whose moves are below). Move through a free name, in two
19
+ deploys, or remove the target first.
20
+ - ⚠️ A NEW logical id for a live name adopts it, and under `destroy` the old id's delete then
21
+ removes it. Change a logical id with Alchemy's `renamedFrom` (REPLACE.md).
17
22
 
18
23
  ## assertBaoIdentity: call it first
19
24
 
@@ -70,9 +75,10 @@ export const hostRoles = Effect.gen(function* () {
70
75
  - A role is named `<class>--<host>`. Class and host names may contain only lowercase letters,
71
76
  digits and single hyphens.
72
77
  - It refuses `secretIdTtl` 0 (a secret_id that never expires), empty policies, `root`, unknown
73
- classes and duplicate hosts.
78
+ classes and a host listed twice in one class. A host in several classes gets one role per class.
74
79
  - ⚠️ Renaming a host or class makes a new role. Under `retain` the old one stays live and keeps
75
- admitting its secret_ids (REPLACE.md).
80
+ admitting its secret_ids (REPLACE.md). Under `destroy` its secret_ids go, but tokens it already
81
+ issued run to their TTL: deleting a role revokes no token.
76
82
 
77
83
  ## BaoJwtRole / BaoKubernetesRole / BaoJwtAuthConfig
78
84
 
@@ -1,7 +1,13 @@
1
1
  # Replace semantics: the `Bao.*` providers
2
2
 
3
- Audited 2026-09-21, against `alchemy@2.0.0-beta.79` and OpenBao v2.6.2 source. Every kit
4
- provider must answer `replace` correctly (vault consolidation plan, "Replace freely").
3
+ Audited 2026-09-21, against `alchemy@2.0.0-beta.79` and OpenBao v2.6.2 source, in two batches.
4
+ Every kit provider must answer `replace` correctly (vault consolidation plan, "Replace freely").
5
+
6
+ ★ **Measured, not only read.** `fake-stack.ts` runs beta.79's own `Plan.make` and `apply` over an
7
+ in-memory state store against the fake engines in `fake-engines.ts` and `fake-engines-roles.ts`.
8
+ `policy-rename.test.ts`, `role-rename.test.ts`, `rename-occupied.test.ts`,
9
+ `rename-families.test.ts`, `rename-identity.test.ts` and `rename-adoption.test.ts` deploy, rename,
10
+ and deploy again, then check the plan and every call that reached the fake.
5
11
 
6
12
  ## What Alchemy does with each answer
7
13
 
@@ -9,6 +15,12 @@ provider must answer `replace` correctly (vault consolidation plan, "Replace fre
9
15
  (its path) is unchanged.
10
16
  - **`replace`**: create-first by default. The new generation is created, dependents are updated in
11
17
  the same graph, then the old generation is deleted (`Apply.ts`, delete-first comment near :1180).
18
+ ★ That delete gets the **old** generation's attributes (`output: old.attr`), so a provider that
19
+ deletes by `output` deletes the old path. Measured: `PUT` of the new path, then `DELETE` of the
20
+ old one.
21
+ - **No answer** (`undefined`, which every diff returns while `news` holds a pending Output): the
22
+ engine plans `update` if any prop changed (`Plan.ts`, `havePropsChanged`). ⚠️ That is the bug
23
+ class below whenever the identity changed too.
12
24
  - **`deleteFirst: true`**: tears the old one down first. It is only for identities that cannot
13
25
  coexist, such as a unique physical name. No `Bao.*` provider uses it. A rename is a new API
14
26
  path, which never collides with the old one. ⚠️ A mount or auth method whose `type` changes
@@ -27,24 +39,117 @@ state record, so no later plan would mention it. For a mount this meant an **emp
27
39
  beside the retained one that held every secret. For an AppRole it meant the old role's secret_ids
28
40
  kept working for their whole TTL.
29
41
 
30
- ## Touched in this batch
31
-
32
- | Resource | Identity change | Answer now | Other changes |
33
- | ------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
34
- | `Bao.Mount` | `path` | ⛔ **fails the plan**, unless `remountFrom` names the old path. Then it is an in-place `update` that moves the mount with `sys/remount`. | `type` → `replace` that **fails at apply** (below). Description and TTLs → `update` (tune). |
35
- | `Bao.AuthMethod` | `path` | Same as `Bao.Mount` (`auth/` prefix). The accessor survives the move. | `type` → `replace` that **fails at apply** (below) |
36
- | `Bao.AuthRole` | `name` | `replace`. Before 2026-09-21 it was `update`, and the old role was orphaned. | `update` |
37
- | `Bao.PkiRole` | `mount`, `name` | `replace`. Before, only a `mount` change was caught; a `name` change orphaned the old role. | `update` (full-replace write) |
38
- | `Bao.JwtRole` | `mount`, `name` | `replace` | `update` (merge write; every managed field is sent) |
39
- | `Bao.KubernetesRole` | `mount`, `name` | `replace` | `update` |
40
- | `Bao.JwtAuthConfig` | `mount` | `replace`. The old delete is a no-op because there is no delete endpoint. | `update` (full-replace write) |
41
- | `Bao.MfaTotpMethod` | `name` | ⛔ **fails the plan**: a new id would strand every enrolled secret | `update` (the upsert keeps the id) |
42
- | `Bao.MfaLoginEnforcement` | `name` | `replace`. Its delete is **refused** (openbao#4030) | `update` |
42
+ 🔴 Reproduced through the engine on 2026-09-21 for `Bao.Policy` under `RemovalPolicy.destroy()`:
43
+ deploy `app-old`, redeclare it as `app-new`, deploy. The plan said `update`, and both policies
44
+ stayed live. `destroy` could not help, because nothing recorded the old one.
45
+
46
+ ## Answers, by resource
47
+
48
+ ⛔ Every family below except `Bao.Mount` and `Bao.AuthMethod` judges its identity **before**
49
+ `isResolved(news)`, and **fails the plan** on a move onto an object that already exists
50
+ (`rename-identity.ts`, below). Names compare as the server keys them: exact, except where a row
51
+ says folded.
52
+
53
+ | Resource | Identity change | Answer now | Other changes |
54
+ | ------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
55
+ | `Bao.Mount` | `path` | ⛔ **fails the plan**, unless `remountFrom` names the old path. Then it is an in-place `update` that moves the mount with `sys/remount`. | `type` → `replace` that **fails at apply** (below). Description and TTLs → `update` (tune). |
56
+ | `Bao.AuthMethod` | `path` | Same as `Bao.Mount` (`auth/` prefix). The accessor survives the move. | `type` → `replace` that **fails at apply** (below) |
57
+ | `Bao.AuthRole` | `name` (folded) | `replace`. Before 2026-09-21 it was `update`, and the old role was orphaned. Its delete clears the secret_ids; issued tokens run to their TTL (below). | `update` |
58
+ | `Bao.PkiRole` | `mount`, `name` | `replace`. Before, only a `mount` change was caught; a `name` change orphaned the old role. | `update` (full-replace write) |
59
+ | `Bao.JwtRole` | `mount`, `name` (folded) | `replace`. The name is folded: the server reads it lowercased (TypeLowerCaseString). | `update` (merge write; every managed field is sent) |
60
+ | `Bao.KubernetesRole` | `mount`, `name` (folded) | `replace`. The name is folded, as the server stores it. | `update` |
61
+ | `Bao.JwtAuthConfig` | `mount` | `replace`. The old delete is a no-op because there is no delete endpoint. A move onto a mount with a config fails the plan too: it would rewrite that mount's logins. | `update` (full-replace write) |
62
+ | `Bao.MfaTotpMethod` | `name` | ⛔ **fails the plan**: a new id would strand every enrolled secret. Reconcile refuses it too, when the name was an Output. | `update` (the upsert keeps the id) |
63
+ | `Bao.MfaLoginEnforcement` | `name` | `replace`. Its delete is **refused** (openbao#4030) | `update` |
64
+ | `Bao.SshRole` | `mount`, `name` | `replace` | `update` (full-replace write) |
65
+ | `Bao.Plugin` | `type`, `name`, `version` | `replace`: a version bump is a new catalog entry, and `retain` keeps the old version registered. | `update` (full-replace register) |
66
+ | `Bao.Policy` | `name` | `replace` (batch 3A). Compared as OpenBao keys it, trimmed and lowercased, so `Admin` → `admin` is the same policy: `noop`. | `update` (fragments digest) |
67
+ | `Bao.CloudflareRole` | `mount`, `name` | `replace` (batch 3A). Exact; a trailing `/` on the mount is the same path. | `update` |
68
+ | `Bao.ProxmoxRole` | `mount`, `name` | `replace` (batch 3A). ⛔ **Fails the plan** if the rename also changes `mintUser`, unless `allowMintUserChange` names the old one. | `update`. A `mintUser` change at the same path is refused at apply, never replaced. |
43
69
 
44
70
  `hostAppRoles` makes no resources, but its names are identities: renaming a host or a class
45
71
  renames the role. With the logical id taken from the role name, as the README does, the old id
46
72
  leaves the stack: that is an orphan delete, not a `replace`. With a stable logical id,
47
- `Bao.AuthRole` plans a `replace`. Under the default `retain`, both leave the old role live.
73
+ `Bao.AuthRole` plans a `replace`. Under the default `retain`, both leave the old role live. A
74
+ host may sit in several classes: it gets one role per class, and only the same host twice in one
75
+ class is refused.
76
+
77
+ ## When the new identity is not known at plan
78
+
79
+ - **Only another prop is pending** (say `fragments` is an Output of an upstream that is also
80
+ changing). Every family judges the identity props before `isResolved(news)`
81
+ (`rename-identity.ts`), so the rename still plans `replace`. Measured for all twelve. Before
82
+ 2026-09-21 the nine batch-2 families planned `update` here and orphaned the old object.
83
+ - **The identity itself is pending.** Then the diff defers: it never answers `replace` on an
84
+ unknown identity, because under `destroy` a replace onto the same path deletes what it just
85
+ wrote. The engine plans `update`, and reconcile, seeing the old attributes name another
86
+ object, **refuses before any write**. The row is left `updating`, with the new props and the
87
+ old attributes. The next deploy sees both names and plans `replace`. Measured for all twelve.
88
+ `Bao.MfaTotpMethod` refuses there with its rename refusal, and its next plan fails the same way.
89
+ - `Bao.ProxmoxRole` defers the same way while `mintUser` or `allowMintUserChange` is pending on a
90
+ rename, because the re-scope guard cannot be checked. Measured for `allowMintUserChange`.
91
+
92
+ ## A move onto an object that already exists fails the plan
93
+
94
+ ⛔ Every family in the table reads the new path at plan (`judgeMove`, `rename.ts`) and refuses the
95
+ move if anything is there. 🔴 Measured before the guard: two policies that swapped names under
96
+ `RemovalPolicy.destroy()` both planned `replace`. Each new generation wrote over the other's live
97
+ policy, and each old generation's delete then removed the name the other had just written. The
98
+ deploy was green, and both policies were gone, every grant revoked. The same swap deleted both
99
+ objects for `Bao.AuthRole`, `Bao.PkiRole`, `Bao.JwtRole`, `Bao.KubernetesRole`, `Bao.SshRole` and
100
+ `Bao.Plugin`. `Bao.JwtAuthConfig` (no delete) and `Bao.MfaLoginEnforcement` (delete refused) lost
101
+ nothing, but each new generation silently rewrote the other's live object. Measured the same way:
102
+
103
+ - a shift (`a → b` while `b → c`) deleted `b`, and a move onto the name of a resource leaving the
104
+ stack deleted the name it moved onto;
105
+ - reverting a move whose new generation failed. The old generation still holds the name, and its
106
+ delete runs after the revert rewrites it. So the diff compares against the props of an
107
+ unfinished create or replacement when there are no attributes yet. Declaring the new name again
108
+ finishes the move instead.
109
+
110
+ ⚠️ The diff cannot see the removal policy, so this refuses under `retain` too, where a swap would
111
+ have been harmless. So would a move back onto a retained old generation. Move in two deploys
112
+ through a name nothing holds, or remove the target by hand first. For `Bao.Plugin` that includes a
113
+ version bump onto a version already registered by hand. A change of case the server folds (policy,
114
+ AppRole, JWT and Kubernetes role names), or a trailing `/` on a mount, is the same object, so it is
115
+ not a move and is never refused (`rename-identity.test.ts`).
116
+
117
+ ## What `retain` means for batch 3A and AppRole
118
+
119
+ Under the default `retain` the old generation is kept, and the apply says so ("Replaced resource
120
+ retained."). It is live and unmanaged from then on:
121
+
122
+ - **`Bao.Policy`**: the old policy keeps every grant, for every token, role and group that still
123
+ names it. Under `destroy` they lose those grants at the delete. ⚠️ Only a role that takes the
124
+ policy's `name` Output follows the rename in the same graph. A role that names the policy as a
125
+ literal string (`tokenPolicies: ['deploy']`) keeps the old name, so change it in the same PR.
126
+ - **`Bao.CloudflareRole`**: the old role still mints, for any token whose ACL reaches its
127
+ `creds/<name>`. Under `destroy` every consumer still minting from the old path fails. Tokens
128
+ already minted are leases and live to their own expiry either way.
129
+ - **`Bao.ProxmoxRole`**: the same, and the old role keeps minting under its old `mint_user`. The
130
+ plugin's delete leaves outstanding leases revocable (its own
131
+ `TestDeletingARoleLeavesItsOutstandingLeasesRevocable`).
132
+ - **`Bao.AuthRole`**: the old role keeps admitting its secret_ids. Under `destroy` the delete
133
+ removes every secret_id and the role_id, so no new login succeeds, but it revokes no token:
134
+ tokens already issued live to their TTL and only fail to renew (openbao v2.6.2 approle
135
+ `pathRoleDelete`, path_role.go:1858-1902; `pathLoginRenew`, path_login.go:414-438). Revoke by
136
+ accessor to contain one.
137
+
138
+ ★ A stack that puts `mount` and `name` in the logical id, as homeflare-openbao's
139
+ `declareCloudflareRoles` does, never reaches the `replace`: the old id leaves the stack as an
140
+ orphan delete, which `retain` also keeps live.
141
+
142
+ ## A new declaration of a live name: the swap the guard cannot see
143
+
144
+ ⚠️ A NEW logical id whose name already exists takes that object over and rewrites it: these
145
+ families' `read` never answers `Unowned`, so Alchemy silently adopts it. Nothing moved, so
146
+ `judgeMove` never runs. 🔴 Measured (`rename-adoption.test.ts`): under `destroy` the old owner's
147
+ delete then runs after the adoption wrote, and removes the object the new id now claims, in a
148
+ green deploy. It happened when a logical id changed with the name kept, to each of the nine
149
+ batch-2 families whose delete runs (a TOTP method and its enrolments included), and when a new
150
+ `Bao.AuthRole` took a name another one moved off in the same deploy. Change a logical id with
151
+ Alchemy's `renamedFrom('<old id>')`, which migrates the state row and plans an `update`. Take a
152
+ freed name in the deploy after the move.
48
153
 
49
154
  ## Why the moves fail instead of replacing
50
155
 
@@ -78,10 +183,9 @@ take every secret with it. (Corrected 2026-09-21: the first audit listed this as
78
183
  3. **Unique names need `deleteFirst`.** Only a mount's `type` change keeps its unique path, and
79
184
  it fails on purpose instead (above).
80
185
 
81
- ## Audited, not touched in this batch (open)
186
+ ## Closed
82
187
 
83
- - ⚠️ `Bao.Policy`, `Bao.CloudflareRole` and `Bao.ProxmoxRole` still compare only the live object
84
- at the declared `name` (and `mount`). A rename plans `update` and orphans the old object, which
85
- is the same bug class as above. `Bao.ProxmoxRole` deliberately never answers `replace` for a
86
- `mint_user` change (its own comment), and that choice is sound. The rename case is separate.
87
- - `Bao.SshRole` and `Bao.Plugin` already answer `replace` on an identity change.
188
+ - ✅ **Nine families checked the identity after `isResolved(news)`** and never read the target
189
+ (closed 2026-09-21 by `rename-identity.ts`, measured in `rename-families.test.ts`).
190
+ `Bao.Mount` and `Bao.AuthMethod` never needed it: their reconcile re-plans the move from the
191
+ stated path.
@@ -5,17 +5,11 @@
5
5
  * sdk v2.6.2 framework.FieldData.Validate on the approle role fields (path_role.go:166-167,
6
6
  * TypeDurationSecond): `secret_id_ttl: "2160h"` validates and parses to 7776000 seconds. A role
7
7
  * read hands back that integer, which attributesOf renders as `90d` — the same role.
8
- * And what the diff calls a rename (a `replace`): only a change the server would not fold away.
8
+ * (What the diff calls a rename is pinned in rename-families.test.ts, `foldName`.)
9
9
  */
10
10
  import assert from 'node:assert/strict';
11
11
  import { describe, it } from 'node:test';
12
- import {
13
- type BaoAuthRoleProps,
14
- attributesOf,
15
- isRename,
16
- matches,
17
- writeBody,
18
- } from './auth-role-form.ts';
12
+ import { type BaoAuthRoleProps, attributesOf, matches, writeBody } from './auth-role-form.ts';
19
13
 
20
14
  const PROPS: BaoAuthRoleProps = {
21
15
  bindSecretId: true,
@@ -47,11 +41,3 @@ describe('auth role secret_id TTL', () => {
47
41
  assert.equal(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS), false);
48
42
  });
49
43
  });
50
-
51
- describe('auth role rename', () => {
52
- it('is a rename only when the lower-cased names differ, as the server stores them', () => {
53
- assert.equal(isRename('pve-node--node-a', 'pve-node--node-b'), true);
54
- assert.equal(isRename('Host-Cert', 'host-cert'), false);
55
- assert.equal(isRename('host-cert', 'host-cert'), false);
56
- });
57
- });
@@ -89,17 +89,6 @@ export const writeBody = (props: BaoAuthRoleProps): Record<string, string> => {
89
89
  return body;
90
90
  };
91
91
 
92
- /**
93
- * Whether a changed `name` names a DIFFERENT role — the diff's `replace` question.
94
- *
95
- * ⚠️ CASE-INSENSITIVELY, BECAUSE THE SERVER IS. AppRole stores `role/<lowercased name>` (openbao
96
- * v2.6.2 builtin/credential/approle/path_role.go:1485, and roleEntry reads the same key, :1537),
97
- * so `Host` → `host` is the SAME role: a `replace` there would write it, then — under `destroy` —
98
- * delete the old generation, which is that same role.
99
- */
100
- export const isRename = (from: string, to: string): boolean =>
101
- from.toLowerCase() !== to.toLowerCase();
102
-
103
92
  const samePolicies = (want: readonly string[], have: readonly string[]) =>
104
93
  want.length === have.length && want.every((policy, index) => policy === have[index]);
105
94
 
@@ -5,8 +5,9 @@
5
5
  * are credential issuance, not configuration, and are OUT OF SCOPE. This declares the
6
6
  * role shape an approle login inherits — policy names and TTL strings only.
7
7
  *
8
- * ★ `defaultRemovalPolicy: 'retain'` — deleting a role revokes every token bound to it.
9
- * Opt in with `.pipe(RemovalPolicy.destroy())`; see resource.ts in house/proxmox.
8
+ * ★ `defaultRemovalPolicy: 'retain'` — deleting a role locks out every host that logs in with it:
9
+ * its secret_ids go with it (the ⚠️ on `delete` below). Opt in with
10
+ * `.pipe(RemovalPolicy.destroy())`; see resource.ts in house/proxmox.
10
11
  */
11
12
  import { Resource } from 'alchemy';
12
13
  import { isResolved } from 'alchemy/Diff';
@@ -17,12 +18,12 @@ import {
17
18
  type BaoAuthRoleAttributes,
18
19
  type BaoAuthRoleProps,
19
20
  attributesOf,
20
- isRename,
21
21
  matches,
22
22
  readPath,
23
23
  writeBody,
24
24
  } from './auth-role-form.ts';
25
25
  import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
26
+ import { foldName, guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
26
27
 
27
28
  export type { BaoAuthRoleAttributes, BaoAuthRoleProps };
28
29
 
@@ -38,6 +39,14 @@ export const BaoAuthRole = Resource<BaoAuthRole>('Bao.AuthRole', {
38
39
  defaultRemovalPolicy: 'retain',
39
40
  });
40
41
 
42
+ /**
43
+ * ⚠️ CASE-INSENSITIVELY, BECAUSE THE SERVER IS. AppRole stores `role/<lowercased name>` (openbao
44
+ * v2.6.2 builtin/credential/approle/path_role.go:1485, and roleEntry reads the same key,
45
+ * :1537), so `Host` → `host` is the SAME role: a `replace` there would write it, then — under
46
+ * `destroy` — delete the old generation, which is that same role.
47
+ */
48
+ const IDENTITY = nameIdentity<BaoAuthRoleAttributes>('Bao.AuthRole', readPath, foldName);
49
+
41
50
  const readRole = (props: BaoAuthRoleProps) =>
42
51
  Effect.gen(function* () {
43
52
  const live = yield* baoRead(readPath(props.name));
@@ -65,26 +74,31 @@ export const BaoAuthRoleProvider = () =>
65
74
  * ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. A role edited in the OpenBao
66
75
  * UI is exactly the drift the check-* gates exist to catch.
67
76
  */
68
- diff: Effect.fn(function* ({ news, output }) {
69
- if (output === undefined || !isResolved(news)) return undefined;
77
+ diff: Effect.fn(function* ({ news, olds, output }) {
70
78
  /**
71
- * ⛔ A RENAMED ROLE IS A NEW ROLE — NEW role_id, NEW secret_ids — SO IT IS A `replace`.
72
- * Until 2026-09-21 a new `name` read nothing, planned `update` and wrote the new role,
73
- * leaving the old one live under no state record: its secret_ids kept logging in for
74
- * their whole TTL, and no plan would ever mention it again (REPLACE.md).
79
+ * ⛔ A RENAMED ROLE IS A NEW ROLE — NEW role_id, NEW secret_ids — SO IT IS A `replace`,
80
+ * judged before `isResolved(news)` (rename-identity.ts). Until 2026-09-21 a new `name`
81
+ * read nothing, planned `update` and wrote the new role, leaving the old one live
82
+ * under no state record: its secret_ids kept logging in for their whole TTL, and no
83
+ * plan would ever mention it again (REPLACE.md). ⛔ A rename onto a role that exists
84
+ * fails the plan.
75
85
  * ⚠️ UNDER THE DEFAULT `retain` THE OLD ROLE STILL STAYS LIVE — retain keeps the old
76
86
  * generation of a replace (Apply.ts:2164-2173). For per-host roles (host-approles.ts),
77
87
  * opt into `RemovalPolicy.destroy()` or destroy the old role's accessors by hand.
78
88
  */
79
- // ⚠️ Case-insensitively, because the server is — the ⚠️ on isRename in auth-role-form.ts.
80
- if (isRename(output.name, news.name)) return { action: 'replace' } as const;
89
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
90
+ if (output === undefined) return undefined;
91
+ if (move !== undefined) return { action: 'replace' } as const;
92
+ if (!isResolved(news)) return undefined;
81
93
  const live = yield* readRole(news);
82
94
  if (live === undefined) return { action: 'update' } as const;
83
95
  if (matches(live, news)) return { action: 'noop' } as const;
84
96
  return { action: 'update' } as const;
85
97
  }),
86
98
 
87
- reconcile: Effect.fn(function* ({ news }) {
99
+ reconcile: Effect.fn(function* ({ news, output }) {
100
+ // ⛔ An `update` across a rename the diff could not see — refused before any write.
101
+ yield* guardRename(IDENTITY, news, output);
88
102
  const live = yield* readRole(news);
89
103
  if (live === undefined || !matches(live, news)) {
90
104
  yield* baoWrite('PUT', readPath(news.name), writeBody(news));
@@ -101,8 +115,15 @@ export const BaoAuthRoleProvider = () =>
101
115
  }),
102
116
 
103
117
  /**
104
- * ⛔ DELETING A ROLE REVOKES EVERY TOKEN BOUND TO IT. Idempotent as Alchemy requires
105
- * — already gone is success — but retain is the default for a reason.
118
+ * ⚠️ DELETING A ROLE DOES NOT REVOKE THE TOKENS IT ISSUED. openbao v2.6.2 pathRoleDelete
119
+ * (builtin/credential/approle/path_role.go:1858-1902) deletes every secret_id
120
+ * (flushRoleSecrets, validation.go:420-446), the role_id mapping and the role, so no new
121
+ * login succeeds, and it revokes nothing. A token already issued lives to its TTL; only
122
+ * its renewal fails, because pathLoginRenew (path_login.go:414-438) refuses once the role
123
+ * is gone. So this is not a containment action: to contain a leaked token, revoke it or
124
+ * its accessor. (Corrected 2026-09-21: this said the delete revoked every token.)
125
+ * Idempotent as Alchemy requires — already gone is success — but retain is the default,
126
+ * because every host logging in with the role is locked out at the delete.
106
127
  */
107
128
  delete: Effect.fn(function* ({ output }) {
108
129
  yield* baoDelete(readPath(output.name));