@telorun/kernel 0.76.0 → 0.78.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 (47) hide show
  1. package/dist/bundle/tar.d.ts +3 -0
  2. package/dist/bundle/tar.d.ts.map +1 -1
  3. package/dist/bundle/tar.js +27 -1
  4. package/dist/bundle/tar.js.map +1 -1
  5. package/dist/host-versions.d.ts +23 -0
  6. package/dist/host-versions.d.ts.map +1 -0
  7. package/dist/host-versions.js +25 -0
  8. package/dist/host-versions.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/kernel.d.ts.map +1 -1
  14. package/dist/kernel.js +6 -1
  15. package/dist/kernel.js.map +1 -1
  16. package/dist/manifest-schemas.d.ts +1 -1
  17. package/dist/manifest-schemas.d.ts.map +1 -1
  18. package/dist/manifest-schemas.js +5 -3
  19. package/dist/manifest-schemas.js.map +1 -1
  20. package/dist/runtime-seam.d.ts.map +1 -1
  21. package/dist/runtime-seam.js +2 -0
  22. package/dist/runtime-seam.js.map +1 -1
  23. package/dist/transports/oci/oci-client.d.ts +4 -0
  24. package/dist/transports/oci/oci-client.d.ts.map +1 -1
  25. package/dist/transports/oci/oci-client.js +7 -1
  26. package/dist/transports/oci/oci-client.js.map +1 -1
  27. package/dist/transports/oci/oci-transport.d.ts +8 -3
  28. package/dist/transports/oci/oci-transport.d.ts.map +1 -1
  29. package/dist/transports/oci/oci-transport.js +67 -21
  30. package/dist/transports/oci/oci-transport.js.map +1 -1
  31. package/dist/transports/registry-transport.d.ts +3 -2
  32. package/dist/transports/registry-transport.d.ts.map +1 -1
  33. package/dist/transports/registry-transport.js +10 -0
  34. package/dist/transports/registry-transport.js.map +1 -1
  35. package/dist/transports/transport.d.ts +25 -6
  36. package/dist/transports/transport.d.ts.map +1 -1
  37. package/package.json +3 -3
  38. package/src/bundle/tar.ts +28 -1
  39. package/src/host-versions.ts +26 -0
  40. package/src/index.ts +1 -0
  41. package/src/kernel.ts +6 -1
  42. package/src/manifest-schemas.ts +8 -2
  43. package/src/runtime-seam.ts +2 -0
  44. package/src/transports/oci/oci-client.ts +8 -1
  45. package/src/transports/oci/oci-transport.ts +76 -22
  46. package/src/transports/registry-transport.ts +14 -0
  47. package/src/transports/transport.ts +26 -5
@@ -71,6 +71,13 @@ export interface OciManifest {
71
71
  annotations?: Record<string, string>;
72
72
  }
73
73
 
74
+ /** A blob's OCI digest, as `pushBlob` addresses it. Exported because the
75
+ * `layers:` index has to name a layer's digest BEFORE it is pushed — a second
76
+ * spelling of this would be a second answer to what a blob is called. */
77
+ export function blobDigest(bytes: Uint8Array): string {
78
+ return `sha256:${sha256Hex(bytes)}`;
79
+ }
80
+
74
81
  function sha256Hex(bytes: Uint8Array): string {
75
82
  return createHash("sha256").update(bytes).digest("hex");
76
83
  }
@@ -270,7 +277,7 @@ export class OciClient {
270
277
  * `sha256:<hex>` digest. Two-step upload: obtain a session, then PUT with
271
278
  * the digest. */
272
279
  async pushBlob(bytes: Uint8Array): Promise<string> {
273
- const digest = `sha256:${sha256Hex(bytes)}`;
280
+ const digest = blobDigest(bytes);
274
281
 
275
282
  const head = await this.authedFetch(
276
283
  `${this.base()}/blobs/${digest}`,
@@ -5,25 +5,25 @@ import {
5
5
  sha256Base64Url,
6
6
  verifyIntegrity,
7
7
  type ArtifactLayer,
8
+ type ArtifactSelector,
8
9
  type ManifestCacheCoords,
9
10
  type ManifestSource,
10
11
  } from "@telorun/analyzer";
11
- import { createHash } from "node:crypto";
12
-
13
12
  import {
14
13
  computeFilesIntegrity,
15
- injectLayerIndex,
16
14
  type PayloadFile,
17
15
  } from "../../bundle/files-integrity.js";
18
16
  import { readOwnerManifest, type OwnerManifest } from "../../bundle/module-manifest.js";
19
17
  import { makeTarGz, readTarGz, toPayloadFiles } from "../../bundle/tar.js";
20
18
  import type {
19
+ PayloadLayer,
21
20
  PublishBundle,
22
21
  PublishOptions,
23
22
  PublishResult,
24
23
  Transport,
25
24
  } from "../transport.js";
26
25
  import {
26
+ blobDigest,
27
27
  OciClient,
28
28
  OCI_MANIFEST_MEDIA_TYPE,
29
29
  TELO_LAYER_ROLE_ANNOTATION,
@@ -42,6 +42,21 @@ import {
42
42
  withRefVersion,
43
43
  } from "./oci-ref.js";
44
44
 
45
+ /** A layer's identity within one artifact: role plus, for a controller layer,
46
+ * its selector. Role alone is not it — there is one controller layer per
47
+ * selector. Same shape the release ledger keys on. */
48
+ function layerKey(role: string, selector?: ArtifactSelector): string {
49
+ return selector ? `${role}/${selectorKey(selector)}` : role;
50
+ }
51
+
52
+ /** The `layers:` index the bundle's own manifest declares, keyed for lookup.
53
+ * Read back out of the text rather than passed alongside it, because the text
54
+ * is what ships — anything else would be a second copy to keep in agreement. */
55
+ function declaredLayerIndex(bundle: PublishBundle): Map<string, ArtifactLayer> {
56
+ const declared = readOwnerManifest(bundle.manifest).layers ?? [];
57
+ return new Map(declared.map((layer) => [layerKey(layer.role, layer.selector), layer]));
58
+ }
59
+
45
60
  /**
46
61
  * Pull only the **manifest layer** and return its verified `telo.yaml` text.
47
62
  *
@@ -211,17 +226,17 @@ export class OciTransport implements Transport {
211
226
  * republish that reorders layers is simply invisible here rather than a
212
227
  * failure. Content verification is the artifact handle's, which holds the
213
228
  * expected `integrity`. */
214
- async fetchLayer(ref: string, blobDigest: string): Promise<PayloadFile[]> {
229
+ async fetchLayer(ref: string, digest: string): Promise<PayloadFile[]> {
215
230
  const { host, repo } = parseOciRef(ref);
216
- const tar = await this.readClient(host, repo).pullBlob(blobDigest);
231
+ const tar = await this.readClient(host, repo).pullBlob(digest);
217
232
  // Verify the transfer against the digest that addressed it. A registry is
218
233
  // not trusted to return the blob that was asked for, and this is the only
219
234
  // place the pushed bytes exist — the content digest checked after extraction
220
235
  // covers the file set, not the archive that carried it.
221
- const actual = `sha256:${createHash("sha256").update(tar).digest("hex")}`;
222
- if (actual !== blobDigest) {
236
+ const actual = blobDigest(tar);
237
+ if (actual !== digest) {
223
238
  throw new IntegrityError(
224
- `Blob digest mismatch fetching a layer of ${ref}: requested ${blobDigest}, ` +
239
+ `Blob digest mismatch fetching a layer of ${ref}: requested ${digest}, ` +
225
240
  `received ${actual}. The registry returned different bytes than were addressed.`,
226
241
  );
227
242
  }
@@ -258,6 +273,27 @@ export class OciTransport implements Transport {
258
273
  return Object.fromEntries(mapped.filter((e): e is [string, string] => Boolean(e[1])));
259
274
  }
260
275
 
276
+ /** The index as it will be published: `blob` over the gzipped tar this
277
+ * transport pushes, `integrity` over the layer's file contents. Both are
278
+ * computed from the files alone, which is what lets the payload builder
279
+ * write the index into `telo.yaml` before a single byte is pushed. */
280
+ async layerIndex(layers: readonly PayloadLayer[]): Promise<ArtifactLayer[]> {
281
+ const index: ArtifactLayer[] = [];
282
+ for (const layer of layers) {
283
+ if (layer.files.length === 0) continue;
284
+ const tar = await makeTarGz(
285
+ layer.files.map((f) => ({ name: f.name, content: Buffer.from(f.content) })),
286
+ );
287
+ index.push({
288
+ role: layer.role,
289
+ ...(layer.selector ? { selector: layer.selector } : {}),
290
+ blob: blobDigest(tar),
291
+ integrity: await computeFilesIntegrity(layer.files),
292
+ });
293
+ }
294
+ return index;
295
+ }
296
+
261
297
  async publish(
262
298
  destination: string,
263
299
  bundle: PublishBundle,
@@ -285,17 +321,42 @@ export class OciTransport implements Transport {
285
321
  const tag = identity.version;
286
322
  const client = new OciClient(host, repo);
287
323
 
288
- // Push every payload layer first, collecting the digests that address them.
289
- // This ordering is what keeps the index non-circular: the manifest layer is
290
- // pushed last and names only the layers pushed before it, never itself.
324
+ // Push every payload layer first. This ordering is what keeps the index
325
+ // non-circular: the manifest layer is pushed last and names only the layers
326
+ // pushed before it, never itself.
327
+ //
328
+ // The index is NOT written here — `bundle.manifest` already carries it, and
329
+ // rewriting the manifest at this point is precisely the bug this replaced:
330
+ // the bytes a dependent hashed to derive its pin would then never be the
331
+ // bytes pushed. So each pushed blob is CHECKED against what the manifest
332
+ // already claims, which is also the standing test that framing stayed
333
+ // deterministic — a claim nobody can satisfy is a hard failure, not a
334
+ // silently corrected index.
335
+ const declared = declaredLayerIndex(bundle);
291
336
  const payloadDescriptors: OciDescriptor[] = [];
292
- const index: ArtifactLayer[] = [];
293
337
  for (const layer of bundle.layers) {
294
338
  if (layer.files.length === 0) continue;
339
+ const key = layerKey(layer.role, layer.selector);
295
340
  const tar = await makeTarGz(
296
341
  layer.files.map((f) => ({ name: f.name, content: Buffer.from(f.content) })),
297
342
  );
298
343
  const blob = await client.pushBlob(tar);
344
+ const claim = declared.get(key);
345
+ if (!claim) {
346
+ throw new Error(
347
+ `layer '${key}' is being pushed but the manifest's 'layers:' index does not name it. ` +
348
+ `The index is written by the payload builder before publish, so a layer missing ` +
349
+ `from it would be unaddressable by any importer.`,
350
+ );
351
+ }
352
+ if (claim.blob !== blob) {
353
+ throw new Error(
354
+ `layer '${key}' framed to ${blob}, but the manifest's 'layers:' index claims ` +
355
+ `${claim.blob}. The published telo.yaml is hashed into every dependent's import ` +
356
+ `pin, so it cannot be corrected here — the archive framing must be a pure ` +
357
+ `function of the layer's files.`,
358
+ );
359
+ }
299
360
  payloadDescriptors.push({
300
361
  mediaType: TELO_PAYLOAD_LAYER_MEDIA_TYPE,
301
362
  digest: blob,
@@ -307,18 +368,11 @@ export class OciTransport implements Transport {
307
368
  : {}),
308
369
  },
309
370
  });
310
- index.push({
311
- role: layer.role,
312
- ...(layer.selector ? { selector: layer.selector } : {}),
313
- blob,
314
- integrity: await computeFilesIntegrity(layer.files),
315
- });
316
371
  }
317
372
 
318
- // Inject the index, then push telo.yaml as its own layer so a manifest read
319
- // never has to pull a payload.
320
- const manifestText =
321
- index.length > 0 ? injectLayerIndex(bundle.manifest, index) : bundle.manifest;
373
+ // Push telo.yaml as its own layer so a manifest read never has to pull a
374
+ // payload. Verbatim these are the bytes importers pin.
375
+ const manifestText = bundle.manifest;
322
376
  const manifestTar = await makeTarGz([
323
377
  { name: DEFAULT_MANIFEST_FILENAME, content: manifestText },
324
378
  ]);
@@ -8,6 +8,7 @@ import {
8
8
  sha256Base64Url,
9
9
  withRefVersion,
10
10
  splitIntegrity,
11
+ type ArtifactLayer,
11
12
  type ManifestCacheCoords,
12
13
  type ManifestSource,
13
14
  } from "@telorun/analyzer";
@@ -17,6 +18,7 @@ import { createHash } from "crypto";
17
18
  import type { PayloadFile } from "../bundle/files-integrity.js";
18
19
  import { assertPublicEgress } from "./egress-guard.js";
19
20
  import type {
21
+ PayloadLayer,
20
22
  PublishBundle,
21
23
  PublishOptions,
22
24
  PublishResult,
@@ -279,6 +281,18 @@ export class RegistryTransport implements Transport {
279
281
  );
280
282
  }
281
283
 
284
+ async layerIndex(layers: readonly PayloadLayer[]): Promise<ArtifactLayer[]> {
285
+ // Same boundary `fetchLayer` and `publish` draw: the HTTP registry serves
286
+ // manifests only, so it frames no layer and can name no blob. An empty set
287
+ // is not a payload, so it answers rather than throws — a manifest-only
288
+ // module builds its payload through this transport during analysis.
289
+ if (layers.every((layer) => layer.files.length === 0)) return [];
290
+ throw new Error(
291
+ "The Telo registry serves manifests only, so it cannot index payload layers. " +
292
+ "A module with a bundled payload is published as an OCI artifact (oci://host/repo).",
293
+ );
294
+ }
295
+
282
296
  async publish(
283
297
  destination: string,
284
298
  bundle: PublishBundle,
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ArtifactLayer,
2
3
  ArtifactSelector,
3
4
  LayerRole,
4
5
  ManifestCacheCoords,
@@ -19,10 +20,10 @@ export interface PayloadLayer {
19
20
 
20
21
  /** The module bundle handed to a transport for publishing: the final,
21
22
  * already-analyzed / pinned / canonicalized `telo.yaml` bytes plus the payload
22
- * partitioned into layers (empty for a manifest-only module). The transport
23
- * pushes each layer as its own blob, injects the resulting `layers:` index into
24
- * the manifest, and only then pushes the manifest layer the order that keeps
25
- * the index non-circular. */
23
+ * partitioned into layers (empty for a manifest-only module), whose `layers:`
24
+ * index the manifest already carries. The transport pushes each layer as its
25
+ * own blob, checks the digest against what the index claims, and only then
26
+ * pushes the manifest layer — the order that keeps the index non-circular. */
26
27
  export interface PublishBundle {
27
28
  manifest: string;
28
29
  layers: PayloadLayer[];
@@ -151,9 +152,29 @@ export interface Transport {
151
152
  * verifies. */
152
153
  manifestHash(ref: string): Promise<string>;
153
154
 
155
+ /** The `layers:` index `layers` will be published under: each entry's
156
+ * `integrity` over the layer's file contents and its `blob` over the exact
157
+ * bytes this transport will push.
158
+ *
159
+ * On the interface, and called BEFORE anything is pushed, because the index
160
+ * goes *inside* `telo.yaml` and a dependent's import pin is a hash of that
161
+ * file. Injecting the index at push time — which is what this replaced —
162
+ * meant the payload builder's manifest was never the published one, so every
163
+ * sibling pin derived from it named bytes the registry does not have. The
164
+ * builder therefore needs the index up front, and only the transport knows
165
+ * the framing a `blob` digest covers.
166
+ *
167
+ * Requires the framing to be a pure function of the files (see `makeTarGz`),
168
+ * since `publish` re-frames the same layers and hard-fails if a digest moved.
169
+ * Returns `[]` for an empty layer set; throws when this transport cannot
170
+ * publish payload layers at all. */
171
+ layerIndex(layers: readonly PayloadLayer[]): Promise<ArtifactLayer[]>;
172
+
154
173
  /** Push `bundle` to `destination` (a base ref / repo whose scheme this
155
174
  * transport owns), pinning the payload and writing the transport-native
156
- * artifact shape. Throws on failure. Used by `telo publish`. */
175
+ * artifact shape. `bundle.manifest` already carries the `layers:` index this
176
+ * transport returned from {@link layerIndex}; publish verifies rather than
177
+ * rewrites it. Throws on failure. Used by `telo publish`. */
157
178
  publish(
158
179
  destination: string,
159
180
  bundle: PublishBundle,