@homeflare/alchemy 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +26 -37
  2. package/dist/caddy/config-lifecycle.d.ts +49 -3
  3. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  4. package/dist/caddy/config.d.ts +6 -2
  5. package/dist/caddy/config.d.ts.map +1 -1
  6. package/dist/caddy/fake-caddy.d.ts +11 -0
  7. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  8. package/dist/caddy/index.d.ts +2 -1
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +71 -13
  11. package/dist/caddy/index.js.map +9 -9
  12. package/dist/caddy/with-file.d.ts +7 -3
  13. package/dist/caddy/with-file.d.ts.map +1 -1
  14. package/dist/launchd/fake-sudo.d.ts +50 -0
  15. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  16. package/dist/launchd/host-effect.d.ts +18 -9
  17. package/dist/launchd/host-effect.d.ts.map +1 -1
  18. package/dist/launchd/host-file-form.d.ts +5 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  21. package/dist/launchd/index.d.ts +2 -0
  22. package/dist/launchd/index.d.ts.map +1 -1
  23. package/dist/launchd/index.js +342 -8
  24. package/dist/launchd/index.js.map +15 -11
  25. package/dist/launchd/job-preflight.d.ts.map +1 -1
  26. package/dist/launchd/job-validate.d.ts +2 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -1
  28. package/dist/launchd/local-runner.d.ts +2 -0
  29. package/dist/launchd/local-runner.d.ts.map +1 -1
  30. package/dist/launchd/providers.d.ts +2 -1
  31. package/dist/launchd/providers.d.ts.map +1 -1
  32. package/dist/launchd/runner.d.ts +7 -5
  33. package/dist/launchd/runner.d.ts.map +1 -1
  34. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  35. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  36. package/dist/launchd/sudo-guard.d.ts +56 -0
  37. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  38. package/dist/launchd/sudo-runner.d.ts +60 -0
  39. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  40. package/dist/launchd/sudo-stage.d.ts +7 -0
  41. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  42. package/dist/openbao/auth-role-form.d.ts +0 -9
  43. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-role.d.ts +3 -2
  45. package/dist/openbao/auth-role.d.ts.map +1 -1
  46. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  47. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  48. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  49. package/dist/openbao/fake-engines.d.ts +8 -2
  50. package/dist/openbao/fake-engines.d.ts.map +1 -1
  51. package/dist/openbao/fake-families.d.ts +49 -0
  52. package/dist/openbao/fake-families.d.ts.map +1 -0
  53. package/dist/openbao/host-approles.d.ts.map +1 -1
  54. package/dist/openbao/index.js +359 -280
  55. package/dist/openbao/index.js.map +21 -19
  56. package/dist/openbao/jwt-config.d.ts +5 -0
  57. package/dist/openbao/jwt-config.d.ts.map +1 -1
  58. package/dist/openbao/jwt-role.d.ts +2 -1
  59. package/dist/openbao/jwt-role.d.ts.map +1 -1
  60. package/dist/openbao/kubernetes-role.d.ts +2 -1
  61. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  62. package/dist/openbao/mfa-enforcement.d.ts +6 -3
  63. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  64. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  65. package/dist/openbao/pki-role.d.ts.map +1 -1
  66. package/dist/openbao/plugin.d.ts.map +1 -1
  67. package/dist/openbao/policy.d.ts.map +1 -1
  68. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  69. package/dist/openbao/rename-identity.d.ts +69 -0
  70. package/dist/openbao/rename-identity.d.ts.map +1 -0
  71. package/dist/openbao/rename.d.ts +13 -12
  72. package/dist/openbao/rename.d.ts.map +1 -1
  73. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  74. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  75. package/dist/openbao/ssh-role.d.ts.map +1 -1
  76. package/docs/caddy-admin.md +24 -0
  77. package/docs/caddy.md +73 -54
  78. package/docs/launchd-sudo.md +122 -0
  79. package/docs/launchd.md +7 -4
  80. package/docs/peers.md +39 -0
  81. package/package.json +1 -1
  82. package/src/caddy/adopt-scope.test.ts +82 -0
  83. package/src/caddy/config-adopt.test.ts +235 -0
  84. package/src/caddy/config-lifecycle.test.ts +31 -53
  85. package/src/caddy/config-lifecycle.ts +98 -7
  86. package/src/caddy/config.ts +48 -11
  87. package/src/caddy/fake-caddy.ts +19 -0
  88. package/src/caddy/index.ts +2 -1
  89. package/src/caddy/providers.test.ts +8 -23
  90. package/src/caddy/with-file.ts +7 -3
  91. package/src/launchd/fake-sudo.ts +136 -0
  92. package/src/launchd/host-effect.ts +33 -1
  93. package/src/launchd/host-file-form.ts +10 -2
  94. package/src/launchd/host-file-lifecycle.ts +2 -1
  95. package/src/launchd/index.ts +2 -0
  96. package/src/launchd/job-preflight.ts +3 -2
  97. package/src/launchd/job-validate.ts +1 -1
  98. package/src/launchd/local-runner.ts +6 -3
  99. package/src/launchd/providers.test.ts +3 -0
  100. package/src/launchd/providers.ts +2 -1
  101. package/src/launchd/runner.ts +7 -5
  102. package/src/launchd/sudo-allowlist.test.ts +191 -0
  103. package/src/launchd/sudo-allowlist.ts +213 -0
  104. package/src/launchd/sudo-guard.test.ts +100 -0
  105. package/src/launchd/sudo-guard.ts +151 -0
  106. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  107. package/src/launchd/sudo-runner-files.test.ts +240 -0
  108. package/src/launchd/sudo-runner.test.ts +126 -0
  109. package/src/launchd/sudo-runner.ts +235 -0
  110. package/src/launchd/sudo-stage.ts +28 -0
  111. package/src/openbao/README.md +8 -2
  112. package/src/openbao/REPLACE.md +65 -37
  113. package/src/openbao/auth-role-form.test.ts +2 -16
  114. package/src/openbao/auth-role-form.ts +0 -11
  115. package/src/openbao/auth-role.ts +35 -14
  116. package/src/openbao/cloudflare-role.ts +6 -14
  117. package/src/openbao/fake-engines-roles.ts +114 -0
  118. package/src/openbao/fake-engines.ts +18 -11
  119. package/src/openbao/fake-families.ts +182 -0
  120. package/src/openbao/host-approles.test.ts +15 -2
  121. package/src/openbao/host-approles.ts +19 -4
  122. package/src/openbao/jwt-config.ts +26 -5
  123. package/src/openbao/jwt-role.ts +24 -8
  124. package/src/openbao/kubernetes-role.ts +20 -8
  125. package/src/openbao/mfa-enforcement.ts +23 -8
  126. package/src/openbao/mfa-totp.ts +21 -8
  127. package/src/openbao/pki-role.ts +19 -7
  128. package/src/openbao/plugin.ts +41 -15
  129. package/src/openbao/policy.ts +7 -7
  130. package/src/openbao/proxmox-role.ts +7 -16
  131. package/src/openbao/rename-adoption.test.ts +82 -0
  132. package/src/openbao/rename-families.test.ts +122 -0
  133. package/src/openbao/rename-identity.test.ts +129 -0
  134. package/src/openbao/rename-identity.ts +117 -0
  135. package/src/openbao/rename.ts +19 -15
  136. package/src/openbao/ssh-role-erase.ts +50 -0
  137. package/src/openbao/ssh-role.ts +16 -54
@@ -0,0 +1,126 @@
1
+ /**
2
+ * sudoRunner over the fake host (fake-sudo.ts): which calls reach sudo, in what exact argv, what
3
+ * is logged, and what is refused before sudo is ever asked. No real sudo runs anywhere.
4
+ * File writes and removes are in sudo-runner-files.test.ts.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { OPERATOR, PREFIXES, fakeSudoHost } from './fake-sudo.ts';
8
+ import { LAUNCHCTL } from './launchctl.ts';
9
+ import { RM, SUDO } from './sudo-allowlist.ts';
10
+ import { SudoRefusedError, makeSudoRunner, sudoRunner } from './sudo-runner.ts';
11
+
12
+ const PLIST = '/Library/LaunchDaemons/com.example.a.plist';
13
+ const plistBytes = new TextEncoder().encode('<key>Label</key><string>com.example.a</string>');
14
+
15
+ describe('what it claims', () => {
16
+ test('privileged, as the operator: it elevates calls, it is not root', () => {
17
+ const { runner } = fakeSudoHost();
18
+ expect(runner.privileged).toBe(true);
19
+ expect(runner.effectiveUid()).toBe(OPERATOR);
20
+ });
21
+
22
+ test('prefixes are required and checked when it is built', () => {
23
+ expect(() => sudoRunner({ prefixes: [] })).toThrow('no prefixes');
24
+ expect(() => sudoRunner({ prefixes: ['/'] })).toThrow('every path');
25
+ });
26
+ });
27
+
28
+ describe('exec', () => {
29
+ test('a system bootstrap runs as `sudo -n --`, logged once, argv only', async () => {
30
+ const host = fakeSudoHost();
31
+ host.fake.files.set(PLIST, { bytes: plistBytes, gid: 0, kind: 'file', mode: 0o644, uid: 0 });
32
+ const result = await host.runner.exec([LAUNCHCTL, 'bootstrap', 'system', PLIST]);
33
+ expect(result.exitCode).toBe(0);
34
+ expect(host.sudoCalls).toEqual([[SUDO, '-n', '--', LAUNCHCTL, 'bootstrap', 'system', PLIST]]);
35
+ expect(host.logs).toEqual([
36
+ `homeflare/launchd sudo -n ${JSON.stringify([LAUNCHCTL, 'bootstrap', 'system', PLIST])}`,
37
+ ]);
38
+ });
39
+
40
+ test('bootstrap of a plist that is not a plain file is refused before sudo', async () => {
41
+ const host = fakeSudoHost();
42
+ const missing = host.runner.exec([LAUNCHCTL, 'bootstrap', 'system', PLIST]);
43
+ // ★ The host checks throw the same class as the allowlist: nothing ran as root.
44
+ await expect(missing).rejects.toBeInstanceOf(SudoRefusedError);
45
+ await expect(missing).rejects.toThrow('is missing, not a file');
46
+ host.fake.files.set(PLIST, { bytes: plistBytes, gid: 0, kind: 'symlink', mode: 0o755, uid: 0 });
47
+ await expect(host.runner.exec([LAUNCHCTL, 'bootstrap', 'system', PLIST])).rejects.toThrow(
48
+ 'is a symlink',
49
+ );
50
+ expect(host.sudoCalls).toEqual([]);
51
+ expect(host.logs).toEqual([]);
52
+ });
53
+
54
+ test('reads stay unelevated: no sudo, no log line', async () => {
55
+ const host = fakeSudoHost();
56
+ const result = await host.runner.exec([LAUNCHCTL, 'print', 'system/com.example.a']);
57
+ expect(result.exitCode).toBe(113);
58
+ await host.runner.exec([LAUNCHCTL, 'print-disabled', 'system']);
59
+ expect(host.sudoCalls).toEqual([]);
60
+ expect(host.logs).toEqual([]);
61
+ });
62
+
63
+ test.each([
64
+ ['a bare-domain bootout', [LAUNCHCTL, 'bootout', 'system']],
65
+ ["another user's gui domain", [LAUNCHCTL, 'bootout', 'gui/502/com.example.a']],
66
+ ['a sudo argv', [SUDO, '-n', RM, '-rf', '/']],
67
+ ])('%s is refused before sudo is asked', async (_name, argv) => {
68
+ const host = fakeSudoHost();
69
+ await expect(host.runner.exec(argv)).rejects.toBeInstanceOf(SudoRefusedError);
70
+ expect(host.sudoCalls).toEqual([]);
71
+ expect(host.logs).toEqual([]);
72
+ });
73
+
74
+ test('a password-required sudo fails at once, saying what to do', async () => {
75
+ const host = fakeSudoHost();
76
+ host.state.sudo = 'password';
77
+ const run = host.runner.exec([LAUNCHCTL, 'bootout', 'system/com.example.a']);
78
+ await expect(run).rejects.toBeInstanceOf(SudoRefusedError);
79
+ await expect(run).rejects.toThrow('a password is required');
80
+ await expect(run).rejects.toThrow('sudo -v');
81
+ expect(host.sudoCalls).toHaveLength(1);
82
+ });
83
+
84
+ test.each(['denied', 'not-in-sudoers', 'not-on-host'] as const)(
85
+ 'a sudoers refusal (%s) is named as one',
86
+ async (refusal) => {
87
+ const host = fakeSudoHost();
88
+ host.state.sudo = refusal;
89
+ const run = host.runner.exec([LAUNCHCTL, 'bootout', 'system/com.example.a']);
90
+ await expect(run).rejects.toBeInstanceOf(SudoRefusedError);
91
+ await expect(run).rejects.toThrow('sudoers does not let');
92
+ },
93
+ );
94
+
95
+ test("launchctl's own failure comes back as a result, for the provider to judge", async () => {
96
+ const host = fakeSudoHost();
97
+ const result = await host.runner.exec([LAUNCHCTL, 'bootout', 'system/com.example.a']);
98
+ expect(result.exitCode).toBe(3);
99
+ expect(host.sudoCalls).toHaveLength(1);
100
+ });
101
+
102
+ test('a launchctl error that says "not allowed" is still launchctl\'s, not sudo\'s', async () => {
103
+ const host = fakeSudoHost();
104
+ host.fake.files.set(PLIST, { bytes: plistBytes, gid: 0, kind: 'file', mode: 0o644, uid: 0 });
105
+ // ⚠️ Exit 1 like sudo's own failures; only sudo's message forms may turn it into a refusal.
106
+ host.fake.state.bootstrapFailure = {
107
+ exitCode: 1,
108
+ stderr: 'Bootstrap failed: 1: service is not allowed to execute in this domain',
109
+ stdout: '',
110
+ };
111
+ const result = await host.runner.exec([LAUNCHCTL, 'bootstrap', 'system', PLIST]);
112
+ expect(result.exitCode).toBe(1);
113
+ });
114
+ });
115
+
116
+ test('makeSudoRunner validates prefixes too', () => {
117
+ const { fake } = fakeSudoHost();
118
+ const deps = {
119
+ base: fake.runner,
120
+ groups: async () => [],
121
+ log: () => undefined,
122
+ stage: async () => ({ dispose: async () => undefined, path: '' }),
123
+ };
124
+ expect(() => makeSudoRunner([], deps)).toThrow('no prefixes');
125
+ expect(() => makeSudoRunner(PREFIXES, deps)).not.toThrow();
126
+ });
@@ -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
 
@@ -4,9 +4,10 @@ Audited 2026-09-21, against `alchemy@2.0.0-beta.79` and OpenBao v2.6.2 source, i
4
4
  Every kit provider must answer `replace` correctly (vault consolidation plan, "Replace freely").
5
5
 
6
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`. `policy-rename.test.ts`,
8
- `role-rename.test.ts` and `rename-occupied.test.ts` deploy, rename, and deploy again, then check
9
- the plan and every call that reached the fake.
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.
10
11
 
11
12
  ## What Alchemy does with each answer
12
13
 
@@ -44,46 +45,60 @@ stayed live. `destroy` could not help, because nothing recorded the old one.
44
45
 
45
46
  ## Answers, by resource
46
47
 
47
- | Resource | Identity change | Answer now | Other changes |
48
- | ------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
49
- | `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). |
50
- | `Bao.AuthMethod` | `path` | Same as `Bao.Mount` (`auth/` prefix). The accessor survives the move. | `type` → `replace` that **fails at apply** (below) |
51
- | `Bao.AuthRole` | `name` | `replace`. Before 2026-09-21 it was `update`, and the old role was orphaned. | `update` |
52
- | `Bao.PkiRole` | `mount`, `name` | `replace`. Before, only a `mount` change was caught; a `name` change orphaned the old role. | `update` (full-replace write) |
53
- | `Bao.JwtRole` | `mount`, `name` | `replace` | `update` (merge write; every managed field is sent) |
54
- | `Bao.KubernetesRole` | `mount`, `name` | `replace` | `update` |
55
- | `Bao.JwtAuthConfig` | `mount` | `replace`. The old delete is a no-op because there is no delete endpoint. | `update` (full-replace write) |
56
- | `Bao.MfaTotpMethod` | `name` | ⛔ **fails the plan**: a new id would strand every enrolled secret | `update` (the upsert keeps the id) |
57
- | `Bao.MfaLoginEnforcement` | `name` | `replace`. Its delete is **refused** (openbao#4030) | `update` |
58
- | `Bao.Policy` | `name` | `replace` (batch 3A). Compared as OpenBao keys it, trimmed and lowercased, so `Admin` → `admin` is the same policy: `noop`. ⛔ **Fails the plan** onto a name that exists (below). | `update` (fragments digest) |
59
- | `Bao.CloudflareRole` | `mount`, `name` | `replace` (batch 3A). Exact; a trailing `/` on the mount is the same path. ⛔ **Fails the plan** onto a role that exists (below). | `update` |
60
- | `Bao.ProxmoxRole` | `mount`, `name` | `replace` (batch 3A). ⛔ **Fails the plan** onto a role that exists, or 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. |
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. |
61
69
 
62
70
  `hostAppRoles` makes no resources, but its names are identities: renaming a host or a class
63
71
  renames the role. With the logical id taken from the role name, as the README does, the old id
64
72
  leaves the stack: that is an orphan delete, not a `replace`. With a stable logical id,
65
- `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.
66
76
 
67
77
  ## When the new identity is not known at plan
68
78
 
69
79
  - **Only another prop is pending** (say `fragments` is an Output of an upstream that is also
70
- changing). `Bao.Policy`, `Bao.CloudflareRole` and `Bao.ProxmoxRole` judge the identity props
71
- before `isResolved(news)` (`rename.ts`), so the rename still plans `replace`. Measured.
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.
72
83
  - **The identity itself is pending.** Then the diff defers: it never answers `replace` on an
73
84
  unknown identity, because under `destroy` a replace onto the same path deletes what it just
74
85
  wrote. The engine plans `update`, and reconcile, seeing the old attributes name another
75
86
  object, **refuses before any write**. The row is left `updating`, with the new props and the
76
- old attributes. The next deploy sees both names and plans `replace`. Measured, for all three.
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.
77
89
  - `Bao.ProxmoxRole` defers the same way while `mintUser` or `allowMintUserChange` is pending on a
78
90
  rename, because the re-scope guard cannot be checked. Measured for `allowMintUserChange`.
79
91
 
80
92
  ## A move onto an object that already exists fails the plan
81
93
 
82
- ⛔ All three read the new path at plan (`judgeMove`, `rename.ts`) and refuse the move if anything
83
- is there. 🔴 Measured before the guard: two policies that swapped names under
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
84
96
  `RemovalPolicy.destroy()` both planned `replace`. Each new generation wrote over the other's live
85
97
  policy, and each old generation's delete then removed the name the other had just written. The
86
- deploy was green, and both policies were gone, every grant revoked. Measured the same way:
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:
87
102
 
88
103
  - a shift (`a → b` while `b → c`) deleted `b`, and a move onto the name of a resource leaving the
89
104
  stack deleted the name it moved onto;
@@ -94,10 +109,12 @@ deploy was green, and both policies were gone, every grant revoked. Measured the
94
109
 
95
110
  ⚠️ The diff cannot see the removal policy, so this refuses under `retain` too, where a swap would
96
111
  have been harmless. So would a move back onto a retained old generation. Move in two deploys
97
- through a name nothing holds, or remove the target by hand first. A change of policy-name case, or
98
- a trailing `/` on a mount, is the same object, so it is not a move and is never refused.
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`).
99
116
 
100
- ## What `retain` means for these three
117
+ ## What `retain` means for batch 3A and AppRole
101
118
 
102
119
  Under the default `retain` the old generation is kept, and the apply says so ("Replaced resource
103
120
  retained."). It is live and unmanaged from then on:
@@ -112,13 +129,27 @@ retained."). It is live and unmanaged from then on:
112
129
  - **`Bao.ProxmoxRole`**: the same, and the old role keeps minting under its old `mint_user`. The
113
130
  plugin's delete leaves outstanding leases revocable (its own
114
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.
115
137
 
116
138
  ★ A stack that puts `mount` and `name` in the logical id, as homeflare-openbao's
117
139
  `declareCloudflareRoles` does, never reaches the `replace`: the old id leaves the stack as an
118
140
  orphan delete, which `retain` also keeps live.
119
141
 
120
- ⚠️ A NEW declaration (a new logical id) of a name that already exists still takes that object over
121
- and rewrites it: these families' `read` never answers `Unowned`, so Alchemy adopts it.
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.
122
153
 
123
154
  ## Why the moves fail instead of replacing
124
155
 
@@ -152,12 +183,9 @@ take every secret with it. (Corrected 2026-09-21: the first audit listed this as
152
183
  3. **Unique names need `deleteFirst`.** Only a mount's `type` change keeps its unique path, and
153
184
  it fails on purpose instead (above).
154
185
 
155
- ## Open
186
+ ## Closed
156
187
 
157
- - ⚠️ **Nine families still check the identity after `isResolved(news)`**: `Bao.AuthRole`,
158
- `Bao.PkiRole`, `Bao.JwtRole`, `Bao.KubernetesRole`, `Bao.JwtAuthConfig`, `Bao.MfaTotpMethod`,
159
- `Bao.MfaLoginEnforcement`, `Bao.SshRole` and `Bao.Plugin`. A rename that lands in the same
160
- deploy as any pending Output plans `update`, and none of their reconciles has the moved-update
161
- guard. For `Bao.MfaTotpMethod` that writes a second method and strands every enrolment. The
162
- helpers in `rename.ts` close it the same way. (`Bao.Mount` and `Bao.AuthMethod` are safe: their
163
- reconcile re-plans the move from the stated path.)
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