@homeflare/alchemy 0.19.1 → 0.21.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 (198) hide show
  1. package/README.md +25 -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.js +173 -120
  24. package/dist/launchd/index.js.map +12 -10
  25. package/dist/launchd/local-runner.d.ts.map +1 -1
  26. package/dist/launchd/runner.d.ts +17 -2
  27. package/dist/launchd/runner.d.ts.map +1 -1
  28. package/dist/launchd/sudo-runner.d.ts +11 -0
  29. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  30. package/dist/linux/index.js +37 -11
  31. package/dist/linux/index.js.map +10 -10
  32. package/dist/linux/region.d.ts +11 -0
  33. package/dist/linux/region.d.ts.map +1 -1
  34. package/dist/linux/remote-file-lifecycle.d.ts.map +1 -1
  35. package/dist/linux/remote-file-plan.d.ts +22 -0
  36. package/dist/linux/remote-file-plan.d.ts.map +1 -1
  37. package/dist/linux/remote-file.d.ts +4 -1
  38. package/dist/linux/remote-file.d.ts.map +1 -1
  39. package/dist/linux/sudo-listing.d.ts +47 -0
  40. package/dist/linux/sudo-listing.d.ts.map +1 -0
  41. package/dist/linux/unit-handlers.d.ts.map +1 -1
  42. package/dist/linux/unit-preflight.d.ts +10 -1
  43. package/dist/linux/unit-preflight.d.ts.map +1 -1
  44. package/dist/proxmox/credential-literals.d.ts +1 -1
  45. package/dist/proxmox/credential-literals.d.ts.map +1 -1
  46. package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
  47. package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
  48. package/dist/proxmox/generated/pbs.d.ts +7 -0
  49. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  50. package/dist/proxmox/generated/pve.d.ts +7 -0
  51. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  52. package/dist/proxmox/index.d.ts +16 -4
  53. package/dist/proxmox/index.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +5 -2
  55. package/dist/proxmox/index.js.map +9 -9
  56. package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
  57. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
  58. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  59. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
  60. package/dist/release/archive.d.ts +11 -0
  61. package/dist/release/archive.d.ts.map +1 -0
  62. package/dist/release/binary-claim.d.ts +53 -0
  63. package/dist/release/binary-claim.d.ts.map +1 -0
  64. package/dist/release/binary-diff.d.ts +38 -0
  65. package/dist/release/binary-diff.d.ts.map +1 -0
  66. package/dist/release/binary-form.d.ts +118 -0
  67. package/dist/release/binary-form.d.ts.map +1 -0
  68. package/dist/release/binary-lifecycle.d.ts +55 -0
  69. package/dist/release/binary-lifecycle.d.ts.map +1 -0
  70. package/dist/release/binary-read.d.ts +11 -0
  71. package/dist/release/binary-read.d.ts.map +1 -0
  72. package/dist/release/binary.d.ts +68 -0
  73. package/dist/release/binary.d.ts.map +1 -0
  74. package/dist/release/catalog.d.ts +76 -0
  75. package/dist/release/catalog.d.ts.map +1 -0
  76. package/dist/release/checksums.d.ts +22 -0
  77. package/dist/release/checksums.d.ts.map +1 -0
  78. package/dist/release/declared-pins.d.ts +4 -0
  79. package/dist/release/declared-pins.d.ts.map +1 -0
  80. package/dist/release/download.d.ts +44 -0
  81. package/dist/release/download.d.ts.map +1 -0
  82. package/dist/release/fake-deploy.d.ts +48 -0
  83. package/dist/release/fake-deploy.d.ts.map +1 -0
  84. package/dist/release/fake-engine.d.ts +8 -0
  85. package/dist/release/fake-engine.d.ts.map +1 -0
  86. package/dist/release/fake-install.d.ts +39 -0
  87. package/dist/release/fake-install.d.ts.map +1 -0
  88. package/dist/release/fake-provider.d.ts +58 -0
  89. package/dist/release/fake-provider.d.ts.map +1 -0
  90. package/dist/release/fake-release.d.ts +95 -0
  91. package/dist/release/fake-release.d.ts.map +1 -0
  92. package/dist/release/index.d.ts +24 -0
  93. package/dist/release/index.d.ts.map +1 -0
  94. package/dist/release/index.js +1365 -0
  95. package/dist/release/index.js.map +35 -0
  96. package/dist/release/providers.d.ts +19 -0
  97. package/dist/release/providers.d.ts.map +1 -0
  98. package/dist/release/refused.d.ts +39 -0
  99. package/dist/release/refused.d.ts.map +1 -0
  100. package/dist/release/tar.d.ts +14 -0
  101. package/dist/release/tar.d.ts.map +1 -0
  102. package/dist/release/victoria.d.ts +36 -0
  103. package/dist/release/victoria.d.ts.map +1 -0
  104. package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
  105. package/docs/adopted-deploys.md +13 -0
  106. package/docs/linux-host.md +5 -1
  107. package/docs/provider-standard.md +138 -0
  108. package/docs/release-binary-catalogs.md +113 -0
  109. package/docs/release-binary-measured.md +163 -0
  110. package/docs/release-binary-refusals.md +57 -0
  111. package/docs/release-binary-upstream.md +163 -0
  112. package/docs/release-binary.md +154 -0
  113. package/docs/upstream-conformance.md +164 -0
  114. package/package.json +5 -1
  115. package/src/caddy/access-forward-auth.test.ts +63 -0
  116. package/src/caddy/access-forward-auth.ts +120 -0
  117. package/src/caddy/index.ts +10 -0
  118. package/src/caddy/providers.test.ts +4 -0
  119. package/src/cloudflare/mcp-server-api.ts +294 -0
  120. package/src/cloudflare/mcp-server-form.ts +219 -0
  121. package/src/index.ts +1 -0
  122. package/src/launchd/file-converge.ts +233 -0
  123. package/src/launchd/file-identity.ts +69 -0
  124. package/src/launchd/host-file-alias.test.ts +65 -0
  125. package/src/launchd/host-file-form.ts +2 -1
  126. package/src/launchd/host-file-lifecycle.ts +25 -144
  127. package/src/launchd/host-file.ts +8 -2
  128. package/src/launchd/local-runner.test.ts +12 -1
  129. package/src/launchd/local-runner.ts +2 -0
  130. package/src/launchd/runner.ts +17 -2
  131. package/src/launchd/sudo-runner.ts +11 -0
  132. package/src/linux/region.ts +15 -0
  133. package/src/linux/remote-file-lifecycle.test.ts +0 -88
  134. package/src/linux/remote-file-lifecycle.ts +25 -7
  135. package/src/linux/remote-file-plan.ts +37 -1
  136. package/src/linux/remote-file-region.test.ts +181 -0
  137. package/src/linux/remote-file.ts +4 -1
  138. package/src/linux/sudo-listing.ts +122 -0
  139. package/src/linux/unit-handlers.ts +12 -5
  140. package/src/linux/unit-preflight.ts +20 -4
  141. package/src/linux/unit-rename.test.ts +76 -0
  142. package/src/proxmox/ceph-adopt.test.ts +210 -0
  143. package/src/proxmox/credential-literals.ts +1 -1
  144. package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
  145. package/src/proxmox/generated/pbs.ts +7 -0
  146. package/src/proxmox/generated/pve.ts +7 -0
  147. package/src/proxmox/index.test.ts +7 -4
  148. package/src/proxmox/index.ts +16 -4
  149. package/src/proxmox/pbs-notification-target-form.ts +1 -1
  150. package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
  151. package/src/proxmox/pbs-notification-target-wire.ts +1 -1
  152. package/src/proxmox/pbs-notification-target.ts +1 -1
  153. package/src/release/adopt-parity.test.ts +106 -0
  154. package/src/release/apply-pins.test.ts +128 -0
  155. package/src/release/archive.ts +94 -0
  156. package/src/release/binary-alias.test.ts +109 -0
  157. package/src/release/binary-claim.ts +80 -0
  158. package/src/release/binary-claims.test.ts +47 -0
  159. package/src/release/binary-diff.ts +73 -0
  160. package/src/release/binary-form.ts +229 -0
  161. package/src/release/binary-lifecycle.test.ts +90 -0
  162. package/src/release/binary-lifecycle.ts +202 -0
  163. package/src/release/binary-read.ts +55 -0
  164. package/src/release/binary-refusals.test.ts +192 -0
  165. package/src/release/binary-sudo.test.ts +93 -0
  166. package/src/release/binary.ts +181 -0
  167. package/src/release/catalog.test.ts +96 -0
  168. package/src/release/catalog.ts +175 -0
  169. package/src/release/checksums.test.ts +35 -0
  170. package/src/release/checksums.ts +43 -0
  171. package/src/release/declared-pins.ts +39 -0
  172. package/src/release/download.test.ts +138 -0
  173. package/src/release/download.ts +110 -0
  174. package/src/release/error-channel.test.ts +69 -0
  175. package/src/release/fake-deploy.ts +87 -0
  176. package/src/release/fake-engine.ts +50 -0
  177. package/src/release/fake-install.ts +59 -0
  178. package/src/release/fake-provider.ts +68 -0
  179. package/src/release/fake-release.ts +185 -0
  180. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
  181. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
  182. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
  183. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
  184. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
  185. package/src/release/index.ts +41 -0
  186. package/src/release/plan.test.ts +189 -0
  187. package/src/release/provider-refusals.test.ts +48 -0
  188. package/src/release/providers.test.ts +174 -0
  189. package/src/release/providers.ts +24 -0
  190. package/src/release/recovery-read.test.ts +44 -0
  191. package/src/release/refused.ts +40 -0
  192. package/src/release/tar.test.ts +142 -0
  193. package/src/release/tar.ts +195 -0
  194. package/src/release/victoria.test.ts +108 -0
  195. package/src/release/victoria.ts +154 -0
  196. package/src/{proxmox → secrets}/write-only.ts +6 -0
  197. /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
  198. /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
@@ -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.
@@ -34,6 +34,21 @@ const tokens = (spec: RegionSpec) => {
34
34
  };
35
35
  };
36
36
 
37
+ /**
38
+ * Whether two declarations name the SAME block.
39
+ *
40
+ * ⛔ THE COMMENT TOKEN IS PART OF THE IDENTITY, not a formatting preference. The markers are
41
+ * matched as literal whole lines, so `# BEGIN pf` and `// BEGIN pf` are two different blocks:
42
+ * changing the token leaves the old block invisible to every later read, exactly as renaming
43
+ * does. A resource that treated either as a mere content change would splice a second block in
44
+ * and never be able to find the first one again.
45
+ * ★ Whole-file mode is its own identity: `undefined` equals only `undefined`.
46
+ */
47
+ export const sameRegion = (a: RegionSpec | undefined, b: RegionSpec | undefined): boolean =>
48
+ a === undefined || b === undefined
49
+ ? a === undefined && b === undefined
50
+ : a.name === b.name && (a.comment ?? DEFAULT_COMMENT) === (b.comment ?? DEFAULT_COMMENT);
51
+
37
52
  export const regionProblems = (spec: RegionSpec): string[] => {
38
53
  const found: string[] = [];
39
54
  const comment = spec.comment ?? DEFAULT_COMMENT;
@@ -4,7 +4,6 @@
4
4
  */
5
5
  import { describe, expect, test } from 'bun:test';
6
6
  import { fakeLinuxHost } from './fake-linux-host.ts';
7
- import { fileProblems } from './remote-file-form.ts';
8
7
  import {
9
8
  deleteFile,
10
9
  diffFile,
@@ -89,93 +88,6 @@ describe('whole-file mode', () => {
89
88
  });
90
89
  });
91
90
 
92
- describe('region mode', () => {
93
- const VENDOR = '# vendor ruleset\nanchor "system"\nrule one\n';
94
- const region = { name: 'homeflare' };
95
- const block = { content: 'anchor "declared"\n', path: '/etc/vendor.conf', region };
96
-
97
- const withVendor = () => {
98
- const fake = host();
99
- fake.files.set('/etc/vendor.conf', {
100
- bytes: new TextEncoder().encode(VENDOR),
101
- gid: 0,
102
- kind: 'file',
103
- mode: 0o600,
104
- uid: 0,
105
- });
106
- return fake;
107
- };
108
-
109
- test('editing the block leaves the rest of the file byte-identical', async () => {
110
- const fake = withVendor();
111
- const output = await reconcileFile(fake.runner, block);
112
- const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
113
- expect(after.startsWith(VENDOR)).toBe(true);
114
- await reconcileFile(fake.runner, { ...block, content: 'anchor "changed"\n' }, output);
115
- expect(decode(fake.files.get('/etc/vendor.conf')?.bytes).startsWith(VENDOR)).toBe(true);
116
- });
117
-
118
- test("the other owner's mode and uid are preserved, never re-declared", async () => {
119
- const fake = withVendor();
120
- await reconcileFile(fake.runner, block);
121
- expect(fake.files.get('/etc/vendor.conf')?.mode).toBe(0o600);
122
- });
123
-
124
- test('a change anywhere else in the file is not this resource’s drift', async () => {
125
- const fake = withVendor();
126
- const output = await reconcileFile(fake.runner, block);
127
- const edited = `${decode(fake.files.get('/etc/vendor.conf')?.bytes)}rule two\n`;
128
- fake.files.set('/etc/vendor.conf', {
129
- bytes: new TextEncoder().encode(edited),
130
- gid: 0,
131
- kind: 'file',
132
- mode: 0o600,
133
- uid: 0,
134
- });
135
- expect(await diffFile(fake.runner, block, output)).toEqual({ action: 'noop' });
136
- });
137
-
138
- test('delete removes only the block', async () => {
139
- const fake = withVendor();
140
- const output = await reconcileFile(fake.runner, block);
141
- await deleteFile(fake.runner, output);
142
- expect(decode(fake.files.get('/etc/vendor.conf')?.bytes)).toBe(VENDOR);
143
- });
144
-
145
- test('a missing file is a refusal, because a typo is likelier than an intention', async () => {
146
- const fake = host();
147
- await expect(
148
- reconcileFile(fake.runner, { ...block, path: '/etc/example/absent.conf' }),
149
- ).rejects.toThrow(/does not create the file it lives in/);
150
- const created = await reconcileFile(fake.runner, {
151
- ...block,
152
- create: true,
153
- path: '/etc/example/absent.conf',
154
- });
155
- expect(created.region).toEqual(region);
156
- });
157
-
158
- test('mode and owner without create are refused at validation', () => {
159
- expect(fileProblems({ ...block, mode: 0o600 })).toHaveLength(1);
160
- expect(fileProblems({ ...block, create: true, mode: 0o600 })).toEqual([]);
161
- });
162
-
163
- test('a block already there with other content needs --adopt', async () => {
164
- const fake = withVendor();
165
- fake.files.set('/etc/vendor.conf', {
166
- bytes: new TextEncoder().encode(
167
- `${VENDOR}# BEGIN homeflare\nsomething else\n# END homeflare\n`,
168
- ),
169
- gid: 0,
170
- kind: 'file',
171
- mode: 0o600,
172
- uid: 0,
173
- });
174
- await expect(reconcileFile(fake.runner, block)).rejects.toThrow(/managed region/);
175
- await expect(reconcileFile(fake.runner, block, undefined, true)).resolves.toBeDefined();
176
- });
177
- });
178
-
179
91
  describe('a failed read refuses', () => {
180
92
  test('a stat that throws is never read as “nothing is there”', async () => {
181
93
  const fake = host();
@@ -28,6 +28,7 @@ import {
28
28
  planWrite,
29
29
  readFileAttributes,
30
30
  refuse,
31
+ relocation,
31
32
  } from './remote-file-plan.ts';
32
33
 
33
34
  export { readFileAttributes } from './remote-file-plan.ts';
@@ -42,7 +43,8 @@ export const diffFile = async (
42
43
  output: RemoteFileAttributes,
43
44
  ): Promise<Diff> => {
44
45
  const want = await identity(runner, news);
45
- if (news.path !== output.path) {
46
+ // ⛔ A mode flip throws HERE, at plan time, before anything is written (remote-file-plan.ts).
47
+ if (relocation(news, output) === 'path') {
46
48
  // ★ Create-before-delete: two paths hold two files at once, so nothing forces deleteFirst.
47
49
  await runner.checkWrite?.(news.path, { mode: news.mode ?? DEFAULT_MODE, ...want });
48
50
  return { action: 'replace' };
@@ -80,8 +82,13 @@ export const reconcileFile = async (
80
82
  'a root destination or a privileged HostRunner. This provider never calls sudo itself.',
81
83
  );
82
84
  }
83
- const moved = output !== undefined && output.path !== props.path;
84
- const prior = moved ? undefined : output;
85
+ /**
86
+ * ⛔ WHAT WE OWNED LAST TIME MAY NOT BE WHAT WE OWN NOW. A new path, or a new region name or
87
+ * comment token, means the old block or file is still out there and must be taken back — and
88
+ * a flip between the two modes is refused outright (remote-file-plan.ts relocation).
89
+ */
90
+ const move = output === undefined ? 'none' : relocation(props, output);
91
+ const prior = move === 'none' ? output : undefined;
85
92
  const planned = await planWrite(runner, props, want);
86
93
  const live = ownedDigest(planned.before, props.region);
87
94
  const declared = digestOf(props.content);
@@ -127,10 +134,21 @@ export const reconcileFile = async (
127
134
  'the write returned but the file on disk does not match the declaration',
128
135
  );
129
136
  }
130
- // ★ Create-before-delete, as the replace would have been: the old path goes once the new one is
131
- // written and verified.
132
- if (moved && output !== undefined) await deleteFile(runner, output);
133
- return after;
137
+ // ★ Create-before-delete, as the replace would have been: the old path — or, for a rename inside
138
+ // one file, the old block — goes once the new one is written and verified.
139
+ if (move === 'none' || output === undefined) return after;
140
+ await deleteFile(runner, output);
141
+ if (move === 'path') return after;
142
+ /**
143
+ * ⛔ A REGION RENAME TOOK THE OLD BLOCK OUT OF THIS SAME FILE, so `after` — read before that
144
+ * removal — describes bytes that no longer exist. Storing it would make the whole-file digest
145
+ * wrong from the moment it was written and every later plan would read it as drift.
146
+ */
147
+ const settled = await readFileAttributes(runner, props.path, props.region);
148
+ if (settled === undefined || settled.contentSha256 !== declared) {
149
+ throw refuse(props.path, 'removing the previous managed block did not leave ours intact');
150
+ }
151
+ return settled;
134
152
  };
135
153
 
136
154
  /**
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { sha256Hex } from '../launchd/job-form.ts';
10
10
  import type { FileStat, HostRunner, WriteOptions } from '../launchd/runner.ts';
11
- import { type RegionSpec, readRegion, spliceRegion } from './region.ts';
11
+ import { type RegionSpec, readRegion, sameRegion, spliceRegion } from './region.ts';
12
12
  import {
13
13
  DEFAULT_MODE,
14
14
  type RemoteFileAttributes,
@@ -46,6 +46,42 @@ export const identity = async (runner: HostRunner, props: RemoteFileProps): Prom
46
46
 
47
47
  export const digestOf = (text: string): string => sha256Hex(encoder.encode(text));
48
48
 
49
+ /** What this declaration now owns, compared with what the last deploy owned. */
50
+ export type Move = 'none' | 'path' | 'region';
51
+
52
+ /**
53
+ * Has the thing this resource owns MOVED since the last deploy — and if so, how?
54
+ *
55
+ * 🔴 WHY THIS EXISTS (found by review, reproduced against the fake host, 2026-09-22). Without it,
56
+ * only `path` was identity and both of these read as a routine `update`:
57
+ * - RENAMING THE REGION left the old block in the file FOREVER. The new markers were spliced in,
58
+ * the old ones were never touched, and `delete` — which can only look for the name in state,
59
+ * now the new one — could never remove them. Two `anchor` lines in a packet filter, two
60
+ * entries in a host table, and nothing in the stack able to take either back.
61
+ * - DROPPING `region` TOOK OVER THE WHOLE FILE. The plan said `update`; the apply replaced every
62
+ * byte of a file belonging to a vendor package with this resource's four lines. That is the
63
+ * one thing the managed-region design exists to make impossible.
64
+ * ⛔ A FLIP BETWEEN THE TWO MODES IS A REFUSAL, NOT A MOVE. Neither order is safe: writing the
65
+ * whole file first destroys the other owner's bytes before anything can be undone, and removing
66
+ * the block first destroys our own claim and then refuses. The operator destroys the resource
67
+ * and declares a new one, which is the only sequence with a reviewable diff at each step.
68
+ * ★ A region-to-region rename IS safe as a move, because both blocks can exist at once: write the
69
+ * new one, verify it, then take the old one out (remote-file-lifecycle.ts reconcileFile).
70
+ */
71
+ export const relocation = (props: RemoteFileProps, output: RemoteFileAttributes): Move => {
72
+ if (props.path !== output.path) return 'path';
73
+ if (sameRegion(props.region, output.region)) return 'none';
74
+ if (props.region === undefined || output.region === undefined) {
75
+ throw refuse(
76
+ props.path,
77
+ `changes between owning the whole file and owning the managed region ` +
78
+ `${JSON.stringify((props.region ?? output.region)?.name ?? '')} at the same path. Those ` +
79
+ 'are two different claims on one file: destroy this resource and declare a new one.',
80
+ );
81
+ }
82
+ return 'region';
83
+ };
84
+
49
85
  /** The digest of the part this resource owns, or `undefined` when that part is not there. */
50
86
  export const ownedDigest = (
51
87
  file: string | undefined,
@@ -0,0 +1,181 @@
1
+ /**
2
+ * RemoteFile in MANAGED REGION mode against the fake Linux host: the promise that every byte
3
+ * outside the markers survives, and the identity rules that keep it true when a declaration
4
+ * changes. Split from remote-file-lifecycle.test.ts, which covers whole-file mode.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { fakeLinuxHost } from './fake-linux-host.ts';
8
+ import { fileProblems } from './remote-file-form.ts';
9
+ import {
10
+ deleteFile,
11
+ diffFile,
12
+ readFileAttributes,
13
+ reconcileFile,
14
+ } from './remote-file-lifecycle.ts';
15
+
16
+ const decode = (bytes: Uint8Array | undefined) => new TextDecoder().decode(bytes);
17
+
18
+ const host = (euid = 0) =>
19
+ fakeLinuxHost({
20
+ dirs: { '/etc': 0, '/etc/example': 0, '/home/someone': 1001 },
21
+ euid,
22
+ groups: { root: 0, staff: 50 },
23
+ users: {
24
+ root: { gid: 0, home: '/root', uid: 0 },
25
+ someone: { gid: 1001, home: '/home/someone', uid: 1001 },
26
+ },
27
+ });
28
+
29
+ describe('region mode', () => {
30
+ const VENDOR = '# vendor ruleset\nanchor "system"\nrule one\n';
31
+ const region = { name: 'homeflare' };
32
+ const block = { content: 'anchor "declared"\n', path: '/etc/vendor.conf', region };
33
+
34
+ const withVendor = () => {
35
+ const fake = host();
36
+ fake.files.set('/etc/vendor.conf', {
37
+ bytes: new TextEncoder().encode(VENDOR),
38
+ gid: 0,
39
+ kind: 'file',
40
+ mode: 0o600,
41
+ uid: 0,
42
+ });
43
+ return fake;
44
+ };
45
+
46
+ test('editing the block leaves the rest of the file byte-identical', async () => {
47
+ const fake = withVendor();
48
+ const output = await reconcileFile(fake.runner, block);
49
+ const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
50
+ expect(after.startsWith(VENDOR)).toBe(true);
51
+ await reconcileFile(fake.runner, { ...block, content: 'anchor "changed"\n' }, output);
52
+ expect(decode(fake.files.get('/etc/vendor.conf')?.bytes).startsWith(VENDOR)).toBe(true);
53
+ });
54
+
55
+ test("the other owner's mode and uid are preserved, never re-declared", async () => {
56
+ const fake = withVendor();
57
+ await reconcileFile(fake.runner, block);
58
+ expect(fake.files.get('/etc/vendor.conf')?.mode).toBe(0o600);
59
+ });
60
+
61
+ test('a change anywhere else in the file is not this resource’s drift', async () => {
62
+ const fake = withVendor();
63
+ const output = await reconcileFile(fake.runner, block);
64
+ const edited = `${decode(fake.files.get('/etc/vendor.conf')?.bytes)}rule two\n`;
65
+ fake.files.set('/etc/vendor.conf', {
66
+ bytes: new TextEncoder().encode(edited),
67
+ gid: 0,
68
+ kind: 'file',
69
+ mode: 0o600,
70
+ uid: 0,
71
+ });
72
+ expect(await diffFile(fake.runner, block, output)).toEqual({ action: 'noop' });
73
+ });
74
+
75
+ test('delete removes only the block', async () => {
76
+ const fake = withVendor();
77
+ const output = await reconcileFile(fake.runner, block);
78
+ await deleteFile(fake.runner, output);
79
+ expect(decode(fake.files.get('/etc/vendor.conf')?.bytes)).toBe(VENDOR);
80
+ });
81
+
82
+ test('a missing file is a refusal, because a typo is likelier than an intention', async () => {
83
+ const fake = host();
84
+ await expect(
85
+ reconcileFile(fake.runner, { ...block, path: '/etc/example/absent.conf' }),
86
+ ).rejects.toThrow(/does not create the file it lives in/);
87
+ const created = await reconcileFile(fake.runner, {
88
+ ...block,
89
+ create: true,
90
+ path: '/etc/example/absent.conf',
91
+ });
92
+ expect(created.region).toEqual(region);
93
+ });
94
+
95
+ /**
96
+ * 🔴 BOTH OF THESE SHIPPED AS `update` AND WERE FOUND BY REVIEW (2026-09-22). They are the two
97
+ * ways the region promise could be broken from an ordinary edit to a stack file.
98
+ */
99
+ test('renaming the region moves the block; the old markers do not survive', async () => {
100
+ const fake = withVendor();
101
+ const output = await reconcileFile(fake.runner, block);
102
+ const renamed = { ...block, region: { name: 'homeflare-mcp' } };
103
+ // ⛔ An update, not a noop: the new block is not in the file yet.
104
+ expect(await diffFile(fake.runner, renamed, output)).toEqual({ action: 'update' });
105
+ const moved = await reconcileFile(fake.runner, renamed, output);
106
+ const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
107
+ expect(after.startsWith(VENDOR)).toBe(true);
108
+ expect(after).not.toContain('BEGIN homeflare\n');
109
+ expect(after).toContain('BEGIN homeflare-mcp');
110
+ // ⛔ The stored digest must describe the file AFTER the old block was taken out.
111
+ const live = await readFileAttributes(fake.runner, '/etc/vendor.conf', renamed.region);
112
+ expect(moved.sha256).toBe(live?.sha256 ?? '');
113
+ await deleteFile(fake.runner, moved);
114
+ expect(decode(fake.files.get('/etc/vendor.conf')?.bytes)).toBe(VENDOR);
115
+ });
116
+
117
+ test('changing the comment token is a rename, not a reformat', async () => {
118
+ const fake = withVendor();
119
+ const output = await reconcileFile(fake.runner, block);
120
+ const retokened = { ...block, region: { comment: '//', name: 'homeflare' } };
121
+ await reconcileFile(fake.runner, retokened, output);
122
+ const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
123
+ expect(after).toContain('// BEGIN homeflare');
124
+ expect(after).not.toContain('# BEGIN homeflare');
125
+ });
126
+
127
+ test('a file this resource borrowed is never taken over by dropping the region', async () => {
128
+ const fake = withVendor();
129
+ const output = await reconcileFile(fake.runner, block);
130
+ const whole = { content: block.content, path: block.path };
131
+ // ⛔ At plan time, before a single byte moves.
132
+ await expect(diffFile(fake.runner, whole, output)).rejects.toThrow(/two different claims/);
133
+ await expect(reconcileFile(fake.runner, whole, output)).rejects.toThrow(/two different claims/);
134
+ expect(decode(fake.files.get('/etc/vendor.conf')?.bytes).startsWith(VENDOR)).toBe(true);
135
+ });
136
+
137
+ test('adding a region to a file this resource owned whole is refused the same way', async () => {
138
+ const fake = host();
139
+ const whole = { content: 'a = 1\n', path: '/etc/example/whole.conf' };
140
+ const output = await reconcileFile(fake.runner, whole);
141
+ await expect(
142
+ diffFile(fake.runner, { ...whole, region: { name: 'homeflare' } }, output),
143
+ ).rejects.toThrow(/two different claims/);
144
+ });
145
+
146
+ test('a rename onto a block someone else already owns is refused', async () => {
147
+ const fake = withVendor();
148
+ const output = await reconcileFile(fake.runner, block);
149
+ const taken = `${decode(fake.files.get('/etc/vendor.conf')?.bytes)}# BEGIN other\nnot ours\n# END other\n`;
150
+ fake.files.set('/etc/vendor.conf', {
151
+ bytes: new TextEncoder().encode(taken),
152
+ gid: 0,
153
+ kind: 'file',
154
+ mode: 0o600,
155
+ uid: 0,
156
+ });
157
+ await expect(
158
+ reconcileFile(fake.runner, { ...block, region: { name: 'other' } }, output),
159
+ ).rejects.toThrow(/already carries that managed region/);
160
+ });
161
+
162
+ test('mode and owner without create are refused at validation', () => {
163
+ expect(fileProblems({ ...block, mode: 0o600 })).toHaveLength(1);
164
+ expect(fileProblems({ ...block, create: true, mode: 0o600 })).toEqual([]);
165
+ });
166
+
167
+ test('a block already there with other content needs --adopt', async () => {
168
+ const fake = withVendor();
169
+ fake.files.set('/etc/vendor.conf', {
170
+ bytes: new TextEncoder().encode(
171
+ `${VENDOR}# BEGIN homeflare\nsomething else\n# END homeflare\n`,
172
+ ),
173
+ gid: 0,
174
+ kind: 'file',
175
+ mode: 0o600,
176
+ uid: 0,
177
+ });
178
+ await expect(reconcileFile(fake.runner, block)).rejects.toThrow(/managed region/);
179
+ await expect(reconcileFile(fake.runner, block, undefined, true)).resolves.toBeDefined();
180
+ });
181
+ });
@@ -6,10 +6,13 @@
6
6
  * the host already says what we declare; then read back and compared.
7
7
  * - read — lstat plus the SHA-256 of the file and of the part this resource owns.
8
8
  * - diff — the declared digest against the stored AND the on-disk one, plus mode and owner.
9
- * - replace — only when `path` changes; create-before-delete.
9
+ * - replace — only when `path` changes; create-before-delete. A region renamed inside one file
10
+ * is an update that writes the new block, verifies it, then takes the old one out.
10
11
  * - delete — removes the file, or in region mode only the block.
11
12
  *
12
13
  * ⛔ NEVER A SECRET — see remote-file-form.ts.
14
+ * ⛔ THE REGION IS PART OF THE IDENTITY. Renaming it, or changing its comment token, moves the block;
15
+ * swapping between owning the whole file and owning a block is refused (remote-file-plan.ts).
13
16
  * ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt` (docs/ownership.md): a file already at the path, or a
14
17
  * block already carrying the markers, reads as `Unowned`.
15
18
  * ★ IT IS A SEPARATE RESOURCE FROM `Host.File`, not a flag on it: the two have different attributes
@@ -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
+ };
@@ -17,6 +17,7 @@ import { adoptsAtApply } from '../ownership/adopt.ts';
17
17
  import { noteUnfinished } from '../ownership/resume.ts';
18
18
  import type { SystemdUnitAttributes, SystemdUnitProps } from './unit-form.ts';
19
19
  import { deleteUnit, diffUnit, readUnit, reconcileUnit } from './unit-lifecycle.ts';
20
+ import { assertRenameTarget } from './unit-preflight.ts';
20
21
 
21
22
  export const readHandler = (
22
23
  runner: HostRunner,
@@ -46,11 +47,17 @@ export const diffHandler = (
46
47
  * when a rendered config file's digest is templated into the same deploy.
47
48
  */
48
49
  const name = resolvedString(news, 'name');
49
- return Effect.succeed(
50
- name !== undefined && name !== output.name
51
- ? { action: 'replace' as const, deleteFirst: true }
52
- : undefined,
53
- );
50
+ if (name === undefined || name === output.name) return Effect.succeed(undefined);
51
+ /**
52
+ * ⛔ THE NAME IS ENOUGH TO REFUSE A MASKED TARGET, and the refusal has to happen here: the old
53
+ * unit is removed before the new one is reconciled. The unit file cannot be compared while
54
+ * `content` is still an Output, so this is the name-only half of assertReplaceable
55
+ * (unit-preflight.ts).
56
+ */
57
+ return lift(async () => {
58
+ await assertRenameTarget(runner, output, name);
59
+ return { action: 'replace' as const, deleteFirst: true };
60
+ });
54
61
  };
55
62
 
56
63
  export const reconcileHandler = (