@celilo/cli 1.12.0 → 1.14.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 (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +21 -2
  3. package/package.json +3 -3
  4. package/src/capabilities/public-web-helpers.test.ts +12 -6
  5. package/src/capabilities/public-web-publish.test.ts +24 -13
  6. package/src/capabilities/validation.test.ts +31 -0
  7. package/src/cli/commands/alerts-list.ts +16 -1
  8. package/src/cli/commands/backup-list.test.ts +82 -1
  9. package/src/cli/commands/backup-list.ts +113 -4
  10. package/src/cli/commands/console-get-chain.test.ts +96 -0
  11. package/src/cli/commands/console.ts +130 -0
  12. package/src/cli/commands/module-list.ts +3 -41
  13. package/src/cli/commands/notify-config.test.ts +79 -0
  14. package/src/cli/commands/notify-config.ts +13 -2
  15. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  16. package/src/cli/completion.ts +6 -0
  17. package/src/cli/index.ts +31 -1
  18. package/src/console/closure.test.ts +322 -0
  19. package/src/console/closure.ts +294 -0
  20. package/src/console/control-plane-boundary.test.ts +75 -0
  21. package/src/console/projection.test.ts +293 -0
  22. package/src/console/projection.ts +364 -0
  23. package/src/db/schema.ts +19 -14
  24. package/src/hooks/broker.test.ts +4 -6
  25. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  26. package/src/hooks/capability-loader.ts +67 -10
  27. package/src/hooks/executor.test.ts +85 -4
  28. package/src/hooks/executor.ts +164 -9
  29. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  30. package/src/hooks/hook-state-dir.test.ts +14 -2
  31. package/src/hooks/hook-timeout.test.ts +2 -4
  32. package/src/hooks/hook-trespass.test.ts +50 -5
  33. package/src/hooks/jail.test.ts +370 -0
  34. package/src/hooks/jail.ts +491 -0
  35. package/src/hooks/mount-set.ts +24 -0
  36. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  37. package/src/manifest/contracts/v1.ts +22 -1
  38. package/src/manifest/schema.ts +35 -0
  39. package/src/manifest/validate.test.ts +142 -0
  40. package/src/manifest/validate.ts +126 -4
  41. package/src/module/import.test.ts +116 -0
  42. package/src/module/import.ts +73 -1
  43. package/src/module/packaging/audit.ts +103 -1
  44. package/src/module/packaging/classify-module-path.test.ts +36 -0
  45. package/src/module/packaging/package-rules.ts +18 -0
  46. package/src/module/web-root.ts +35 -0
  47. package/src/policy/capability-shape-baseline.ts +8 -0
  48. package/src/policy/module-business-baseline.ts +26 -2
  49. package/src/policy/module-script-scan.test.ts +22 -0
  50. package/src/policy/module-script-scan.ts +32 -0
  51. package/src/services/alerting/observed-health.ts +71 -0
  52. package/src/services/api-principal-enrolment.test.ts +252 -0
  53. package/src/services/api-principal-enrolment.ts +158 -0
  54. package/src/services/audit/backups.ts +10 -1
  55. package/src/services/backup-create.ts +33 -7
  56. package/src/services/backup-metadata.ts +19 -11
  57. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  58. package/src/services/consumer-cleanup.ts +31 -5
  59. package/src/services/fleet-key.test.ts +47 -0
  60. package/src/services/fleet-key.ts +75 -0
  61. package/src/services/instance-ops.test.ts +302 -0
  62. package/src/services/instance-ops.ts +292 -0
  63. package/src/services/module-instances.test.ts +428 -42
  64. package/src/services/module-instances.ts +219 -26
  65. package/src/services/restore-from-file.ts +6 -5
  66. package/src/services/system-state-stage.test.ts +165 -0
  67. package/src/services/system-state-stage.ts +196 -0
@@ -1,9 +1,28 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
- import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import {
4
+ lstat,
5
+ mkdir,
6
+ mkdtemp,
7
+ readFile,
8
+ readlink,
9
+ rm,
10
+ symlink,
11
+ writeFile,
12
+ } from 'node:fs/promises';
3
13
  import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
14
+ import { join, resolve } from 'node:path';
5
15
  import { type DbClient, createDbClient } from '../db/client';
6
- import { deriveInstanceModuleId, ownedSystemModuleIds } from './module-instances';
16
+ import { auditModule } from '../module/packaging/audit';
17
+ import { computeFileChecksum } from '../module/packaging/checksum';
18
+ import {
19
+ buildInstanceLinkFarm,
20
+ deriveInstanceModuleId,
21
+ ownedSystemModuleIds,
22
+ planInstanceLinks,
23
+ submoduleSourcePath,
24
+ verifyInstanceLinks,
25
+ } from './module-instances';
7
26
 
8
27
  describe('deriveInstanceModuleId', () => {
9
28
  test('is deterministic, so an instantiate is safe to retry', () => {
@@ -13,30 +32,30 @@ describe('deriveInstanceModuleId', () => {
13
32
  });
14
33
 
15
34
  test('produces a valid kebab-case module id from an opaque key', () => {
16
- // celilo never interprets the key, so it may be anything at all.
35
+ // D2: celilo never interprets the key, so it may be anything at all.
17
36
  const id = deriveInstanceModuleId('byoi', 'lab', 'Sally Smith <sally@example.com> 🎉');
18
37
  expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
19
38
  expect(id.startsWith('byoi-lab-')).toBe(true);
20
39
  });
21
40
 
22
41
  test('two parents using the same key get different ids', () => {
23
- expect(deriveInstanceModuleId('byoi', 'lab', 'shared-key')).not.toBe(
24
- deriveInstanceModuleId('forgejo', 'lab', 'shared-key'),
25
- );
42
+ const a = deriveInstanceModuleId('byoi', 'lab', 'shared-key');
43
+ const b = deriveInstanceModuleId('forgejo', 'lab', 'shared-key');
44
+ expect(a).not.toBe(b);
26
45
  });
27
46
 
28
47
  test('one parent reusing a key across two submodules gets different ids', () => {
29
- expect(deriveInstanceModuleId('forgejo', 'runner', 'proj-1')).not.toBe(
30
- deriveInstanceModuleId('forgejo', 'builder', 'proj-1'),
31
- );
48
+ const a = deriveInstanceModuleId('forgejo', 'runner', 'proj-1');
49
+ const b = deriveInstanceModuleId('forgejo', 'builder', 'proj-1');
50
+ expect(a).not.toBe(b);
32
51
  });
33
52
 
34
53
  // Without a separator in the hash input, ("a", "b-c") and ("a-b", "c") hash
35
54
  // the same string. The kind of ambiguity that never shows up until it does.
36
55
  test('a hyphen moving between parent and submodule changes the id', () => {
37
- expect(deriveInstanceModuleId('a', 'b-c', 'k')).not.toBe(
38
- deriveInstanceModuleId('a-b', 'c', 'k'),
39
- );
56
+ const a = deriveInstanceModuleId('a', 'b-c', 'k');
57
+ const b = deriveInstanceModuleId('a-b', 'c', 'k');
58
+ expect(a).not.toBe(b);
40
59
  });
41
60
 
42
61
  test('a different key changes the id', () => {
@@ -46,7 +65,212 @@ describe('deriveInstanceModuleId', () => {
46
65
  });
47
66
  });
48
67
 
49
- describe('module_instances storage', () => {
68
+ describe('planInstanceLinks', () => {
69
+ test('links the authored subtrees', () => {
70
+ expect(planInstanceLinks(['ansible', 'terraform', 'scripts', 'manifest.yml'])).toEqual([
71
+ 'ansible',
72
+ 'manifest.yml',
73
+ 'scripts',
74
+ 'terraform',
75
+ ]);
76
+ });
77
+
78
+ // Linking these would put every instance's writes back into one shared
79
+ // directory, which is the exact failure the farm exists to prevent.
80
+ test('never links generated/ or the derived hook outputs', () => {
81
+ const planned = planInstanceLinks([
82
+ 'ansible',
83
+ 'generated',
84
+ 'screenshots',
85
+ 'cookies.json',
86
+ 'checksums.json',
87
+ ]);
88
+ expect(planned).toEqual(['ansible']);
89
+ });
90
+ });
91
+
92
+ describe('the instance symlink farm', () => {
93
+ let root: string;
94
+ let submodulePath: string;
95
+ let instancePath: string;
96
+
97
+ beforeEach(async () => {
98
+ root = await mkdtemp(join(tmpdir(), 'celilo-instances-'));
99
+ submodulePath = submoduleSourcePath(join(root, 'forgejo'), 'runner');
100
+ instancePath = join(root, 'forgejo-runner-abc123def456');
101
+
102
+ await mkdir(join(submodulePath, 'ansible'), { recursive: true });
103
+ await mkdir(join(submodulePath, 'terraform'), { recursive: true });
104
+ await writeFile(join(submodulePath, 'manifest.yml'), 'id: runner\n');
105
+ });
106
+
107
+ afterEach(async () => {
108
+ await rm(root, { recursive: true, force: true });
109
+ });
110
+
111
+ test('links the source and gives the instance its own real generated/', async () => {
112
+ const linked = await buildInstanceLinkFarm({ instancePath, submodulePath });
113
+
114
+ expect(linked).toEqual(['ansible', 'manifest.yml', 'terraform']);
115
+ expect((await lstat(join(instancePath, 'ansible'))).isSymbolicLink()).toBe(true);
116
+ expect((await lstat(join(instancePath, 'generated'))).isSymbolicLink()).toBe(false);
117
+ expect((await lstat(join(instancePath, 'generated'))).isDirectory()).toBe(true);
118
+ });
119
+
120
+ // This is the invariant nine call sites depend on and the reason the farm
121
+ // exists rather than a rewritten sourcePath.
122
+ test('join(instancePath, "generated") is the instance own directory, not the submodule one', async () => {
123
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
124
+ await writeFile(join(instancePath, 'generated', 'terraform.tfstate'), '{}');
125
+
126
+ expect(existsSync(join(submodulePath, 'generated'))).toBe(false);
127
+ });
128
+
129
+ test('two instances of one submodule get separate generated/ and shared source', async () => {
130
+ const other = join(root, 'forgejo-runner-999888777666');
131
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
132
+ await buildInstanceLinkFarm({ instancePath: other, submodulePath });
133
+
134
+ await writeFile(join(instancePath, 'generated', 'terraform.tfstate'), 'first');
135
+ await writeFile(join(other, 'generated', 'terraform.tfstate'), 'second');
136
+
137
+ expect(await readFile(join(instancePath, 'generated', 'terraform.tfstate'), 'utf-8')).toBe(
138
+ 'first',
139
+ );
140
+ expect(await readFile(join(other, 'generated', 'terraform.tfstate'), 'utf-8')).toBe('second');
141
+ // Same authored bytes, one copy on disk.
142
+ expect(await readFile(join(instancePath, 'manifest.yml'), 'utf-8')).toBe('id: runner\n');
143
+ expect(await readFile(join(other, 'manifest.yml'), 'utf-8')).toBe('id: runner\n');
144
+ });
145
+
146
+ test('links are relative, so the farm survives the data directory moving', async () => {
147
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
148
+ const target = await readlink(join(instancePath, 'ansible'));
149
+ expect(target.startsWith('/')).toBe(false);
150
+ });
151
+
152
+ test('is idempotent', async () => {
153
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
154
+ const second = await buildInstanceLinkFarm({ instancePath, submodulePath });
155
+ expect(second).toEqual(['ansible', 'manifest.yml', 'terraform']);
156
+ expect(await readFile(join(instancePath, 'manifest.yml'), 'utf-8')).toBe('id: runner\n');
157
+ });
158
+
159
+ // A submodule gaining a directory in a later version would otherwise leave
160
+ // every existing instance stale, which is why this converges rather than creates.
161
+ test('picks up a subtree the submodule gained since the instance was built', async () => {
162
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
163
+ await mkdir(join(submodulePath, 'base-module-aspect'), { recursive: true });
164
+
165
+ const linked = await buildInstanceLinkFarm({ instancePath, submodulePath });
166
+
167
+ expect(linked).toContain('base-module-aspect');
168
+ expect(existsSync(join(instancePath, 'base-module-aspect'))).toBe(true);
169
+ });
170
+
171
+ test('removes a link whose subtree the submodule dropped', async () => {
172
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
173
+ await rm(join(submodulePath, 'terraform'), { recursive: true });
174
+
175
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
176
+
177
+ await expect(lstat(join(instancePath, 'terraform'))).rejects.toThrow();
178
+ });
179
+
180
+ test('repairs a link that points somewhere else', async () => {
181
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
182
+ const elsewhere = join(root, 'somebody-elses-source');
183
+ await mkdir(elsewhere, { recursive: true });
184
+ await rm(join(instancePath, 'ansible'), { force: true });
185
+ await symlink(elsewhere, join(instancePath, 'ansible'));
186
+
187
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
188
+
189
+ expect(resolve(instancePath, await readlink(join(instancePath, 'ansible')))).toBe(
190
+ resolve(submodulePath, 'ansible'),
191
+ );
192
+ });
193
+
194
+ // existsSync follows links, so a dangling one reports absent while symlink()
195
+ // still refuses to overwrite it. Worth a test because it fails confusingly.
196
+ test('replaces a dangling link rather than failing on it', async () => {
197
+ await mkdir(instancePath, { recursive: true });
198
+ await symlink('../gone-away', join(instancePath, 'ansible'));
199
+
200
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
201
+
202
+ expect(existsSync(join(instancePath, 'ansible'))).toBe(true);
203
+ });
204
+
205
+ test('refuses to build against a submodule source that is not there', async () => {
206
+ await expect(
207
+ buildInstanceLinkFarm({ instancePath, submodulePath: join(root, 'nope') }),
208
+ ).rejects.toThrow(/does not exist/);
209
+ });
210
+ });
211
+
212
+ describe('verifyInstanceLinks', () => {
213
+ let root: string;
214
+ let submodulePath: string;
215
+ let instancePath: string;
216
+
217
+ beforeEach(async () => {
218
+ root = await mkdtemp(join(tmpdir(), 'celilo-verify-'));
219
+ submodulePath = submoduleSourcePath(join(root, 'forgejo'), 'runner');
220
+ instancePath = join(root, 'forgejo-runner-abc123def456');
221
+ await mkdir(join(submodulePath, 'ansible'), { recursive: true });
222
+ await writeFile(join(submodulePath, 'manifest.yml'), 'id: runner\n');
223
+ });
224
+
225
+ afterEach(async () => {
226
+ await rm(root, { recursive: true, force: true });
227
+ });
228
+
229
+ test('a freshly built farm is clean', async () => {
230
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
231
+ expect(await verifyInstanceLinks({ instancePath, submodulePath })).toEqual([]);
232
+ });
233
+
234
+ // The one integrity property unique to an instance. Nothing else checks it,
235
+ // because an instance carries no authored bytes of its own to checksum.
236
+ test('reports a link repointed at somebody else source', async () => {
237
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
238
+ const elsewhere = join(root, 'attacker');
239
+ await mkdir(elsewhere, { recursive: true });
240
+ await rm(join(instancePath, 'ansible'), { force: true });
241
+ await symlink(elsewhere, join(instancePath, 'ansible'));
242
+
243
+ const violations = await verifyInstanceLinks({ instancePath, submodulePath });
244
+
245
+ expect(violations).toHaveLength(1);
246
+ expect(violations[0]?.entry).toBe('ansible');
247
+ expect(violations[0]?.actual).toBe(resolve(elsewhere));
248
+ });
249
+
250
+ test('reports a missing link', async () => {
251
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
252
+ await rm(join(instancePath, 'manifest.yml'), { force: true });
253
+
254
+ const violations = await verifyInstanceLinks({ instancePath, submodulePath });
255
+
256
+ expect(violations).toHaveLength(1);
257
+ expect(violations[0]?.entry).toBe('manifest.yml');
258
+ expect(violations[0]?.actual).toBeNull();
259
+ });
260
+
261
+ test('reports a real file standing where a link belongs', async () => {
262
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
263
+ await rm(join(instancePath, 'manifest.yml'), { force: true });
264
+ await writeFile(join(instancePath, 'manifest.yml'), 'id: not-the-real-one\n');
265
+
266
+ const violations = await verifyInstanceLinks({ instancePath, submodulePath });
267
+
268
+ expect(violations).toHaveLength(1);
269
+ expect(violations[0]?.entry).toBe('manifest.yml');
270
+ });
271
+ });
272
+
273
+ describe('module_instances storage (D2, D5)', () => {
50
274
  let root: string;
51
275
  let db: DbClient;
52
276
 
@@ -56,21 +280,17 @@ describe('module_instances storage', () => {
56
280
  [id, id, id],
57
281
  );
58
282
 
59
- const addInstance = (parent: string, submodule: string, key: string) => {
60
- const id = deriveInstanceModuleId(parent, submodule, key);
61
- addModule(id);
283
+ const addInstance = (parent: string, submodule: string, key: string) =>
62
284
  db.$client.run(
63
285
  'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)',
64
- [id, parent, submodule, key],
286
+ [deriveInstanceModuleId(parent, submodule, key), parent, submodule, key],
65
287
  );
66
- return id;
67
- };
68
288
 
69
289
  const instanceCount = () =>
70
290
  (db.$client.query('SELECT count(*) n FROM module_instances').get() as { n: number }).n;
71
291
 
72
292
  beforeEach(async () => {
73
- root = await mkdtemp(join(tmpdir(), 'celilo-instances-'));
293
+ root = await mkdtemp(join(tmpdir(), 'celilo-instance-db-'));
74
294
  db = createDbClient({ path: join(root, 'celilo.db') });
75
295
  for (const id of ['byoi', 'forgejo']) addModule(id);
76
296
  });
@@ -87,49 +307,150 @@ describe('module_instances storage', () => {
87
307
  expect(columns.map((c) => c.name)).toEqual(['parent_id', 'submodule', 'instance_key']);
88
308
  });
89
309
 
310
+ // The spec scenario: identity is the owner, module and id together.
90
311
  test('two parents may use the same instance key', () => {
312
+ addModule(deriveInstanceModuleId('byoi', 'lab', 'shared'));
313
+ addModule(deriveInstanceModuleId('forgejo', 'lab', 'shared'));
314
+
91
315
  addInstance('byoi', 'lab', 'shared');
92
316
  expect(() => addInstance('forgejo', 'lab', 'shared')).not.toThrow();
93
317
  expect(instanceCount()).toBe(2);
94
318
  });
95
319
 
96
320
  test('one parent cannot reuse a key for the same submodule', () => {
321
+ addModule(deriveInstanceModuleId('byoi', 'lab', 'dup'));
97
322
  addInstance('byoi', 'lab', 'dup');
98
- expect(() =>
99
- db.$client.run(
100
- 'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)',
101
- ['byoi-lab-other', 'byoi', 'lab', 'dup'],
102
- ),
103
- ).toThrow(/UNIQUE constraint failed/);
323
+ expect(() => addInstance('byoi', 'lab', 'dup')).toThrow(/UNIQUE constraint failed/);
104
324
  });
105
325
 
106
- // A row must never outlive the parent it names.
326
+ // D5's containment, at the storage layer. The cascade is not the whole of the
327
+ // removal path (that refuses first and destroys deliberately), but a row must
328
+ // never outlive the parent it names.
107
329
  test('removing a parent removes its instance rows and leaves other parents alone', () => {
330
+ addModule(deriveInstanceModuleId('byoi', 'lab', 'a'));
331
+ addModule(deriveInstanceModuleId('forgejo', 'runner', 'b'));
108
332
  addInstance('byoi', 'lab', 'a');
109
333
  addInstance('forgejo', 'runner', 'b');
110
334
  expect(instanceCount()).toBe(2);
111
335
 
112
336
  db.$client.run("DELETE FROM modules WHERE id='byoi'");
113
337
 
114
- expect(
115
- db.$client.query('SELECT parent_id FROM module_instances').all() as Array<{
116
- parent_id: string;
117
- }>,
118
- ).toEqual([{ parent_id: 'forgejo' }]);
338
+ const left = db.$client.query('SELECT parent_id FROM module_instances').all() as Array<{
339
+ parent_id: string;
340
+ }>;
341
+ expect(left).toEqual([{ parent_id: 'forgejo' }]);
119
342
  });
120
343
 
121
344
  test('an instance defaults to pending with no failure recorded', () => {
345
+ addModule(deriveInstanceModuleId('byoi', 'lab', 'fresh'));
122
346
  addInstance('byoi', 'lab', 'fresh');
123
347
  const row = db.$client
124
348
  .query('SELECT state, failure_reason, retryable FROM module_instances')
125
- .get() as { state: string; failure_reason: string | null; retryable: number | null };
349
+ .get() as {
350
+ state: string;
351
+ failure_reason: string | null;
352
+ retryable: number | null;
353
+ };
126
354
  expect(row.state).toBe('pending');
127
355
  expect(row.failure_reason).toBeNull();
128
356
  expect(row.retryable).toBeNull();
129
357
  });
130
358
  });
131
359
 
132
- describe('ownedSystemModuleIds', () => {
360
+ describe('auditing an instance (D4, task 3.8)', () => {
361
+ let root: string;
362
+ let db: DbClient;
363
+ let parentPath: string;
364
+ let submodulePath: string;
365
+ let instanceId: string;
366
+ let instancePath: string;
367
+
368
+ /** Baseline the parent's own tree, so its audit is genuinely clean. */
369
+ async function baselineParent() {
370
+ const checksums: Record<string, string> = {};
371
+ for (const rel of ['manifest.yml', 'submodules/runner/manifest.yml']) {
372
+ checksums[rel] = await computeFileChecksum(join(parentPath, rel));
373
+ }
374
+ db.$client.run(
375
+ "INSERT INTO module_integrity (module_id, checksums, version) VALUES ('forgejo', ?, '1.0.0')",
376
+ [JSON.stringify(checksums)],
377
+ );
378
+ }
379
+
380
+ beforeEach(async () => {
381
+ root = await mkdtemp(join(tmpdir(), 'celilo-instance-audit-'));
382
+ db = createDbClient({ path: join(root, 'celilo.db') });
383
+
384
+ parentPath = join(root, 'modules', 'forgejo');
385
+ submodulePath = submoduleSourcePath(parentPath, 'runner');
386
+ await mkdir(submodulePath, { recursive: true });
387
+ await writeFile(join(parentPath, 'manifest.yml'), 'id: forgejo\n');
388
+ await writeFile(join(submodulePath, 'manifest.yml'), 'id: runner\n');
389
+
390
+ instanceId = deriveInstanceModuleId('forgejo', 'runner', 'proj-1');
391
+ instancePath = join(root, 'modules', instanceId);
392
+ await buildInstanceLinkFarm({ instancePath, submodulePath });
393
+
394
+ for (const [id, path] of [
395
+ ['forgejo', parentPath],
396
+ [instanceId, instancePath],
397
+ ] as const) {
398
+ db.$client.run(
399
+ "INSERT INTO modules (id,name,version,manifest_data,source_path) VALUES (?,?,'1.0.0','{}',?)",
400
+ [id, id, path],
401
+ );
402
+ }
403
+ db.$client.run(
404
+ 'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)',
405
+ [instanceId, 'forgejo', 'runner', 'proj-1'],
406
+ );
407
+ });
408
+
409
+ afterEach(async () => {
410
+ db.$client.close();
411
+ await rm(root, { recursive: true, force: true });
412
+ });
413
+
414
+ // The failure this exists to prevent: forty instances each reporting
415
+ // "No integrity data found" on a fleet that is entirely healthy.
416
+ test('an instance does not report a missing baseline', async () => {
417
+ await baselineParent();
418
+ const result = await auditModule(instanceId, db);
419
+
420
+ expect(result.error).toBeUndefined();
421
+ expect(result.violations).toEqual([]);
422
+ expect(result.success).toBe(true);
423
+ });
424
+
425
+ // The one integrity property unique to an instance. Nothing else checks it,
426
+ // and every checksum in the fleet still reconciles while it is wrong.
427
+ test('reports a link repointed at another module source', async () => {
428
+ await baselineParent();
429
+ const elsewhere = join(root, 'modules', 'somebody-else');
430
+ await mkdir(elsewhere, { recursive: true });
431
+ await rm(join(instancePath, 'manifest.yml'), { force: true });
432
+ await symlink(elsewhere, join(instancePath, 'manifest.yml'));
433
+
434
+ const result = await auditModule(instanceId, db);
435
+
436
+ expect(result.success).toBe(false);
437
+ expect(
438
+ result.violations.some((v) => v.message.includes('running source it does not own')),
439
+ ).toBe(true);
440
+ });
441
+
442
+ // An instance runs its parent's bytes, so a clean instance row must not read
443
+ // as evidence when the parent itself is unverified.
444
+ test("carries the parent's verdict rather than reporting clean beneath it", async () => {
445
+ // No baseline for the parent at all: its own audit cannot pass.
446
+ const result = await auditModule(instanceId, db);
447
+
448
+ expect(result.success).toBe(false);
449
+ expect(result.violations.some((v) => v.message.includes("belong to 'forgejo'"))).toBe(true);
450
+ });
451
+ });
452
+
453
+ describe('ownedSystemModuleIds (allow-list for hook-process-boundary D12)', () => {
133
454
  let root: string;
134
455
  let db: DbClient;
135
456
 
@@ -160,12 +481,8 @@ describe('ownedSystemModuleIds', () => {
160
481
  await rm(root, { recursive: true, force: true });
161
482
  });
162
483
 
163
- // The state of every fleet until submodules ship, and the reason this is
164
- // correct on its own rather than merely inert: an empty table gives every
165
- // module itself, so a caller reaches only what it provisioned.
166
- test('with no instances in existence, a module owns exactly itself', () => {
484
+ test('a module owning no instances gets only itself', () => {
167
485
  expect(ownedSystemModuleIds('caddy', db)).toEqual(['caddy']);
168
- expect(ownedSystemModuleIds('forgejo', db)).toEqual(['forgejo']);
169
486
  });
170
487
 
171
488
  test('a parent gets itself and every instance it owns', () => {
@@ -185,8 +502,8 @@ describe('ownedSystemModuleIds', () => {
185
502
  expect(ownedSystemModuleIds('caddy', db)).toEqual(['caddy']);
186
503
  });
187
504
 
188
- // Ownership is one level deep by construction, so the answer terminates
189
- // without anyone reasoning about depth.
505
+ // The whole point: nesting is refused at import, so ownership is one level
506
+ // deep and this stays one indexed lookup rather than a walk.
190
507
  test('an instance owns nobody, so the answer is one level and terminates', () => {
191
508
  const instance = addInstance('forgejo', 'runner', 'proj-1');
192
509
  expect(ownedSystemModuleIds(instance, db)).toEqual([instance]);
@@ -196,3 +513,72 @@ describe('ownedSystemModuleIds', () => {
196
513
  expect(ownedSystemModuleIds('never-installed', db)).toEqual(['never-installed']);
197
514
  });
198
515
  });
516
+
517
+ describe('a hook writing into its module root stays in its own instance (task 3.6)', () => {
518
+ let root: string;
519
+ let submodulePath: string;
520
+ let first: string;
521
+ let second: string;
522
+
523
+ beforeEach(async () => {
524
+ root = await mkdtemp(join(tmpdir(), 'celilo-hook-writes-'));
525
+ submodulePath = submoduleSourcePath(join(root, 'forgejo'), 'runner');
526
+ await mkdir(join(submodulePath, 'scripts'), { recursive: true });
527
+ await writeFile(join(submodulePath, 'manifest.yml'), 'id: runner\n');
528
+
529
+ first = join(root, deriveInstanceModuleId('forgejo', 'runner', 'proj-1'));
530
+ second = join(root, deriveInstanceModuleId('forgejo', 'runner', 'proj-2'));
531
+ await buildInstanceLinkFarm({ instancePath: first, submodulePath });
532
+ await buildInstanceLinkFarm({ instancePath: second, submodulePath });
533
+ });
534
+
535
+ afterEach(async () => {
536
+ await rm(root, { recursive: true, force: true });
537
+ });
538
+
539
+ // This is the failure the symlink farm exists to prevent, and the reason
540
+ // `sourcePath` was NOT pointed at the shared submodule directory. Hooks run
541
+ // with the module directory as their root and write into it: browser.ts does
542
+ // join(modulePath, 'screenshots', key), and `module build` uses it as cwd.
543
+ test('two instances writing screenshots do not land on top of each other', async () => {
544
+ for (const [instance, content] of [
545
+ [first, 'first'],
546
+ [second, 'second'],
547
+ ] as const) {
548
+ await mkdir(join(instance, 'screenshots'), { recursive: true });
549
+ await writeFile(join(instance, 'screenshots', 'failure.png'), content);
550
+ }
551
+
552
+ expect(await readFile(join(first, 'screenshots', 'failure.png'), 'utf-8')).toBe('first');
553
+ expect(await readFile(join(second, 'screenshots', 'failure.png'), 'utf-8')).toBe('second');
554
+ });
555
+
556
+ test('cookies.json is per instance, not shared', async () => {
557
+ await writeFile(join(first, 'cookies.json'), '["first"]');
558
+ await writeFile(join(second, 'cookies.json'), '["second"]');
559
+
560
+ expect(await readFile(join(first, 'cookies.json'), 'utf-8')).toBe('["first"]');
561
+ expect(await readFile(join(second, 'cookies.json'), 'utf-8')).toBe('["second"]');
562
+ });
563
+
564
+ test('neither write reaches the shared submodule source', async () => {
565
+ await mkdir(join(first, 'screenshots'), { recursive: true });
566
+ await writeFile(join(first, 'screenshots', 'failure.png'), 'first');
567
+ await writeFile(join(first, 'cookies.json'), '["first"]');
568
+
569
+ expect(existsSync(join(submodulePath, 'screenshots'))).toBe(false);
570
+ expect(existsSync(join(submodulePath, 'cookies.json'))).toBe(false);
571
+ });
572
+
573
+ // A converge must not sweep away what a hook wrote. `generated/` and the
574
+ // derived outputs are the instance's, not the farm's to manage.
575
+ test('rebuilding the farm leaves what the hooks wrote alone', async () => {
576
+ await writeFile(join(first, 'cookies.json'), '["first"]');
577
+ await writeFile(join(first, 'generated', 'terraform.tfstate'), '{}');
578
+
579
+ await buildInstanceLinkFarm({ instancePath: first, submodulePath });
580
+
581
+ expect(await readFile(join(first, 'cookies.json'), 'utf-8')).toBe('["first"]');
582
+ expect(existsSync(join(first, 'generated', 'terraform.tfstate'))).toBe(true);
583
+ });
584
+ });