owlrunkit 0.16.0 → 0.16.1

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.
@@ -19,6 +19,10 @@ const MAX_SCAN_DIRECTORIES = 10_000;
19
19
  const MAX_SCAN_DEPTH = 16;
20
20
  const FLEET_REGISTRY_SCHEMA =
21
21
  "OwlCodaRunKitFleetRootRegistryV1";
22
+ const FLEET_REGISTRY_SCHEMA_V2 =
23
+ "OwlCodaRunKitFleetRootRegistryV2";
24
+ const FLEET_MEMBERSHIP_SCHEMA =
25
+ "OwlCodaRunKitFleetMembershipV1";
22
26
  const SKIPPED_DIRECTORIES = new Set([
23
27
  ".cache",
24
28
  ".git",
@@ -43,12 +47,17 @@ async function canonicalWorkspace(workspaceRoot) {
43
47
  return realpath(requested);
44
48
  }
45
49
 
46
- function frozenManifestSha256({ coverageRoots, workspaceRoots }) {
50
+ function frozenManifestSha256({
51
+ coverageRoots,
52
+ workspaceRoots,
53
+ classifications = emptyClassifications(),
54
+ }) {
47
55
  const frozen = {
48
56
  schemaVersion: "OwlCodaRunKitFrozenFleetManifestV1",
49
57
  skillName: "owlcoda-runkit",
50
58
  coverageRoots,
51
59
  workspaceRoots,
60
+ classifications,
52
61
  authorizationGranted: false,
53
62
  };
54
63
  return `sha256:${createHash("sha256")
@@ -62,6 +71,29 @@ function sha256Json(value) {
62
71
  .digest("hex")}`;
63
72
  }
64
73
 
74
+ function sha256Bytes(value) {
75
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
76
+ }
77
+
78
+ function within(root, candidate) {
79
+ const relative = path.relative(root, candidate);
80
+ return relative === ""
81
+ || (
82
+ relative !== ".."
83
+ && !relative.startsWith(`..${path.sep}`)
84
+ && !path.isAbsolute(relative)
85
+ );
86
+ }
87
+
88
+ function emptyClassifications() {
89
+ return {
90
+ active: [],
91
+ retired: [],
92
+ excluded: [],
93
+ invalid: [],
94
+ };
95
+ }
96
+
65
97
  export function defaultFleetRegistryPath() {
66
98
  const owlcodaHome = process.env.OWLCODA_HOME
67
99
  ? path.resolve(process.env.OWLCODA_HOME)
@@ -69,7 +101,7 @@ export function defaultFleetRegistryPath() {
69
101
  return path.join(owlcodaHome, "runkit-fleet-root-registry-v1.json");
70
102
  }
71
103
 
72
- async function hasRunKitConfig(directory) {
104
+ async function inspectRunKitMarker(directory) {
73
105
  const owlRoot = path.join(directory, ".owlcoda");
74
106
  const runkitRoot = path.join(owlRoot, "runkit");
75
107
  const configPath = path.join(runkitRoot, "config.json");
@@ -78,7 +110,7 @@ async function hasRunKitConfig(directory) {
78
110
  try {
79
111
  owlStat = await lstat(owlRoot);
80
112
  } catch (error) {
81
- if (error?.code === "ENOENT") return false;
113
+ if (error?.code === "ENOENT") return { status: "none" };
82
114
  throw error;
83
115
  }
84
116
  if (
@@ -86,14 +118,14 @@ async function hasRunKitConfig(directory) {
86
118
  || !owlStat.isDirectory()
87
119
  || await realpath(owlRoot) !== path.resolve(owlRoot)
88
120
  ) {
89
- throw new Error("fleet_project_marker_invalid");
121
+ return { status: "issue", code: "fleet_project_marker_invalid" };
90
122
  }
91
123
 
92
124
  let runkitStat;
93
125
  try {
94
126
  runkitStat = await lstat(runkitRoot);
95
127
  } catch (error) {
96
- if (error?.code === "ENOENT") return false;
128
+ if (error?.code === "ENOENT") return { status: "none" };
97
129
  throw error;
98
130
  }
99
131
  if (
@@ -101,7 +133,7 @@ async function hasRunKitConfig(directory) {
101
133
  || !runkitStat.isDirectory()
102
134
  || await realpath(runkitRoot) !== path.resolve(runkitRoot)
103
135
  ) {
104
- throw new Error("fleet_project_marker_invalid");
136
+ return { status: "issue", code: "fleet_project_marker_invalid" };
105
137
  }
106
138
 
107
139
  let configStat;
@@ -109,7 +141,7 @@ async function hasRunKitConfig(directory) {
109
141
  configStat = await lstat(configPath);
110
142
  } catch (error) {
111
143
  if (error?.code === "ENOENT") {
112
- throw new Error("fleet_project_marker_incomplete");
144
+ return { status: "issue", code: "fleet_project_marker_incomplete" };
113
145
  }
114
146
  throw error;
115
147
  }
@@ -118,23 +150,76 @@ async function hasRunKitConfig(directory) {
118
150
  || !configStat.isFile()
119
151
  || await realpath(configPath) !== path.resolve(configPath)
120
152
  ) {
121
- throw new Error("fleet_project_marker_invalid");
153
+ return { status: "issue", code: "fleet_project_marker_invalid" };
122
154
  }
123
- return true;
155
+ return { status: "project" };
156
+ }
157
+
158
+ async function inspectRepositoryBoundary(directory) {
159
+ const markerPath = path.join(directory, ".git");
160
+ let marker;
161
+ try {
162
+ marker = await lstat(markerPath);
163
+ } catch (error) {
164
+ if (error?.code === "ENOENT") return { status: "none" };
165
+ throw error;
166
+ }
167
+ if (marker.isSymbolicLink() || (!marker.isDirectory() && !marker.isFile())) {
168
+ return { status: "issue", code: "fleet_repository_marker_invalid" };
169
+ }
170
+ return { status: "repository" };
124
171
  }
125
172
 
126
173
  async function scanFleetRoot(fleetRoot) {
127
174
  const requested = path.resolve(fleetRoot);
128
175
  const canonicalRoot = await canonicalWorkspace(requested);
129
176
  const projects = [];
177
+ const issues = [];
130
178
  let visitedDirectories = 0;
179
+ let scanStopped = false;
131
180
  async function visit(directory, depth) {
132
- if (depth > MAX_SCAN_DEPTH) throw new Error("fleet_scan_depth_exceeded");
181
+ if (scanStopped) return;
182
+ if (depth > MAX_SCAN_DEPTH) {
183
+ issues.push({
184
+ code: "fleet_scan_depth_exceeded",
185
+ path: directory,
186
+ coverageRoot: canonicalRoot,
187
+ });
188
+ return;
189
+ }
133
190
  visitedDirectories += 1;
134
191
  if (visitedDirectories > MAX_SCAN_DIRECTORIES) {
135
- throw new Error("fleet_scan_directory_limit_exceeded");
192
+ scanStopped = true;
193
+ issues.push({
194
+ code: "fleet_scan_directory_limit_exceeded",
195
+ path: directory,
196
+ coverageRoot: canonicalRoot,
197
+ });
198
+ return;
199
+ }
200
+ const marker = await inspectRunKitMarker(directory);
201
+ if (marker.status === "project") {
202
+ projects.push(directory);
203
+ return;
204
+ }
205
+ if (marker.status === "issue") {
206
+ issues.push({
207
+ code: marker.code,
208
+ path: directory,
209
+ coverageRoot: canonicalRoot,
210
+ });
211
+ return;
212
+ }
213
+ const repository = await inspectRepositoryBoundary(directory);
214
+ if (repository.status === "repository") return;
215
+ if (repository.status === "issue") {
216
+ issues.push({
217
+ code: repository.code,
218
+ path: directory,
219
+ coverageRoot: canonicalRoot,
220
+ });
221
+ return;
136
222
  }
137
- if (await hasRunKitConfig(directory)) projects.push(directory);
138
223
  const entries = await readdir(directory, { withFileTypes: true });
139
224
  entries.sort((left, right) => compareCodeUnits(left.name, right.name));
140
225
  for (const entry of entries) {
@@ -147,6 +232,7 @@ async function scanFleetRoot(fleetRoot) {
147
232
  return {
148
233
  coverageRoot: canonicalRoot,
149
234
  workspaceRoots: projects,
235
+ issues,
150
236
  };
151
237
  }
152
238
 
@@ -168,6 +254,7 @@ async function discoverFromFleetRoots(
168
254
  const coverageRoots = [];
169
255
  const unreachableRoots = [];
170
256
  const workspaceRoots = [];
257
+ const issues = [];
171
258
  for (const fleetRoot of fleetRoots) {
172
259
  if (typeof fleetRoot !== "string" || fleetRoot.length === 0) {
173
260
  throw new Error("fleet_root_invalid");
@@ -176,13 +263,25 @@ async function discoverFromFleetRoots(
176
263
  const scanned = await scanFleetRoot(fleetRoot);
177
264
  coverageRoots.push(scanned.coverageRoot);
178
265
  workspaceRoots.push(...scanned.workspaceRoots);
179
- } catch {
266
+ issues.push(...scanned.issues);
267
+ } catch (error) {
180
268
  unreachableRoots.push(path.resolve(fleetRoot));
269
+ issues.push({
270
+ code: error instanceof Error ? error.message : "fleet_root_unreachable",
271
+ path: path.resolve(fleetRoot),
272
+ coverageRoot: path.resolve(fleetRoot),
273
+ });
181
274
  }
182
275
  }
183
276
  const normalizedCoverage = [...new Set(coverageRoots)].sort(compareCodeUnits);
184
277
  const normalizedUnreachable = [...new Set(unreachableRoots)].sort(compareCodeUnits);
185
278
  const normalizedWorkspaces = [...new Set(workspaceRoots)].sort(compareCodeUnits);
279
+ issues.sort((left, right) => (
280
+ compareCodeUnits(left.path, right.path)
281
+ || compareCodeUnits(left.code, right.code)
282
+ ));
283
+ const classifications = emptyClassifications();
284
+ classifications.active = normalizedWorkspaces;
186
285
  if (normalizedWorkspaces.length > MAX_FLEET_WORKSPACES) {
187
286
  throw new Error("fleet_workspace_limit_exceeded");
188
287
  }
@@ -195,15 +294,198 @@ async function discoverFromFleetRoots(
195
294
  coverageRoots: normalizedCoverage,
196
295
  unreachableRoots: normalizedUnreachable,
197
296
  workspaceRoots: normalizedWorkspaces,
198
- complete: normalizedUnreachable.length === 0,
297
+ classifications,
298
+ issues,
299
+ complete: normalizedUnreachable.length === 0 && issues.length === 0,
199
300
  frozenManifestSha256: frozenManifestSha256({
200
301
  coverageRoots: normalizedCoverage,
201
302
  workspaceRoots: normalizedWorkspaces,
303
+ classifications,
202
304
  }),
203
305
  authorizationGranted: false,
204
306
  };
205
307
  }
206
308
 
309
+ function expectedMembershipEntryKeys(classification) {
310
+ if (classification === "active") return ["classification", "path"];
311
+ return ["classification", "evidence", "path", "reasonCode"];
312
+ }
313
+
314
+ async function validateMembership(
315
+ membership,
316
+ coverageRoots,
317
+ { verifyEvidence = false } = {},
318
+ ) {
319
+ if (
320
+ membership === null
321
+ || typeof membership !== "object"
322
+ || Array.isArray(membership)
323
+ || Object.keys(membership).some(key => ![
324
+ "authorizationGranted",
325
+ "entries",
326
+ "manifestSha256",
327
+ "schemaVersion",
328
+ ].includes(key))
329
+ || membership.schemaVersion !== FLEET_MEMBERSHIP_SCHEMA
330
+ || membership.authorizationGranted !== false
331
+ || !Array.isArray(membership.entries)
332
+ || membership.entries.length === 0
333
+ || membership.entries.length > MAX_FLEET_WORKSPACES
334
+ ) throw new Error("fleet_membership_invalid");
335
+
336
+ const normalizedEntries = [];
337
+ const issues = [];
338
+ for (const entry of membership.entries) {
339
+ if (
340
+ entry === null
341
+ || typeof entry !== "object"
342
+ || Array.isArray(entry)
343
+ || !["active", "retired", "excluded", "invalid"].includes(
344
+ entry.classification,
345
+ )
346
+ || Object.keys(entry).sort().join(",") !== expectedMembershipEntryKeys(
347
+ entry.classification,
348
+ ).sort().join(",")
349
+ || typeof entry.path !== "string"
350
+ || entry.path.length === 0
351
+ || !path.isAbsolute(entry.path)
352
+ ) throw new Error("fleet_membership_entry_invalid");
353
+ const normalizedPath = path.resolve(entry.path);
354
+ if (!coverageRoots.some(root => within(root, normalizedPath))) {
355
+ throw new Error("fleet_membership_entry_outside_coverage");
356
+ }
357
+ if (entry.classification !== "active") {
358
+ if (
359
+ typeof entry.reasonCode !== "string"
360
+ || entry.reasonCode.length === 0
361
+ || entry.evidence === null
362
+ || typeof entry.evidence !== "object"
363
+ || Array.isArray(entry.evidence)
364
+ || Object.keys(entry.evidence).sort().join(",") !== "path,sha256"
365
+ || typeof entry.evidence.path !== "string"
366
+ || !path.isAbsolute(entry.evidence.path)
367
+ || !/^sha256:[a-f0-9]{64}$/u.test(entry.evidence.sha256 ?? "")
368
+ ) throw new Error("fleet_membership_evidence_invalid");
369
+ }
370
+ normalizedEntries.push({
371
+ ...entry,
372
+ path: normalizedPath,
373
+ ...(entry.evidence === undefined
374
+ ? {}
375
+ : {
376
+ evidence: {
377
+ path: path.resolve(entry.evidence.path),
378
+ sha256: entry.evidence.sha256,
379
+ },
380
+ }),
381
+ });
382
+ }
383
+ normalizedEntries.sort((left, right) => compareCodeUnits(left.path, right.path));
384
+ if (new Set(normalizedEntries.map(entry => entry.path)).size !== normalizedEntries.length) {
385
+ throw new Error("fleet_membership_path_duplicate");
386
+ }
387
+ const body = {
388
+ schemaVersion: FLEET_MEMBERSHIP_SCHEMA,
389
+ entries: normalizedEntries,
390
+ authorizationGranted: false,
391
+ };
392
+ const manifestSha256 = sha256Json(body);
393
+ if (
394
+ membership.manifestSha256 !== undefined
395
+ && membership.manifestSha256 !== manifestSha256
396
+ ) throw new Error("fleet_membership_hash_mismatch");
397
+
398
+ if (verifyEvidence) {
399
+ for (const entry of normalizedEntries) {
400
+ if (entry.classification === "active") {
401
+ try {
402
+ const canonical = await canonicalWorkspace(entry.path);
403
+ const marker = await inspectRunKitMarker(canonical);
404
+ if (marker.status !== "project") {
405
+ issues.push({
406
+ code: marker.code ?? "fleet_active_project_marker_missing",
407
+ path: entry.path,
408
+ coverageRoot: coverageRoots.find(root => within(root, entry.path)),
409
+ });
410
+ } else {
411
+ entry.path = canonical;
412
+ }
413
+ } catch (error) {
414
+ issues.push({
415
+ code: error instanceof Error
416
+ ? error.message
417
+ : "fleet_active_project_unreadable",
418
+ path: entry.path,
419
+ coverageRoot: coverageRoots.find(root => within(root, entry.path)),
420
+ });
421
+ }
422
+ continue;
423
+ }
424
+ let evidence;
425
+ try {
426
+ evidence = await readManifestBounded(entry.evidence.path);
427
+ if (evidence.fileSha256 !== entry.evidence.sha256) {
428
+ throw new Error("fleet_membership_evidence_hash_mismatch");
429
+ }
430
+ } catch (error) {
431
+ issues.push({
432
+ code: error instanceof Error
433
+ ? error.message
434
+ : "fleet_membership_evidence_invalid",
435
+ path: entry.path,
436
+ coverageRoot: coverageRoots.find(root => within(root, entry.path)),
437
+ });
438
+ continue;
439
+ }
440
+ if (entry.classification === "retired") {
441
+ const receipt = evidence.manifest;
442
+ if (
443
+ receipt?.status !== "retired"
444
+ || path.resolve(receipt.sourceProjectPath ?? "") !== entry.path
445
+ || receipt.sourceRemoval?.sourcePathAbsent !== true
446
+ || receipt.revokedControlFacts?.execution?.disposition
447
+ !== "explicitly_voided"
448
+ || receipt.revokedControlFacts?.lease?.disposition
449
+ !== "explicitly_voided"
450
+ || receipt.archive?.manifestsIdentical !== true
451
+ || receipt.retirementAuthorization !== "explicit_user_authorization"
452
+ || receipt.authorizationGranted !== false
453
+ || await pathExists(path.join(entry.path, ".owlcoda/runkit"))
454
+ ) {
455
+ issues.push({
456
+ code: "fleet_retirement_evidence_invalid",
457
+ path: entry.path,
458
+ coverageRoot: coverageRoots.find(root => within(root, entry.path)),
459
+ });
460
+ }
461
+ } else {
462
+ const matched = evidence.manifest?.schemaVersion
463
+ === "OwlCodaRunKitFleetClassificationEvidenceV1"
464
+ && evidence.manifest.authorizationGranted === false
465
+ && Array.isArray(evidence.manifest.entries)
466
+ && evidence.manifest.entries.some(candidate => (
467
+ path.resolve(candidate?.path ?? "") === entry.path
468
+ && candidate.classification === entry.classification
469
+ && candidate.reasonCode === entry.reasonCode
470
+ ));
471
+ if (!matched) {
472
+ issues.push({
473
+ code: "fleet_classification_evidence_invalid",
474
+ path: entry.path,
475
+ coverageRoot: coverageRoots.find(root => within(root, entry.path)),
476
+ });
477
+ }
478
+ }
479
+ }
480
+ }
481
+
482
+ return {
483
+ ...body,
484
+ manifestSha256,
485
+ issues,
486
+ };
487
+ }
488
+
207
489
  async function readFleetRegistry(fleetRegistryPath) {
208
490
  const selected = path.resolve(
209
491
  fleetRegistryPath ?? defaultFleetRegistryPath(),
@@ -216,32 +498,77 @@ async function readFleetRegistry(fleetRegistryPath) {
216
498
  throw error;
217
499
  }
218
500
  const registry = loaded.manifest;
219
- if (
501
+ const commonInvalid = (
220
502
  registry === null
221
503
  || typeof registry !== "object"
222
504
  || Array.isArray(registry)
223
- || Object.keys(registry).sort().join(",")
224
- !== "authorizationGranted,coverageRoots,registrySha256,schemaVersion"
225
- || registry.schemaVersion !== FLEET_REGISTRY_SCHEMA
226
505
  || registry.authorizationGranted !== false
227
506
  || !Array.isArray(registry.coverageRoots)
228
507
  || registry.coverageRoots.length === 0
229
508
  || registry.coverageRoots.length > MAX_FLEET_ROOTS
230
- ) {
231
- throw new Error("fleet_registry_invalid");
232
- }
233
- const body = {
234
- schemaVersion: registry.schemaVersion,
235
- coverageRoots: registry.coverageRoots,
236
- authorizationGranted: false,
237
- };
509
+ );
510
+ if (commonInvalid) throw new Error("fleet_registry_invalid");
511
+ const v1 = registry.schemaVersion === FLEET_REGISTRY_SCHEMA
512
+ && Object.keys(registry).sort().join(",")
513
+ === "authorizationGranted,coverageRoots,registrySha256,schemaVersion";
514
+ const v2 = registry.schemaVersion === FLEET_REGISTRY_SCHEMA_V2
515
+ && Object.keys(registry).sort().join(",")
516
+ === "authorizationGranted,coverageRoots,membership,registrySha256,schemaVersion";
517
+ if (!v1 && !v2) throw new Error("fleet_registry_invalid");
518
+ const membership = v2
519
+ ? await validateMembership(registry.membership, registry.coverageRoots)
520
+ : null;
521
+ const body = v1
522
+ ? {
523
+ schemaVersion: registry.schemaVersion,
524
+ coverageRoots: registry.coverageRoots,
525
+ authorizationGranted: false,
526
+ }
527
+ : {
528
+ schemaVersion: registry.schemaVersion,
529
+ coverageRoots: registry.coverageRoots,
530
+ membership: {
531
+ schemaVersion: membership.schemaVersion,
532
+ entries: membership.entries,
533
+ authorizationGranted: false,
534
+ manifestSha256: membership.manifestSha256,
535
+ },
536
+ authorizationGranted: false,
537
+ };
238
538
  if (registry.registrySha256 !== sha256Json(body)) {
239
539
  throw new Error("fleet_registry_hash_mismatch");
240
540
  }
241
541
  return {
242
542
  registryPath: loaded.manifestPath,
543
+ schemaVersion: registry.schemaVersion,
243
544
  registrySha256: registry.registrySha256,
545
+ fileSha256: loaded.fileSha256,
546
+ fileBytes: loaded.bytes,
244
547
  coverageRoots: [...registry.coverageRoots],
548
+ membership: membership === null
549
+ ? null
550
+ : {
551
+ schemaVersion: membership.schemaVersion,
552
+ entries: membership.entries,
553
+ authorizationGranted: false,
554
+ manifestSha256: membership.manifestSha256,
555
+ },
556
+ };
557
+ }
558
+
559
+ export async function inspectFleetRegistry({
560
+ fleetRegistryPath = defaultFleetRegistryPath(),
561
+ } = {}) {
562
+ const registry = await readFleetRegistry(fleetRegistryPath);
563
+ return {
564
+ status: "fleet_registry_inspected",
565
+ schemaVersion: registry.schemaVersion,
566
+ registryPath: registry.registryPath,
567
+ registrySha256: registry.registrySha256,
568
+ fileSha256: registry.fileSha256,
569
+ coverageRoots: registry.coverageRoots,
570
+ membership: registry.membership,
571
+ authorizationGranted: false,
245
572
  };
246
573
  }
247
574
 
@@ -311,6 +638,342 @@ export async function registerFleetCoverageRoot({
311
638
  }
312
639
  }
313
640
 
641
+ async function pathExists(selected) {
642
+ try {
643
+ await lstat(selected);
644
+ return true;
645
+ } catch (error) {
646
+ if (error?.code === "ENOENT") return false;
647
+ throw error;
648
+ }
649
+ }
650
+
651
+ async function validateRemovedCoverageEvidence(removedRoots, entries) {
652
+ if (!Array.isArray(entries)) {
653
+ throw new Error("fleet_registry_removed_coverage_evidence_invalid");
654
+ }
655
+ const byRoot = new Map();
656
+ for (const entry of entries) {
657
+ if (
658
+ entry === null
659
+ || typeof entry !== "object"
660
+ || Array.isArray(entry)
661
+ || Object.keys(entry).sort().join(",") !== "coverageRoot,evidence"
662
+ || typeof entry.coverageRoot !== "string"
663
+ || !path.isAbsolute(entry.coverageRoot)
664
+ || entry.evidence === null
665
+ || typeof entry.evidence !== "object"
666
+ || Array.isArray(entry.evidence)
667
+ || Object.keys(entry.evidence).sort().join(",") !== "path,sha256"
668
+ || typeof entry.evidence.path !== "string"
669
+ || !path.isAbsolute(entry.evidence.path)
670
+ || !/^sha256:[a-f0-9]{64}$/u.test(entry.evidence.sha256 ?? "")
671
+ ) throw new Error("fleet_registry_removed_coverage_evidence_invalid");
672
+ const coverageRoot = path.resolve(entry.coverageRoot);
673
+ if (byRoot.has(coverageRoot)) {
674
+ throw new Error("fleet_registry_removed_coverage_evidence_invalid");
675
+ }
676
+ const loaded = await readManifestBounded(entry.evidence.path);
677
+ if (
678
+ loaded.fileSha256 !== entry.evidence.sha256
679
+ || loaded.manifest?.schemaVersion
680
+ !== "OwlCodaRunKitFleetCoverageExclusionEvidenceV1"
681
+ || path.resolve(loaded.manifest.coverageRoot ?? "") !== coverageRoot
682
+ || typeof loaded.manifest.reasonCode !== "string"
683
+ || loaded.manifest.reasonCode.length === 0
684
+ || loaded.manifest.authorizationGranted !== false
685
+ ) throw new Error("fleet_registry_removed_coverage_evidence_invalid");
686
+ byRoot.set(coverageRoot, {
687
+ coverageRoot,
688
+ evidence: {
689
+ path: loaded.manifestPath,
690
+ sha256: loaded.fileSha256,
691
+ },
692
+ });
693
+ }
694
+ if (
695
+ removedRoots.length !== byRoot.size
696
+ || removedRoots.some(root => !byRoot.has(root))
697
+ ) throw new Error("fleet_registry_coverage_reduction_evidence_required");
698
+ return [...byRoot.values()].sort((left, right) => (
699
+ compareCodeUnits(left.coverageRoot, right.coverageRoot)
700
+ ));
701
+ }
702
+
703
+ function registryReplacementResult({
704
+ status,
705
+ registryPath,
706
+ before,
707
+ afterRegistry,
708
+ afterBytes,
709
+ coverageDelta,
710
+ receiptPath = null,
711
+ }) {
712
+ return {
713
+ status,
714
+ registryPath,
715
+ beforeRegistrySha256: before.registrySha256,
716
+ afterRegistrySha256: afterRegistry.registrySha256,
717
+ beforeFileSha256: before.fileSha256,
718
+ afterFileSha256: sha256Bytes(afterBytes),
719
+ coverageDelta,
720
+ ...(receiptPath === null ? {} : { receiptPath }),
721
+ authorizationGranted: false,
722
+ };
723
+ }
724
+
725
+ export async function replaceFleetRegistry({
726
+ replacementRequestPath,
727
+ receiptPath,
728
+ dryRun = false,
729
+ fleetRegistryPath = defaultFleetRegistryPath(),
730
+ } = {}) {
731
+ const request = await readManifestBounded(replacementRequestPath);
732
+ if (
733
+ request.manifest === null
734
+ || typeof request.manifest !== "object"
735
+ || Array.isArray(request.manifest)
736
+ || Object.keys(request.manifest).sort().join(",")
737
+ !== "authorizationGranted,coverageRoots,expectedRegistrySha256,membership,removedCoverageEvidence,schemaVersion"
738
+ || request.manifest.schemaVersion
739
+ !== "OwlCodaRunKitFleetRegistryReplacementRequestV1"
740
+ || request.manifest.authorizationGranted !== false
741
+ || !/^sha256:[a-f0-9]{64}$/u.test(
742
+ request.manifest.expectedRegistrySha256 ?? "",
743
+ )
744
+ || !Array.isArray(request.manifest.coverageRoots)
745
+ || request.manifest.coverageRoots.length === 0
746
+ || request.manifest.coverageRoots.length > MAX_FLEET_ROOTS
747
+ ) throw new Error("fleet_registry_replacement_request_invalid");
748
+
749
+ const before = await readFleetRegistry(fleetRegistryPath);
750
+ if (before.registrySha256 !== request.manifest.expectedRegistrySha256) {
751
+ throw new Error("fleet_registry_replacement_preimage_mismatch");
752
+ }
753
+ const coverageRoots = [];
754
+ for (const fleetRoot of request.manifest.coverageRoots) {
755
+ coverageRoots.push(await canonicalWorkspace(fleetRoot));
756
+ }
757
+ const normalizedCoverage = [...new Set(coverageRoots)].sort(compareCodeUnits);
758
+ if (normalizedCoverage.length !== request.manifest.coverageRoots.length) {
759
+ throw new Error("fleet_registry_replacement_coverage_duplicate");
760
+ }
761
+ const removed = before.coverageRoots
762
+ .filter(root => !normalizedCoverage.includes(root))
763
+ .sort(compareCodeUnits);
764
+ const added = normalizedCoverage
765
+ .filter(root => !before.coverageRoots.includes(root))
766
+ .sort(compareCodeUnits);
767
+ await validateRemovedCoverageEvidence(
768
+ removed,
769
+ request.manifest.removedCoverageEvidence,
770
+ );
771
+ const membership = await validateMembership(
772
+ request.manifest.membership,
773
+ normalizedCoverage,
774
+ { verifyEvidence: true },
775
+ );
776
+ if (membership.issues.length > 0) {
777
+ throw new Error(`fleet_registry_membership_invalid:${membership.issues
778
+ .map(issue => `${issue.code}:${issue.path}`)
779
+ .join(",")}`);
780
+ }
781
+ const membershipBody = {
782
+ schemaVersion: membership.schemaVersion,
783
+ entries: membership.entries,
784
+ authorizationGranted: false,
785
+ manifestSha256: membership.manifestSha256,
786
+ };
787
+ const body = {
788
+ schemaVersion: FLEET_REGISTRY_SCHEMA_V2,
789
+ coverageRoots: normalizedCoverage,
790
+ membership: membershipBody,
791
+ authorizationGranted: false,
792
+ };
793
+ const afterRegistry = {
794
+ ...body,
795
+ registrySha256: sha256Json(body),
796
+ };
797
+ const afterBytes = Buffer.from(`${JSON.stringify(afterRegistry, null, 2)}\n`);
798
+ const coverageDelta = { added, removed };
799
+ if (dryRun) {
800
+ return registryReplacementResult({
801
+ status: "fleet_registry_replacement_dry_run",
802
+ registryPath: before.registryPath,
803
+ before,
804
+ afterRegistry,
805
+ afterBytes,
806
+ coverageDelta,
807
+ });
808
+ }
809
+ if (typeof receiptPath !== "string" || receiptPath.length === 0) {
810
+ throw new Error("fleet_registry_replacement_receipt_required");
811
+ }
812
+ const resolvedReceipt = path.resolve(receiptPath);
813
+ if (await pathExists(resolvedReceipt)) {
814
+ throw new Error("fleet_registry_replacement_receipt_exists");
815
+ }
816
+ const registryPath = before.registryPath;
817
+ const lockPath = `${registryPath}.lock`;
818
+ try {
819
+ await mkdir(lockPath);
820
+ } catch (error) {
821
+ if (error?.code === "EEXIST") throw new Error("fleet_registry_transaction_active");
822
+ throw error;
823
+ }
824
+ const registryTemporary = `${registryPath}.tmp-${process.pid}`;
825
+ const receiptTemporary = `${resolvedReceipt}.tmp-${process.pid}`;
826
+ let registryReplaced = false;
827
+ try {
828
+ const current = await readFleetRegistry(registryPath);
829
+ if (
830
+ current.registrySha256 !== before.registrySha256
831
+ || current.fileSha256 !== before.fileSha256
832
+ ) throw new Error("fleet_registry_changed_during_replacement");
833
+ await mkdir(path.dirname(resolvedReceipt), { recursive: true, mode: 0o700 });
834
+ const receipt = {
835
+ schemaVersion: "OwlCodaRunKitFleetRegistryReplacementReceiptV1",
836
+ requestPath: request.manifestPath,
837
+ requestSha256: request.fileSha256,
838
+ registryPath,
839
+ beforeRegistrySha256: before.registrySha256,
840
+ afterRegistrySha256: afterRegistry.registrySha256,
841
+ beforeFileSha256: before.fileSha256,
842
+ afterFileSha256: sha256Bytes(afterBytes),
843
+ previousRegistryBytesBase64: Buffer.from(before.fileBytes).toString("base64"),
844
+ coverageDelta,
845
+ rollbackAvailable: true,
846
+ authorizationGranted: false,
847
+ };
848
+ await writeFile(registryTemporary, afterBytes, { flag: "wx", mode: 0o600 });
849
+ await writeFile(
850
+ receiptTemporary,
851
+ `${JSON.stringify(receipt, null, 2)}\n`,
852
+ { flag: "wx", mode: 0o600 },
853
+ );
854
+ await rename(registryTemporary, registryPath);
855
+ registryReplaced = true;
856
+ await rename(receiptTemporary, resolvedReceipt);
857
+ } catch (error) {
858
+ if (registryReplaced) {
859
+ const rollbackTemporary = `${registryPath}.rollback-${process.pid}`;
860
+ await writeFile(rollbackTemporary, before.fileBytes, {
861
+ flag: "wx",
862
+ mode: 0o600,
863
+ });
864
+ await rename(rollbackTemporary, registryPath);
865
+ }
866
+ throw error;
867
+ } finally {
868
+ await rm(registryTemporary, { force: true });
869
+ await rm(receiptTemporary, { force: true });
870
+ await rm(lockPath, { recursive: true, force: true });
871
+ }
872
+ return registryReplacementResult({
873
+ status: "fleet_registry_replaced",
874
+ registryPath,
875
+ before,
876
+ afterRegistry,
877
+ afterBytes,
878
+ coverageDelta,
879
+ receiptPath: resolvedReceipt,
880
+ });
881
+ }
882
+
883
+ export async function rollbackFleetRegistry({
884
+ replacementReceiptPath,
885
+ rollbackReceiptPath,
886
+ fleetRegistryPath = defaultFleetRegistryPath(),
887
+ } = {}) {
888
+ const replacement = await readManifestBounded(replacementReceiptPath);
889
+ const receipt = replacement.manifest;
890
+ if (
891
+ receipt?.schemaVersion
892
+ !== "OwlCodaRunKitFleetRegistryReplacementReceiptV1"
893
+ || receipt.authorizationGranted !== false
894
+ || receipt.rollbackAvailable !== true
895
+ || typeof receipt.previousRegistryBytesBase64 !== "string"
896
+ ) throw new Error("fleet_registry_rollback_receipt_invalid");
897
+ const current = await readFleetRegistry(fleetRegistryPath);
898
+ if (
899
+ current.registryPath !== path.resolve(receipt.registryPath)
900
+ || current.registrySha256 !== receipt.afterRegistrySha256
901
+ || current.fileSha256 !== receipt.afterFileSha256
902
+ ) throw new Error("fleet_registry_rollback_current_mismatch");
903
+ const previousBytes = Buffer.from(receipt.previousRegistryBytesBase64, "base64");
904
+ if (sha256Bytes(previousBytes) !== receipt.beforeFileSha256) {
905
+ throw new Error("fleet_registry_rollback_preimage_hash_mismatch");
906
+ }
907
+ const rollbackPath = path.resolve(rollbackReceiptPath);
908
+ if (await pathExists(rollbackPath)) throw new Error("fleet_registry_rollback_receipt_exists");
909
+ const lockPath = `${current.registryPath}.lock`;
910
+ try {
911
+ await mkdir(lockPath);
912
+ } catch (error) {
913
+ if (error?.code === "EEXIST") throw new Error("fleet_registry_transaction_active");
914
+ throw error;
915
+ }
916
+ const registryTemporary = `${current.registryPath}.rollback-${process.pid}`;
917
+ const receiptTemporary = `${rollbackPath}.tmp-${process.pid}`;
918
+ let registryRestored = false;
919
+ try {
920
+ const lockedCurrent = await readFleetRegistry(current.registryPath);
921
+ if (
922
+ lockedCurrent.registrySha256 !== current.registrySha256
923
+ || lockedCurrent.fileSha256 !== current.fileSha256
924
+ ) throw new Error("fleet_registry_changed_during_rollback");
925
+ await mkdir(path.dirname(rollbackPath), { recursive: true, mode: 0o700 });
926
+ const rollbackReceipt = {
927
+ schemaVersion: "OwlCodaRunKitFleetRegistryRollbackReceiptV1",
928
+ replacementReceiptPath: replacement.manifestPath,
929
+ replacementReceiptSha256: replacement.fileSha256,
930
+ registryPath: current.registryPath,
931
+ restoredRegistrySha256: receipt.beforeRegistrySha256,
932
+ restoredFileSha256: receipt.beforeFileSha256,
933
+ authorizationGranted: false,
934
+ };
935
+ await writeFile(registryTemporary, previousBytes, {
936
+ flag: "wx",
937
+ mode: 0o600,
938
+ });
939
+ await writeFile(
940
+ receiptTemporary,
941
+ `${JSON.stringify(rollbackReceipt, null, 2)}\n`,
942
+ { flag: "wx", mode: 0o600 },
943
+ );
944
+ await rename(registryTemporary, current.registryPath);
945
+ registryRestored = true;
946
+ const restored = await readFleetRegistry(current.registryPath);
947
+ if (
948
+ restored.registrySha256 !== receipt.beforeRegistrySha256
949
+ || restored.fileSha256 !== receipt.beforeFileSha256
950
+ ) throw new Error("fleet_registry_rollback_verification_failed");
951
+ await rename(receiptTemporary, rollbackPath);
952
+ return {
953
+ status: "fleet_registry_rolled_back",
954
+ registryPath: restored.registryPath,
955
+ registrySha256: restored.registrySha256,
956
+ fileSha256: restored.fileSha256,
957
+ rollbackReceiptPath: rollbackPath,
958
+ authorizationGranted: false,
959
+ };
960
+ } catch (error) {
961
+ if (registryRestored) {
962
+ const restoreCurrentTemporary = `${current.registryPath}.restore-${process.pid}`;
963
+ await writeFile(restoreCurrentTemporary, current.fileBytes, {
964
+ flag: "wx",
965
+ mode: 0o600,
966
+ });
967
+ await rename(restoreCurrentTemporary, current.registryPath);
968
+ }
969
+ throw error;
970
+ } finally {
971
+ await rm(registryTemporary, { force: true });
972
+ await rm(receiptTemporary, { force: true });
973
+ await rm(lockPath, { recursive: true, force: true });
974
+ }
975
+ }
976
+
314
977
  async function readManifestBounded(fleetManifestPath) {
315
978
  if (typeof fleetManifestPath !== "string" || fleetManifestPath.length === 0) {
316
979
  throw new Error("fleet_manifest_path_invalid");
@@ -346,6 +1009,8 @@ async function readManifestBounded(fleetManifestPath) {
346
1009
  return {
347
1010
  manifest: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
348
1011
  manifestPath: canonicalPath,
1012
+ bytes,
1013
+ fileSha256: sha256Bytes(bytes),
349
1014
  };
350
1015
  } catch {
351
1016
  throw new Error("fleet_manifest_invalid_json");
@@ -395,6 +1060,70 @@ async function canonicalizeRoots(workspaceRoots, { rejectDuplicates }) {
395
1060
  return [...new Set(roots)].sort(compareCodeUnits);
396
1061
  }
397
1062
 
1063
+ async function discoverFromRegisteredMembership(registry) {
1064
+ const coverageRoots = [];
1065
+ const unreachableRoots = [];
1066
+ const coverageIssues = [];
1067
+ for (const fleetRoot of registry.coverageRoots) {
1068
+ try {
1069
+ coverageRoots.push(await canonicalWorkspace(fleetRoot));
1070
+ } catch (error) {
1071
+ const resolved = path.resolve(fleetRoot);
1072
+ unreachableRoots.push(resolved);
1073
+ coverageIssues.push({
1074
+ code: error instanceof Error
1075
+ ? error.message
1076
+ : "fleet_root_unreachable",
1077
+ path: resolved,
1078
+ coverageRoot: resolved,
1079
+ });
1080
+ }
1081
+ }
1082
+ const normalizedCoverage = [...new Set(coverageRoots)].sort(compareCodeUnits);
1083
+ const normalizedUnreachable = [...new Set(unreachableRoots)].sort(
1084
+ compareCodeUnits,
1085
+ );
1086
+ const declaredCoverage = [...new Set(registry.coverageRoots.map(root => (
1087
+ path.resolve(root)
1088
+ )))].sort(compareCodeUnits);
1089
+ const membership = await validateMembership(
1090
+ registry.membership,
1091
+ declaredCoverage,
1092
+ { verifyEvidence: true },
1093
+ );
1094
+ const classifications = emptyClassifications();
1095
+ for (const entry of membership.entries) {
1096
+ classifications[entry.classification].push(entry.path);
1097
+ }
1098
+ for (const values of Object.values(classifications)) {
1099
+ values.sort(compareCodeUnits);
1100
+ }
1101
+ const issues = [...coverageIssues, ...membership.issues].sort(
1102
+ (left, right) => (
1103
+ compareCodeUnits(left.path, right.path)
1104
+ || compareCodeUnits(left.code, right.code)
1105
+ ),
1106
+ );
1107
+ return {
1108
+ schemaVersion: "OwlCodaRunKitFleetDiscoveryV1",
1109
+ source: "fleet_registry_membership",
1110
+ registryPath: registry.registryPath,
1111
+ registrySha256: registry.registrySha256,
1112
+ coverageRoots: normalizedCoverage,
1113
+ unreachableRoots: normalizedUnreachable,
1114
+ workspaceRoots: classifications.active,
1115
+ classifications,
1116
+ issues,
1117
+ complete: normalizedUnreachable.length === 0 && issues.length === 0,
1118
+ frozenManifestSha256: frozenManifestSha256({
1119
+ coverageRoots: normalizedCoverage,
1120
+ workspaceRoots: classifications.active,
1121
+ classifications,
1122
+ }),
1123
+ authorizationGranted: false,
1124
+ };
1125
+ }
1126
+
398
1127
  export async function discoverFleet({
399
1128
  workspaceRoots = null,
400
1129
  fleetManifestPath = null,
@@ -414,6 +1143,8 @@ export async function discoverFleet({
414
1143
  manifest.workspaceRoots,
415
1144
  { rejectDuplicates: true },
416
1145
  );
1146
+ const classifications = emptyClassifications();
1147
+ classifications.active = normalizedWorkspaces;
417
1148
  return {
418
1149
  schemaVersion: "OwlCodaRunKitFleetDiscoveryV1",
419
1150
  source: "fleet_manifest",
@@ -421,16 +1152,22 @@ export async function discoverFleet({
421
1152
  coverageRoots: [],
422
1153
  unreachableRoots: [],
423
1154
  workspaceRoots: normalizedWorkspaces,
1155
+ classifications,
1156
+ issues: [],
424
1157
  complete: true,
425
1158
  frozenManifestSha256: frozenManifestSha256({
426
1159
  coverageRoots: [],
427
1160
  workspaceRoots: normalizedWorkspaces,
1161
+ classifications,
428
1162
  }),
429
1163
  authorizationGranted: false,
430
1164
  };
431
1165
  }
432
1166
  if (workspaceRoots === null) {
433
1167
  const registry = await readFleetRegistry(fleetRegistryPath);
1168
+ if (registry.schemaVersion === FLEET_REGISTRY_SCHEMA_V2) {
1169
+ return discoverFromRegisteredMembership(registry);
1170
+ }
434
1171
  return discoverFromFleetRoots(registry.coverageRoots, {
435
1172
  source: "fleet_registry",
436
1173
  registryPath: registry.registryPath,
@@ -440,16 +1177,21 @@ export async function discoverFleet({
440
1177
  const normalizedWorkspaces = await canonicalizeRoots(workspaceRoots, {
441
1178
  rejectDuplicates: false,
442
1179
  });
1180
+ const classifications = emptyClassifications();
1181
+ classifications.active = normalizedWorkspaces;
443
1182
  return {
444
1183
  schemaVersion: "OwlCodaRunKitFleetDiscoveryV1",
445
1184
  source: "explicit_workspaces",
446
1185
  coverageRoots: [],
447
1186
  unreachableRoots: [],
448
1187
  workspaceRoots: normalizedWorkspaces,
1188
+ classifications,
1189
+ issues: [],
449
1190
  complete: true,
450
1191
  frozenManifestSha256: frozenManifestSha256({
451
1192
  coverageRoots: [],
452
1193
  workspaceRoots: normalizedWorkspaces,
1194
+ classifications,
453
1195
  }),
454
1196
  authorizationGranted: false,
455
1197
  };