@botiverse/k-carrier 0.1.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 (115) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +24 -0
  3. package/README.md +62 -0
  4. package/core/src/artifact/README.md +1 -0
  5. package/core/src/artifact/collectStream.ts +49 -0
  6. package/core/src/artifact/download.ts +300 -0
  7. package/core/src/artifact/errors.ts +24 -0
  8. package/core/src/artifact/partialPath.ts +18 -0
  9. package/core/src/artifact/source.ts +59 -0
  10. package/core/src/artifact/staticManifestSource.ts +185 -0
  11. package/core/src/artifact/swap.ts +21 -0
  12. package/core/src/clock.ts +22 -0
  13. package/core/src/converge/lifecycle.ts +114 -0
  14. package/core/src/converge/predicates.ts +57 -0
  15. package/core/src/converge/report.ts +38 -0
  16. package/core/src/createUpgrader.ts +300 -0
  17. package/core/src/drive/README.md +1 -0
  18. package/core/src/index.ts +17 -0
  19. package/core/src/invariants.ts +243 -0
  20. package/core/src/lifecycle/hostAdapter.ts +77 -0
  21. package/core/src/liveness.ts +80 -0
  22. package/core/src/platform/README.md +1 -0
  23. package/core/src/platform/index.ts +11 -0
  24. package/core/src/platform/ops.ts +53 -0
  25. package/core/src/platform/posix.ts +99 -0
  26. package/core/src/platform/windows.ts +73 -0
  27. package/core/src/policy/README.md +1 -0
  28. package/core/src/progress.ts +54 -0
  29. package/core/src/provenance/journal.ts +216 -0
  30. package/core/src/status/report.ts +76 -0
  31. package/core/src/status/reportStore.ts +67 -0
  32. package/core/src/txn/effects.ts +40 -0
  33. package/core/src/txn/engine.ts +275 -0
  34. package/core/src/txn/fileEffects.ts +129 -0
  35. package/core/src/txn/hostCallBudget.ts +28 -0
  36. package/core/src/txn/lock.ts +124 -0
  37. package/core/src/txn/state.ts +77 -0
  38. package/core/src/txn/transitions.ts +49 -0
  39. package/core/src/upgrade/outcome.ts +53 -0
  40. package/core/src/upgrade/retire.ts +44 -0
  41. package/core/src/upgrader.ts +124 -0
  42. package/docs/design-v1.md +246 -0
  43. package/docs/harness-design.md +170 -0
  44. package/docs/integration.md +326 -0
  45. package/docs/prior-art.md +150 -0
  46. package/docs/test-plan.md +100 -0
  47. package/harness/README.md +20 -0
  48. package/harness/src/adapter/probeChecks.ts +93 -0
  49. package/harness/src/adapter/releaseKnob.ts +65 -0
  50. package/harness/src/adapter/serviceChecks.ts +260 -0
  51. package/harness/src/artifact/checks.ts +232 -0
  52. package/harness/src/artifact/downloadHoles.ts +300 -0
  53. package/harness/src/artifact/downloadHolesMutations.ts +170 -0
  54. package/harness/src/artifact/m1.ts +204 -0
  55. package/harness/src/artifact/m1Resume.ts +141 -0
  56. package/harness/src/artifact/m3.ts +296 -0
  57. package/harness/src/artifact/m3Hosts.ts +241 -0
  58. package/harness/src/artifact/m4.ts +201 -0
  59. package/harness/src/artifact/m5.ts +239 -0
  60. package/harness/src/artifact/m6.ts +274 -0
  61. package/harness/src/artifact/m6Drive.ts +296 -0
  62. package/harness/src/artifact/m6DriveMutations.ts +70 -0
  63. package/harness/src/artifact/m6Mutations.ts +136 -0
  64. package/harness/src/artifact/m6Status.ts +224 -0
  65. package/harness/src/artifact-factory/checks.ts +68 -0
  66. package/harness/src/artifact-factory/demo.ts +70 -0
  67. package/harness/src/artifact-factory/factory.ts +134 -0
  68. package/harness/src/artifact-factory/run.ts +107 -0
  69. package/harness/src/blackbox.ts +284 -0
  70. package/harness/src/cli.ts +281 -0
  71. package/harness/src/crash/enumerate.ts +66 -0
  72. package/harness/src/crash/runner.ts +162 -0
  73. package/harness/src/examples/checks.ts +286 -0
  74. package/harness/src/fake-host/checks.ts +188 -0
  75. package/harness/src/fake-host/daemon-entry.ts +39 -0
  76. package/harness/src/fake-host/daemon.ts +159 -0
  77. package/harness/src/fake-host/inproc.ts +256 -0
  78. package/harness/src/fake-server/manifest.ts +68 -0
  79. package/harness/src/fake-server/server.ts +205 -0
  80. package/harness/src/fake-server/store.ts +208 -0
  81. package/harness/src/fixtures/minimal-adapter.ts +108 -0
  82. package/harness/src/fixtures/service-adapter.ts +187 -0
  83. package/harness/src/index.ts +11 -0
  84. package/harness/src/receipt.ts +83 -0
  85. package/harness/src/runner.ts +268 -0
  86. package/harness/src/scenario/processScan.ts +55 -0
  87. package/harness/src/scenario/sandbox.ts +259 -0
  88. package/harness/src/scenario/virtualClock.ts +56 -0
  89. package/harness/src/selfverify/selfVerify.ts +136 -0
  90. package/harness/src/sim/checks.ts +46 -0
  91. package/harness/src/sim/corpus.ts +36 -0
  92. package/harness/src/sim/effectRuntime.ts +113 -0
  93. package/harness/src/sim/error.ts +14 -0
  94. package/harness/src/sim/prng.ts +29 -0
  95. package/harness/src/sim/record.ts +76 -0
  96. package/harness/src/sim/run.ts +148 -0
  97. package/harness/src/sim/scheduler.ts +72 -0
  98. package/harness/src/sim/world.ts +284 -0
  99. package/harness/src/target.ts +86 -0
  100. package/harness/src/targetCheck.ts +47 -0
  101. package/harness/src/teeth/artifact.ts +215 -0
  102. package/harness/src/teeth/artifactDownload.ts +136 -0
  103. package/harness/src/teeth/artifactFactory.ts +41 -0
  104. package/harness/src/teeth/artifactM5.ts +68 -0
  105. package/harness/src/teeth/artifactM6.ts +264 -0
  106. package/harness/src/teeth/blackbox.ts +22 -0
  107. package/harness/src/teeth/checks.ts +278 -0
  108. package/harness/src/teeth/examples.ts +57 -0
  109. package/harness/src/teeth/fakeHost.ts +120 -0
  110. package/harness/src/teeth/index.ts +16 -0
  111. package/harness/src/teeth/m0.ts +135 -0
  112. package/harness/src/teeth/registry.ts +267 -0
  113. package/harness/src/teeth/selfCheck.ts +45 -0
  114. package/harness/src/teeth/sim.ts +61 -0
  115. package/package.json +43 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * M6 provenance teeth (test-plan M6 rows: provenance journal forward-only;
3
+ * a genesis machine is NOT_OBSERVED and never counted as "recorded"; an
4
+ * unreadable/corrupt journal is a THIRD state, never genesis; every
5
+ * reconcile that reaches the transaction records WHO drove it, write-ahead).
6
+ * Registration site only — check bodies live in harness/src/artifact/m6.ts.
7
+ */
8
+ import { registerTooth } from "./registry.ts";
9
+ import {
10
+ checkM6ProvenanceForwardOnly,
11
+ checkM6ProvenanceGenesisNotObserved,
12
+ checkM6ProvenanceRecordsEachReconcile,
13
+ } from "../artifact/m6.ts";
14
+ import {
15
+ checkM6StatusReportMatchesLocal,
16
+ checkM6StatusReportSilenceNotEvidence,
17
+ } from "../artifact/m6Status.ts";
18
+ import {
19
+ checkM6DriveStageThroughPolicy,
20
+ checkM6DrivePromoteThroughPolicy,
21
+ checkM6DriveRollbackThroughOwnership,
22
+ checkM6RollbackSettlesInflightOwnershipFlip,
23
+ checkM6PushRollbackThroughPolicy,
24
+ checkM6AutoRollbackNeedsNoConsent,
25
+ } from "../artifact/m6Drive.ts";
26
+
27
+ registerTooth({
28
+ id: "m6.provenance-forward-only",
29
+ profiles: ["service"],
30
+ layers: ["L5"],
31
+ kind: { kind: "invariant" },
32
+ mustRed: [
33
+ {
34
+ mutate: "an overwrite of an existing entry is accepted (provenance becomes rewritable)",
35
+ caughtOnlyBy: "this", // nobody else pins the forward-only journal
36
+ },
37
+ {
38
+ mutate: "a torn final line is kept (history includes an entry that never completed)",
39
+ caughtOnlyBy: "this",
40
+ },
41
+ {
42
+ mutate: "appends are not unique/ordered (history can be collapsed or reordered)",
43
+ caughtOnlyBy: "this",
44
+ },
45
+ {
46
+ mutate:
47
+ "appending on an unreadable/corrupt history is allowed (a truncated view re-issues a seq)",
48
+ caughtOnlyBy: "this", // the rewrite-under-a-truncated-view attack
49
+ },
50
+ ],
51
+ run: checkM6ProvenanceForwardOnly,
52
+ });
53
+
54
+ registerTooth({
55
+ id: "m6.provenance-genesis-not-observed",
56
+ profiles: ["service"],
57
+ layers: ["L5"],
58
+ kind: { kind: "invariant" },
59
+ mustRed: [
60
+ {
61
+ mutate:
62
+ "genesis machines are counted as recorded (recorded ∪ not-observed collapses into one bucket)",
63
+ caughtOnlyBy: "this", // "no data" ≠ "no problem": the M6 genesis guard
64
+ },
65
+ {
66
+ mutate:
67
+ "an empty-but-present journal is reported as genesis (machinery present, misreported as never-had-it)",
68
+ caughtOnlyBy: "this",
69
+ },
70
+ {
71
+ mutate:
72
+ "an unreadable journal is reported as genesis (\"I cannot see the data\" becomes \"there is no data\")",
73
+ caughtOnlyBy: "this", // only this tooth pins the third state
74
+ },
75
+ {
76
+ mutate:
77
+ "aggregation folds unreadable into notObserved (the 'definitely absent' bucket)",
78
+ caughtOnlyBy: "this",
79
+ },
80
+ ],
81
+ run: checkM6ProvenanceGenesisNotObserved,
82
+ });
83
+
84
+ registerTooth({
85
+ id: "m6.status-report-matches-local",
86
+ profiles: ["service"],
87
+ layers: ["L5"],
88
+ kind: { kind: "invariant" },
89
+ mustRed: [
90
+ {
91
+ mutate: "the report invents/fabricates a field (stable says X while the machine is on Y)",
92
+ caughtOnlyBy: "this", // only this tooth pins the read-back equality
93
+ },
94
+ {
95
+ mutate: "the report's predicates are not the last real evaluation's (a report from a different machine/moment)",
96
+ caughtOnlyBy: "this",
97
+ },
98
+ {
99
+ mutate:
100
+ "a real conclusion is attached to the wrong version (no stamp / wrong stamp — consumers cannot join correctly)",
101
+ caughtOnlyBy: "this", // a true fact pasted on the wrong version reads like a lie
102
+ },
103
+ ],
104
+ run: checkM6StatusReportMatchesLocal,
105
+ });
106
+
107
+ registerTooth({
108
+ id: "m6.status-report-silence-not-evidence",
109
+ profiles: ["service"],
110
+ layers: ["L5"],
111
+ kind: { kind: "invariant" },
112
+ mustRed: [
113
+ {
114
+ mutate:
115
+ "a machine that never observed a promote reports binaryAtTarget: passed:true",
116
+ caughtOnlyBy: "this", // silence cannot be spent as evidence (M5 rule, fleet surface)
117
+ },
118
+ {
119
+ mutate:
120
+ "a machine that never observed a promote reports hostLifecycleConverged: passed:true",
121
+ caughtOnlyBy: "this",
122
+ },
123
+ {
124
+ mutate:
125
+ "a rolled-back reconcile is reported as an observed pass (failure converted into evidence)",
126
+ caughtOnlyBy: "this",
127
+ },
128
+ {
129
+ mutate:
130
+ "a machine that DID converge reports NOT_OBSERVED after a restart (the report is not persisted)",
131
+ caughtOnlyBy: "this", // "observed, I restarted" is not "never observed"
132
+ },
133
+ {
134
+ mutate:
135
+ "an unreadable report is read as genesis (\"I cannot see the data\" becomes \"there is no data\" — the report store collapses to two states)",
136
+ caughtOnlyBy: "this", // same family as the provenance third state, one package
137
+ },
138
+ ],
139
+ run: checkM6StatusReportSilenceNotEvidence,
140
+ });
141
+
142
+ registerTooth({
143
+ id: "m6.provenance-records-each-reconcile",
144
+ profiles: ["service"],
145
+ layers: ["L5"],
146
+ kind: { kind: "invariant" },
147
+ mustRed: [
148
+ {
149
+ mutate: "a successful reconcile leaves no provenance entry (the 'who' is lost)",
150
+ caughtOnlyBy: "this", // only this tooth pins who-drove-it on every reconcile
151
+ },
152
+ {
153
+ mutate:
154
+ "a failed reconcile (auto-rollback) is not recorded (journaling after the outcome, not write-ahead)",
155
+ caughtOnlyBy: "this",
156
+ },
157
+ ],
158
+ run: checkM6ProvenanceRecordsEachReconcile,
159
+ });
160
+
161
+ registerTooth({
162
+ id: "m6.drive-stage-through-policy",
163
+ profiles: ["service"],
164
+ layers: ["L5", "L4"],
165
+ requiresCapability: "fleet-drive",
166
+ kind: { kind: "invariant" },
167
+ mustRed: [
168
+ {
169
+ mutate: "the drive path stages bytes without consent (policy=confirm is not honored)",
170
+ caughtOnlyBy: "this", // only this tooth pins drive stage through the policy gate
171
+ },
172
+ {
173
+ mutate: "a held drive stage leaves a provenance entry (it never reached the transaction)",
174
+ caughtOnlyBy: "this", // "reaches the transaction" is the provenance threshold
175
+ },
176
+ ],
177
+ run: checkM6DriveStageThroughPolicy,
178
+ });
179
+
180
+ registerTooth({
181
+ id: "m6.drive-promote-through-policy",
182
+ profiles: ["service"],
183
+ layers: ["L5", "L4"],
184
+ requiresCapability: "fleet-drive",
185
+ kind: { kind: "invariant" },
186
+ mustRed: [
187
+ {
188
+ mutate: "the drive path promotes without consent",
189
+ caughtOnlyBy: "this",
190
+ },
191
+ {
192
+ mutate: "the drive installs a different version than the one approved (consent does not bind)",
193
+ caughtOnlyBy: "this", // consent is to a VERSION, never to "the upgrade"
194
+ },
195
+ ],
196
+ run: checkM6DrivePromoteThroughPolicy,
197
+ });
198
+
199
+ registerTooth({
200
+ id: "m6.drive-rollback-through-ownership",
201
+ profiles: ["service"],
202
+ layers: ["L5", "L1"],
203
+ requiresCapability: "fleet-drive",
204
+ kind: { kind: "invariant" },
205
+ mustRed: [
206
+ {
207
+ mutate:
208
+ "a drive rollback on a managed-elsewhere machine at rest rolls back (someone else's copy modified)",
209
+ caughtOnlyBy: "this", // only this tooth pins the at-rest ownership gate
210
+ },
211
+ {
212
+ mutate:
213
+ "a TERMINAL rolled-back state is classified as in-flight (an exclusion-list at-rest check would touch a machine that is not ours)",
214
+ caughtOnlyBy: "this", // the gate enumerates positively: the next terminal phase cannot silently become "in flight"
215
+ },
216
+ ],
217
+ run: checkM6DriveRollbackThroughOwnership,
218
+ });
219
+
220
+ registerTooth({
221
+ id: "m6.rollback-settles-inflight-ownership-flip",
222
+ profiles: ["service"],
223
+ layers: ["L1"],
224
+ kind: { kind: "invariant" },
225
+ mustRed: [
226
+ {
227
+ mutate:
228
+ "a machine with an in-flight transaction is held from settling when ownership flipped (bricked mid-transaction)",
229
+ caughtOnlyBy: "this", // the reverse: a held on an opened transaction is a brick
230
+ },
231
+ ],
232
+ run: checkM6RollbackSettlesInflightOwnershipFlip,
233
+ });
234
+
235
+ registerTooth({
236
+ id: "m6.push-rollback-through-policy",
237
+ profiles: ["service"],
238
+ layers: ["L5", "L4"],
239
+ requiresCapability: "fleet-drive",
240
+ kind: { kind: "invariant" },
241
+ mustRed: [
242
+ {
243
+ mutate:
244
+ "a pushed rollback of an ALREADY-PROMOTED version executes without consent (the downgrade attack by another name)",
245
+ caughtOnlyBy: "this", // safe direction is byte safety, not authority (the ruling)
246
+ },
247
+ ],
248
+ run: checkM6PushRollbackThroughPolicy,
249
+ });
250
+
251
+ registerTooth({
252
+ id: "m6.auto-rollback-needs-no-consent",
253
+ profiles: ["service"],
254
+ layers: ["L5", "L4"],
255
+ kind: { kind: "invariant" },
256
+ mustRed: [
257
+ {
258
+ mutate:
259
+ "the in-transaction auto-rollback waits for consent that never comes (the promise 'you'll always get back' is false)",
260
+ caughtOnlyBy: "this", // the controlling twin of push-rollback-through-policy
261
+ },
262
+ ],
263
+ run: checkM6AutoRollbackNeedsNoConsent,
264
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Black-box contract teeth — registration site for the --bin mode's
3
+ * fail-closed discipline (harness-design §1.76, ruling: no
4
+ * zero-config defaults, no convention probing — the target MUST be
5
+ * declared). Check bodies live in blackbox.ts.
6
+ */
7
+ import { registerTooth } from "./registry.ts";
8
+ import { checkMissingTargetFails } from "../targetCheck.ts";
9
+
10
+ registerTooth({
11
+ id: "blackbox.missing-target-fails",
12
+ profiles: ["swap", "service"],
13
+ layers: ["L0", "L1p"],
14
+ kind: { kind: "invariant" },
15
+ mustRed: [
16
+ {
17
+ mutate: "the harness guesses commands (probes --version / self upgrade) when no target file exists",
18
+ caughtOnlyBy: "this", // only this tooth pins that a missing target is a typed FAIL, never a guess
19
+ },
20
+ ],
21
+ run: checkMissingTargetFails,
22
+ });
@@ -0,0 +1,278 @@
1
+ /**
2
+ * M0 acceptance checks — the run bodies of the fake-server + sandbox teeth
3
+ * (registered in m0.ts).
4
+ *
5
+ * Each check throws on violation (a tooth goes red by throwing). The checks
6
+ * are exported separately so the self-verification tests can drive
7
+ * known-red by running a check against a mutated world (a no-op tamper, a
8
+ * shared dir) — the tooth must throw then, exactly like the mutation-runner
9
+ * will demand.
10
+ *
11
+ * The oracle is sha256: K verifies integrity, not authenticity (design-v1
12
+ * §L0.5), so every tamper op here is judged by "do the served bytes still
13
+ * hash to what the manifest promises". Signature-chain checks lived here
14
+ * until 2026-08-06 and were removed with the feature -- a tooth for a
15
+ * guarantee the product does not make is worse than no tooth.
16
+ */
17
+ import assert from "node:assert/strict";
18
+ import { promises as fs } from "node:fs";
19
+ import * as path from "node:path";
20
+ import { type ToothContext } from "./registry.ts";
21
+ import { FakeServer, type PublishedRelease } from "../fake-server/server.ts";
22
+ import {
23
+ MANIFEST_FILE,
24
+ compareVersions,
25
+ sha256Hex,
26
+ type Manifest,
27
+ } from "../fake-server/manifest.ts";
28
+ import { createSandbox } from "../scenario/sandbox.ts";
29
+
30
+ export const M0_ARTIFACT = "app-1.0.0.bin";
31
+ export const M0_ARTIFACT_B = "app-1.0.0-extra.bin";
32
+ const PAYLOAD = "K fake-server payload";
33
+ const PAYLOAD_V2 = "K fake-server payload v2";
34
+
35
+ export interface ReleaseFixture {
36
+ server: FakeServer;
37
+ artifact: string;
38
+ artifactBytes: Uint8Array;
39
+ manifest: Manifest;
40
+ }
41
+
42
+ /** Start a fake-server with one published release inside the tooth sandbox. */
43
+ export async function setupRelease(
44
+ ctx: ToothContext,
45
+ opts: {
46
+ artifacts?: Record<string, string>;
47
+ version?: string;
48
+ platform?: string;
49
+ binary?: string;
50
+ } = {},
51
+ ): Promise<ReleaseFixture> {
52
+ const server = new FakeServer({ storeDir: path.join(ctx.sandboxDir, "store") });
53
+ await server.start();
54
+ const artifacts = opts.artifacts ?? { [M0_ARTIFACT]: PAYLOAD };
55
+ const names = Object.keys(artifacts);
56
+ const artifact = opts.binary ?? (names.length === 1 ? names[0] : undefined);
57
+ if (!artifact) throw new Error("setupRelease: no artifact selected");
58
+ const content = artifacts[artifact];
59
+ if (content === undefined) throw new Error(`setupRelease: artifact ${artifact} has no content`);
60
+ const released: PublishedRelease = await server.publishRelease({
61
+ version: opts.version ?? "1.0.0",
62
+ artifacts,
63
+ platform: opts.platform ?? "darwin-arm64",
64
+ binary: artifact,
65
+ });
66
+ return {
67
+ server,
68
+ artifact,
69
+ artifactBytes: new TextEncoder().encode(content),
70
+ manifest: released.manifest,
71
+ };
72
+ }
73
+
74
+ export async function fetchBytes(
75
+ server: FakeServer,
76
+ file: string,
77
+ ): Promise<{ status: number; body: Uint8Array }> {
78
+ const res = await fetch(`${server.url}/${file}`);
79
+ return { status: res.status, body: new Uint8Array(await res.arrayBuffer()) };
80
+ }
81
+
82
+ /**
83
+ * Does the file the server currently serves still hash to `expected`?
84
+ *
85
+ * Fetch failures return false rather than throwing: for these checks a file
86
+ * that cannot be fetched is as much a mismatch as wrong bytes, and both are
87
+ * reported by the caller's own assertion message.
88
+ */
89
+ export async function servedHashMatches(
90
+ server: FakeServer,
91
+ file: string,
92
+ expected: string,
93
+ ): Promise<boolean> {
94
+ const res = await fetchBytes(server, file);
95
+ if (res.status !== 200) return false;
96
+ return sha256Hex(res.body) === expected;
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Checks (exported for known-red driving). Each throws on violation.
101
+ // ---------------------------------------------------------------------------
102
+
103
+ export async function checkServesConsistentRelease(
104
+ ctx: ToothContext,
105
+ mutate?: (server: FakeServer, fx: ReleaseFixture) => Promise<void>,
106
+ ): Promise<void> {
107
+ const fx = await setupRelease(ctx);
108
+ try {
109
+ if (mutate) await mutate(fx.server, fx);
110
+ const m = await fetchBytes(fx.server, MANIFEST_FILE);
111
+ assert.equal(m.status, 200, "manifest must be served");
112
+ const manifest = JSON.parse(new TextDecoder().decode(m.body)) as Manifest;
113
+ assert.equal(manifest.version, "1.0.0");
114
+ const target = manifest.targets["darwin-arm64"];
115
+ assert.ok(target, "manifest must carry the published platform target");
116
+ assert.equal(target.file, fx.artifact);
117
+ assert.equal(target.sha256, sha256Hex(fx.artifactBytes));
118
+ assert.equal(target.size, fx.artifactBytes.length);
119
+
120
+ const art = await fetchBytes(fx.server, fx.artifact);
121
+ assert.equal(art.status, 200, "artifact must be served");
122
+ // sha256, not deepEqual: a byte-equality assertion here would fire FIRST
123
+ // and report "bytes differ" for a corruption whose tested property is
124
+ // manifest/bytes disagreement -- an unrelated earlier reason masking the
125
+ // one under test.
126
+ assert.equal(sha256Hex(art.body), target.sha256, "served bytes must match manifest sha256");
127
+
128
+ } finally {
129
+ await fx.server.stop();
130
+ }
131
+ }
132
+
133
+ export async function checkCorruptByteRejects(
134
+ ctx: ToothContext,
135
+ mutate?: (server: FakeServer) => Promise<void>,
136
+ ): Promise<void> {
137
+ const fx = await setupRelease(ctx);
138
+ try {
139
+ if (mutate) await mutate(fx.server);
140
+ else await fx.server.corruptByte(fx.artifact, 0);
141
+ const published = sha256Hex(fx.artifactBytes);
142
+ assert.equal(
143
+ await servedHashMatches(fx.server, fx.artifact, published),
144
+ false,
145
+ "corrupted artifact must no longer hash to the published digest",
146
+ );
147
+ // isolation: untouched files must still be intact
148
+ const m = await fetchBytes(fx.server, MANIFEST_FILE);
149
+ assert.equal(m.status, 200, "untampered manifest must still be served");
150
+ const manifest = JSON.parse(new TextDecoder().decode(m.body)) as Manifest;
151
+ assert.equal(
152
+ manifest.targets["darwin-arm64"]?.sha256,
153
+ published,
154
+ "tampering the artifact must not have rewritten the manifest",
155
+ );
156
+ } finally {
157
+ await fx.server.stop();
158
+ }
159
+ }
160
+
161
+ export async function checkSwapArtifactsRejects(
162
+ ctx: ToothContext,
163
+ mutate?: (server: FakeServer) => Promise<void>,
164
+ ): Promise<void> {
165
+ const bytesB = "K fake-server payload B";
166
+ const fx = await setupRelease(ctx, {
167
+ artifacts: { [M0_ARTIFACT]: PAYLOAD, [M0_ARTIFACT_B]: bytesB },
168
+ binary: M0_ARTIFACT,
169
+ });
170
+ const expected = new Map([
171
+ [M0_ARTIFACT, sha256Hex(fx.artifactBytes)],
172
+ [M0_ARTIFACT_B, sha256Hex(new TextEncoder().encode(bytesB))],
173
+ ]);
174
+ try {
175
+ if (mutate) await mutate(fx.server);
176
+ else await fx.server.swapFiles(M0_ARTIFACT, M0_ARTIFACT_B);
177
+ // Both names must be wrong, not just one: a swap that moved a single file
178
+ // would leave the other intact, and a check that only looked at the binary
179
+ // would call that a detected swap.
180
+ for (const [name, digest] of expected) {
181
+ assert.equal(
182
+ await servedHashMatches(fx.server, name, digest),
183
+ false,
184
+ `swapped artifact ${name} must no longer hash to its published digest`,
185
+ );
186
+ }
187
+ } finally {
188
+ await fx.server.stop();
189
+ }
190
+ }
191
+
192
+ export async function checkServeOlderVersion(
193
+ ctx: ToothContext,
194
+ mutate?: (server: FakeServer) => Promise<string>,
195
+ ): Promise<void> {
196
+ const server = new FakeServer({ storeDir: path.join(ctx.sandboxDir, "store") });
197
+ await server.start();
198
+ try {
199
+ await server.publishRelease({
200
+ version: "1.0.0",
201
+ artifacts: { "app-1.0.0.bin": PAYLOAD },
202
+ platform: "darwin-arm64",
203
+ });
204
+ await server.publishRelease({
205
+ version: "2.0.0",
206
+ artifacts: { "app-2.0.0.bin": PAYLOAD_V2 },
207
+ platform: "darwin-arm64",
208
+ });
209
+ const older = mutate ? await mutate(server) : await server.serveOlderVersion();
210
+ assert.equal(older, "1.0.0", "serveOlderVersion must switch to the older release");
211
+ const m = await fetchBytes(server, MANIFEST_FILE);
212
+ const manifest = JSON.parse(new TextDecoder().decode(m.body)) as Manifest;
213
+ assert.equal(
214
+ compareVersions(manifest.version, "2.0.0"),
215
+ -1,
216
+ "served manifest version must be strictly older than the previously active one",
217
+ );
218
+ const oldArt = await fetchBytes(server, "app-1.0.0.bin");
219
+ assert.equal(oldArt.status, 200, "older release's artifacts must be served");
220
+ } finally {
221
+ await server.stop();
222
+ }
223
+ }
224
+
225
+ export async function checkDropFileRemoves(
226
+ ctx: ToothContext,
227
+ mutate?: (server: FakeServer) => Promise<void>,
228
+ ): Promise<void> {
229
+ const fx = await setupRelease(ctx);
230
+ try {
231
+ if (mutate) await mutate(fx.server);
232
+ else await fx.server.dropFile(fx.artifact);
233
+ const art = await fetchBytes(fx.server, fx.artifact);
234
+ assert.equal(art.status, 404, "dropped file must no longer be served");
235
+ const m = await fetchBytes(fx.server, MANIFEST_FILE);
236
+ assert.equal(m.status, 200, "remaining files must still be served");
237
+ } finally {
238
+ await fx.server.stop();
239
+ }
240
+ }
241
+
242
+ /** The distinctness assertions of the sandbox tooth, standalone for known-red. */
243
+ export function assertSandboxDistinct(
244
+ a: { dir: string; port: number },
245
+ b: { dir: string; port: number },
246
+ ): void {
247
+ assert.notEqual(a.dir, b.dir, "two live sandboxes must have distinct dirs");
248
+ assert.notEqual(a.port, b.port, "two live sandboxes must have distinct ports");
249
+ }
250
+
251
+ export async function assertDirGone(dir: string): Promise<void> {
252
+ await assert.rejects(fs.access(dir), "teardown must remove the sandbox dir");
253
+ }
254
+
255
+ export async function checkSandboxIsolation(ctx: ToothContext): Promise<void> {
256
+ const a = await createSandbox({ prefix: "iso-a", baseDir: ctx.sandboxDir });
257
+ const b = await createSandbox({ prefix: "iso-b", baseDir: ctx.sandboxDir });
258
+ try {
259
+ assertSandboxDistinct(a, b);
260
+ await assertDirGone(path.join(a.dir, "does-not-exist")); // sanity: helper works
261
+ } finally {
262
+ await a.teardown();
263
+ await b.teardown();
264
+ }
265
+ await assertDirGone(a.dir);
266
+ await assertDirGone(b.dir);
267
+ }
268
+
269
+ /**
270
+ * Sandbox verify-dead check lives with the sandbox module (its domain):
271
+ * re-exported here so the M0 teeth and tests import from one place.
272
+ */
273
+ export { checkSandboxVerifyDead } from "../scenario/sandbox.ts";
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Registered teeth (must-red answered per "who else would catch this?").
277
+ // ---------------------------------------------------------------------------
278
+
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Examples teeth — each demo is the credential for its profile's support
3
+ * claim (examples/README.md). Registration site only; check bodies live in
4
+ * harness/src/examples/checks.ts.
5
+ */
6
+ import { registerTooth } from "./registry.ts";
7
+ import {
8
+ checkCliToolBlackbox,
9
+ checkPlainDaemonContract,
10
+ checkManagedHostAdapter,
11
+ } from "../examples/checks.ts";
12
+
13
+ registerTooth({
14
+ id: "examples.swap-tool-blackbox",
15
+ profiles: ["swap"],
16
+ layers: ["L0", "L1p"],
17
+ kind: { kind: "invariant" },
18
+ mustRed: [
19
+ {
20
+ mutate: "self upgrade swaps no bytes (serves the current version's artifact)",
21
+ caughtOnlyBy: {
22
+ alsoCaughtBy: "k-harness --bin contract.self-upgrade (CONTRACT_UPGRADE_SELF_UNCHANGED)",
23
+ whyStillNeeded:
24
+ "this tooth makes the swap-tool demo's claim a CI-enforced registered tooth in the cli tier, not just an ad-hoc CLI invocation",
25
+ },
26
+ },
27
+ ],
28
+ run: checkCliToolBlackbox,
29
+ });
30
+
31
+ registerTooth({
32
+ id: "examples.service-daemon-contract",
33
+ profiles: ["service"],
34
+ layers: ["L0", "L2", "L3"],
35
+ kind: { kind: "invariant" },
36
+ mustRed: [
37
+ {
38
+ mutate: "the daemon crashes on start (or never answers the probe)",
39
+ caughtOnlyBy: "this", // only this tooth exercises the daemon demo in process reality
40
+ },
41
+ ],
42
+ run: checkPlainDaemonContract,
43
+ });
44
+
45
+ registerTooth({
46
+ id: "examples.hosted-service-adapter",
47
+ profiles: ["service"],
48
+ layers: ["L2", "L3"],
49
+ kind: { kind: "invariant" },
50
+ mustRed: [
51
+ {
52
+ mutate: "the managed host loses the session ledger on resume",
53
+ caughtOnlyBy: "this", // only this tooth pins the managed demo's session preservation
54
+ },
55
+ ],
56
+ run: checkManagedHostAdapter,
57
+ });