@homeflare/alchemy 0.20.0 → 0.22.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 (191) hide show
  1. package/README.md +27 -0
  2. package/dist/caddy/access-forward-auth.d.ts +56 -0
  3. package/dist/caddy/access-forward-auth.d.ts.map +1 -0
  4. package/dist/caddy/index.d.ts +6 -0
  5. package/dist/caddy/index.d.ts.map +1 -1
  6. package/dist/caddy/index.js +169 -75
  7. package/dist/caddy/index.js.map +10 -7
  8. package/dist/cloudflare/mcp-server-api.d.ts +135 -0
  9. package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
  10. package/dist/cloudflare/mcp-server-form.d.ts +116 -0
  11. package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/launchd/file-converge.d.ts +70 -0
  15. package/dist/launchd/file-converge.d.ts.map +1 -0
  16. package/dist/launchd/file-identity.d.ts +32 -0
  17. package/dist/launchd/file-identity.d.ts.map +1 -0
  18. package/dist/launchd/host-file-form.d.ts +2 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts +11 -24
  21. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  22. package/dist/launchd/host-file.d.ts.map +1 -1
  23. package/dist/launchd/index.d.ts +2 -0
  24. package/dist/launchd/index.d.ts.map +1 -1
  25. package/dist/launchd/index.js +225 -120
  26. package/dist/launchd/index.js.map +13 -10
  27. package/dist/launchd/local-runner.d.ts.map +1 -1
  28. package/dist/launchd/port-claims.d.ts +31 -0
  29. package/dist/launchd/port-claims.d.ts.map +1 -0
  30. package/dist/launchd/runner.d.ts +17 -2
  31. package/dist/launchd/runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-runner.d.ts +11 -0
  33. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  34. package/dist/linux/index.js.map +3 -3
  35. package/dist/linux/sudo-listing.d.ts +47 -0
  36. package/dist/linux/sudo-listing.d.ts.map +1 -0
  37. package/dist/proxmox/credential-literals.d.ts +1 -1
  38. package/dist/proxmox/credential-literals.d.ts.map +1 -1
  39. package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
  40. package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
  41. package/dist/proxmox/generated/pbs.d.ts +7 -0
  42. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  43. package/dist/proxmox/generated/pve.d.ts +7 -0
  44. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  45. package/dist/proxmox/index.d.ts +1 -1
  46. package/dist/proxmox/index.d.ts.map +1 -1
  47. package/dist/proxmox/index.js +2 -2
  48. package/dist/proxmox/index.js.map +9 -9
  49. package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
  50. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
  51. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  52. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
  53. package/dist/release/archive.d.ts +11 -0
  54. package/dist/release/archive.d.ts.map +1 -0
  55. package/dist/release/binary-claim.d.ts +53 -0
  56. package/dist/release/binary-claim.d.ts.map +1 -0
  57. package/dist/release/binary-diff.d.ts +38 -0
  58. package/dist/release/binary-diff.d.ts.map +1 -0
  59. package/dist/release/binary-form.d.ts +118 -0
  60. package/dist/release/binary-form.d.ts.map +1 -0
  61. package/dist/release/binary-lifecycle.d.ts +55 -0
  62. package/dist/release/binary-lifecycle.d.ts.map +1 -0
  63. package/dist/release/binary-read.d.ts +11 -0
  64. package/dist/release/binary-read.d.ts.map +1 -0
  65. package/dist/release/binary.d.ts +68 -0
  66. package/dist/release/binary.d.ts.map +1 -0
  67. package/dist/release/catalog.d.ts +113 -0
  68. package/dist/release/catalog.d.ts.map +1 -0
  69. package/dist/release/checksums.d.ts +22 -0
  70. package/dist/release/checksums.d.ts.map +1 -0
  71. package/dist/release/declared-pins.d.ts +4 -0
  72. package/dist/release/declared-pins.d.ts.map +1 -0
  73. package/dist/release/download.d.ts +44 -0
  74. package/dist/release/download.d.ts.map +1 -0
  75. package/dist/release/fake-deploy.d.ts +48 -0
  76. package/dist/release/fake-deploy.d.ts.map +1 -0
  77. package/dist/release/fake-engine.d.ts +8 -0
  78. package/dist/release/fake-engine.d.ts.map +1 -0
  79. package/dist/release/fake-install.d.ts +39 -0
  80. package/dist/release/fake-install.d.ts.map +1 -0
  81. package/dist/release/fake-provider.d.ts +58 -0
  82. package/dist/release/fake-provider.d.ts.map +1 -0
  83. package/dist/release/fake-release.d.ts +95 -0
  84. package/dist/release/fake-release.d.ts.map +1 -0
  85. package/dist/release/index.d.ts +25 -0
  86. package/dist/release/index.d.ts.map +1 -0
  87. package/dist/release/index.js +1425 -0
  88. package/dist/release/index.js.map +36 -0
  89. package/dist/release/openbao.d.ts +35 -0
  90. package/dist/release/openbao.d.ts.map +1 -0
  91. package/dist/release/providers.d.ts +19 -0
  92. package/dist/release/providers.d.ts.map +1 -0
  93. package/dist/release/refused.d.ts +39 -0
  94. package/dist/release/refused.d.ts.map +1 -0
  95. package/dist/release/tar.d.ts +14 -0
  96. package/dist/release/tar.d.ts.map +1 -0
  97. package/dist/release/victoria.d.ts +36 -0
  98. package/dist/release/victoria.d.ts.map +1 -0
  99. package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
  100. package/docs/launchd-ports.md +109 -0
  101. package/docs/launchd.md +5 -0
  102. package/docs/release-binary-catalogs.md +138 -0
  103. package/docs/release-binary-measured.md +163 -0
  104. package/docs/release-binary-openbao.md +151 -0
  105. package/docs/release-binary-refusals.md +57 -0
  106. package/docs/release-binary-upstream.md +163 -0
  107. package/docs/release-binary.md +165 -0
  108. package/package.json +5 -1
  109. package/src/caddy/access-forward-auth.test.ts +63 -0
  110. package/src/caddy/access-forward-auth.ts +120 -0
  111. package/src/caddy/index.ts +10 -0
  112. package/src/caddy/providers.test.ts +4 -0
  113. package/src/cloudflare/mcp-server-api.ts +294 -0
  114. package/src/cloudflare/mcp-server-form.ts +219 -0
  115. package/src/index.ts +1 -0
  116. package/src/launchd/file-converge.ts +233 -0
  117. package/src/launchd/file-identity.ts +69 -0
  118. package/src/launchd/host-file-alias.test.ts +65 -0
  119. package/src/launchd/host-file-form.ts +2 -1
  120. package/src/launchd/host-file-lifecycle.ts +25 -144
  121. package/src/launchd/host-file.ts +8 -2
  122. package/src/launchd/index.ts +2 -0
  123. package/src/launchd/local-runner.test.ts +12 -1
  124. package/src/launchd/local-runner.ts +2 -0
  125. package/src/launchd/port-claims.test.ts +124 -0
  126. package/src/launchd/port-claims.ts +102 -0
  127. package/src/launchd/providers.test.ts +3 -0
  128. package/src/launchd/runner.ts +17 -2
  129. package/src/launchd/sudo-runner.ts +11 -0
  130. package/src/linux/sudo-listing.ts +122 -0
  131. package/src/proxmox/credential-literals.ts +1 -1
  132. package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
  133. package/src/proxmox/generated/pbs.ts +7 -0
  134. package/src/proxmox/generated/pve.ts +7 -0
  135. package/src/proxmox/index.ts +1 -1
  136. package/src/proxmox/pbs-notification-target-form.ts +1 -1
  137. package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
  138. package/src/proxmox/pbs-notification-target-wire.ts +1 -1
  139. package/src/proxmox/pbs-notification-target.ts +1 -1
  140. package/src/release/adopt-parity.test.ts +106 -0
  141. package/src/release/apply-pins.test.ts +128 -0
  142. package/src/release/archive.ts +94 -0
  143. package/src/release/binary-alias.test.ts +109 -0
  144. package/src/release/binary-claim.ts +80 -0
  145. package/src/release/binary-claims.test.ts +47 -0
  146. package/src/release/binary-diff.ts +73 -0
  147. package/src/release/binary-form.ts +229 -0
  148. package/src/release/binary-lifecycle.test.ts +90 -0
  149. package/src/release/binary-lifecycle.ts +202 -0
  150. package/src/release/binary-read.ts +55 -0
  151. package/src/release/binary-refusals.test.ts +192 -0
  152. package/src/release/binary-sudo.test.ts +93 -0
  153. package/src/release/binary.ts +181 -0
  154. package/src/release/catalog.test.ts +167 -0
  155. package/src/release/catalog.ts +238 -0
  156. package/src/release/checksums.test.ts +35 -0
  157. package/src/release/checksums.ts +43 -0
  158. package/src/release/declared-pins.ts +39 -0
  159. package/src/release/download.test.ts +138 -0
  160. package/src/release/download.ts +110 -0
  161. package/src/release/error-channel.test.ts +69 -0
  162. package/src/release/fake-deploy.ts +87 -0
  163. package/src/release/fake-engine.ts +50 -0
  164. package/src/release/fake-install.ts +59 -0
  165. package/src/release/fake-provider.ts +68 -0
  166. package/src/release/fake-release.ts +185 -0
  167. package/src/release/fixtures/openbao/checksums.txt +83 -0
  168. package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
  169. package/src/release/fixtures/openbao/darwin-assets.txt +12 -0
  170. package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +109 -0
  171. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
  172. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
  173. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
  174. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
  175. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
  176. package/src/release/index.ts +44 -0
  177. package/src/release/openbao.test.ts +120 -0
  178. package/src/release/openbao.ts +86 -0
  179. package/src/release/plan.test.ts +189 -0
  180. package/src/release/provider-refusals.test.ts +48 -0
  181. package/src/release/providers.test.ts +175 -0
  182. package/src/release/providers.ts +24 -0
  183. package/src/release/recovery-read.test.ts +44 -0
  184. package/src/release/refused.ts +40 -0
  185. package/src/release/tar.test.ts +142 -0
  186. package/src/release/tar.ts +195 -0
  187. package/src/release/victoria.test.ts +108 -0
  188. package/src/release/victoria.ts +154 -0
  189. package/src/{proxmox → secrets}/write-only.ts +6 -0
  190. /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
  191. /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
@@ -130,7 +130,9 @@ export const localRunner = (options: LocalRunnerOptions = {}): HostRunner => {
130
130
  try {
131
131
  const stats = await lstat(path);
132
132
  return {
133
+ dev: stats.dev,
133
134
  gid: stats.gid,
135
+ ino: stats.ino,
134
136
  kind: kindOf(stats),
135
137
  mode: stats.mode & 0o7777,
136
138
  size: stats.size,
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Port collisions, pure and through the engine. Every refusal here happens in the stack's own
3
+ * program (an ordinary `throw` before any resource is even declared), so plan.test.ts's proof for
4
+ * `catalogBinary()` — that the engine never runs a provider or touches a host once the body throws
5
+ * — is re-proved below for `claimPorts`, not assumed to carry over.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Effect from 'effect/Effect';
9
+ import { fakeStack } from '../openbao/fake-stack.ts';
10
+ import { fakeRunner } from './fake-runner.ts';
11
+ import { LaunchdJob } from './job.ts';
12
+ import { type PortClaim, PortRefused, claimPorts, portClaimProblems } from './port-claims.ts';
13
+ import { launchdProviders } from './providers.ts';
14
+ import type { HostRunner } from './runner.ts';
15
+
16
+ describe('portClaimProblems', () => {
17
+ test.each([
18
+ [
19
+ 'a port that is not an integer 1–65535 (too low)',
20
+ [{ owner: 'vector', port: 0 }],
21
+ 'port must be an integer from 1 to 65535',
22
+ ],
23
+ [
24
+ 'a port that is not an integer 1–65535 (too high)',
25
+ [{ owner: 'vector', port: 65536 }],
26
+ 'port must be an integer from 1 to 65535',
27
+ ],
28
+ [
29
+ 'a port that is not an integer 1–65535 (fractional)',
30
+ [{ owner: 'vector', port: 9094.5 }],
31
+ 'port must be an integer from 1 to 65535',
32
+ ],
33
+ ['an empty owner', [{ owner: '', port: 9094 }], 'has an empty owner'],
34
+ [
35
+ 'one owner claiming a port twice',
36
+ [
37
+ { owner: 'vector', port: 9094 },
38
+ { owner: 'vector', port: 9094, address: '127.0.0.1' },
39
+ ],
40
+ 'vector claims port 9094 more than once',
41
+ ],
42
+ [
43
+ 'two owners on one port, naming both owners and both addresses',
44
+ [
45
+ { owner: 'vector', port: 9094, address: '0.0.0.0' },
46
+ { owner: 'victoria-logs', port: 9094, address: '127.0.0.1' },
47
+ ],
48
+ 'port 9094 is claimed by more than one owner: vector (0.0.0.0) and victoria-logs (127.0.0.1)',
49
+ ],
50
+ ] satisfies Array<[string, PortClaim[], string]>)('refuses %s', (_, claims, message) => {
51
+ expect(portClaimProblems(claims).join('; ')).toContain(message);
52
+ expect(() => claimPorts(claims)).toThrow(message);
53
+ });
54
+
55
+ test('distinct ports return no problems', () => {
56
+ const claims: PortClaim[] = [
57
+ { owner: 'vector', port: 9094 },
58
+ { owner: 'victoria-logs', port: 9428, address: '127.0.0.1' },
59
+ ];
60
+ expect(portClaimProblems(claims)).toEqual([]);
61
+ expect(() => claimPorts(claims)).not.toThrow();
62
+ });
63
+
64
+ // ★ MEASURED (docs/launchd-ports.md): on macOS both of these actually bind — SO_REUSEADDR plus
65
+ // an address mismatch lets a wildcard and a specific address share one port at the OS level.
66
+ // The check refuses it anyway: the key is the port NUMBER alone, like lib-ports.nix.
67
+ test('0.0.0.0:9094 against 127.0.0.1:9094 is refused, though the OS would let both bind', () => {
68
+ const problems = portClaimProblems([
69
+ { owner: 'vector', port: 9094, address: '0.0.0.0' },
70
+ { owner: 'victoria-logs', port: 9094, address: '127.0.0.1' },
71
+ ]);
72
+ expect(problems).toEqual([
73
+ 'port 9094 is claimed by more than one owner: vector (0.0.0.0) and victoria-logs (127.0.0.1)',
74
+ ]);
75
+ });
76
+
77
+ test('claimPorts throws PortRefused carrying the house refusal sentence', () => {
78
+ let caught: unknown;
79
+ try {
80
+ claimPorts([
81
+ { owner: 'a', port: 9094 },
82
+ { owner: 'b', port: 9094 },
83
+ ]);
84
+ } catch (cause) {
85
+ caught = cause;
86
+ }
87
+ expect(caught).toBeInstanceOf(PortRefused);
88
+ expect((caught as Error).message).toMatch(/^Launchd ports: .* Nothing was declared\.$/);
89
+ });
90
+ });
91
+
92
+ describe('through the engine', () => {
93
+ test('a collision fails the plan before any provider or host call', async () => {
94
+ const fake = fakeRunner({ euid: 0 });
95
+ const events: string[] = [];
96
+ const runner: HostRunner = {
97
+ ...fake.runner,
98
+ exec: (argv) => {
99
+ events.push(`exec ${argv.join(' ')}`);
100
+ return fake.runner.exec(argv);
101
+ },
102
+ writeFileAtomic: (path, bytes, options) => {
103
+ events.push(`write ${path}`);
104
+ return fake.runner.writeFileAtomic(path, bytes, options);
105
+ },
106
+ };
107
+ const stack = fakeStack(launchdProviders(runner), {}, 'PortClaimsStack');
108
+ const body = Effect.gen(function* () {
109
+ claimPorts([
110
+ { owner: 'vector', port: 9094 },
111
+ { owner: 'victoria-logs', port: 9094 },
112
+ ]);
113
+ // Never reached: proves the collision is caught before this job — or any provider/host
114
+ // call it would make — runs at all.
115
+ yield* LaunchdJob('never-created', {
116
+ domain: 'system',
117
+ label: 'com.example.never-created',
118
+ programArguments: ['/usr/local/bin/never-created'],
119
+ });
120
+ });
121
+ await expect(stack.deploy(body)).rejects.toThrow('Nothing was declared');
122
+ expect(events).toEqual([]);
123
+ });
124
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Port collision refusal for a stack's own launchd jobs — pure: no host, no network, and
3
+ * runtime-portable (no `Bun.*`, no `node:*`), so a future Linux consumer (systemd units, under
4
+ * `/linux`) can import it unchanged.
5
+ *
6
+ * ★ MIRRORS house/nix's REGISTRY, NOT A NEW RULE. `lib-ports.nix` (house @ 92cff7f) collides on
7
+ * `lib.attrValues ports` alone — `dupes = lib.subtractLists (lib.unique vals) vals` — keying on
8
+ * the PORT NUMBER by itself; address and protocol are never read. `portClaimProblems` below keys
9
+ * the same way on purpose: it replaces that check for the jobs a kit stack declares, and a looser
10
+ * or stricter rule would silently diverge from the registry it takes over from. What that trades
11
+ * away — a wildcard bind beside a specific one on the same port both succeed, silently splitting
12
+ * traffic — is measured in docs/launchd-ports.md, not assumed.
13
+ * ★ IN THE STACK PROGRAM, NOT A PROVIDER — same shape as `catalogBinary()` (../release/catalog.ts).
14
+ * Two reasons, both structural to Alchemy 2.0.0-beta.79: Alchemy never diffs a first `create`
15
+ * whose props still hold an unresolved `Output`, and every mini job's `programArguments` carries
16
+ * a `HostFile` config digest as an `Output` — so a provider-side check would miss the very first
17
+ * deploy of every job. And a provider never sees its sibling resources, so it could not compare
18
+ * one job's port against another's even if props were resolved. A plain function called from the
19
+ * stack's own `Effect.gen` body sees every claim the stack passes, before anything is applied.
20
+ * ⚠️ IT SEES ONLY WHAT THE STACK PASSES. A job the stack forgets to add to its claim list is
21
+ * invisible to this check — there is no argv-parsing or host inspection here to catch that, and
22
+ * adding one would be guessing at a job's own config shape. docs/launchd-ports.md says what this
23
+ * cannot see, in full.
24
+ */
25
+ import * as Data from 'effect/Data';
26
+
27
+ /** One job's claim on one port. `address` is informational only — the key is `port` alone. */
28
+ export interface PortClaim {
29
+ /** Whose claim this is, for the refusal message: a job id or label, never empty. */
30
+ readonly owner: string;
31
+ readonly port: number;
32
+ /** What the job binds, if the stack knows it. Omitted claims still collide by port number. */
33
+ readonly address?: string;
34
+ }
35
+
36
+ const describeAddress = (address: string | undefined): string => address ?? 'unspecified address';
37
+
38
+ const describeOwner = (owner: string): string => (owner === '' ? '(empty owner)' : owner);
39
+
40
+ /**
41
+ * Everything wrong with a set of claims; empty when none collide. ★ KEYS ON `port` ALONE, exactly
42
+ * like `lib-ports.nix`'s `assertNoCollision` — see the file header for why.
43
+ */
44
+ export const portClaimProblems = (claims: readonly PortClaim[]): string[] => {
45
+ const found: string[] = [];
46
+ const byPort = new Map<number, PortClaim[]>();
47
+ for (const claim of claims) {
48
+ if (!Number.isSafeInteger(claim.port) || claim.port < 1 || claim.port > 65535) {
49
+ found.push(
50
+ `${describeOwner(claim.owner)}: port must be an integer from 1 to 65535, got ${String(claim.port)}`,
51
+ );
52
+ }
53
+ if (claim.owner === '') {
54
+ found.push(`a claim on port ${String(claim.port)} has an empty owner`);
55
+ }
56
+ const list = byPort.get(claim.port) ?? [];
57
+ list.push(claim);
58
+ byPort.set(claim.port, list);
59
+ }
60
+ for (const [port, list] of byPort) {
61
+ if (list.length < 2) continue;
62
+ // One owner naming the same port more than once — flagged the first time it repeats.
63
+ const seen = new Set<string>();
64
+ for (const claim of list) {
65
+ if (claim.owner === '') continue; // already reported above
66
+ if (seen.has(claim.owner)) {
67
+ found.push(`${claim.owner} claims port ${String(port)} more than once`);
68
+ }
69
+ seen.add(claim.owner);
70
+ }
71
+ // Two different owners on the same port number — the collision the registry exists to catch.
72
+ const distinctOwners = [...new Set(list.map((claim) => claim.owner).filter((o) => o !== ''))];
73
+ if (distinctOwners.length > 1) {
74
+ const described = list
75
+ .map((claim) => `${describeOwner(claim.owner)} (${describeAddress(claim.address)})`)
76
+ .join(' and ');
77
+ found.push(`port ${String(port)} is claimed by more than one owner: ${described}`);
78
+ }
79
+ }
80
+ return found;
81
+ };
82
+
83
+ /** The declaration itself: a bad port, an empty owner, or two owners sharing one port number. */
84
+ export class PortRefused extends Data.TaggedError('PortRefused')<{
85
+ readonly message: string;
86
+ }> {}
87
+
88
+ /**
89
+ * Refuse a colliding set of claims, or do nothing. ⛔ THROWS `PortRefused` — exactly like
90
+ * `catalogBinary()`, this is a DEFECT in a stack program's `Effect.gen` body, not a typed failure
91
+ * a caller can `Effect.catchTag` on: gap 10 of docs/release-binary-upstream.md applies here
92
+ * unchanged (measured the same way, in port-claims.test.ts). The plan still fails before anything
93
+ * is applied; the error itself is just not a typed channel yet.
94
+ */
95
+ export const claimPorts = (claims: readonly PortClaim[]): void => {
96
+ const problems = portClaimProblems(claims);
97
+ if (problems.length > 0) {
98
+ throw new PortRefused({
99
+ message: `Launchd ports: ${problems.join('; ')}. Nothing was declared.`,
100
+ });
101
+ }
102
+ };
@@ -212,11 +212,14 @@ describe('the barrel', () => {
212
212
  'LaunchdJob',
213
213
  'LaunchdJobProvider',
214
214
  'PlistError',
215
+ 'PortRefused',
215
216
  'SudoRefusedError',
216
217
  'canActAsRoot',
218
+ 'claimPorts',
217
219
  'hostRunnerLayer',
218
220
  'launchdProviders',
219
221
  'localRunner',
222
+ 'portClaimProblems',
220
223
  'renderPlist',
221
224
  // ★ The opt-in elevating runner; makeSudoRunner and the allowlist internals stay off.
222
225
  'sudoRunner',
@@ -33,10 +33,23 @@ export type FileStat = {
33
33
  readonly uid: number;
34
34
  readonly gid: number;
35
35
  readonly size: number;
36
+ /**
37
+ * The entry's identity, device and inode, when the runner can say. ★ Two spellings of ONE file
38
+ * (`/tmp/x` and `/private/tmp/x`; `Bin` and `bin` on case-insensitive APFS) differ as strings
39
+ * and match here, and a move must not delete its old path when that path IS the new file
40
+ * (file-identity.ts). Optional: a runner that leaves them out gets a check after the fact.
41
+ */
42
+ readonly dev?: number;
43
+ readonly ino?: number;
36
44
  };
37
45
 
38
46
  export type WriteOptions = {
39
- /** Permission bits for the final file, applied BEFORE it becomes visible at `path`. */
47
+ /**
48
+ * Permission bits for the final file. localRunner applies them BEFORE the file is visible at
49
+ * `path`. ⚠️ sudoRunner's `install -S` does NOT: MEASURED 2026-09-22 on macOS 27.2 (an lstat
50
+ * poller racing /usr/bin/install), the new inode appears at `path` as 0600 and is chmodded
51
+ * 0.2–0.4 ms later. Never torn, never wider than declared, but briefly not executable.
52
+ */
40
53
  readonly mode: number;
41
54
  /** Numeric owner; omitted means whoever the runner writes as. */
42
55
  readonly uid?: number;
@@ -69,7 +82,9 @@ export interface HostRunner {
69
82
  stat(path: string): Promise<FileStat | undefined>;
70
83
  /**
71
84
  * Write `bytes` so that `path` holds either the old file or the new one, never a torn mix:
72
- * a temporary file in the same directory, mode and owner set on it, then rename(2) over `path`.
85
+ * a temporary file in the same directory, mode and owner set on it, then rename(2) over `path`
86
+ * (sudoRunner: the mode just after the rename, measured — see WriteOptions.mode; the owner too,
87
+ * ⚠️ REASONED NOT MEASURED, since measuring it needs root).
73
88
  * ⚠️ The parent directory must exist; creating it would invent an owner and mode nobody declared.
74
89
  */
75
90
  writeFileAtomic(path: string, bytes: Uint8Array, options: WriteOptions): Promise<void>;
@@ -21,6 +21,17 @@
21
21
  * ★ `install -S`: install(1) on macOS 27.2 (read 2026-09-21) always writes a temp file in the
22
22
  * target directory and renames it, so the file is never torn, and -S adds the fsync that
23
23
  * localRunner's `handle.sync()` does.
24
+ * ⚠️ BUT IT CHMODS AFTER THE RENAME. MEASURED 2026-09-22 (an lstat poller racing
25
+ * `/usr/bin/install -S -m 0755`, 300 MB, six runs, target absent and present): the new inode
26
+ * is visible at the path as 0600, then 0755 0.2–0.4 ms later. Only ever narrower than declared.
27
+ * ⚠️ REASONED NOT MEASURED: a launchd start inside that window gets EACCES and retries after its
28
+ * throttle, and an install killed inside it leaves a root-owned 0600 file the operator cannot
29
+ * read back. Closing it needs a second privileged step (install to a temp name, then rename),
30
+ * which is a change to this allowlist and wants its own red team.
31
+ * ⚠️ AND IT COPIES XATTRS, com.apple.quarantine included (MEASURED 2026-09-22: a quarantined
32
+ * source installed as a quarantined target). The staged file is written by this process, and
33
+ * node:fs writes here carried only com.apple.provenance (measured the same day) — so nothing
34
+ * is quarantined today, but a quarantined staging file would be installed quarantined.
24
35
  * ⚠️ REASONED, NOT MEASURED: sudo's failure text (sudo-said.ts has the strings and their sources).
25
36
  * ⛔ EVERY CHECK BEFORE SUDO ALSO RUNS AT PLAN TIME (`checkWrite`, 2026-09-21): the mode, the
26
37
  * directories from the prefix down, the read-back. Reads only, as the operator — never sudo.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * ONE READ OF `ls -ldn --` FOR A WHOLE LIST OF PATHS, parsed into the same FileStat the seam uses
3
+ * plus the one thing `stat` cannot say on Linux: whether the path carries a POSIX ACL.
4
+ *
5
+ * ★ WHY `ls` AND NOT `stat`, AND WHY ONE CALL. The Mac guard lstats every directory from `/` down
6
+ * with a local syscall each; over ssh each of those is a TCP connection with no multiplexing
7
+ * (ssh-runner.ts), so a five-deep chain would be five handshakes per check and per plan. `ls -ldn`
8
+ * takes every path as an operand and answers in one connection, and its mode column carries the
9
+ * ACL flag the chain check needs anyway.
10
+ * ★ MEASURED 2026-09-22, read-only over ssh, on two host classes — an unprivileged LXC container
11
+ * and a Proxmox VE node, both Debian 13 / coreutils 9.7, LANG=C, ssh user uid 1001 and 1000:
12
+ * $ ls -ldn / /etc /etc/systemd /etc/systemd/system /usr /usr/lib /usr/lib/systemd …
13
+ * drwxr-xr-x 18 0 0 4096 Sep 22 10:12 /etc (all ten root:root 0755, no flag)
14
+ * $ ls -ld /var/log/journal
15
+ * drwxr-sr-x+ 3 root systemd-journal … (the `+` IS the POSIX ACL flag here)
16
+ * $ ls -lden … → ls: invalid option -- 'e' (the Mac's spelling does not exist)
17
+ * $ command -v getfacl → absent on both Debian classes (present on the NixOS class)
18
+ * ⛔ SO THE ACL SIGNAL IS THE `+` FLAG, NOT `getfacl`: the tool that would enumerate the entries
19
+ * is not installed on the hosts this runner is for, and a guard that shells out to a missing
20
+ * program and fails closed would refuse every write on them. `ls` is coreutils, always there.
21
+ * ⚠️ THE PRICE, STATED: we learn THAT a directory has an ACL, never WHAT it grants, so any ACL on
22
+ * the chain is a refusal — including a read-only one the Mac guard would allow. Fail closed and
23
+ * say so; the remedy is a prefix without one.
24
+ * ⚠️ `.` in that column is an SELinux context, not an ACL, and is not a grant. `@` is macOS's.
25
+ * ⛔ FAIL CLOSED ON ANYTHING WE DO NOT UNDERSTAND. A line that does not parse, a path that no line
26
+ * named, two lines for one path, or a non-zero exit is an error — never "nothing is there".
27
+ */
28
+ import type { FileStat, HostRunner } from '../launchd/runner.ts';
29
+ import { SudoRefusedError } from '../launchd/sudo-allowlist.ts';
30
+
31
+ /** What one `ls -ldn` line says: the lstat fields, plus whether the path carries an ACL. */
32
+ export type Listed = FileStat & { readonly acl: boolean };
33
+
34
+ const KINDS = new Map<string, FileStat['kind']>([
35
+ ['-', 'file'],
36
+ ['d', 'directory'],
37
+ ['l', 'symlink'],
38
+ ]);
39
+
40
+ /**
41
+ * `rwxr-x---` → 0o750, with the three special bits `ls` hides in the execute columns:
42
+ * `s`/`S` = setuid (user) or setgid (group), `t`/`T` = sticky; the capital is the bit without
43
+ * execute. ⚠️ Anything else in a column is a shape we do not know, and returns undefined.
44
+ */
45
+ export const permBits = (perms: string): number | undefined => {
46
+ if (perms.length !== 9) return undefined;
47
+ let mode = 0;
48
+ for (const index of [0, 1, 2]) {
49
+ const at = index * 3;
50
+ const shift = 6 - at;
51
+ const [read, write, exec] = [perms[at], perms[at + 1], perms[at + 2]];
52
+ if ((read !== 'r' && read !== '-') || (write !== 'w' && write !== '-')) return undefined;
53
+ if (read === 'r') mode |= 0o4 << shift;
54
+ if (write === 'w') mode |= 0o2 << shift;
55
+ const [set, unset] = index === 2 ? ['t', 'T'] : ['s', 'S'];
56
+ if (exec === 'x' || exec === set) mode |= 0o1 << shift;
57
+ else if (exec !== '-' && exec !== unset) return undefined;
58
+ if (exec === set || exec === unset) mode |= 0o4000 >> index;
59
+ }
60
+ return mode;
61
+ };
62
+
63
+ /** `drwxr-xr-x+ 4 0 0 4096 Sep 22 10:12 /etc/systemd` — the tail holds the date and the name. */
64
+ const LINE = /^([-dlbcps])(\S{9})([+.@]?)\s+\d+\s+(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/;
65
+
66
+ /**
67
+ * Match each line to the path that asked for it, by NAME rather than by position.
68
+ * ⚠️ `ls` SORTS ITS OPERANDS, so the answers do not come back in the order they were asked; and a
69
+ * symlink's line ends `<path> -> <target>`, so it is not a suffix match either. The longest
70
+ * candidate wins, so a path that is a tail of another cannot steal its line.
71
+ */
72
+ const nameOf = (tail: string, paths: readonly string[]): string | undefined =>
73
+ [...paths]
74
+ .sort((left, right) => right.length - left.length)
75
+ .find((path) => tail.endsWith(` ${path}`) || tail.includes(` ${path} -> `));
76
+
77
+ /** Every requested path's entry, or `undefined` when the listing is not exactly that. */
78
+ export const parseListing = (
79
+ stdout: string,
80
+ paths: readonly string[],
81
+ ): Map<string, Listed> | undefined => {
82
+ const entries = new Map<string, Listed>();
83
+ for (const line of stdout.split('\n').filter((text) => text.trim() !== '')) {
84
+ const match = LINE.exec(line);
85
+ if (match === null) return undefined;
86
+ const [, type, perms, flag, uid, gid, size, tail] = match;
87
+ const mode = permBits(perms ?? '');
88
+ const path = nameOf(tail ?? '', paths);
89
+ if (mode === undefined || path === undefined || entries.has(path)) return undefined;
90
+ entries.set(path, {
91
+ acl: flag === '+',
92
+ gid: Number(gid),
93
+ kind: KINDS.get(type ?? '') ?? 'other',
94
+ mode,
95
+ size: Number(size),
96
+ uid: Number(uid),
97
+ });
98
+ }
99
+ return entries.size === paths.length ? entries : undefined;
100
+ };
101
+
102
+ /**
103
+ * Read every path in one call, as the deploying user — no sudo, the same read at plan time and
104
+ * before the write. ⛔ Anything but a complete, understood listing is a SudoRefusedError: a chain
105
+ * we could not read is a chain we cannot say is root's alone.
106
+ */
107
+ export const readListing = async (
108
+ base: HostRunner,
109
+ ls: string,
110
+ what: string,
111
+ paths: readonly string[],
112
+ ): Promise<Map<string, Listed>> => {
113
+ const result = await base.exec([ls, '-ldn', '--', ...paths]);
114
+ const entries = result.exitCode === 0 ? parseListing(result.stdout, paths) : undefined;
115
+ if (entries === undefined) {
116
+ throw new SudoRefusedError(
117
+ `sudoRunner ${what}: could not read ${paths.join(', ')} (${ls} -ldn exit ` +
118
+ `${String(result.exitCode)}: ${result.stderr.trim().slice(0, 200)}). Nothing ran as root.`,
119
+ );
120
+ }
121
+ return entries;
122
+ };
@@ -23,7 +23,7 @@
23
23
  * it from the template. docs/pbs-notifications.md says the same to the reader.
24
24
  * ⛔ A REFUSAL NAMES THE FIELD, NEVER THE VALUE — the message lands in a terminal and a CI log.
25
25
  */
26
- import type { FromEnv } from './write-only.ts';
26
+ import type { FromEnv } from '../secrets/write-only.ts';
27
27
 
28
28
  /** `{{ secrets.x }}`, `{{secrets.x}}`, `{{ url-encode secrets.x }}` — a reference, not a value. */
29
29
  const READS_SECRET = /\{\{[^}]*\bsecrets\./;
@@ -241,4 +241,4 @@ export type NodesNodeDisksZfsPostParams = {
241
241
  export type NodesNodeDisksZfsPostReturn = string;
242
242
 
243
243
  /** GET /nodes/{node}/disks/zfs/{name} — `data` payload after client unwrap. */
244
- export type NodesNodeDisksZfsNameGetReturn = {};
244
+ export type NodesNodeDisksZfsNameGetReturn = Record<string, unknown>;
@@ -13,6 +13,13 @@
13
13
  * ⚠️ 151 request parameters are typed `\`${number}\`` because the vendor calls them
14
14
  * integer or number. They were `string` before, which accepted 'banana'. A caller holding a
15
15
  * number writes \`${n}\` — `String(n)` is a plain `string` and will not typecheck, deliberately.
16
+ *
17
+ * ⛔ 135 OF THESE NAMES ARE ALSO EXPORTED BY `pve.ts`, MEANING SOMETHING ELSE. Both
18
+ * products document a `/nodes/{node}` subtree, so a name like `NodesNodeCertificatesGetReturn`
19
+ * exists on each side — here an array of objects, there `null`. Nothing stops a pve call
20
+ * importing the pbs spelling: it compiles, and the type is simply wrong about the payload.
21
+ * Import from the barrel that names your product. tests/schema-types.test.ts pins this count, so
22
+ * a vendor upgrade that adds a collision fails there rather than at runtime.
16
23
  */
17
24
  export * from './pbs/access.ts';
18
25
  export * from './pbs/access-users.ts';
@@ -13,6 +13,13 @@
13
13
  * ⚠️ 480 request parameters are typed `\`${number}\`` because the vendor calls them
14
14
  * integer or number. They were `string` before, which accepted 'banana'. A caller holding a
15
15
  * number writes \`${n}\` — `String(n)` is a plain `string` and will not typecheck, deliberately.
16
+ *
17
+ * ⛔ 135 OF THESE NAMES ARE ALSO EXPORTED BY `pbs.ts`, MEANING SOMETHING ELSE. Both
18
+ * products document a `/nodes/{node}` subtree, so a name like `NodesNodeCertificatesGetReturn`
19
+ * exists on each side — here an array of objects, there `null`. Nothing stops a pbs call
20
+ * importing the pve spelling: it compiles, and the type is simply wrong about the payload.
21
+ * Import from the barrel that names your product. tests/schema-types.test.ts pins this count, so
22
+ * a vendor upgrade that adds a collision fails there rather than at runtime.
16
23
  */
17
24
  export * from './pve/access.ts';
18
25
  export * from './pve/access-roles.ts';
@@ -104,5 +104,5 @@ export { ProxmoxSdnVnet, ProxmoxSdnVnetProvider } from './sdn-vnet.ts';
104
104
  export { ProxmoxSdnZone, ProxmoxSdnZoneProvider } from './sdn-zone.ts';
105
105
  export { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
106
106
  export { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
107
- export { type FromEnv } from './write-only.ts';
107
+ export { type FromEnv } from '../secrets/write-only.ts';
108
108
  export { ProxmoxZfsPoolProvider } from './zfs-pool.ts';
@@ -25,7 +25,7 @@ import type {
25
25
  PbsNotificationTargetProps,
26
26
  } from './pbs-notification-target.ts';
27
27
  import { base64, headerDigest, names, wireItem } from './pbs-notification-target-wire.ts';
28
- import { type Environment, type FromEnv, resolveAll, sealMatches } from './write-only.ts';
28
+ import { type Environment, type FromEnv, resolveAll, sealMatches } from '../secrets/write-only.ts';
29
29
 
30
30
  type Props = PbsNotificationTargetProps;
31
31
  type Field = keyof Props;
@@ -32,7 +32,7 @@ import {
32
32
  targetForm,
33
33
  } from './pbs-notification-target-form.ts';
34
34
  import { targetAttributes } from './pbs-notification-target-wire.ts';
35
- import { seal } from './write-only.ts';
35
+ import { seal } from '../secrets/write-only.ts';
36
36
 
37
37
  type Props = PbsNotificationTargetProps;
38
38
  type Attributes = PbsNotificationTargetAttributes;
@@ -23,7 +23,7 @@ import type {
23
23
  PbsNotificationTargetProps,
24
24
  } from './pbs-notification-target.ts';
25
25
  import { bool, text } from './values.ts';
26
- import { seal } from './write-only.ts';
26
+ import { seal } from '../secrets/write-only.ts';
27
27
 
28
28
  export const base64 = (value: string): string => Buffer.from(value, 'utf8').toString('base64');
29
29
 
@@ -31,7 +31,7 @@ import * as Provider from 'alchemy/Provider';
31
31
  import * as Effect from 'effect/Effect';
32
32
  import { handlers } from './pbs-notification-target-lifecycle.ts';
33
33
  import type { PveRequirements, WithPbsTarget } from './resource.ts';
34
- import type { FromEnv } from './write-only.ts';
34
+ import type { FromEnv } from '../secrets/write-only.ts';
35
35
 
36
36
  export type PbsNotificationTargetType = 'sendmail' | 'smtp' | 'webhook';
37
37
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Adoption through Alchemy's OWN Plan and Apply (openbao/fake-stack.ts): a file already at the path,
3
+ * and a stack holding no state for it. What binary-claim.ts decides, measured end to end — the plan
4
+ * and the apply must give the same answer, and `adopted` must never stand for other bytes.
5
+ *
6
+ * 🔴 EVERY REFUSAL BELOW WAS A SILENT WRITE OR LOSS BEFORE 2026-09-22 (adversarial review of PR 138,
7
+ * lens: adoption and plan parity), each measured with this harness against the code before the
8
+ * fix: other bytes under `--adopt` planned `adopted` (resolved) or `create` (directory an Output)
9
+ * and were downloaded over in place; a symlink planned `adopted` and was refused only at apply;
10
+ * a renamed declaration claimed the identical file at apply and its old name's delete removed it.
11
+ */
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { BINARY } from './fake-release.ts';
14
+ import { DIR, PATH, THEIRS, deployHarness as harness } from './fake-deploy.ts';
15
+
16
+ describe('a file already at the path, with no state', () => {
17
+ test('OTHER bytes under --adopt, probed: the plan refuses; nothing fetched, their file kept', async () => {
18
+ const h = harness(true);
19
+ h.place(THEIRS);
20
+ await expect(h.stack.deploy(h.literal, { adopt: true })).rejects.toThrow(
21
+ '--adopt takes over only the pinned binary',
22
+ );
23
+ expect(h.events).toEqual([]);
24
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(THEIRS);
25
+ });
26
+
27
+ test('OTHER bytes under --adopt, the directory an Output: refused at apply, before the download', async () => {
28
+ const h = harness(true);
29
+ h.place(THEIRS);
30
+ await expect(h.stack.deploy(h.wired(), { adopt: true })).rejects.toThrow(
31
+ '--adopt takes over only the pinned binary',
32
+ );
33
+ expect(h.events.filter((e) => e.startsWith('GET ') || e.startsWith('write '))).toEqual([]);
34
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(THEIRS);
35
+ });
36
+
37
+ test('a symlink under --adopt: refused by the PLAN, not first by the apply', async () => {
38
+ const h = harness(true);
39
+ h.place(new Uint8Array(), 0o755, 'symlink');
40
+ // ★ The plan-side wording; the apply's own would be "is a symlink".
41
+ await expect(h.stack.deploy(h.literal, { adopt: true })).rejects.toThrow(
42
+ 'something that is not a regular file',
43
+ );
44
+ expect(h.events).toEqual([]);
45
+ });
46
+
47
+ test('the pinned bytes with a wrong mode, under --adopt: adopted, re-written from disk, never fetched', async () => {
48
+ const h = harness(true);
49
+ h.place(BINARY.vmalert, 0o775);
50
+ expect(await h.stack.deploy(h.literal, { adopt: true })).toEqual({ vmalert: 'adopted' });
51
+ expect(h.events).toEqual([`write ${PATH}`]);
52
+ expect(h.fake.files.get(PATH)?.mode).toBe(0o755);
53
+ });
54
+
55
+ test('the pinned bytes, the directory an Output, under --adopt: kept, never fetched', async () => {
56
+ const h = harness(true);
57
+ h.place(BINARY.vmalert);
58
+ const planned = await h.stack.deploy(h.wired(), { adopt: true });
59
+ expect(planned).toEqual({ dir: 'adopted', vmalert: 'create' });
60
+ expect(h.events).toEqual([]);
61
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
62
+ });
63
+
64
+ test('a renamed declaration while its directory changes: refused at apply, the binary kept', async () => {
65
+ const h = harness(false);
66
+ await h.stack.deploy(h.wired('vmalert'));
67
+ h.events.length = 0;
68
+ // The directory's mode change leaves `dir.path` an Output at plan, so nothing probes the path.
69
+ await expect(h.stack.deploy(h.wired('vmalert-rules', 0o750))).rejects.toThrow(
70
+ 'it holds the pinned binary, but this stack holds no state for it',
71
+ );
72
+ // 🔴 Before: planned {dir: update, vmalert-rules: create, vmalert: delete}; the new name took
73
+ // the identical file as its own, the old name's delete removed it, and the deploy succeeded.
74
+ expect(h.events).not.toContain(`remove ${PATH}`);
75
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
76
+ });
77
+
78
+ test('the same rename declared with renamedFrom (the remedy the refusal names): the binary kept', async () => {
79
+ const h = harness(false);
80
+ await h.stack.deploy(h.wired('vmalert'));
81
+ h.events.length = 0;
82
+ const planned = await h.stack.deploy(h.wired('vmalert-rules', 0o750, 'vmalert'));
83
+ expect(planned).toEqual({ dir: 'update', 'vmalert-rules': 'update' });
84
+ expect(h.events).toEqual([`chmod 750 ${DIR}`]);
85
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
86
+ });
87
+
88
+ test('the same rename under --adopt: the new name takes the file, the old name leaves it', async () => {
89
+ const h = harness(false);
90
+ await h.stack.deploy(h.wired('vmalert'));
91
+ h.events.length = 0;
92
+ const planned = await h.stack.deploy(h.wired('vmalert-rules', 0o750), { adopt: true });
93
+ expect(planned).toEqual({ dir: 'update', vmalert: 'delete', 'vmalert-rules': 'create' });
94
+ // 🔴 Before 2026-09-22 (release gate): the old name's orphan delete ran `remove ${PATH}` after
95
+ // the new name had claimed it, and the deploy succeeded with the binary gone.
96
+ expect(h.events).toEqual([`chmod 750 ${DIR}`]);
97
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
98
+ // The new name owns it now: the next deploy of the same declaration changes nothing.
99
+ h.events.length = 0;
100
+ expect(await h.stack.deploy(h.wired('vmalert-rules', 0o750))).toEqual({
101
+ dir: 'noop',
102
+ 'vmalert-rules': 'noop',
103
+ });
104
+ expect(h.events).toEqual([]);
105
+ });
106
+ });