@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,172 @@
1
+ /**
2
+ * The real LaunchdJob and HostFile lifecycles, driven through sudoRunner over the fake host: the
3
+ * deploying user is uid 501, and the only way anything root-owned changes is an allowlisted
4
+ * `sudo -n` call. No real sudo, launchctl or /Library write happens.
5
+ *
6
+ * ★ The assertions are on the PRIVILEGED SEQUENCE, because that list is the whole point: a plan
7
+ * needs none, a create is install + bootstrap, a delete is bootout + rm.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { fakeSudoHost } from './fake-sudo.ts';
11
+ import { deleteFile, reconcileFile } from './host-file-lifecycle.ts';
12
+ import type { LaunchdJobAttributes, LaunchdJobProps } from './job-form.ts';
13
+ import { deleteJob, diffJob, readJob, reconcileJob } from './job-lifecycle.ts';
14
+ import { INSTALL, RM } from './sudo-allowlist.ts';
15
+
16
+ const DAEMON = '/Library/LaunchDaemons/com.example.job.plist';
17
+ const job: LaunchdJobProps = {
18
+ domain: 'system',
19
+ keepAlive: true,
20
+ label: 'com.example.job',
21
+ programArguments: ['/usr/local/bin/job'],
22
+ };
23
+
24
+ /** Each privileged call as its program and first operand, e.g. `launchctl bootout`. */
25
+ const fail = (): never => {
26
+ throw new Error('the deployed plist is missing');
27
+ };
28
+
29
+ const verbs = (privileged: string[][]) =>
30
+ privileged.map(([program, first]) =>
31
+ program === INSTALL ? 'install' : program === RM ? 'rm' : `launchctl ${String(first)}`,
32
+ );
33
+
34
+ describe('a system daemon, deployed as the operator', () => {
35
+ test('create is install then bootstrap; the plist lands root:wheel 0644', async () => {
36
+ const host = fakeSudoHost();
37
+ const attrs = await reconcileJob(host.runner, job, undefined);
38
+ expect(verbs(host.privileged())).toEqual(['install', 'launchctl bootstrap']);
39
+ expect(host.fake.files.get(DAEMON)).toMatchObject({ gid: 0, mode: 0o644, uid: 0 });
40
+ expect(attrs).toMatchObject({ loaded: true, plistPath: DAEMON });
41
+ expect(host.logs).toHaveLength(2);
42
+ });
43
+
44
+ test('a plan (read, diff) needs no sudo at all', async () => {
45
+ const host = fakeSudoHost();
46
+ const attrs = await reconcileJob(host.runner, job, undefined);
47
+ const before = host.sudoCalls.length;
48
+ expect(await readJob(host.runner, job)).toMatchObject({ loaded: true });
49
+ expect(await diffJob(host.runner, job, attrs)).toEqual({ action: 'noop' });
50
+ expect(await diffJob(host.runner, { ...job, runAtLoad: true }, attrs)).toEqual({
51
+ action: 'update',
52
+ });
53
+ expect(host.sudoCalls).toHaveLength(before);
54
+ });
55
+
56
+ test('update is install, bootout, bootstrap — in that order', async () => {
57
+ const host = fakeSudoHost();
58
+ const attrs = await reconcileJob(host.runner, job, undefined);
59
+ await reconcileJob(host.runner, { ...job, runAtLoad: true }, attrs);
60
+ expect(verbs(host.privileged())).toEqual([
61
+ 'install',
62
+ 'launchctl bootstrap',
63
+ 'install',
64
+ 'launchctl bootout',
65
+ 'launchctl bootstrap',
66
+ ]);
67
+ });
68
+
69
+ test('delete is bootout then rm', async () => {
70
+ const host = fakeSudoHost();
71
+ const attrs = await reconcileJob(host.runner, job, undefined);
72
+ await deleteJob(host.runner, attrs);
73
+ expect(verbs(host.privileged()).slice(2)).toEqual(['launchctl bootout', 'rm']);
74
+ expect(host.fake.files.has(DAEMON)).toBe(false);
75
+ expect(host.fake.loaded.has('system/com.example.job')).toBe(false);
76
+ });
77
+
78
+ test('sudo wanting a password on an update leaves the old job running, untouched', async () => {
79
+ const host = fakeSudoHost();
80
+ const attrs = await reconcileJob(host.runner, job, undefined);
81
+ const pid = host.fake.loaded.get('system/com.example.job');
82
+ const before = host.fake.files.get(DAEMON)?.bytes;
83
+ host.state.sudo = 'password';
84
+ await expect(reconcileJob(host.runner, { ...job, runAtLoad: true }, attrs)).rejects.toThrow(
85
+ 'a password is required',
86
+ );
87
+ expect(host.fake.loaded.get('system/com.example.job')).toBe(pid);
88
+ expect(host.fake.files.get(DAEMON)?.bytes).toBe(before);
89
+ });
90
+ });
91
+
92
+ test('without /Library/LaunchDaemons declared, a delete is refused before the bootout', async () => {
93
+ const deployed = fakeSudoHost();
94
+ const attrs = await reconcileJob(deployed.runner, job, undefined);
95
+ // The same host, now driven by a runner that declares only /opt/example.
96
+ const narrow = fakeSudoHost([20, 80], ['/opt/example']);
97
+ narrow.fake.files.set(DAEMON, deployed.fake.files.get(DAEMON) ?? fail());
98
+ narrow.fake.loaded.set('system/com.example.job', 4242);
99
+ await expect(deleteJob(narrow.runner, attrs)).rejects.toThrow('/Library/LaunchDaemons');
100
+ // ⚠️ Not half a delete: the job still runs and its plist is still there, both or neither.
101
+ expect(narrow.fake.loaded.get('system/com.example.job')).toBe(4242);
102
+ expect(narrow.fake.files.has(DAEMON)).toBe(true);
103
+ expect(narrow.sudoCalls).toEqual([]);
104
+ });
105
+
106
+ describe("another user's gui domain is not elevated", () => {
107
+ const theirs: LaunchdJobProps = { ...job, domain: 'gui/502' };
108
+
109
+ test('create is refused at the write, with nothing written or loaded', async () => {
110
+ const host = fakeSudoHost();
111
+ await expect(reconcileJob(host.runner, theirs, undefined)).rejects.toThrow(
112
+ 'owner 502 needs root',
113
+ );
114
+ expect(host.fake.files.size).toBe(0);
115
+ expect(host.sudoCalls).toEqual([]);
116
+ });
117
+
118
+ test('delete is refused at the bootout, and their job keeps running', async () => {
119
+ const host = fakeSudoHost();
120
+ host.fake.loaded.set('gui/502/com.example.job', 4242);
121
+ const output: LaunchdJobAttributes = {
122
+ domain: 'gui/502',
123
+ label: 'com.example.job',
124
+ loaded: true,
125
+ plistPath: '/Users/other/Library/LaunchAgents/com.example.job.plist',
126
+ plistSha256: '',
127
+ serviceTarget: 'gui/502/com.example.job',
128
+ };
129
+ await expect(deleteJob(host.runner, output)).rejects.toThrow('elevates only the system domain');
130
+ expect(host.fake.loaded.get('gui/502/com.example.job')).toBe(4242);
131
+ expect(host.sudoCalls).toEqual([]);
132
+ });
133
+ });
134
+
135
+ describe('a root-owned HostFile under a declared prefix', () => {
136
+ const PATH = '/opt/example/app/exporter.yml';
137
+ const file = {
138
+ content: 'listen: 127.0.0.1:9100\n',
139
+ group: 80,
140
+ mode: 0o640,
141
+ owner: 0,
142
+ path: PATH,
143
+ };
144
+
145
+ test('is installed with its owner, group and mode, verified by the read-back', async () => {
146
+ const host = fakeSudoHost();
147
+ const attrs = await reconcileFile(host.runner, file);
148
+ expect(attrs).toMatchObject({ gid: 80, mode: 0o640, uid: 0 });
149
+ expect(host.privileged()[0]?.slice(0, 8)).toEqual([
150
+ INSTALL,
151
+ '-S',
152
+ '-m',
153
+ '0640',
154
+ '-o',
155
+ '0',
156
+ '-g',
157
+ '80',
158
+ ]);
159
+ await deleteFile(host.runner, attrs);
160
+ expect(verbs(host.privileged())).toEqual(['install', 'rm']);
161
+ expect(host.fake.files.has(PATH)).toBe(false);
162
+ });
163
+
164
+ test('a mode the operator could not read back is refused with nothing written', async () => {
165
+ const host = fakeSudoHost();
166
+ await expect(reconcileFile(host.runner, { ...file, group: 0, mode: 0o600 })).rejects.toThrow(
167
+ 'Nothing was written',
168
+ );
169
+ expect(host.fake.files.has(PATH)).toBe(false);
170
+ expect(host.sudoCalls).toEqual([]);
171
+ });
172
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * sudoRunner's file writes and removes over the fake host (fake-sudo.ts): under a prefix they go
3
+ * through `install` / `rm` as root; everything a mistaken declaration could turn into a root write
4
+ * somewhere unintended is refused before anything is staged. No real sudo runs anywhere.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { OPERATOR, fakeSudoHost } from './fake-sudo.ts';
8
+ import type { FileStat } from './runner.ts';
9
+ import { INSTALL, RM, SudoRefusedError } from './sudo-allowlist.ts';
10
+
11
+ const CONF = '/opt/example/app/a.conf';
12
+ const bytes = (text: string) => new TextEncoder().encode(text);
13
+ const plistBytes = bytes('<key>Label</key><string>com.example.a</string>');
14
+
15
+ describe('writeFileAtomic', () => {
16
+ test('under a prefix: staged, installed with mode and owner from argv, staged file removed', async () => {
17
+ const host = fakeSudoHost();
18
+ await host.runner.writeFileAtomic(CONF, bytes('listen: 127.0.0.1:9100\n'), {
19
+ gid: 80,
20
+ mode: 0o640,
21
+ uid: 0,
22
+ });
23
+ const [install] = host.privileged();
24
+ expect(install).toEqual([
25
+ INSTALL,
26
+ '-S',
27
+ '-m',
28
+ '0640',
29
+ '-o',
30
+ '0',
31
+ '-g',
32
+ '80',
33
+ expect.any(String),
34
+ CONF,
35
+ ]);
36
+ expect(host.state.installedFrom.get(CONF)).toBe('listen: 127.0.0.1:9100\n');
37
+ expect(host.fake.files.get(CONF)).toMatchObject({ gid: 80, mode: 0o640, uid: 0 });
38
+ expect(host.staged.size).toBe(0);
39
+ // ⛔ The log carries the argv, never the content.
40
+ expect(host.logs.join('\n')).not.toContain('127.0.0.1');
41
+ });
42
+
43
+ test('a failed install throws and still removes the staged file', async () => {
44
+ const host = fakeSudoHost();
45
+ host.state.installFailure = { exitCode: 71, stderr: 'install: disk full', stdout: '' };
46
+ await expect(host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 })).rejects.toThrow(
47
+ '-> 71: install: disk full',
48
+ );
49
+ expect(host.staged.size).toBe(0);
50
+ expect(host.fake.files.has(CONF)).toBe(false);
51
+ });
52
+
53
+ test("outside every prefix, the operator's own file is written by the operator", async () => {
54
+ const host = fakeSudoHost();
55
+ const path = '/Users/someone/Library/LaunchAgents/com.example.a.plist';
56
+ await host.runner.writeFileAtomic(path, plistBytes, { mode: 0o644, uid: OPERATOR });
57
+ expect(host.fake.files.get(path)).toMatchObject({ uid: OPERATOR });
58
+ expect(host.sudoCalls).toEqual([]);
59
+ });
60
+
61
+ test('outside every prefix, a foreign owner is refused and nothing is written', async () => {
62
+ const host = fakeSudoHost();
63
+ const path = '/Users/someone/Library/LaunchAgents/x.conf';
64
+ await expect(
65
+ host.runner.writeFileAtomic(path, bytes('x'), { mode: 0o644, uid: 502 }),
66
+ ).rejects.toThrow('owner 502 needs root');
67
+ expect(host.fake.files.has(path)).toBe(false);
68
+ expect(host.sudoCalls).toEqual([]);
69
+ });
70
+
71
+ test.each([
72
+ ['a directory at the path', '/opt/example/app', 'is a directory'],
73
+ ['a missing directory on the way', '/opt/example/none/a.conf', '/opt/example/none is missing'],
74
+ ])('%s is refused before anything is staged', async (_name, path, message) => {
75
+ const host = fakeSudoHost();
76
+ await expect(host.runner.writeFileAtomic(path, bytes('x'), { mode: 0o644 })).rejects.toThrow(
77
+ message,
78
+ );
79
+ expect(host.state.stagedCount).toBe(0);
80
+ expect(host.sudoCalls).toEqual([]);
81
+ });
82
+
83
+ test('a symlinked directory under the prefix is refused', async () => {
84
+ const host = fakeSudoHost();
85
+ host.fake.files.set('/opt/example/link', {
86
+ bytes: bytes(''),
87
+ gid: 0,
88
+ kind: 'symlink',
89
+ mode: 0o755,
90
+ uid: 0,
91
+ });
92
+ await expect(
93
+ host.runner.writeFileAtomic('/opt/example/link/a.conf', bytes('x'), { mode: 0o644 }),
94
+ ).rejects.toThrow('/opt/example/link is a symlink');
95
+ expect(host.sudoCalls).toEqual([]);
96
+ });
97
+
98
+ test('a file the operator could not read back is refused before staging', async () => {
99
+ const host = fakeSudoHost();
100
+ await expect(
101
+ host.runner.writeFileAtomic(CONF, bytes('x'), { gid: 0, mode: 0o600, uid: 0 }),
102
+ ).rejects.toThrow('would hide it from the deploying user');
103
+ expect(host.state.stagedCount).toBe(0);
104
+ // Group-readable through one of the operator's groups is fine; so are unknown groups.
105
+ await host.runner.writeFileAtomic(CONF, bytes('x'), { gid: 80, mode: 0o640, uid: 0 });
106
+ await fakeSudoHost('unknown').runner.writeFileAtomic(CONF, bytes('x'), {
107
+ gid: 99,
108
+ mode: 0o640,
109
+ uid: 0,
110
+ });
111
+ });
112
+
113
+ test('an omitted owner is root and an omitted group the directory’s, for the read-back check', async () => {
114
+ // ⚠️ Omitted owner: root, so 0600 is root-only and refused (not the operator's own 0600).
115
+ const host = fakeSudoHost();
116
+ await expect(host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o600 })).rejects.toThrow(
117
+ 'would hide it from the deploying user',
118
+ );
119
+ // Omitted group: the parent's (80, one of the operator's), so 0640 is readable and allowed.
120
+ host.fake.files.set('/opt/example/app', {
121
+ bytes: bytes(''),
122
+ gid: 80,
123
+ kind: 'directory',
124
+ mode: 0o755,
125
+ uid: 0,
126
+ });
127
+ await host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o640 });
128
+ expect(host.fake.files.get(CONF)).toMatchObject({ gid: 80, mode: 0o640, uid: 0 });
129
+ });
130
+
131
+ test('a mode chmod cannot mean is a refusal, before anything is staged', async () => {
132
+ const host = fakeSudoHost();
133
+ await expect(
134
+ host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o10000 }),
135
+ ).rejects.toBeInstanceOf(SudoRefusedError);
136
+ expect(host.state.stagedCount).toBe(0);
137
+ });
138
+
139
+ test('outside every prefix, EACCES names the missing prefix instead of a temp file', async () => {
140
+ const host = fakeSudoHost();
141
+ await expect(
142
+ host.runner.writeFileAtomic('/etc/example/a.conf', bytes('x'), { mode: 0o644 }),
143
+ ).rejects.toThrow('declare that directory as a prefix');
144
+ await expect(
145
+ host.runner.writeFileAtomic('/nowhere/a.conf', bytes('x'), { mode: 0o644 }),
146
+ ).rejects.toThrow('ENOENT');
147
+ });
148
+ });
149
+
150
+ describe('the declared prefix itself is checked at every call', () => {
151
+ type Host = ReturnType<typeof fakeSudoHost>;
152
+ const entry = (kind: FileStat['kind'], mode: number) => ({
153
+ bytes: bytes(''),
154
+ gid: 0,
155
+ kind,
156
+ mode,
157
+ uid: 0,
158
+ });
159
+ test.each([
160
+ // ⛔ `/opt/example` -> `/etc` would make root write /etc/a.conf, a path the log never names.
161
+ [
162
+ 'a symlink',
163
+ (h: Host) => h.fake.files.set('/opt/example', entry('symlink', 0o755)),
164
+ 'is a symlink',
165
+ ],
166
+ ['owned by the operator', (h: Host) => h.fake.dirs.set('/opt/example', OPERATOR), 'uid 501'],
167
+ [
168
+ 'group-writable',
169
+ (h: Host) => h.fake.files.set('/opt/example', entry('directory', 0o775)),
170
+ '0775',
171
+ ],
172
+ [
173
+ 'world-writable',
174
+ (h: Host) => h.fake.files.set('/opt/example', entry('directory', 0o757)),
175
+ '0757',
176
+ ],
177
+ ['missing', (h: Host) => h.fake.dirs.delete('/opt/example'), '/opt/example is missing'],
178
+ ])('%s is refused, before anything is staged or run', async (_name, setup, message) => {
179
+ const host = fakeSudoHost();
180
+ setup(host);
181
+ const write = host.runner.writeFileAtomic('/opt/example/a.conf', bytes('x'), { mode: 0o644 });
182
+ await expect(write).rejects.toBeInstanceOf(SudoRefusedError);
183
+ await expect(write).rejects.toThrow(message);
184
+ expect(host.state.stagedCount).toBe(0);
185
+ expect(host.sudoCalls).toEqual([]);
186
+ });
187
+
188
+ test('a remove under a group-writable prefix is refused too', async () => {
189
+ const host = fakeSudoHost();
190
+ host.fake.files.set('/opt/example', entry('directory', 0o775));
191
+ host.fake.files.set('/opt/example/a.conf', entry('file', 0o644));
192
+ await expect(host.runner.removeFile('/opt/example/a.conf')).rejects.toThrow('0775');
193
+ expect(host.sudoCalls).toEqual([]);
194
+ });
195
+ });
196
+
197
+ describe('removeFile', () => {
198
+ test('a directory on the way that is already gone: nothing is there, so success, no sudo', async () => {
199
+ const host = fakeSudoHost();
200
+ await host.runner.removeFile('/opt/example/gone/a.conf');
201
+ expect(host.sudoCalls).toEqual([]);
202
+ expect(host.logs).toEqual([]);
203
+ });
204
+
205
+ test('under a prefix: `rm -f --` through sudo', async () => {
206
+ const host = fakeSudoHost();
207
+ host.fake.files.set(CONF, { bytes: bytes('x'), gid: 0, kind: 'file', mode: 0o644, uid: 0 });
208
+ await host.runner.removeFile(CONF);
209
+ expect(host.privileged()).toEqual([[RM, '-f', '--', CONF]]);
210
+ expect(host.fake.files.has(CONF)).toBe(false);
211
+ });
212
+
213
+ test('nothing there is success with no privileged call and no log line', async () => {
214
+ const host = fakeSudoHost();
215
+ await host.runner.removeFile(CONF);
216
+ expect(host.sudoCalls).toEqual([]);
217
+ expect(host.logs).toEqual([]);
218
+ });
219
+
220
+ test('a directory is refused, not removed', async () => {
221
+ const host = fakeSudoHost();
222
+ await expect(host.runner.removeFile('/opt/example/app')).rejects.toThrow('is a directory');
223
+ expect(host.sudoCalls).toEqual([]);
224
+ });
225
+
226
+ test('outside every prefix it is the operator removing their own file', async () => {
227
+ const host = fakeSudoHost();
228
+ const path = '/Users/someone/Library/LaunchAgents/a.plist';
229
+ host.fake.files.set(path, {
230
+ bytes: bytes('x'),
231
+ gid: 20,
232
+ kind: 'file',
233
+ mode: 0o644,
234
+ uid: OPERATOR,
235
+ });
236
+ await host.runner.removeFile(path);
237
+ expect(host.fake.files.has(path)).toBe(false);
238
+ expect(host.sudoCalls).toEqual([]);
239
+ });
240
+ });
@@ -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
+ });