@homeflare/alchemy 0.4.1 → 0.6.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 (215) hide show
  1. package/README.md +26 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/launchd/fake-runner.d.ts +36 -0
  5. package/dist/launchd/fake-runner.d.ts.map +1 -0
  6. package/dist/launchd/host-effect.d.ts +19 -0
  7. package/dist/launchd/host-effect.d.ts.map +1 -0
  8. package/dist/launchd/host-file-form.d.ts +24 -0
  9. package/dist/launchd/host-file-form.d.ts.map +1 -0
  10. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  11. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  12. package/dist/launchd/host-file.d.ts +25 -0
  13. package/dist/launchd/host-file.d.ts.map +1 -0
  14. package/dist/launchd/host-lookup.d.ts +27 -0
  15. package/dist/launchd/host-lookup.d.ts.map +1 -0
  16. package/dist/launchd/index.d.ts +21 -0
  17. package/dist/launchd/index.d.ts.map +1 -0
  18. package/dist/launchd/index.js +969 -0
  19. package/dist/launchd/index.js.map +25 -0
  20. package/dist/launchd/job-form.d.ts +90 -0
  21. package/dist/launchd/job-form.d.ts.map +1 -0
  22. package/dist/launchd/job-lifecycle.d.ts +34 -0
  23. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  24. package/dist/launchd/job-preflight.d.ts +50 -0
  25. package/dist/launchd/job-preflight.d.ts.map +1 -0
  26. package/dist/launchd/job-validate.d.ts +25 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -0
  28. package/dist/launchd/job.d.ts +34 -0
  29. package/dist/launchd/job.d.ts.map +1 -0
  30. package/dist/launchd/launchctl.d.ts +62 -0
  31. package/dist/launchd/launchctl.d.ts.map +1 -0
  32. package/dist/launchd/local-runner.d.ts +7 -0
  33. package/dist/launchd/local-runner.d.ts.map +1 -0
  34. package/dist/launchd/plist.d.ts +41 -0
  35. package/dist/launchd/plist.d.ts.map +1 -0
  36. package/dist/launchd/providers.d.ts +15 -0
  37. package/dist/launchd/providers.d.ts.map +1 -0
  38. package/dist/launchd/runner.d.ts +91 -0
  39. package/dist/launchd/runner.d.ts.map +1 -0
  40. package/dist/launchd/secret-tripwire.d.ts +23 -0
  41. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  42. package/dist/openbao/approle-login-form.d.ts +72 -0
  43. package/dist/openbao/approle-login-form.d.ts.map +1 -0
  44. package/dist/openbao/approle-login-result.d.ts +39 -0
  45. package/dist/openbao/approle-login-result.d.ts.map +1 -0
  46. package/dist/openbao/approle-login.d.ts +77 -0
  47. package/dist/openbao/approle-login.d.ts.map +1 -0
  48. package/dist/openbao/auth-method-form.d.ts +7 -0
  49. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  50. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  51. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  52. package/dist/openbao/auth-method.d.ts +6 -0
  53. package/dist/openbao/auth-method.d.ts.map +1 -1
  54. package/dist/openbao/auth-role-form.d.ts +9 -0
  55. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  56. package/dist/openbao/auth-role.d.ts.map +1 -1
  57. package/dist/openbao/auth-token-form.d.ts +48 -0
  58. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  59. package/dist/openbao/bao-http.d.ts +2 -0
  60. package/dist/openbao/bao-http.d.ts.map +1 -1
  61. package/dist/openbao/bao-identity.d.ts +81 -0
  62. package/dist/openbao/bao-identity.d.ts.map +1 -0
  63. package/dist/openbao/bao-namespace.d.ts +20 -0
  64. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  65. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  66. package/dist/openbao/host-approles.d.ts +62 -0
  67. package/dist/openbao/host-approles.d.ts.map +1 -0
  68. package/dist/openbao/index.d.ts +26 -0
  69. package/dist/openbao/index.d.ts.map +1 -1
  70. package/dist/openbao/index.js +1889 -477
  71. package/dist/openbao/index.js.map +46 -17
  72. package/dist/openbao/jwt-config-form.d.ts +51 -0
  73. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  74. package/dist/openbao/jwt-config.d.ts +25 -0
  75. package/dist/openbao/jwt-config.d.ts.map +1 -0
  76. package/dist/openbao/jwt-role-form.d.ts +91 -0
  77. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  78. package/dist/openbao/jwt-role.d.ts +31 -0
  79. package/dist/openbao/jwt-role.d.ts.map +1 -0
  80. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  81. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  82. package/dist/openbao/kubernetes-role.d.ts +25 -0
  83. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  84. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  85. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  86. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  87. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  88. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  89. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  90. package/dist/openbao/mfa-totp.d.ts +33 -0
  91. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  92. package/dist/openbao/mfa-wire.d.ts +63 -0
  93. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  94. package/dist/openbao/mount-form.d.ts +7 -0
  95. package/dist/openbao/mount-form.d.ts.map +1 -1
  96. package/dist/openbao/mount-move.d.ts +49 -0
  97. package/dist/openbao/mount-move.d.ts.map +1 -0
  98. package/dist/openbao/mount-path.d.ts +18 -0
  99. package/dist/openbao/mount-path.d.ts.map +1 -0
  100. package/dist/openbao/mount-reconcile.d.ts +16 -0
  101. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  102. package/dist/openbao/mount.d.ts +9 -0
  103. package/dist/openbao/mount.d.ts.map +1 -1
  104. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  105. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  106. package/dist/openbao/pki-role-form.d.ts +38 -30
  107. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  108. package/dist/openbao/pki-role.d.ts.map +1 -1
  109. package/dist/openbao/plugin-form.d.ts +60 -0
  110. package/dist/openbao/plugin-form.d.ts.map +1 -0
  111. package/dist/openbao/plugin-reconcile.d.ts +18 -0
  112. package/dist/openbao/plugin-reconcile.d.ts.map +1 -0
  113. package/dist/openbao/plugin-wire.d.ts +45 -0
  114. package/dist/openbao/plugin-wire.d.ts.map +1 -0
  115. package/dist/openbao/plugin.d.ts +31 -0
  116. package/dist/openbao/plugin.d.ts.map +1 -0
  117. package/dist/openbao/remount-wire.d.ts +44 -0
  118. package/dist/openbao/remount-wire.d.ts.map +1 -0
  119. package/dist/openbao/role-reconcile.d.ts +40 -0
  120. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  121. package/dist/openbao/ssh-role-form.d.ts +6 -5
  122. package/dist/openbao/ssh-role-form.d.ts.map +1 -1
  123. package/dist/openbao/ssh-role.d.ts.map +1 -1
  124. package/docs/launchd.md +167 -0
  125. package/package.json +7 -2
  126. package/src/index.ts +1 -0
  127. package/src/launchd/fake-runner.ts +176 -0
  128. package/src/launchd/host-effect.ts +28 -0
  129. package/src/launchd/host-file-form.ts +71 -0
  130. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  131. package/src/launchd/host-file-lifecycle.ts +164 -0
  132. package/src/launchd/host-file.ts +64 -0
  133. package/src/launchd/host-lookup.test.ts +92 -0
  134. package/src/launchd/host-lookup.ts +94 -0
  135. package/src/launchd/index.ts +26 -0
  136. package/src/launchd/job-form.test.ts +171 -0
  137. package/src/launchd/job-form.ts +180 -0
  138. package/src/launchd/job-identity.test.ts +147 -0
  139. package/src/launchd/job-lifecycle.test.ts +220 -0
  140. package/src/launchd/job-lifecycle.ts +194 -0
  141. package/src/launchd/job-preflight.ts +152 -0
  142. package/src/launchd/job-validate.ts +134 -0
  143. package/src/launchd/job.ts +96 -0
  144. package/src/launchd/launchctl.test.ts +181 -0
  145. package/src/launchd/launchctl.ts +188 -0
  146. package/src/launchd/local-runner.test.ts +125 -0
  147. package/src/launchd/local-runner.ts +143 -0
  148. package/src/launchd/plist.test.ts +186 -0
  149. package/src/launchd/plist.ts +156 -0
  150. package/src/launchd/providers.test.ts +222 -0
  151. package/src/launchd/providers.ts +21 -0
  152. package/src/launchd/runner.ts +95 -0
  153. package/src/launchd/secret-tripwire.test.ts +110 -0
  154. package/src/launchd/secret-tripwire.ts +81 -0
  155. package/src/openbao/README.md +181 -0
  156. package/src/openbao/REPLACE.md +87 -0
  157. package/src/openbao/approle-login-form.test.ts +64 -0
  158. package/src/openbao/approle-login-form.ts +153 -0
  159. package/src/openbao/approle-login-result.ts +96 -0
  160. package/src/openbao/approle-login.test.ts +200 -0
  161. package/src/openbao/approle-login.ts +156 -0
  162. package/src/openbao/auth-method-form.ts +7 -0
  163. package/src/openbao/auth-method-reconcile.ts +74 -0
  164. package/src/openbao/auth-method.ts +20 -41
  165. package/src/openbao/auth-role-form.test.ts +57 -0
  166. package/src/openbao/auth-role-form.ts +11 -0
  167. package/src/openbao/auth-role.ts +12 -0
  168. package/src/openbao/auth-token-form.ts +161 -0
  169. package/src/openbao/bao-http.ts +2 -0
  170. package/src/openbao/bao-identity.test.ts +131 -0
  171. package/src/openbao/bao-identity.ts +188 -0
  172. package/src/openbao/bao-namespace.ts +32 -0
  173. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  174. package/src/openbao/host-approles.test.ts +121 -0
  175. package/src/openbao/host-approles.ts +126 -0
  176. package/src/openbao/index.ts +32 -0
  177. package/src/openbao/jwt-config-form.ts +161 -0
  178. package/src/openbao/jwt-config.ts +85 -0
  179. package/src/openbao/jwt-role-form.test.ts +130 -0
  180. package/src/openbao/jwt-role-form.ts +231 -0
  181. package/src/openbao/jwt-role.ts +102 -0
  182. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  183. package/src/openbao/kubernetes-role-form.ts +149 -0
  184. package/src/openbao/kubernetes-role.ts +94 -0
  185. package/src/openbao/mfa-enforcement-form.ts +147 -0
  186. package/src/openbao/mfa-enforcement.ts +143 -0
  187. package/src/openbao/mfa-totp-form.ts +155 -0
  188. package/src/openbao/mfa-totp.ts +118 -0
  189. package/src/openbao/mfa-wire.ts +117 -0
  190. package/src/openbao/mfa.test.ts +200 -0
  191. package/src/openbao/mount-form.ts +9 -1
  192. package/src/openbao/mount-move.test.ts +164 -0
  193. package/src/openbao/mount-move.ts +113 -0
  194. package/src/openbao/mount-path.test.ts +33 -0
  195. package/src/openbao/mount-path.ts +31 -0
  196. package/src/openbao/mount-reconcile.ts +75 -0
  197. package/src/openbao/mount-wire.test.ts +13 -0
  198. package/src/openbao/mount.ts +26 -43
  199. package/src/openbao/pki-role-canonical.ts +163 -0
  200. package/src/openbao/pki-role-form.test.ts +112 -0
  201. package/src/openbao/pki-role-form.ts +76 -110
  202. package/src/openbao/pki-role.ts +12 -27
  203. package/src/openbao/plugin-form.test.ts +86 -0
  204. package/src/openbao/plugin-form.ts +164 -0
  205. package/src/openbao/plugin-reconcile.ts +92 -0
  206. package/src/openbao/plugin-wire.test.ts +146 -0
  207. package/src/openbao/plugin-wire.ts +65 -0
  208. package/src/openbao/plugin.ts +100 -0
  209. package/src/openbao/remount-wire.test.ts +87 -0
  210. package/src/openbao/remount-wire.ts +112 -0
  211. package/src/openbao/role-reconcile.test.ts +104 -0
  212. package/src/openbao/role-reconcile.ts +84 -0
  213. package/src/openbao/ssh-role-form.test.ts +105 -0
  214. package/src/openbao/ssh-role-form.ts +18 -16
  215. package/src/openbao/ssh-role.ts +3 -4
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The Alchemy wiring: each provider built as a Layer over a fake HostRunner, its handlers called
3
+ * the way the engine calls them — plus the barrel, which is the subpath's public API.
4
+ *
5
+ * ★ `read` with no prior state is the adoption probe. Anything found must come back `Unowned`, or
6
+ * Alchemy would silently take over a job or file some other tool (or a person) put there.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { Unowned } from 'alchemy/AdoptPolicy';
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Layer from 'effect/Layer';
12
+ import { fakeRunner } from './fake-runner.ts';
13
+ import { HostFile, HostFileProvider } from './host-file.ts';
14
+ import * as barrel from './index.ts';
15
+ import { LaunchdJob, LaunchdJobProvider } from './job.ts';
16
+ import type { LaunchdJobProps } from './job-form.ts';
17
+ import { launchdProviders } from './providers.ts';
18
+ import { hostRunnerLayer } from './runner.ts';
19
+
20
+ const job: LaunchdJobProps = {
21
+ domain: 'system',
22
+ label: 'com.example.job',
23
+ programArguments: ['/usr/local/bin/job'],
24
+ };
25
+ const ids = { fqn: 'stack/job', id: 'job', instanceId: 'i-1' };
26
+
27
+ /** A handler the provider must define; a missing one fails the test by name. */
28
+ const handler = <F>(name: string, fn: F | undefined): F => {
29
+ if (fn === undefined) throw new Error(`provider has no ${name} handler`);
30
+ return fn;
31
+ };
32
+
33
+ const host = () =>
34
+ fakeRunner({ dirs: { '/Library/LaunchDaemons': 0, '/etc/example': 0 }, euid: 0 });
35
+
36
+ const withJobProvider = <A>(
37
+ fake: ReturnType<typeof host>,
38
+ use: (p: Effect.Success<typeof LaunchdJob.Provider>) => Effect.Effect<A, Error>,
39
+ ) =>
40
+ Effect.runPromise(
41
+ Effect.gen(function* () {
42
+ return yield* use(yield* LaunchdJob.Provider);
43
+ }).pipe(Effect.provide(LaunchdJobProvider().pipe(Layer.provide(hostRunnerLayer(fake.runner))))),
44
+ );
45
+
46
+ describe('LaunchdJobProvider', () => {
47
+ test('reconcile, then read with state (owned) and without (Unowned)', async () => {
48
+ const fake = host();
49
+ const [owned, probe] = await withJobProvider(fake, (provider) =>
50
+ Effect.gen(function* () {
51
+ const session = undefined as never;
52
+ const output = yield* provider.reconcile({
53
+ ...ids,
54
+ bindings: [] as never,
55
+ news: job,
56
+ olds: undefined,
57
+ output: undefined,
58
+ session,
59
+ });
60
+ const owned = yield* handler('read', provider.read)({ ...ids, olds: job, output });
61
+ const probe = yield* handler(
62
+ 'read',
63
+ provider.read,
64
+ )({ ...ids, olds: job, output: undefined });
65
+ return [owned, probe] as const;
66
+ }),
67
+ );
68
+ expect(Unowned.is(owned)).toBe(false);
69
+ expect(Unowned.is(probe)).toBe(true);
70
+ expect(probe).toMatchObject({ label: 'com.example.job', loaded: true });
71
+ });
72
+
73
+ test('diff defers to the engine when there is no prior output', async () => {
74
+ const diff = await withJobProvider(host(), (provider) =>
75
+ Effect.map(
76
+ handler(
77
+ 'diff',
78
+ provider.diff,
79
+ )({
80
+ ...ids,
81
+ news: job,
82
+ newBindings: [] as never,
83
+ oldBindings: [] as never,
84
+ olds: job,
85
+ output: undefined,
86
+ }),
87
+ (value) => value,
88
+ ),
89
+ );
90
+ expect(diff).toBeUndefined();
91
+ });
92
+
93
+ test('a rename is a replace even while other props are unresolved', async () => {
94
+ // ★ An Effect is what isResolved() calls unresolved — the stand-in for `config.path` while its
95
+ // HostFile is changing in the same deploy. The engine's default here would be `update`.
96
+ const fake = host();
97
+ const pending = { ...job, programArguments: [Effect.succeed('/usr/local/bin/job')] } as never;
98
+ const diffs = await withJobProvider(fake, (provider) =>
99
+ Effect.gen(function* () {
100
+ const output = yield* provider.reconcile({
101
+ ...ids,
102
+ bindings: [] as never,
103
+ news: job,
104
+ olds: undefined,
105
+ output: undefined,
106
+ session: undefined as never,
107
+ });
108
+ const diff = handler('diff', provider.diff);
109
+ const base = {
110
+ ...ids,
111
+ newBindings: [] as never,
112
+ oldBindings: [] as never,
113
+ olds: job,
114
+ output,
115
+ };
116
+ const rename = { ...(pending as object), label: 'com.example.renamed' } as never;
117
+ return [yield* diff({ ...base, news: rename }), yield* diff({ ...base, news: pending })];
118
+ }),
119
+ );
120
+ expect(diffs).toEqual([{ action: 'replace', deleteFirst: true }, undefined]);
121
+ });
122
+
123
+ test('a refusal reaches Alchemy as a failure carrying its message', async () => {
124
+ const fake = fakeRunner({ dirs: { '/Library/LaunchDaemons': 0 }, euid: 501 });
125
+ await expect(
126
+ withJobProvider(fake, (provider) =>
127
+ provider.reconcile({
128
+ ...ids,
129
+ bindings: [] as never,
130
+ news: job,
131
+ olds: undefined,
132
+ output: undefined,
133
+ session: undefined as never,
134
+ }),
135
+ ),
136
+ ).rejects.toThrow('never calls sudo');
137
+ });
138
+ });
139
+
140
+ describe('HostFileProvider', () => {
141
+ test('a file already on disk probes as Unowned', async () => {
142
+ const fake = host();
143
+ fake.files.set('/etc/example/a.conf', {
144
+ bytes: new Uint8Array([120]),
145
+ gid: 0,
146
+ kind: 'file',
147
+ mode: 0o644,
148
+ uid: 0,
149
+ });
150
+ const found = await Effect.runPromise(
151
+ Effect.gen(function* () {
152
+ const provider = yield* HostFile.Provider;
153
+ return yield* handler(
154
+ 'read',
155
+ provider.read,
156
+ )({ ...ids, olds: { content: 'x', path: '/etc/example/a.conf' }, output: undefined });
157
+ }).pipe(Effect.provide(HostFileProvider().pipe(Layer.provide(hostRunnerLayer(fake.runner))))),
158
+ );
159
+ expect(Unowned.is(found)).toBe(true);
160
+ expect(found).toMatchObject({ path: '/etc/example/a.conf', size: 1 });
161
+ });
162
+
163
+ test('a new path is a replace even while content is unresolved', async () => {
164
+ const output = {
165
+ gid: 0,
166
+ mode: 0o644,
167
+ path: '/etc/example/a.conf',
168
+ sha256: '',
169
+ size: 1,
170
+ uid: 0,
171
+ };
172
+ const news = { content: Effect.succeed('x'), path: '/etc/example/b.conf' } as never;
173
+ const diff = await Effect.runPromise(
174
+ Effect.gen(function* () {
175
+ const provider = yield* HostFile.Provider;
176
+ return yield* handler(
177
+ 'diff',
178
+ provider.diff,
179
+ )({
180
+ ...ids,
181
+ newBindings: [] as never,
182
+ news,
183
+ oldBindings: [] as never,
184
+ olds: { content: 'x', path: output.path },
185
+ output,
186
+ });
187
+ }).pipe(
188
+ Effect.provide(HostFileProvider().pipe(Layer.provide(hostRunnerLayer(host().runner)))),
189
+ ),
190
+ );
191
+ expect(diff).toEqual({ action: 'replace' });
192
+ });
193
+ });
194
+
195
+ describe('launchdProviders', () => {
196
+ test('provides both providers from one runner', async () => {
197
+ const both = await Effect.runPromise(
198
+ Effect.gen(function* () {
199
+ return [yield* LaunchdJob.Provider, yield* HostFile.Provider];
200
+ }).pipe(Effect.provide(launchdProviders(host().runner))),
201
+ );
202
+ expect(both).toHaveLength(2);
203
+ });
204
+ });
205
+
206
+ describe('the barrel', () => {
207
+ test('exports the public surface and nothing test-only or internal', () => {
208
+ expect(Object.keys(barrel).sort()).toEqual([
209
+ 'HostFile',
210
+ 'HostFileProvider',
211
+ 'HostRunnerService',
212
+ 'LaunchdJob',
213
+ 'LaunchdJobProvider',
214
+ 'PlistError',
215
+ 'canActAsRoot',
216
+ 'hostRunnerLayer',
217
+ 'launchdProviders',
218
+ 'localRunner',
219
+ 'renderPlist',
220
+ ]);
221
+ });
222
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Both launchd-subpath providers with one HostRunner, as a single layer for a host stack.
3
+ *
4
+ * Layer.mergeAll(launchdProviders(), …the stack's other providers)
5
+ *
6
+ * ★ ONE RUNNER FOR BOTH, so a daemon's config file (HostFile) and the daemon (LaunchdJob) are
7
+ * written by the same identity on the same host — a stack cannot write the file as one user and
8
+ * bootstrap the job as another by accident.
9
+ * ⚠️ The default is localRunner(): this process, never elevated. A system-domain job therefore
10
+ * needs the deploy itself started as root, or a runner passed here with `privileged: true`.
11
+ */
12
+ import * as Layer from 'effect/Layer';
13
+ import { HostFileProvider } from './host-file.ts';
14
+ import { LaunchdJobProvider } from './job.ts';
15
+ import { localRunner } from './local-runner.ts';
16
+ import { type HostRunner, hostRunnerLayer } from './runner.ts';
17
+
18
+ export const launchdProviders = (runner: HostRunner = localRunner()) =>
19
+ Layer.mergeAll(LaunchdJobProvider(), HostFileProvider()).pipe(
20
+ Layer.provide(hostRunnerLayer(runner)),
21
+ );
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The one seam between the launchd providers and the host: every file read, file write and
3
+ * `launchctl` call goes through a HostRunner, and nothing in this directory touches the host any
4
+ * other way.
5
+ *
6
+ * ★ WHY A SEAM AND NOT DIRECT node:fs CALLS. launchd has no SDK — the provider renders a plist and
7
+ * drives `launchctl` — so the only way to test the lifecycle without a real launchd (and without
8
+ * writing /Library) is to swap the host out. fake-runner.ts is an in-memory host with a scripted
9
+ * launchd; local-runner.ts is the real one. The same seam is where a consumer plugs in a runner
10
+ * that reaches a host some other way.
11
+ *
12
+ * ★ PROMISES, NOT EFFECTS, ON PURPOSE. A consumer writing their own runner implements plain async
13
+ * functions; the providers lift them into Effect at the call site (host-effect.ts).
14
+ */
15
+ import * as Context from 'effect/Context';
16
+ import * as Layer from 'effect/Layer';
17
+
18
+ /** One finished process. ⛔ `stdout` may carry anything the job's environment holds — never log it. */
19
+ export type ExecResult = {
20
+ readonly exitCode: number;
21
+ readonly stdout: string;
22
+ readonly stderr: string;
23
+ };
24
+
25
+ /**
26
+ * What `lstat` says about a path. ⚠️ lstat, not stat: a symlink is reported as a symlink, because
27
+ * writing through one would change a file this resource never named.
28
+ */
29
+ export type FileStat = {
30
+ readonly kind: 'file' | 'directory' | 'symlink' | 'other';
31
+ /** Permission bits only (`& 0o7777`), never the file-type bits. */
32
+ readonly mode: number;
33
+ readonly uid: number;
34
+ readonly gid: number;
35
+ readonly size: number;
36
+ };
37
+
38
+ export type WriteOptions = {
39
+ /** Permission bits for the final file, applied BEFORE it becomes visible at `path`. */
40
+ readonly mode: number;
41
+ /** Numeric owner; omitted means whoever the runner writes as. */
42
+ readonly uid?: number;
43
+ /** Numeric group; omitted means the directory's default. */
44
+ readonly gid?: number;
45
+ };
46
+
47
+ export type HostUser = { readonly uid: number; readonly gid: number; readonly home: string };
48
+
49
+ export interface HostRunner {
50
+ /**
51
+ * ⛔ DECLARED, NEVER INFERRED. `true` only when every call this runner makes already runs with
52
+ * root's authority — a runner that talks to a root helper, say. The providers refuse
53
+ * system-domain writes unless `effectiveUid()` is 0 or this is `true`, so claiming it falsely
54
+ * turns that refusal into an EACCES halfway through a deploy.
55
+ * ⛔ THE KIT NEVER ELEVATES. No runner here calls `sudo`: a deploy that prompts for a password
56
+ * mid-plan, or silently uses a cached sudo ticket, is exactly the "no silent sudo" failure. A
57
+ * consumer who wants elevation writes that runner deliberately and sets this flag.
58
+ */
59
+ readonly privileged: boolean;
60
+ /** The uid file writes and launchctl calls run as. */
61
+ effectiveUid(): number;
62
+ /** Run a program with an argv — ⛔ never through a shell. */
63
+ exec(argv: readonly string[]): Promise<ExecResult>;
64
+ /** File bytes, or `undefined` when nothing is at `path`. */
65
+ readFile(path: string): Promise<Uint8Array | undefined>;
66
+ /** lstat, or `undefined` when nothing is at `path`. */
67
+ stat(path: string): Promise<FileStat | undefined>;
68
+ /**
69
+ * Write `bytes` so that `path` holds either the old file or the new one, never a torn mix:
70
+ * a temporary file in the same directory, mode and owner set on it, then rename(2) over `path`.
71
+ * ⚠️ The parent directory must exist; creating it would invent an owner and mode nobody declared.
72
+ */
73
+ writeFileAtomic(path: string, bytes: Uint8Array, options: WriteOptions): Promise<void>;
74
+ /** Remove a file. Idempotent: nothing at `path` is success. */
75
+ removeFile(path: string): Promise<void>;
76
+ /** A user by name or numeric id, or `undefined` when the host has none. */
77
+ lookupUser(nameOrId: string): Promise<HostUser | undefined>;
78
+ /** A group's gid by name or numeric id, or `undefined` when the host has none. */
79
+ lookupGroup(nameOrId: string): Promise<number | undefined>;
80
+ /** Wait between launchctl polls. ★ Injected so a test never sleeps for real. */
81
+ sleep(ms: number): Promise<void>;
82
+ }
83
+
84
+ /** The Effect service every launchd provider reads its runner from. */
85
+ export class HostRunnerService extends Context.Service<HostRunnerService, HostRunner>()(
86
+ 'homeflare/launchd/HostRunner',
87
+ ) {}
88
+
89
+ /** Provide a runner to the providers: `Layer.provide(hostRunnerLayer(localRunner()))`. */
90
+ export const hostRunnerLayer = (runner: HostRunner): Layer.Layer<HostRunnerService> =>
91
+ Layer.succeed(HostRunnerService, runner);
92
+
93
+ /** True when this runner may write root-owned paths and drive the system domain. */
94
+ export const canActAsRoot = (runner: HostRunner): boolean =>
95
+ runner.privileged || runner.effectiveUid() === 0;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The tripwire trips on the names and shapes people type — and stays quiet on the file-path
3
+ * pattern it tells them to use instead. A tripwire that fired on `*_FILE` would train people to
4
+ * route around it.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { fileSecretProblems, jobSecretProblems } from './secret-tripwire.ts';
8
+
9
+ /**
10
+ * ⚠️ ASSEMBLED FROM PARTS ON PURPOSE. Written out whole, this fixture trips gitleaks' `private-key`
11
+ * rule in the pre-commit hook — measured 2026-09-21 — even though the body is four letters.
12
+ */
13
+ const KEY = `${'PRIVATE'} KEY-----`;
14
+ const PEM = [`-----BEGIN OPENSSH ${KEY}`, 'AAAA', `-----END OPENSSH ${KEY}`].join('\n');
15
+
16
+ describe('environment names', () => {
17
+ test.each([
18
+ 'API_TOKEN',
19
+ 'VAULT_TOKEN',
20
+ 'TOKEN',
21
+ 'CLIENT_SECRET',
22
+ 'DB_PASSWORD',
23
+ 'SMTP_PASSWD',
24
+ 'OPENAI_API_KEY',
25
+ 'APIKEY',
26
+ 'AWS_SECRET_ACCESS_KEY',
27
+ 'SIGNING_PRIVATE_KEY',
28
+ 'GOOGLE_CREDENTIALS',
29
+ 'api_token',
30
+ ])('%s trips', (name) => {
31
+ expect(jobSecretProblems({ [name]: 'value' }, ['/bin/x'])).toHaveLength(1);
32
+ });
33
+
34
+ test.each([
35
+ 'API_TOKEN_FILE',
36
+ 'DB_PASSWORD_PATH',
37
+ 'SSL_CERT_FILE',
38
+ 'TOKENIZER',
39
+ 'HOME',
40
+ 'PATH',
41
+ 'SECRET_DIR',
42
+ 'AWS_ACCESS_KEY_ID',
43
+ 'KEYCHAIN',
44
+ ])('%s does not', (name) => {
45
+ expect(jobSecretProblems({ [name]: '/some/path' }, ['/bin/x'])).toEqual([]);
46
+ });
47
+
48
+ test('a private key in any variable trips', () => {
49
+ expect(jobSecretProblems({ INNOCENT: PEM }, ['/bin/x'])).toHaveLength(1);
50
+ });
51
+ });
52
+
53
+ describe('arguments', () => {
54
+ test.each([
55
+ [['/bin/x', '--token=abc']],
56
+ [['/bin/x', '--vault-token=abc']],
57
+ [['/bin/x', '-password=abc']],
58
+ [['/bin/x', '--api-key=abc']],
59
+ [['/bin/x', '--token', 'abc']],
60
+ [['/bin/x', '--client-secret', 'abc']],
61
+ [['/bin/x', PEM]],
62
+ ])('%j trips', (argv) => {
63
+ expect(jobSecretProblems(undefined, argv)).toHaveLength(1);
64
+ });
65
+
66
+ test.each([
67
+ [['/bin/x', '--token-file=/run/secret']],
68
+ [['/bin/x', '--token-file', '/run/secret']],
69
+ [['/bin/x', '--password-file', '/run/secret']],
70
+ [['/bin/x', '--token']],
71
+ [['/bin/x', '--config', '/etc/x.yml']],
72
+ ])('%j does not', (argv) => {
73
+ expect(jobSecretProblems(undefined, argv)).toEqual([]);
74
+ });
75
+
76
+ test('a refusal never echoes the secret it found', () => {
77
+ const [problem] = jobSecretProblems(undefined, [
78
+ '/bin/x',
79
+ '--token=hunter2',
80
+ '--secret',
81
+ 'hunter3',
82
+ ]);
83
+ expect(problem).not.toContain('hunter');
84
+ expect(jobSecretProblems({ API_TOKEN: 'hunter4' }, ['/bin/x']).join()).not.toContain('hunter4');
85
+ });
86
+
87
+ test('a private key anywhere in extraKeys trips, by path, without echoing it', () => {
88
+ const found = jobSecretProblems(undefined, ['/bin/x'], {
89
+ Sockets: { Listener: [{ SockServiceName: '9000' }, { Blob: `x\n${PEM}` }] },
90
+ });
91
+ expect(found).toHaveLength(1);
92
+ expect(found[0]).toContain('extraKeys.Sockets.Listener[1].Blob');
93
+ expect(found[0]).not.toContain('BEGIN');
94
+ });
95
+
96
+ test('every refusal names the alternative', () => {
97
+ expect(jobSecretProblems({ API_TOKEN: 'x' }, ['/bin/x']).join()).toContain('openbao-agent');
98
+ });
99
+ });
100
+
101
+ describe('file content', () => {
102
+ test('a PEM private key trips; a certificate or public key does not', () => {
103
+ expect(fileSecretProblems(`config\n${PEM}\n`)).toHaveLength(1);
104
+ expect(fileSecretProblems(`-----BEGIN RSA ${KEY}\n`)).toHaveLength(1);
105
+ expect(
106
+ fileSecretProblems('-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n'),
107
+ ).toEqual([]);
108
+ expect(fileSecretProblems('ssh-ed25519 AAAAC3Nza example\n')).toEqual([]);
109
+ });
110
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A tripwire for the obvious ways a secret ends up in Alchemy state through these providers.
3
+ *
4
+ * ⛔ WHY IT EXISTS. Alchemy persists every prop UNENCRYPTED in its state store — the same reason
5
+ * the OpenBao families are metadata-only (openbao/policy.ts). A LaunchdJob's `environment` and
6
+ * `programArguments`, and a HostFile's `content`, are props. A token declared there is a token
7
+ * copied into the state store, into every plan diff, and into the plist or file on disk.
8
+ * ★ THE ALTERNATIVE, NAMED IN EVERY REFUSAL: let a secret renderer (openbao-agent, or any other)
9
+ * write the secret to a root-owned file, and declare only its PATH — `FOO_TOKEN_FILE=/path`,
10
+ * `--token-file /path`. The job reads the file at start; the state holds a path.
11
+ *
12
+ * ⚠️ A TRIPWIRE, NOT A SCANNER. It catches the names and shapes people actually type. A token in
13
+ * an innocently named variable passes; nothing here can make a secret safe to declare.
14
+ */
15
+ import type { PlistDict, PlistValue } from './plist.ts';
16
+
17
+ const ALTERNATIVE =
18
+ 'props are stored unencrypted in Alchemy state. Have a secret renderer (e.g. openbao-agent) ' +
19
+ 'write it to a root-owned file and declare the file path instead (FOO_FILE=/path, --foo-file /path).';
20
+
21
+ /** Names that end in a word meaning "this is the secret itself". `*_FILE`, `*_PATH` never match. */
22
+ const SECRET_NAME =
23
+ /(?:^|_)(?:TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_KEY|PRIVATE_KEY|SECRET_KEY|CREDENTIALS?)$/i;
24
+
25
+ /** `--token=…` / `-password=…` style flags carrying their value inline. */
26
+ const INLINE_FLAG = /^--?[a-z0-9-]*(?:token|password|passwd|passphrase|secret|api-?key)=./i;
27
+
28
+ /** A flag whose NEXT argv element is the secret: `--token abc`. */
29
+ const BARE_FLAG = /^--?(?:[a-z0-9]+-)*(?:token|password|passwd|passphrase|secret|api-?key)$/i;
30
+
31
+ const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
32
+
33
+ /** Paths of every string leaf in a plist value holding a private key. */
34
+ const keyLeaves = (value: PlistValue, path: string): string[] => {
35
+ if (typeof value === 'string') return PRIVATE_KEY.test(value) ? [path] : [];
36
+ if (Array.isArray(value)) {
37
+ return (value as readonly PlistValue[]).flatMap((item, index) =>
38
+ keyLeaves(item, `${path}[${String(index)}]`),
39
+ );
40
+ }
41
+ if (typeof value === 'object' && value !== null) {
42
+ return Object.entries(value).flatMap(([key, item]) => keyLeaves(item, `${path}.${key}`));
43
+ }
44
+ return [];
45
+ };
46
+
47
+ /**
48
+ * Refusals for a job's environment, argv and extraKeys. Empty means nothing tripped.
49
+ * ★ extraKeys too: it is a prop like the others, so it lands in state just the same.
50
+ */
51
+ export const jobSecretProblems = (
52
+ environment: Readonly<Record<string, string>> | undefined,
53
+ argv: readonly string[],
54
+ extraKeys?: PlistDict,
55
+ ): string[] => {
56
+ const found: string[] = [];
57
+ for (const [name, value] of Object.entries(environment ?? {})) {
58
+ if (SECRET_NAME.test(name))
59
+ found.push(`environment ${name} looks like a secret: ${ALTERNATIVE}`);
60
+ else if (PRIVATE_KEY.test(value))
61
+ found.push(`environment ${name} holds a private key: ${ALTERNATIVE}`);
62
+ }
63
+ for (const [index, arg] of argv.entries()) {
64
+ // ⛔ Never echo the argument itself: if it IS a secret, the refusal would print it.
65
+ if (INLINE_FLAG.test(arg)) {
66
+ found.push(`programArguments[${String(index)}] passes a secret inline: ${ALTERNATIVE}`);
67
+ } else if (BARE_FLAG.test(arg) && index + 1 < argv.length) {
68
+ found.push(`programArguments[${String(index + 1)}] follows a secret flag: ${ALTERNATIVE}`);
69
+ } else if (PRIVATE_KEY.test(arg)) {
70
+ found.push(`programArguments[${String(index)}] holds a private key: ${ALTERNATIVE}`);
71
+ }
72
+ }
73
+ for (const path of keyLeaves(extraKeys ?? {}, 'extraKeys')) {
74
+ found.push(`${path} holds a private key: ${ALTERNATIVE}`);
75
+ }
76
+ return found;
77
+ };
78
+
79
+ /** Refusals for a HostFile's content. */
80
+ export const fileSecretProblems = (content: string): string[] =>
81
+ PRIVATE_KEY.test(content) ? [`content holds a private key: ${ALTERNATIVE}`] : [];