@telorun/kernel 0.82.1 → 0.84.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.
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +237 -12
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/controllers/module/shared-libraries.d.ts +83 -0
- package/dist/controllers/module/shared-libraries.d.ts.map +1 -0
- package/dist/controllers/module/shared-libraries.js +110 -0
- package/dist/controllers/module/shared-libraries.js.map +1 -0
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts +3 -0
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +8 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/controllers/resource-definition/resource-template-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-template-controller.js +104 -30
- package/dist/controllers/resource-definition/resource-template-controller.js.map +1 -1
- package/dist/evaluation-context.d.ts +82 -0
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +200 -5
- package/dist/evaluation-context.js.map +1 -1
- package/dist/instance-sensitive-paths.d.ts +61 -0
- package/dist/instance-sensitive-paths.d.ts.map +1 -0
- package/dist/instance-sensitive-paths.js +116 -0
- package/dist/instance-sensitive-paths.js.map +1 -0
- package/dist/invocation-contract-binding.d.ts +3 -0
- package/dist/invocation-contract-binding.d.ts.map +1 -1
- package/dist/invocation-contract-binding.js +9 -1
- package/dist/invocation-contract-binding.js.map +1 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +25 -0
- package/dist/kernel.js.map +1 -1
- package/dist/module-context.d.ts +5 -0
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +5 -0
- package/dist/module-context.js.map +1 -1
- package/package.json +4 -4
- package/src/controllers/module/import-controller.ts +324 -13
- package/src/controllers/module/shared-libraries.ts +180 -0
- package/src/controllers/resource-definition/resource-definition-controller.ts +14 -0
- package/src/controllers/resource-definition/resource-template-controller.ts +129 -29
- package/src/evaluation-context.ts +216 -7
- package/src/instance-sensitive-paths.ts +123 -0
- package/src/invocation-contract-binding.ts +12 -0
- package/src/kernel.ts +29 -1
- package/src/module-context.ts +6 -0
|
@@ -30,6 +30,11 @@ import {
|
|
|
30
30
|
import { RuntimeError } from "@telorun/sdk";
|
|
31
31
|
import { evalPathCovers } from "@telorun/analyzer";
|
|
32
32
|
import { effectOwnerOf, executeReturnedChain } from "./effect-scope.js";
|
|
33
|
+
import {
|
|
34
|
+
REDACTED,
|
|
35
|
+
redactSensitive,
|
|
36
|
+
sensitivePathsOfInstance,
|
|
37
|
+
} from "./instance-sensitive-paths.js";
|
|
33
38
|
import {
|
|
34
39
|
classifyInitFailures,
|
|
35
40
|
isDeferral,
|
|
@@ -82,6 +87,12 @@ const SCHEMA_AS_CONTRACT_KINDS = new Set(["Telo.Definition", "Telo.Abstract", "T
|
|
|
82
87
|
function collectResourceRefs(resource: ResourceManifest): ResourceRef[] {
|
|
83
88
|
const found = new Map<string, ResourceRef>();
|
|
84
89
|
const skipSchema = SCHEMA_AS_CONTRACT_KINDS.has(resource.kind as string);
|
|
90
|
+
// A re-created resource is rebuilt from the manifest as REGISTERED, but
|
|
91
|
+
// Phase-5 injection mutated that object in place on the previous pass — so a
|
|
92
|
+
// ref slot may already hold a live instance, whose object graph is cyclic.
|
|
93
|
+
// The walk is a best-effort edge collection for failure attribution, so it
|
|
94
|
+
// stops at anything it has already seen rather than recursing forever.
|
|
95
|
+
const seen = new WeakSet<object>();
|
|
85
96
|
const visit = (value: unknown): void => {
|
|
86
97
|
if (isResolvedRef(value)) {
|
|
87
98
|
const key = `${value.alias ?? ""}::${value.name}`;
|
|
@@ -89,8 +100,12 @@ function collectResourceRefs(resource: ResourceManifest): ResourceRef[] {
|
|
|
89
100
|
return;
|
|
90
101
|
}
|
|
91
102
|
if (Array.isArray(value)) {
|
|
103
|
+
if (seen.has(value)) return;
|
|
104
|
+
seen.add(value);
|
|
92
105
|
for (const item of value) visit(item);
|
|
93
106
|
} else if (value && typeof value === "object") {
|
|
107
|
+
if (seen.has(value)) return;
|
|
108
|
+
seen.add(value);
|
|
94
109
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
95
110
|
if (k === "metadata" || (skipSchema && k === "schema")) continue;
|
|
96
111
|
visit(v);
|
|
@@ -132,6 +147,12 @@ function localDependencyNames(refs: ResourceRef[]): string[] {
|
|
|
132
147
|
* whole-value match is redacted. */
|
|
133
148
|
const MIN_SUBSTRING_SCRUB_LEN = 5;
|
|
134
149
|
|
|
150
|
+
/** A live resource instance, as opposed to the raw config value a slot held
|
|
151
|
+
* before Phase-5 injection — the same duck-test the reference-resolving
|
|
152
|
+
* controllers use. */
|
|
153
|
+
const hasSnapshotMethod = (value: unknown): boolean =>
|
|
154
|
+
!!value && typeof (value as { snapshot?: unknown }).snapshot === "function";
|
|
155
|
+
|
|
135
156
|
export function buildResolvedProperties(
|
|
136
157
|
resource: ResourceManifest,
|
|
137
158
|
secretValues: Set<string>,
|
|
@@ -484,6 +505,10 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
484
505
|
parent: IEvaluationContext | undefined = undefined;
|
|
485
506
|
readonly children: IEvaluationContext[] = [];
|
|
486
507
|
|
|
508
|
+
/** Where this node sits in its parent's teardown cascade — ascending, default
|
|
509
|
+
* 0, reverse-registration within a tier. See `childTeardownOrder`. */
|
|
510
|
+
teardownPriority: number | undefined = undefined;
|
|
511
|
+
|
|
487
512
|
/** Current lifecycle state of this context node. */
|
|
488
513
|
state: LifecycleState = "Pending";
|
|
489
514
|
|
|
@@ -515,6 +540,84 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
515
540
|
* Their re-creation is not progress — see the create sub-phase. */
|
|
516
541
|
private readonly recreatedResources = new Set<string>();
|
|
517
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Instances this context can reach by name but does NOT own — a library's
|
|
545
|
+
* declared `resources:` inputs, bound here by the import that handed them
|
|
546
|
+
* down.
|
|
547
|
+
*
|
|
548
|
+
* **Borrowed, not owned.** The instance's effect frame belongs to the scope
|
|
549
|
+
* that DECLARED it, so this context must never include it in its own
|
|
550
|
+
* teardown: a library tearing one down would close the application's
|
|
551
|
+
* connection out from under everything else still using it.
|
|
552
|
+
*/
|
|
553
|
+
protected readonly borrowedResources = new Set<string>();
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Bind an instance this context does not own under `name`, and mirror its
|
|
557
|
+
* published reading into this context's `resources` scope so
|
|
558
|
+
* `resources.<name>.<field>` reads here exactly as it does where the resource
|
|
559
|
+
* is declared.
|
|
560
|
+
*
|
|
561
|
+
* The mirror is a subscription rather than a copy because a published value is
|
|
562
|
+
* a READING: the owner republishes after `run()`, after every `invoke()` and
|
|
563
|
+
* on every `setStatus()`, and a snapshot taken once at binding would go stale
|
|
564
|
+
* at the first of those — silently, since nothing downstream can tell a stale
|
|
565
|
+
* reading from a current one.
|
|
566
|
+
*/
|
|
567
|
+
adoptBorrowedResource(
|
|
568
|
+
name: string,
|
|
569
|
+
resource: ResourceManifest,
|
|
570
|
+
instance: ResourceInstance,
|
|
571
|
+
owner: EvaluationContext,
|
|
572
|
+
): void | (() => void) {
|
|
573
|
+
this.resourceInstances.set(name, { resource, instance });
|
|
574
|
+
this.borrowedResources.add(name);
|
|
575
|
+
this.declaredManifests.set(name, resource);
|
|
576
|
+
const unmirror = owner.mirrorPublications(resource.metadata.name as string, (props) =>
|
|
577
|
+
this.onResourceSnapshotted(name, props),
|
|
578
|
+
);
|
|
579
|
+
// The INVERSE, returned rather than performed: an import whose `init()`
|
|
580
|
+
// fails is discarded and re-created on the next pass, so a subscription left
|
|
581
|
+
// behind would be appended again on every pass — unbounded — and would keep
|
|
582
|
+
// a dead child context reachable from the live owner. The binding is not on
|
|
583
|
+
// this context's teardown path either (`teardownOrder` filters a borrowed
|
|
584
|
+
// name out, and that is also the only place an entry is deleted), so
|
|
585
|
+
// undoing it has to be stated here.
|
|
586
|
+
return () => {
|
|
587
|
+
unmirror();
|
|
588
|
+
this.borrowedResources.delete(name);
|
|
589
|
+
this.resourceInstances.delete(name);
|
|
590
|
+
this.declaredManifests.delete(name);
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Publication mirrors registered by {@link adoptBorrowedResource}, by the
|
|
595
|
+
* OWNER's name for the resource. */
|
|
596
|
+
private readonly publicationMirrors = new Map<
|
|
597
|
+
string,
|
|
598
|
+
Array<(props: Record<string, unknown>) => void>
|
|
599
|
+
>();
|
|
600
|
+
|
|
601
|
+
/** Register a mirror and replay the current reading, so a borrower that binds
|
|
602
|
+
* after the owner has already published does not wait for the next one.
|
|
603
|
+
* Returns the unsubscribe — a subscription with no way to end it outlives
|
|
604
|
+
* whatever registered it. */
|
|
605
|
+
mirrorPublications(name: string, sink: (props: Record<string, unknown>) => void): () => void {
|
|
606
|
+
const bucket = this.publicationMirrors.get(name);
|
|
607
|
+
if (bucket) bucket.push(sink);
|
|
608
|
+
else this.publicationMirrors.set(name, [sink]);
|
|
609
|
+
const entry = this.resourceInstances.get(name) ?? this.createdInstances.get(name);
|
|
610
|
+
const current = entry ? publishedByInstance.get(entry.instance) : undefined;
|
|
611
|
+
if (current) sink(current);
|
|
612
|
+
return () => {
|
|
613
|
+
const sinks = this.publicationMirrors.get(name);
|
|
614
|
+
if (!sinks) return;
|
|
615
|
+
const at = sinks.indexOf(sink);
|
|
616
|
+
if (at >= 0) sinks.splice(at, 1);
|
|
617
|
+
if (sinks.length === 0) this.publicationMirrors.delete(name);
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
518
621
|
/** Resources queued for initialization on this context node. */
|
|
519
622
|
private pendingResources: ResourceManifest[] = [];
|
|
520
623
|
|
|
@@ -660,6 +763,43 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
660
763
|
return def?.status;
|
|
661
764
|
}
|
|
662
765
|
|
|
766
|
+
/**
|
|
767
|
+
* The configured values a merge-form inheriting child publishes over the
|
|
768
|
+
* parent instance's reading, or undefined when the kind declares none.
|
|
769
|
+
*
|
|
770
|
+
* Such a child IS the parent instance — the inherited controller returns it
|
|
771
|
+
* verbatim — so `snapshot()` is the parent's and a field the parent never
|
|
772
|
+
* declared would be readable from nowhere. The field NAMES come from
|
|
773
|
+
* `publishedOwnFields`, stamped onto the definition at registration in the
|
|
774
|
+
* scope that declared the `extends` alias; resolving the chain here would
|
|
775
|
+
* resolve it in the READING module's scope, which may have no alias for the
|
|
776
|
+
* parent's library. Done here rather than by rebinding the instance's
|
|
777
|
+
* `snapshot()` because this is definition-derived data joined into a reading —
|
|
778
|
+
* exactly what `statusSchemaOf` already does — and a second kernel cannot
|
|
779
|
+
* rebind a trait method.
|
|
780
|
+
*
|
|
781
|
+
* Two values are withheld rather than published, both conservatively: a
|
|
782
|
+
* runtime-eval field is still a `CompiledValue` at this point (only compile
|
|
783
|
+
* paths are expanded at create), and a live instance is a collaborator rather
|
|
784
|
+
* than a reading. Publishing either would put a wrong-typed value in the CEL
|
|
785
|
+
* scope, which is the failure this overlay exists to remove.
|
|
786
|
+
*/
|
|
787
|
+
private ownFieldOverlay(
|
|
788
|
+
kind: string,
|
|
789
|
+
resource: Record<string, unknown>,
|
|
790
|
+
): Record<string, unknown> | undefined {
|
|
791
|
+
const def = this.getDefinition?.(this.resolveKindSafe(kind)) ?? this.getDefinition?.(kind);
|
|
792
|
+
const fields = (def as { publishedOwnFields?: string[] } | undefined)?.publishedOwnFields;
|
|
793
|
+
if (!fields?.length) return undefined;
|
|
794
|
+
let overlay: Record<string, unknown> | undefined;
|
|
795
|
+
for (const field of fields) {
|
|
796
|
+
const value = resource[field];
|
|
797
|
+
if (value === undefined || isCompiledValue(value) || hasSnapshotMethod(value)) continue;
|
|
798
|
+
(overlay ??= {})[field] = value;
|
|
799
|
+
}
|
|
800
|
+
return overlay;
|
|
801
|
+
}
|
|
802
|
+
|
|
663
803
|
/**
|
|
664
804
|
* Re-read a resource's `snapshot()` and republish it, joined with whatever
|
|
665
805
|
* observed state the resource has reported. The single publication path — the
|
|
@@ -668,11 +808,14 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
668
808
|
*/
|
|
669
809
|
async publishSnapshot(name: string): Promise<void> {
|
|
670
810
|
const entry = this.resourceInstances.get(name) ?? this.createdInstances.get(name);
|
|
671
|
-
if (!entry
|
|
672
|
-
const snap = (await Promise.resolve(entry.instance.snapshot())) as
|
|
673
|
-
| Record<string, unknown>
|
|
674
|
-
| undefined;
|
|
811
|
+
if (!entry) return;
|
|
675
812
|
const kind = entry.resource.kind as string;
|
|
813
|
+
const own = this.ownFieldOverlay(kind, entry.resource);
|
|
814
|
+
if (!entry.instance.snapshot && !own) return;
|
|
815
|
+
const snapshot = entry.instance.snapshot
|
|
816
|
+
? ((await Promise.resolve(entry.instance.snapshot())) as Record<string, unknown> | undefined)
|
|
817
|
+
: undefined;
|
|
818
|
+
const snap = own ? { ...(snapshot ?? {}), ...own } : snapshot;
|
|
676
819
|
const props = buildPublishedProps(snap, {
|
|
677
820
|
kind,
|
|
678
821
|
name,
|
|
@@ -684,6 +827,7 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
684
827
|
});
|
|
685
828
|
publishedByInstance.set(entry.instance, props);
|
|
686
829
|
this.onResourceSnapshotted(name, props);
|
|
830
|
+
for (const sink of this.publicationMirrors.get(name) ?? []) sink(props);
|
|
687
831
|
}
|
|
688
832
|
|
|
689
833
|
get context(): Record<string, unknown> {
|
|
@@ -801,6 +945,21 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
801
945
|
return child;
|
|
802
946
|
}
|
|
803
947
|
|
|
948
|
+
/**
|
|
949
|
+
* Bind a name into this context's own CEL scope.
|
|
950
|
+
*
|
|
951
|
+
* A copy is written rather than a mutation: `spawnChildContext` hands the
|
|
952
|
+
* child the PARENT's context object by reference, so mutating it in place
|
|
953
|
+
* would put the binding in the parent's scope as well.
|
|
954
|
+
*
|
|
955
|
+
* Used by a template to put `self` in scope for the body it creates, so a node
|
|
956
|
+
* the nested kind evaluates later can read the enclosing resource's
|
|
957
|
+
* configuration beside the call-time names that kind binds.
|
|
958
|
+
*/
|
|
959
|
+
bindContextValue(name: string, value: unknown): void {
|
|
960
|
+
this._context = { ...this._context, [name]: value };
|
|
961
|
+
}
|
|
962
|
+
|
|
804
963
|
/** Spawn a fresh child context attached to this node — the isolated scope a
|
|
805
964
|
* templated definition registers its `resources:` into. Rooting it on the
|
|
806
965
|
* context that DEFINED the template (not the consumer that instantiated the
|
|
@@ -1216,7 +1375,7 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
1216
1375
|
this.state = "Draining";
|
|
1217
1376
|
const failures: Array<{ resource: string; error: unknown }> = [];
|
|
1218
1377
|
|
|
1219
|
-
for (const child of
|
|
1378
|
+
for (const child of this.childTeardownOrder()) {
|
|
1220
1379
|
try {
|
|
1221
1380
|
await child.teardownResources();
|
|
1222
1381
|
} catch (err) {
|
|
@@ -1294,6 +1453,28 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
1294
1453
|
}
|
|
1295
1454
|
}
|
|
1296
1455
|
|
|
1456
|
+
/**
|
|
1457
|
+
* Child contexts in teardown order: ascending `teardownPriority` (default 0),
|
|
1458
|
+
* with the base reverse-registration order preserved within each tier.
|
|
1459
|
+
*
|
|
1460
|
+
* The same rule `teardownOrder` applies to resource instances, and for the
|
|
1461
|
+
* same reason: reverse registration is reverse init order in the happy path,
|
|
1462
|
+
* but a node that must reliably outlive the rest has to say so rather than
|
|
1463
|
+
* depend on when it happened to be created. A `lifecycle: shared` library is
|
|
1464
|
+
* registered when the FIRST import reaches it — which, for an import declared
|
|
1465
|
+
* inside another library, is after that library's own context — so reverse
|
|
1466
|
+
* registration would tear the singleton down while a borrower still holds it.
|
|
1467
|
+
*/
|
|
1468
|
+
private childTeardownOrder(): IEvaluationContext[] {
|
|
1469
|
+
return [...this.children]
|
|
1470
|
+
.reverse()
|
|
1471
|
+
.sort(
|
|
1472
|
+
(a, b) =>
|
|
1473
|
+
((a as { teardownPriority?: number }).teardownPriority ?? 0) -
|
|
1474
|
+
((b as { teardownPriority?: number }).teardownPriority ?? 0),
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1297
1478
|
/**
|
|
1298
1479
|
* Resource instances in teardown order: ascending `teardownPriority`, with the
|
|
1299
1480
|
* base reverse-insertion order preserved within each priority tier.
|
|
@@ -1308,7 +1489,11 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
1308
1489
|
* instance shape.
|
|
1309
1490
|
*/
|
|
1310
1491
|
private teardownOrder(): Array<[string, { resource: any; instance: any }]> {
|
|
1311
|
-
|
|
1492
|
+
// A borrowed instance is torn down by the scope that declared it, never
|
|
1493
|
+
// here — see `borrowedResources`.
|
|
1494
|
+
const entries = [...this.resourceInstances.entries()]
|
|
1495
|
+
.filter(([name]) => !this.borrowedResources.has(name))
|
|
1496
|
+
.reverse();
|
|
1312
1497
|
// Stable sort by priority (default 0); Array.prototype.sort is stable, so
|
|
1313
1498
|
// the reverse-insertion order survives within each tier.
|
|
1314
1499
|
return entries.sort(
|
|
@@ -1472,6 +1657,30 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
1472
1657
|
: undefined;
|
|
1473
1658
|
// Capture the root CEL scope once, on the trace's root span's terminal event.
|
|
1474
1659
|
const rootScope = tracing && parentInvocationId === undefined ? this.traceRootScope() : undefined;
|
|
1660
|
+
// What a payload may say about this call. A credential is a dispatched
|
|
1661
|
+
// invocable, so its material is an invoke OUTPUT — and inputs and outputs
|
|
1662
|
+
// ride the debug wire on every call under `--inspect`, i.e. every watch
|
|
1663
|
+
// session, which the substring scrubbing does not reach (one call site, the
|
|
1664
|
+
// resource-Created event's properties). The kind that owns the contract
|
|
1665
|
+
// marks the field; nothing here knows which kind that is.
|
|
1666
|
+
const hide = (detail: Record<string, unknown>): Record<string, unknown> => {
|
|
1667
|
+
let out = detail;
|
|
1668
|
+
for (const direction of ["inputType", "outputType"] as const) {
|
|
1669
|
+
const key = direction === "inputType" ? "inputs" : "outputs";
|
|
1670
|
+
if (!(key in out)) continue;
|
|
1671
|
+
const paths = sensitivePathsOfInstance(instance, direction);
|
|
1672
|
+
// Unknown, because the contract would not resolve — withhold the whole
|
|
1673
|
+
// value rather than guess. The dispatch is about to raise that same
|
|
1674
|
+
// failure with its own code.
|
|
1675
|
+
if (paths === undefined) {
|
|
1676
|
+
out = { ...out, [key]: REDACTED };
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
if (paths.length === 0) continue;
|
|
1680
|
+
out = { ...out, [key]: redactSensitive(out[key], paths) };
|
|
1681
|
+
}
|
|
1682
|
+
return out;
|
|
1683
|
+
};
|
|
1475
1684
|
const span = (
|
|
1476
1685
|
phase: "start" | "end",
|
|
1477
1686
|
outcome: SpanOutcome | undefined,
|
|
@@ -1486,7 +1695,7 @@ export class EvaluationContext implements IEvaluationContext {
|
|
|
1486
1695
|
"invoke",
|
|
1487
1696
|
phase,
|
|
1488
1697
|
outcome,
|
|
1489
|
-
phase === "end" && rootScope ? { ...detail, context: rootScope } : detail,
|
|
1698
|
+
phase === "end" && rootScope ? { ...hide(detail), context: rootScope } : hide(detail),
|
|
1490
1699
|
);
|
|
1491
1700
|
// When tracing, a derived context carries the new id down the tree so nested
|
|
1492
1701
|
// invokes read it as their parent; it is never `=== ambient`, so the call
|
|
@@ -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";
|
|
@@ -1479,6 +1480,26 @@ export class Kernel implements IKernel {
|
|
|
1479
1480
|
|
|
1480
1481
|
if (!runtime.length) return { instance, ctx, resource: processedResource };
|
|
1481
1482
|
|
|
1483
|
+
// Runtime eval paths are expanded against a CALL's inputs, so they need a
|
|
1484
|
+
// call. `invoke` is the only entry point that takes any — `run()` and
|
|
1485
|
+
// `provide()` are parameterless — so a kind that declares one of these paths
|
|
1486
|
+
// and has no `invoke()` has annotated something nothing can ever expand: the
|
|
1487
|
+
// value stays a compiled expression for the life of the resource. Reported
|
|
1488
|
+
// here rather than dereferenced: this used to be a non-null assertion, and it
|
|
1489
|
+
// failed as `undefined is not an object (evaluating 'instance.invoke.bind')`
|
|
1490
|
+
// against the kernel's own source, naming neither the kind nor the field.
|
|
1491
|
+
// Method presence rather than declared capability, because the kernel
|
|
1492
|
+
// dispatches on the method — a Provider that implements `invoke` is bound
|
|
1493
|
+
// exactly like an Invocable.
|
|
1494
|
+
if (typeof instance.invoke !== "function") {
|
|
1495
|
+
throw new RuntimeError(
|
|
1496
|
+
"ERR_RUNTIME_EVAL_WITHOUT_INVOKE",
|
|
1497
|
+
`Kind ${resolvedKind} declares 'x-telo-eval: runtime' (at ${runtime.join(", ")}), but its resources have no invoke() — ` +
|
|
1498
|
+
`runtime evaluation expands a call's inputs, and run() / provide() take none. ` +
|
|
1499
|
+
`Use 'x-telo-eval: compile' for a value resolved once when the resource is created, or give the kind an invocable controller.`,
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1482
1503
|
// Override invoke in-place so all lifecycle methods (init/invoke/teardown/snapshot)
|
|
1483
1504
|
// share the same `this`. A wrapper object would split identity: state mutated by
|
|
1484
1505
|
// init() on the wrapper would be invisible to the original invoke(), which still
|
|
@@ -1487,7 +1508,7 @@ export class Kernel implements IKernel {
|
|
|
1487
1508
|
// Every argument is forwarded: `invoke(inputs, ctx)` carries the
|
|
1488
1509
|
// InvokeContext (cancellation, tracing) as its second parameter, and a
|
|
1489
1510
|
// wrapper that declares only `inputs` silently drops it.
|
|
1490
|
-
const originalInvoke = instance.invoke
|
|
1511
|
+
const originalInvoke = instance.invoke.bind(instance);
|
|
1491
1512
|
instance.invoke = async (inputs: any, ...rest: unknown[]) => {
|
|
1492
1513
|
const expanded = evalContext.expandPaths(inputs as Record<string, unknown>, runtime);
|
|
1493
1514
|
return (originalInvoke as (i: any, ...r: unknown[]) => Promise<unknown>)(expanded, ...rest);
|
|
@@ -1564,6 +1585,13 @@ export class Kernel implements IKernel {
|
|
|
1564
1585
|
);
|
|
1565
1586
|
if (!input && !output) return;
|
|
1566
1587
|
|
|
1588
|
+
// Recorded here because this is the only point holding both the instance and
|
|
1589
|
+
// its resolved contract — `bindContract` closes over the contract and drops
|
|
1590
|
+
// it, and the trace site has only the instance. Lazily, so a contract is
|
|
1591
|
+
// still compiled on first dispatch rather than at create time.
|
|
1592
|
+
if (input) recordSensitivePaths(instance, "inputType", () => input.sensitivePaths());
|
|
1593
|
+
if (output) recordSensitivePaths(instance, "outputType", () => output.sensitivePaths());
|
|
1594
|
+
|
|
1567
1595
|
bindContract(instance, {
|
|
1568
1596
|
input,
|
|
1569
1597
|
output,
|
package/src/module-context.ts
CHANGED
|
@@ -552,6 +552,12 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
|
|
|
552
552
|
return `${realModule}.${suffix}`;
|
|
553
553
|
}
|
|
554
554
|
|
|
555
|
+
/** A module context IS the alias table, so it answers the resolver seam
|
|
556
|
+
* directly rather than inheriting one from a parent it does not have. Public
|
|
557
|
+
* because a template body's nested kind is written in the DEFINING library's
|
|
558
|
+
* scope and its controller has to resolve it there. */
|
|
559
|
+
override kindResolver = (kind: string): string => this.resolveKindSafe(kind);
|
|
560
|
+
|
|
555
561
|
protected override resolveKindSafe(kind: string): string {
|
|
556
562
|
// `resolveKind` throws for unqualified / ungated kinds — an expected signal,
|
|
557
563
|
// not a failure: a capability probe that can't resolve falls back to the raw
|