@telorun/kernel 0.83.0 → 0.85.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 (64) hide show
  1. package/dist/bundle/module-artifact.d.ts +1 -1
  2. package/dist/bundle/module-artifact.d.ts.map +1 -1
  3. package/dist/bundle/module-artifact.js +3 -3
  4. package/dist/bundle/module-artifact.js.map +1 -1
  5. package/dist/controller-loaders/bundle-loader.d.ts +5 -5
  6. package/dist/controller-loaders/bundle-loader.js +7 -7
  7. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  8. package/dist/controllers/resource-definition/resource-definition-controller.d.ts +3 -0
  9. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  10. package/dist/controllers/resource-definition/resource-definition-controller.js +8 -1
  11. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  12. package/dist/evaluation-context.d.ts +22 -0
  13. package/dist/evaluation-context.d.ts.map +1 -1
  14. package/dist/evaluation-context.js +75 -3
  15. package/dist/evaluation-context.js.map +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/instance-sensitive-paths.d.ts +61 -0
  21. package/dist/instance-sensitive-paths.d.ts.map +1 -0
  22. package/dist/instance-sensitive-paths.js +116 -0
  23. package/dist/instance-sensitive-paths.js.map +1 -0
  24. package/dist/invocation-contract-binding.d.ts +3 -0
  25. package/dist/invocation-contract-binding.d.ts.map +1 -1
  26. package/dist/invocation-contract-binding.js +9 -1
  27. package/dist/invocation-contract-binding.js.map +1 -1
  28. package/dist/kernel.d.ts +0 -5
  29. package/dist/kernel.d.ts.map +1 -1
  30. package/dist/kernel.js +30 -7
  31. package/dist/kernel.js.map +1 -1
  32. package/dist/manifest-sources/local-manifest-cache-source.d.ts +6 -6
  33. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  34. package/dist/manifest-sources/local-manifest-cache-source.js +11 -12
  35. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  36. package/dist/runtime-seam.d.ts.map +1 -1
  37. package/dist/runtime-seam.js +1 -2
  38. package/dist/runtime-seam.js.map +1 -1
  39. package/dist/transports/http-transport.d.ts +37 -0
  40. package/dist/transports/http-transport.d.ts.map +1 -0
  41. package/dist/transports/http-transport.js +168 -0
  42. package/dist/transports/http-transport.js.map +1 -0
  43. package/dist/transports/transport-registry.d.ts +13 -14
  44. package/dist/transports/transport-registry.d.ts.map +1 -1
  45. package/dist/transports/transport-registry.js +17 -24
  46. package/dist/transports/transport-registry.js.map +1 -1
  47. package/package.json +2 -2
  48. package/src/bundle/module-artifact.ts +2 -3
  49. package/src/controller-loaders/bundle-loader.ts +7 -7
  50. package/src/controllers/resource-definition/resource-definition-controller.ts +14 -0
  51. package/src/evaluation-context.ts +80 -5
  52. package/src/index.ts +1 -1
  53. package/src/instance-sensitive-paths.ts +123 -0
  54. package/src/invocation-contract-binding.ts +12 -0
  55. package/src/kernel.ts +33 -12
  56. package/src/manifest-sources/local-manifest-cache-source.ts +9 -16
  57. package/src/runtime-seam.ts +1 -2
  58. package/src/transports/http-transport.ts +209 -0
  59. package/src/transports/transport-registry.ts +17 -24
  60. package/dist/transports/registry-transport.d.ts +0 -41
  61. package/dist/transports/registry-transport.d.ts.map +0 -1
  62. package/dist/transports/registry-transport.js +0 -282
  63. package/dist/transports/registry-transport.js.map +0 -1
  64. package/src/transports/registry-transport.ts +0 -339
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Instance → the contract paths its kind marked `x-telo-sensitive`, recorded at
3
+ * `create()` beside the handle and the declaration.
4
+ *
5
+ * The trace site has only the instance. It cannot re-derive this: the resolved
6
+ * contract is compiled inside `bindContract`'s closure and dropped, the
7
+ * definition carries the DECLARATION rather than the resolved schema, and an
8
+ * instance-manifest override would be missed by re-resolving from the kind. So
9
+ * the answer is recorded where both halves are in hand, exactly as
10
+ * `instance-declaration.ts` records the other direction.
11
+ *
12
+ * TWO entries per instance, kept apart by direction, because a contract may mark
13
+ * a field on the way in as well as on the way out — `forceRefresh` going to a
14
+ * credential is not sensitive, but a signing key handed to one would be, and a
15
+ * single merged list would redact an input path in an output payload where it
16
+ * names something else entirely.
17
+ *
18
+ * Weak and one-way: paths are obtainable FROM an instance, never an instance
19
+ * from paths, so nothing here extends a lifetime or hands out live state.
20
+ * Resolution is LAZY — the paths are read through a thunk rather than eagerly,
21
+ * because compiling a contract at create time would make every contract-bearing
22
+ * kind depend on type-registration order, which is the reason the binding defers
23
+ * it in the first place.
24
+ */
25
+
26
+ export type ContractDirection = "inputType" | "outputType";
27
+
28
+ interface SensitiveThunks {
29
+ inputType?: () => string[][];
30
+ outputType?: () => string[][];
31
+ }
32
+
33
+ const sensitive = new WeakMap<object, SensitiveThunks>();
34
+
35
+ /** Record how to obtain one direction's sensitive paths for a live instance.
36
+ * First record wins, matching the handle and declaration rules: a `base:` child
37
+ * IS its parent instance, and the parent's binding is the one that produced
38
+ * it. */
39
+ export function recordSensitivePaths(
40
+ instance: object,
41
+ direction: ContractDirection,
42
+ paths: () => string[][],
43
+ ): void {
44
+ const entry = sensitive.get(instance) ?? {};
45
+ if (entry[direction] !== undefined) return;
46
+ entry[direction] = paths;
47
+ sensitive.set(instance, entry);
48
+ }
49
+
50
+ /**
51
+ * The paths one direction of a live instance's contract marked sensitive.
52
+ *
53
+ * An EMPTY list means the contract marked nothing, or the kind declares no
54
+ * contract at all — carry the payload verbatim. `undefined` means the contract
55
+ * could not be resolved, so WHICH fields are sensitive is unknown and the
56
+ * payload must be withheld whole.
57
+ *
58
+ * Nothing is swallowed by that: resolving is exactly what the dispatch about to
59
+ * follow does, so the same failure surfaces from it a moment later, with its own
60
+ * code and its own message. What the catch avoids is a trace site becoming the
61
+ * place an unrelated contract defect first appears — and, far worse, emitting
62
+ * auth material onto the wire because a schema failed to compile.
63
+ */
64
+ export function sensitivePathsOfInstance(
65
+ instance: unknown,
66
+ direction: ContractDirection,
67
+ ): string[][] | undefined {
68
+ if (!instance || typeof instance !== "object") return [];
69
+ const thunk = sensitive.get(instance as object)?.[direction];
70
+ if (!thunk) return [];
71
+ try {
72
+ return thunk();
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ /** What a hidden value reads as. The key is KEPT and only the value replaced,
79
+ * per the logging spec §14: a payload that silently loses a key reads as a
80
+ * value that was never produced. */
81
+ export const REDACTED = "[redacted]";
82
+
83
+ /**
84
+ * `value` with the marked paths replaced by {@link REDACTED}.
85
+ *
86
+ * Copy-on-write ALONG THE PATHS ONLY, so the caller's own object — the very
87
+ * object being handed to a controller, or the one it just returned — is never
88
+ * mutated. A whole-payload clone would be the obvious alternative and is wrong
89
+ * twice: it costs a deep copy per span on the dispatch path, and it would
90
+ * rewrite live values (a stream handle, a resource instance) that only survive
91
+ * by identity.
92
+ */
93
+ export function redactSensitive(value: unknown, paths: readonly string[][]): unknown {
94
+ if (paths.length === 0) return value;
95
+ let out = value;
96
+ for (const path of paths) out = redactAt(out, path, 0);
97
+ return out;
98
+ }
99
+
100
+ function redactAt(node: unknown, path: readonly string[], index: number): unknown {
101
+ if (node === null || node === undefined) return node;
102
+ if (index === path.length) return REDACTED;
103
+ const segment = path[index];
104
+ if (segment === "[]") {
105
+ return Array.isArray(node) ? node.map((element) => redactAt(element, path, index + 1)) : node;
106
+ }
107
+ // The map-value wildcard: every own key, whatever it is named. What
108
+ // `additionalProperties` / `patternProperties` emit, since neither carries
109
+ // property names to walk.
110
+ if (segment === "{}") {
111
+ if (typeof node !== "object" || Array.isArray(node)) return node;
112
+ const source = node as Record<string, unknown>;
113
+ const out: Record<string, unknown> = {};
114
+ for (const [key, value] of Object.entries(source)) out[key] = redactAt(value, path, index + 1);
115
+ return out;
116
+ }
117
+ if (typeof node !== "object" || Array.isArray(node)) return node;
118
+ const object = node as Record<string, unknown>;
119
+ // A path the value does not carry is not a defect — the contract describes
120
+ // what MAY be there, and an optional field is routinely absent.
121
+ if (!(segment in object)) return node;
122
+ return { ...object, [segment]: redactAt(object[segment], path, index + 1) };
123
+ }
@@ -5,6 +5,7 @@ import {
5
5
  type DeclaredScalarForm,
6
6
  type DeclaredScalarPath,
7
7
  defaultBearingPaths,
8
+ sensitivePaths,
8
9
  effectiveContractField,
9
10
  describeProjectionFailure,
10
11
  resolveSchemaProjections,
@@ -75,6 +76,9 @@ export interface BoundContract {
75
76
  * value is normalized at, in either direction. Empty when the contract
76
77
  * declares none. */
77
78
  scalarPaths(): DeclaredScalarPath[];
79
+ /** Paths the contract marked `x-telo-sensitive` — the values a trace payload
80
+ * carries as `[redacted]`. Empty when the contract marks none. */
81
+ sensitivePaths(): string[][];
78
82
  }
79
83
 
80
84
  const CONTRACT_ERROR: Record<ContractDirection, string> = {
@@ -149,6 +153,7 @@ export function resolveBoundContract(
149
153
  let compiled: { validate(value: unknown): void } | undefined;
150
154
  let paths: string[][] | undefined;
151
155
  let scalars: DeclaredScalarPath[] | undefined;
156
+ let sensitive: string[][] | undefined;
152
157
 
153
158
  const resolve = (): { validate(value: unknown): void } => {
154
159
  if (compiled !== undefined) return compiled;
@@ -193,6 +198,9 @@ export function resolveBoundContract(
193
198
  const stripped = withLiveValuesSkipped(projected, factory.resolveRef);
194
199
  paths = defaultBearingPaths(stripped, factory.resolveRef);
195
200
  scalars = declaredScalarPaths(stripped, factory.resolveRef);
201
+ // Read off the STRIPPED schema like the other two, so a marked node behind a
202
+ // live value is not reported: nothing walks into a stream to redact it.
203
+ sensitive = sensitivePaths(stripped, factory.resolveRef);
196
204
  // Compile by NAME whenever the declaration is one, so the type's CEL
197
205
  // `rules:` are composed in — including when a stream had to be stripped, in
198
206
  // which case the stream-bearing properties are dropped from the schema the
@@ -220,6 +228,10 @@ export function resolveBoundContract(
220
228
  resolve();
221
229
  return scalars ?? [];
222
230
  },
231
+ sensitivePaths: () => {
232
+ resolve();
233
+ return sensitive ?? [];
234
+ },
223
235
  };
224
236
  }
225
237
 
package/src/kernel.ts CHANGED
@@ -53,6 +53,7 @@ import { ResourceContextImpl } from "./resource-context.js";
53
53
  import { mintResourceHandle } from "./resource-handle.js";
54
54
  import { bindEffectOwner } from "./effect-scope.js";
55
55
  import { declarationOfInstance, recordInstanceDeclaration } from "./instance-declaration.js";
56
+ import { recordSensitivePaths } from "./instance-sensitive-paths.js";
56
57
  import { nodeHostVersions } from "./host-versions.js";
57
58
  import { nodeCelHandlers } from "./cel-handlers.js";
58
59
  import { parseRef, seedInvokeSource } from "./invoke-dispatch.js";
@@ -128,10 +129,6 @@ export interface KernelOptions {
128
129
  * fails to dispatch). Order matters — later entries take priority over
129
130
  * earlier ones (sources are unshifted onto the dispatch chain). */
130
131
  sources: ManifestSource[];
131
- /** Base URL for the registry source. When unset, the `RegistrySource`
132
- * default applies. Callers (e.g. the CLI) are responsible for resolving
133
- * `TELO_REGISTRY_URL` or any other env-based fallback before passing. */
134
- registryUrl?: string;
135
132
  }
136
133
 
137
134
  /**
@@ -194,7 +191,6 @@ export class Kernel implements IKernel {
194
191
  readonly stderr: NodeJS.WritableStream;
195
192
  readonly env: Record<string, string | undefined>;
196
193
  readonly argv: string[];
197
- readonly registryUrl: string | undefined;
198
194
  /** The sources this kernel was constructed with, kept so `ctx.runtime` can
199
195
  * give a child manifest — or a static check of one — the same resolution
200
196
  * chain this kernel runs on. The transports come from the registry and are
@@ -231,11 +227,10 @@ export class Kernel implements IKernel {
231
227
  return { traceId: ambient.traceId, spanId };
232
228
  });
233
229
  this.argv = options.argv ?? [];
234
- this.registryUrl = options.registryUrl;
235
230
  // Resolution sources come from the transport registry, so a scheme-owning
236
231
  // transport (OCI, later S3) joins the loader's dispatch chain by being
237
232
  // registered — no source-chain edits here.
238
- this.loader = new Loader(defaultTransportRegistry(this.registryUrl).sources(), {
233
+ this.loader = new Loader(defaultTransportRegistry().sources(), {
239
234
  celHandlers: nodeCelHandlers,
240
235
  });
241
236
  this.injectedSources = [...options.sources];
@@ -333,8 +328,8 @@ export class Kernel implements IKernel {
333
328
  // import-controller's independent re-resolution onto the winning source so a
334
329
  // sub-library importing a lower version loads the same controller/definition
335
330
  // the analyzer registered — never a second, colliding copy. Keyed by
336
- // canonical URL; `canonicalize` maps a registry ref (returned verbatim by
337
- // the loader) to the URL the graph walk already resolved it to.
331
+ // canonical URL; `canonicalize` maps a ref returned verbatim by the loader
332
+ // to the URL the graph walk already resolved it to.
338
333
  const overrides = this._loadedGraph?.overrides;
339
334
  if (overrides && overrides.size > 0) {
340
335
  const canonical = this.loader.canonicalize(resolved) ?? resolved;
@@ -1063,7 +1058,7 @@ export class Kernel implements IKernel {
1063
1058
  private buildModuleArtifacts(graph: LoadedGraph, manifestsDir: string | undefined): void {
1064
1059
  this.moduleArtifacts.clear();
1065
1060
  this.siblingLibraries.clear();
1066
- const transports = defaultTransportRegistry(this.registryUrl);
1061
+ const transports = defaultTransportRegistry();
1067
1062
  const entryDir = this._entryUrl ? resolveEntryDir(this._entryUrl) ?? "" : "";
1068
1063
  // The same pre-anchor root `LocalManifestCacheSource` falls back to. Layers
1069
1064
  // live beside the cached manifest, so both halves have to look in the same
@@ -1085,7 +1080,6 @@ export class Kernel implements IKernel {
1085
1080
  file.requestedUrl,
1086
1081
  file.source,
1087
1082
  entryDir,
1088
- this.registryUrl,
1089
1083
  manifestsDir,
1090
1084
  legacyDir,
1091
1085
  );
@@ -1479,6 +1473,26 @@ export class Kernel implements IKernel {
1479
1473
 
1480
1474
  if (!runtime.length) return { instance, ctx, resource: processedResource };
1481
1475
 
1476
+ // Runtime eval paths are expanded against a CALL's inputs, so they need a
1477
+ // call. `invoke` is the only entry point that takes any — `run()` and
1478
+ // `provide()` are parameterless — so a kind that declares one of these paths
1479
+ // and has no `invoke()` has annotated something nothing can ever expand: the
1480
+ // value stays a compiled expression for the life of the resource. Reported
1481
+ // here rather than dereferenced: this used to be a non-null assertion, and it
1482
+ // failed as `undefined is not an object (evaluating 'instance.invoke.bind')`
1483
+ // against the kernel's own source, naming neither the kind nor the field.
1484
+ // Method presence rather than declared capability, because the kernel
1485
+ // dispatches on the method — a Provider that implements `invoke` is bound
1486
+ // exactly like an Invocable.
1487
+ if (typeof instance.invoke !== "function") {
1488
+ throw new RuntimeError(
1489
+ "ERR_RUNTIME_EVAL_WITHOUT_INVOKE",
1490
+ `Kind ${resolvedKind} declares 'x-telo-eval: runtime' (at ${runtime.join(", ")}), but its resources have no invoke() — ` +
1491
+ `runtime evaluation expands a call's inputs, and run() / provide() take none. ` +
1492
+ `Use 'x-telo-eval: compile' for a value resolved once when the resource is created, or give the kind an invocable controller.`,
1493
+ );
1494
+ }
1495
+
1482
1496
  // Override invoke in-place so all lifecycle methods (init/invoke/teardown/snapshot)
1483
1497
  // share the same `this`. A wrapper object would split identity: state mutated by
1484
1498
  // init() on the wrapper would be invisible to the original invoke(), which still
@@ -1487,7 +1501,7 @@ export class Kernel implements IKernel {
1487
1501
  // Every argument is forwarded: `invoke(inputs, ctx)` carries the
1488
1502
  // InvokeContext (cancellation, tracing) as its second parameter, and a
1489
1503
  // wrapper that declares only `inputs` silently drops it.
1490
- const originalInvoke = instance.invoke!.bind(instance);
1504
+ const originalInvoke = instance.invoke.bind(instance);
1491
1505
  instance.invoke = async (inputs: any, ...rest: unknown[]) => {
1492
1506
  const expanded = evalContext.expandPaths(inputs as Record<string, unknown>, runtime);
1493
1507
  return (originalInvoke as (i: any, ...r: unknown[]) => Promise<unknown>)(expanded, ...rest);
@@ -1564,6 +1578,13 @@ export class Kernel implements IKernel {
1564
1578
  );
1565
1579
  if (!input && !output) return;
1566
1580
 
1581
+ // Recorded here because this is the only point holding both the instance and
1582
+ // its resolved contract — `bindContract` closes over the contract and drops
1583
+ // it, and the trace site has only the instance. Lazily, so a contract is
1584
+ // still compiled on first dispatch rather than at create time.
1585
+ if (input) recordSensitivePaths(instance, "inputType", () => input.sensitivePaths());
1586
+ if (output) recordSensitivePaths(instance, "outputType", () => output.sensitivePaths());
1587
+
1567
1588
  bindContract(instance, {
1568
1589
  input,
1569
1590
  output,
@@ -15,7 +15,6 @@ import { TransportRegistry, defaultTransportRegistry } from "../transports/trans
15
15
  import { findWorkspaceRoot } from "../workspace-marker.js";
16
16
 
17
17
  const CACHE_SUBDIR = ".telo/manifests";
18
- const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
19
18
 
20
19
  /** Verify that `candidate` resolves to a path under `root`. Returns the
21
20
  * candidate path on success, `null` when any segment escapes the root.
@@ -33,8 +32,8 @@ function joinUnder(root: string, ...segments: string[]): string | null {
33
32
 
34
33
  /** Single source of truth for URL → cache path. Used identically by the
35
34
  * reader (cache lookup) and writer (install-time persistence). For any
36
- * given import ref — registry ref, direct registry URL, arbitrary HTTP, or
37
- * `oci://` — both sides land on the same file: the owning transport supplies
35
+ * given import ref — an HTTP(S) URL or an `oci://` ref both sides land on
36
+ * the same file: the owning transport supplies
38
37
  * the coordinates and the analyzer's `manifestCacheKey` renders them, the same
39
38
  * grammar the hub's static manifest bucket and the editor's read path use.
40
39
  *
@@ -80,8 +79,8 @@ export function legacyManifestsDirFallback(
80
79
 
81
80
  /**
82
81
  * Reads previously-cached manifest YAMLs from the resolved manifest cache. Sits
83
- * ahead of `RegistrySource` / `HttpSource` in the source chain — a hit makes boot
84
- * hermetic, a miss falls through to the network source unchanged.
82
+ * ahead of `HttpSource` in the source chain — a hit makes boot hermetic, a miss
83
+ * falls through to the network source unchanged.
85
84
  *
86
85
  * Populated by `writeManifestCache` at install time.
87
86
  *
@@ -90,7 +89,7 @@ export function legacyManifestsDirFallback(
90
89
  * costs only CPU when it goes cold; this one costs network, so without the
91
90
  * fallback the move to a workspace-anchored root would stop a hermetic setup from
92
91
  * booting — its `telo install` output stranded at the old path, with the failure
93
- * surfacing as a registry fetch on a machine that has no route to one. Read-only
92
+ * surfacing as a network fetch on a machine that has no route out. Read-only
94
93
  * and one directory deep: writes always go to the current root, so the old copy
95
94
  * ages out rather than being maintained.
96
95
  */
@@ -99,17 +98,13 @@ export class LocalManifestCacheSource implements ManifestSource {
99
98
  private readonly legacyRoot: string | null;
100
99
  private readonly transports: TransportRegistry;
101
100
 
102
- constructor(
103
- entryDir: string,
104
- registryUrl: string = DEFAULT_REGISTRY_URL,
105
- manifestsDir?: string,
106
- ) {
101
+ constructor(entryDir: string, manifestsDir?: string) {
107
102
  // `manifestsDir` is the resolved manifest-cache directory threaded from a
108
103
  // single `resolveCacheRoot` (honours `TELO_CACHE_DIR`); when absent we fall
109
104
  // back to the entry-anchored default so library/test callers are unchanged.
110
105
  this.cacheRoot = manifestsDir ?? legacyManifestsDir(entryDir);
111
106
  this.legacyRoot = legacyManifestsDirFallback(entryDir, this.cacheRoot);
112
- this.transports = defaultTransportRegistry(registryUrl);
107
+ this.transports = defaultTransportRegistry();
113
108
  }
114
109
 
115
110
  supports(url: string): boolean {
@@ -176,11 +171,10 @@ export class LocalManifestCacheSource implements ManifestSource {
176
171
  export function cachePathForCanonical(
177
172
  canonicalSource: string,
178
173
  entryDir: string,
179
- registryUrl: string | undefined = DEFAULT_REGISTRY_URL,
180
174
  manifestsDir?: string,
181
175
  ): string | null {
182
176
  const cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
183
- return cachePathForUrl(canonicalSource, cacheRoot, defaultTransportRegistry(registryUrl));
177
+ return cachePathForUrl(canonicalSource, cacheRoot, defaultTransportRegistry());
184
178
  }
185
179
 
186
180
  /**
@@ -198,7 +192,6 @@ export function cachePathForCanonical(
198
192
  export async function writeManifestCache(
199
193
  graph: LoadedGraph,
200
194
  entryDir: string,
201
- registryUrl: string = DEFAULT_REGISTRY_URL,
202
195
  manifestsDir?: string,
203
196
  ): Promise<string[]> {
204
197
  const written: string[] = [];
@@ -210,7 +203,7 @@ export async function writeManifestCache(
210
203
  if (seen.has(file.source)) continue;
211
204
  seen.add(file.source);
212
205
 
213
- const target = cachePathForCanonical(file.source, entryDir, registryUrl, manifestsDir);
206
+ const target = cachePathForCanonical(file.source, entryDir, manifestsDir);
214
207
  if (!target) continue;
215
208
 
216
209
  await fs.mkdir(path.dirname(target), { recursive: true });
@@ -167,7 +167,6 @@ export class KernelRuntimeSeam implements RuntimeSeam {
167
167
  stdout: stdout.writable,
168
168
  stderr: stderr.writable,
169
169
  sources: [...this.kernel.injectedSources],
170
- registryUrl: this.kernel.registryUrl,
171
170
  });
172
171
 
173
172
  // A child that fails to load is not an exception on this side: the caller
@@ -223,7 +222,7 @@ export class KernelRuntimeSeam implements RuntimeSeam {
223
222
  // rather than the kernel's own loader because a checked manifest is often
224
223
  // deliberately broken and has no business entering the running kernel's
225
224
  // parse cache.
226
- const loader = new Loader(defaultTransportRegistry(this.kernel.registryUrl).sources(), {
225
+ const loader = new Loader(defaultTransportRegistry().sources(), {
227
226
  celHandlers: nodeCelHandlers,
228
227
  });
229
228
  for (const injected of this.kernel.injectedSources) {
@@ -0,0 +1,209 @@
1
+ import {
2
+ DEFAULT_MANIFEST_FILENAME,
3
+ HttpSource,
4
+ sha256Base64Url,
5
+ splitIntegrity,
6
+ type ArtifactLayer,
7
+ type ManifestCacheCoords,
8
+ type ManifestSource,
9
+ } from "@telorun/analyzer";
10
+ import { fetchOrThrow } from "@telorun/sdk";
11
+ import { createHash } from "crypto";
12
+
13
+ import type { PayloadFile } from "../bundle/files-integrity.js";
14
+ import { assertPublicEgress } from "./egress-guard.js";
15
+ import type {
16
+ PayloadLayer,
17
+ PublishBundle,
18
+ PublishOptions,
19
+ PublishResult,
20
+ Transport,
21
+ } from "./transport.js";
22
+
23
+ const QUERY_HASH_LENGTH = 12;
24
+
25
+ /** Mirror `HttpSource.read`'s `fetchUrl` derivation: when the URL does not
26
+ * already point at a YAML file, append `/telo.yaml`, so a raw import URL and
27
+ * the canonical source it resolves to map to the same cache path. */
28
+ function normalizePathname(rawUrl: string, parsed: URL): string {
29
+ let pathname = parsed.pathname;
30
+ if (!rawUrl.includes(".yaml")) {
31
+ pathname = pathname.endsWith("/")
32
+ ? `${pathname}${DEFAULT_MANIFEST_FILENAME}`
33
+ : `${pathname}/${DEFAULT_MANIFEST_FILENAME}`;
34
+ }
35
+ return pathname;
36
+ }
37
+
38
+ /** Short hash of `search + hash` so two URLs that differ only in query /
39
+ * fragment do not collide at the same cache path. */
40
+ function disambiguatePath(pathname: string, search: string, hash: string): string {
41
+ if (!search && !hash) return pathname;
42
+ const digest = createHash("sha256")
43
+ .update(search + hash)
44
+ .digest("hex")
45
+ .slice(0, QUERY_HASH_LENGTH);
46
+ const dotIdx = pathname.lastIndexOf(".");
47
+ const slashIdx = pathname.lastIndexOf("/");
48
+ const ext = dotIdx > slashIdx ? pathname.slice(dotIdx) : "";
49
+ const base = pathname.slice(0, pathname.length - ext.length);
50
+ return `${base}.${digest}${ext}`;
51
+ }
52
+
53
+ /** The transport for direct `https://…` (and `http://`) module URLs. Its
54
+ * resolution `source` composes the browser-safe `HttpSource` from `analyzer`;
55
+ * the Node-only management methods live here. This is the fallback transport
56
+ * for any ref that carries no owning scheme, so `oci://` (or a future `s3://`)
57
+ * never falls through to it — those refs are claimed by their own transport's
58
+ * `supports()`.
59
+ *
60
+ * A URL addresses exactly one file, so it names no enumerable version: this
61
+ * transport publishes nothing, lists no versions, and has no `@version` segment
62
+ * to bump. What it does own is reading, hashing and caching those bytes. */
63
+ export class HttpTransport implements Transport {
64
+ private readonly httpSource: HttpSource;
65
+ readonly source: ManifestSource;
66
+
67
+ constructor() {
68
+ this.httpSource = new HttpSource();
69
+ this.source = {
70
+ supports: (url) => this.supports(url),
71
+ read: async (url) => {
72
+ // The browser-safe source does the fetch; the Node-side egress policy
73
+ // is enforced here, on the host the read will actually hit.
74
+ await assertPublicEgress(url);
75
+ return this.httpSource.read(url);
76
+ },
77
+ resolveRelative: (base, relative) => this.httpSource.resolveRelative(base, relative),
78
+ };
79
+ }
80
+
81
+ supports(ref: string): boolean {
82
+ const { base } = splitIntegrity(ref);
83
+ return base.startsWith("http://") || base.startsWith("https://");
84
+ }
85
+
86
+ cacheCoords(ref: string): ManifestCacheCoords | null {
87
+ const url = splitIntegrity(ref).base;
88
+ if (!url.startsWith("http://") && !url.startsWith("https://")) return null;
89
+
90
+ let parsed: URL;
91
+ try {
92
+ parsed = new URL(url);
93
+ } catch {
94
+ return null;
95
+ }
96
+ const pathname = normalizePathname(url, parsed);
97
+
98
+ // `url` subtree, query-hash suffix on collision. No version segment: a URL
99
+ // addresses exactly one file, and the version it declares lives inside
100
+ // bytes the cache maps paths without.
101
+ const cleanPath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
102
+ const segments = disambiguatePath(cleanPath, parsed.search, parsed.hash).split("/");
103
+ const file = segments.pop();
104
+ if (!file) return null;
105
+ return { transport: "url", host: parsed.host, path: segments.join("/"), file };
106
+ }
107
+
108
+ async listVersions(): Promise<string[] | null> {
109
+ // A direct `https://` URL has no version-list endpoint.
110
+ return null;
111
+ }
112
+
113
+ refVersion(): string | null {
114
+ // A direct `https://` URL has no version segment to bump.
115
+ return null;
116
+ }
117
+
118
+ withVersion(ref: string): string {
119
+ throw new Error(
120
+ `cannot set a version on '${ref}': a URL addresses one file and carries no version segment.`,
121
+ );
122
+ }
123
+
124
+ /** Mirrors the source's fetch-URL derivation: the URL points at (or contains)
125
+ * the YAML file. `null` when this transport does not own the ref's shape. */
126
+ private manifestUrl(ref: string): string | null {
127
+ const { base } = splitIntegrity(ref);
128
+ if (!base.startsWith("http://") && !base.startsWith("https://")) return null;
129
+ return base.includes(".yaml") ? base : `${base}/${DEFAULT_MANIFEST_FILENAME}`;
130
+ }
131
+
132
+ async digest(ref: string): Promise<string | null> {
133
+ // The digest is Telo's canonical hash over the `telo.yaml` bytes — the same
134
+ // value `manifestHash` returns, but absent-is-null rather than a throw.
135
+ const fetchUrl = this.manifestUrl(ref);
136
+ if (!fetchUrl) return null;
137
+ await assertPublicEgress(fetchUrl);
138
+ const res = await fetchOrThrow(fetchUrl, undefined, { operation: "Module manifest read" });
139
+ if (res.status === 404) return null;
140
+ if (!res.ok) {
141
+ throw new Error(`${fetchUrl} returned ${res.status} ${res.statusText}`);
142
+ }
143
+ const bytes = new Uint8Array(await res.arrayBuffer());
144
+ return `sha256-${await sha256Base64Url(bytes)}`;
145
+ }
146
+
147
+ /** Hashes the **raw response bytes**, which is exactly what `verifiedFetch`
148
+ * checks an inline `#sha256-…` pin against on the read path. */
149
+ async manifestHash(ref: string): Promise<string> {
150
+ const fetchUrl = this.manifestUrl(ref);
151
+ if (!fetchUrl) {
152
+ throw new Error(`cannot hash non-remote import '${ref}'`);
153
+ }
154
+ await assertPublicEgress(fetchUrl);
155
+ const res = await fetchOrThrow(fetchUrl, undefined, { operation: "Module manifest hash" });
156
+ if (!res.ok) {
157
+ throw new Error(`fetch ${fetchUrl}: ${res.status} ${res.statusText}`);
158
+ }
159
+ const bytes = new Uint8Array(await res.arrayBuffer());
160
+ return `sha256-${await sha256Base64Url(bytes)}`;
161
+ }
162
+
163
+ /** Layered artifacts are an OCI concept — a module reached over a plain URL is
164
+ * manifest-only, and its controllers come from npm. */
165
+ async fetchLayer(ref: string, blobDigest: string): Promise<PayloadFile[]> {
166
+ throw new Error(
167
+ `Cannot fetch layer ${blobDigest} of ${ref}: a plain URL serves the manifest only. ` +
168
+ `A module with a bundled payload is published as an OCI artifact (oci://host/repo).`,
169
+ );
170
+ }
171
+
172
+ async layerIndex(layers: readonly PayloadLayer[]): Promise<ArtifactLayer[]> {
173
+ // Same boundary `fetchLayer` and `publish` draw: a plain URL serves the
174
+ // manifest only, so it frames no layer and can name no blob. An empty set is
175
+ // not a payload, so it answers rather than throws — a manifest-only module
176
+ // builds its payload through this transport during analysis.
177
+ if (layers.every((layer) => layer.files.length === 0)) return [];
178
+ throw new Error(
179
+ "A plain URL serves the manifest only, so it cannot index payload layers. " +
180
+ "A module with a bundled payload is published as an OCI artifact (oci://host/repo).",
181
+ );
182
+ }
183
+
184
+ async publish(
185
+ destination: string,
186
+ bundle: PublishBundle,
187
+ opts: PublishOptions = {},
188
+ ): Promise<PublishResult> {
189
+ // `telo publish` rejects a non-OCI destination up front, so this is only a
190
+ // guard for a direct programmatic call.
191
+ throw new Error(
192
+ "Publishing over HTTP has been removed. Publish to an OCI registry " +
193
+ "(oci://host/repo) instead.",
194
+ );
195
+ }
196
+
197
+ canonicalizeSiblingRef(destination: string, relativeSource: string, version: string): string {
198
+ // Canonicalizing a sibling is publish-path work, and this transport does not
199
+ // publish — so it refuses here for the same reason `publish` does, rather
200
+ // than computing a ref nothing could ever push. Silence would be worse than
201
+ // useless: an `https://host/` destination has no path to resolve `../lib`
202
+ // beside, so joining anyway yields a ref one segment short with no error.
203
+ throw new Error(
204
+ `Cannot canonicalize the relative import '${relativeSource}' against '${destination}': ` +
205
+ `publishing over plain HTTP has been removed. Publish to an OCI registry ` +
206
+ `(oci://host/repo) instead.`,
207
+ );
208
+ }
209
+ }
@@ -1,8 +1,8 @@
1
1
  import type { ManifestCacheCoords, ManifestSource } from "@telorun/analyzer";
2
2
 
3
3
  import type { PayloadFile } from "../bundle/files-integrity.js";
4
+ import { HttpTransport } from "./http-transport.js";
4
5
  import { OciTransport } from "./oci/oci-transport.js";
5
- import { RegistryTransport } from "./registry-transport.js";
6
6
  import type {
7
7
  PublishBundle,
8
8
  PublishOptions,
@@ -12,9 +12,9 @@ import type {
12
12
 
13
13
  /** Dispatches ref-scheme-specific operations to the transport that owns a ref.
14
14
  * The loader, cache source, `upgrade`, and `publish` consult this instead of
15
- * branching on ref shape. `RegistryTransport` is always last so it is the
16
- * fallback for bare / `https` refs, and a scheme-owning transport (OCI, later
17
- * S3) claims its refs via `supports()` before the fallback is reached. */
15
+ * branching on ref shape. `HttpTransport` is always last so it is the fallback
16
+ * for plain `https` refs, and a scheme-owning transport (OCI, later S3) claims
17
+ * its refs via `supports()` before the fallback is reached. */
18
18
  export class TransportRegistry {
19
19
  constructor(private readonly transports: Transport[]) {}
20
20
 
@@ -73,27 +73,20 @@ export class TransportRegistry {
73
73
  }
74
74
 
75
75
  /** The default transport set. Scheme-owning transports come first; the
76
- * `RegistryTransport` is last, the fallback for bare / `https` refs. OCI (and
77
- * later S3) claim their `oci://` / `s3://` refs before the fallback is reached. */
78
- export function defaultTransports(registryUrl?: string): Transport[] {
79
- return [new OciTransport(), new RegistryTransport(registryUrl)];
76
+ * `HttpTransport` is last, the fallback for plain `https` refs. OCI (and later
77
+ * S3) claim their `oci://` / `s3://` refs before the fallback is reached. */
78
+ export function defaultTransports(): Transport[] {
79
+ return [new OciTransport(), new HttpTransport()];
80
80
  }
81
81
 
82
- const defaultRegistryCache = new Map<string, TransportRegistry>();
82
+ let defaultRegistry: TransportRegistry | undefined;
83
83
 
84
- /** A `TransportRegistry` seeded with {@link defaultTransports}, memoized per
85
- * `registryUrl`. The default transports hold no per-call state, so one shared
86
- * instance per registry URL is safe — and avoids re-instantiating the whole set
87
- * on hot paths like `cachePathForCanonical`. It is also what gives
88
- * `OciTransport`'s per-instance read-client pool a process-wide lifetime here,
89
- * so the bearer-token cache survives across operations without the pool having
90
- * to be global. */
91
- export function defaultTransportRegistry(registryUrl?: string): TransportRegistry {
92
- const key = registryUrl ?? "";
93
- let cached = defaultRegistryCache.get(key);
94
- if (!cached) {
95
- cached = new TransportRegistry(defaultTransports(registryUrl));
96
- defaultRegistryCache.set(key, cached);
97
- }
98
- return cached;
84
+ /** A `TransportRegistry` seeded with {@link defaultTransports}, memoized. The
85
+ * default transports hold no per-call state, so one shared instance is safe —
86
+ * and avoids re-instantiating the whole set on hot paths like
87
+ * `cachePathForCanonical`. It is also what gives `OciTransport`'s per-instance
88
+ * read-client pool a process-wide lifetime here, so the bearer-token cache
89
+ * survives across operations without the pool having to be global. */
90
+ export function defaultTransportRegistry(): TransportRegistry {
91
+ return (defaultRegistry ??= new TransportRegistry(defaultTransports()));
99
92
  }