@unoverse-platform/studio 0.1.89 → 0.1.91
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/local/plugin.ts +20 -12
- package/package.json +13 -3
- package/screens/TemplatesLibraryView.tsx +31 -6
- package/screens/TemplatesView.tsx +1 -1
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +140 -0
- package/vendor/base/src/agent-mcp/lean.ts +111 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +172 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +130 -0
- package/vendor/base/src/auth/auth.ts +278 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +128 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +643 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +260 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +199 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +198 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.mjs +64 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +107 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +150 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +187 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +154 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `api.events` — everything that leaves a node, in one ordered table.
|
|
3
|
+
*
|
|
4
|
+
* ONE ROW PER OUTPUT CONNECTOR, in the same order the connectors are declared, so reading
|
|
5
|
+
* the table tells you the node's entire outward behaviour without opening another file.
|
|
6
|
+
* Lint enforces both the coverage and the order.
|
|
7
|
+
*
|
|
8
|
+
* This replaced four separate mechanisms (`response.events`, `response.finalize`,
|
|
9
|
+
* `response.map`, and `narrate.output`), which between them meant a five-output node
|
|
10
|
+
* declared its outputs in four places under three different key names. That is also how
|
|
11
|
+
* a real bug got in: the tool RESULT is produced by our tool loop and never appears in
|
|
12
|
+
* the HTTP stream, but `response.events` was the only list available, so it was wired to
|
|
13
|
+
* the nearest wrong thing (the tool CALL, fired before the tool had even run).
|
|
14
|
+
*
|
|
15
|
+
* A row says WHERE it fires from:
|
|
16
|
+
*
|
|
17
|
+
* from: response streaming: each event whose type equals `match`
|
|
18
|
+
* settling: once, over the whole body
|
|
19
|
+
* from: narrator each line the narrator produces
|
|
20
|
+
* from: tool after each tool call returns, with its result
|
|
21
|
+
* from: complete once, at the end, over everything emitted
|
|
22
|
+
*
|
|
23
|
+
* The executor owns what data cannot express: accumulating, throttling, and flushing what
|
|
24
|
+
* throttling held back. `accumulate` and the two throttles exist because a delta-per-event
|
|
25
|
+
* stream is not what a consumer wants. It wants the text so far, at a readable rate.
|
|
26
|
+
*/
|
|
27
|
+
import type { ComposedNode } from "../compose.js";
|
|
28
|
+
import { evaluate, render } from "./templating.js";
|
|
29
|
+
import type { Emission } from "./http/response.js";
|
|
30
|
+
|
|
31
|
+
export type EventSource = "response" | "narrator" | "tool" | "complete";
|
|
32
|
+
|
|
33
|
+
/** Per-row state. Throttling and accumulation are stateful across a run, so rows own it. */
|
|
34
|
+
interface RowState {
|
|
35
|
+
/** Running total, for `accumulate: true`. */
|
|
36
|
+
acc: string;
|
|
37
|
+
/** Held back by a throttle and not yet emitted. */
|
|
38
|
+
pending?: unknown;
|
|
39
|
+
/** Characters added since the last emission, for `throttleChars`. */
|
|
40
|
+
chars: number;
|
|
41
|
+
/** When we last emitted, for `throttleMs`. */
|
|
42
|
+
lastAt: number;
|
|
43
|
+
/** Resolved `send` target held alongside `pending`: it is known at fire time, not at flush. */
|
|
44
|
+
to?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The emitter for ONE run. Holds every emission it made, because `from: complete` is
|
|
49
|
+
* defined over them and because a node's settled outputs are the last value per connector.
|
|
50
|
+
*/
|
|
51
|
+
export function makeEmitter(node: ComposedNode, onEmit: (e: Emission) => void, base: Record<string, unknown> = {}) {
|
|
52
|
+
const rows: any[] = node.api?.events ?? [];
|
|
53
|
+
const state = new Map<number, RowState>();
|
|
54
|
+
const emissions: Emission[] = [];
|
|
55
|
+
|
|
56
|
+
const stateFor = (i: number) => {
|
|
57
|
+
let s = state.get(i);
|
|
58
|
+
if (!s) state.set(i, (s = { acc: "", chars: 0, lastAt: 0 }));
|
|
59
|
+
return s;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The row's destination. `emit` names an output CONNECTOR; `send` names another NODE.
|
|
64
|
+
*
|
|
65
|
+
* A send row is how one node hands something to a named node without a wire: the loop-back
|
|
66
|
+
* is the first case (LoopEnd already names its partner in `loopStartNodeId`, so an edge would
|
|
67
|
+
* be the same fact stated twice), and it is deliberately not loop-specific.
|
|
68
|
+
*/
|
|
69
|
+
const destination = (row: any, to?: string) =>
|
|
70
|
+
row.send ? { to, handle: row.handle ?? "input" } : { emit: row.emit };
|
|
71
|
+
|
|
72
|
+
/** Apply accumulate + throttle, then emit or hold. */
|
|
73
|
+
const deliver = (row: any, i: number, raw: unknown, to?: string) => {
|
|
74
|
+
if (raw === undefined || raw === null) return;
|
|
75
|
+
const s = stateFor(i);
|
|
76
|
+
|
|
77
|
+
let value = raw;
|
|
78
|
+
if (row.accumulate) {
|
|
79
|
+
s.acc += String(raw);
|
|
80
|
+
value = s.acc;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Throttled: remember the newest value and let a later tick or the flush send it.
|
|
84
|
+
// Never DROP it, or the last words of an answer go missing.
|
|
85
|
+
if (row.throttleMs) {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
if (now - s.lastAt < row.throttleMs) {
|
|
88
|
+
s.pending = value;
|
|
89
|
+
s.to = to;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
s.lastAt = now;
|
|
93
|
+
}
|
|
94
|
+
if (row.throttleChars) {
|
|
95
|
+
s.chars += String(raw).length;
|
|
96
|
+
if (s.chars < row.throttleChars) {
|
|
97
|
+
s.pending = value;
|
|
98
|
+
s.to = to;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
s.chars = 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
s.pending = undefined;
|
|
105
|
+
const emission = { ...destination(row, to), value };
|
|
106
|
+
emissions.push(emission);
|
|
107
|
+
onEmit(emission);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* END OF A TURN: flush what the throttle holds, then start the accumulator over.
|
|
112
|
+
*
|
|
113
|
+
* `accumulate` runs for the length of a RUN, which is one answer over HTTP and a whole
|
|
114
|
+
* CONVERSATION over a socket — so without this every turn carries every turn before it.
|
|
115
|
+
* Flush BEFORE clearing, or the turn's closing words go with it.
|
|
116
|
+
*/
|
|
117
|
+
const resetTurn = (match?: string) => {
|
|
118
|
+
if (match === undefined) return;
|
|
119
|
+
for (let i = 0; i < rows.length; i++) {
|
|
120
|
+
const row = rows[i];
|
|
121
|
+
if (!row.resetOn) continue;
|
|
122
|
+
const hit = Array.isArray(row.resetOn) ? row.resetOn.includes(match) : row.resetOn === match;
|
|
123
|
+
if (!hit) continue;
|
|
124
|
+
const s = state.get(i);
|
|
125
|
+
if (!s) continue;
|
|
126
|
+
if (s.pending !== undefined) {
|
|
127
|
+
const emission = { emit: row.emit, value: s.pending };
|
|
128
|
+
s.pending = undefined;
|
|
129
|
+
emissions.push(emission);
|
|
130
|
+
onEmit(emission);
|
|
131
|
+
}
|
|
132
|
+
s.acc = "";
|
|
133
|
+
s.chars = 0;
|
|
134
|
+
s.lastAt = 0;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// `base` is what every row sees regardless of where it fired: config, the signed-in
|
|
139
|
+
// user, and (for a chained call) each step's reply. A row's own source keys are layered
|
|
140
|
+
// on top, so `response` still means the thing that just arrived.
|
|
141
|
+
const fire = async (source: EventSource, sourceScope: Record<string, unknown>, match?: string) => {
|
|
142
|
+
const scope = { ...base, ...sourceScope };
|
|
143
|
+
for (let i = 0; i < rows.length; i++) {
|
|
144
|
+
const row = rows[i];
|
|
145
|
+
if ((row.from ?? "response") !== source) continue;
|
|
146
|
+
// `match` applies to streaming rows only; a settling row has no event type to match.
|
|
147
|
+
// A row may name SEVERAL event types (a list) when one connector accumulates from
|
|
148
|
+
// more than one wire event — e.g. reasoning summary deltas plus the part-done
|
|
149
|
+
// marker that separates summary sections. The value expression reads
|
|
150
|
+
// `response.type` to tell them apart.
|
|
151
|
+
if (match !== undefined && row.match !== undefined) {
|
|
152
|
+
const hit = Array.isArray(row.match) ? row.match.includes(match) : row.match === match;
|
|
153
|
+
if (!hit) continue;
|
|
154
|
+
}
|
|
155
|
+
if (row.when && !(await evaluate(row.when, scope))) continue;
|
|
156
|
+
// A send row's target is a TEMPLATE over the same scope (`{{ config.loopStartNodeId }}`),
|
|
157
|
+
// rendered here rather than in deliver() because only the fire has the scope. Empty means
|
|
158
|
+
// the author's config field is unset: fail loudly, exactly as a missing loop state does,
|
|
159
|
+
// rather than dropping the message and leaving a loop that silently stops after one pass.
|
|
160
|
+
let to: string | undefined;
|
|
161
|
+
if (row.send) {
|
|
162
|
+
to = String(render(row.send, scope as any) ?? "").trim();
|
|
163
|
+
if (!to) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`${node.type}: events row ${i} sends to "${row.send}", which resolved to nothing. ` +
|
|
166
|
+
`Set that config field to the target node's id.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
deliver(row, i, await evaluate(row.value, scope), to);
|
|
171
|
+
// First match wins per event, which is what keeps a chatty API readable.
|
|
172
|
+
if (match !== undefined) break;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
emissions,
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* One streamed event, or one settled body when `match` is undefined.
|
|
181
|
+
*
|
|
182
|
+
* `extra` carries the replies of earlier calls, which only the caller that ran them
|
|
183
|
+
* knows about.
|
|
184
|
+
*/
|
|
185
|
+
response: async (payload: any, match?: string, extra: Record<string, unknown> = {}) => {
|
|
186
|
+
await fire("response", { ...extra, response: payload }, match);
|
|
187
|
+
// AFTER the rows have seen it. A turn-ending event may itself be something a row emits
|
|
188
|
+
// on, and resetting first would clear the accumulator the row is about to read.
|
|
189
|
+
resetTurn(match);
|
|
190
|
+
},
|
|
191
|
+
narrator: (line: string) => fire("narrator", { narrator: { line } }),
|
|
192
|
+
tool: (call: { name: string; args: unknown; output: unknown }) => fire("tool", { call }),
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Send anything a throttle held back, then run the `complete` rows over the whole run.
|
|
196
|
+
* Flushing first matters: `complete` is usually defined over what was emitted.
|
|
197
|
+
*/
|
|
198
|
+
async finish() {
|
|
199
|
+
for (let i = 0; i < rows.length; i++) {
|
|
200
|
+
const s = state.get(i);
|
|
201
|
+
if (s?.pending === undefined) continue;
|
|
202
|
+
const emission = { ...destination(rows[i], s.to), value: s.pending };
|
|
203
|
+
s.pending = undefined;
|
|
204
|
+
emissions.push(emission);
|
|
205
|
+
onEmit(emission);
|
|
206
|
+
}
|
|
207
|
+
await fire("complete", { events: emissions.slice() });
|
|
208
|
+
return emissions;
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/** A node's settled outputs are the LAST value seen on each connector. */
|
|
212
|
+
outputs(): Record<string, unknown> {
|
|
213
|
+
const out: Record<string, unknown> = {};
|
|
214
|
+
// A send row addresses a node, not a connector, so it is NOT one of this node's outputs.
|
|
215
|
+
// Including it would put a phantom handle in __outputs, and the engine's routing decides
|
|
216
|
+
// which edges fire from exactly that set.
|
|
217
|
+
for (const e of emissions) if (e.emit) out[e.emit] = e.value;
|
|
218
|
+
return out;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export type Emitter = ReturnType<typeof makeEmitter>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NAMED HELPERS, bound for the duration of one run.
|
|
3
|
+
*
|
|
4
|
+
* A package declares functions under `helpers:` in any shared/ file; every expression in
|
|
5
|
+
* every node of that package can then call them by name. What this module solves is getting
|
|
6
|
+
* the bag into scope: `evaluate` is called from 50-odd places, each building its own small
|
|
7
|
+
* scope object ({ response }, { call }, { tool }, ...), and adding a `helpers` key to every
|
|
8
|
+
* one of them would guarantee that someone adds the fifty-first without it. A helper that
|
|
9
|
+
* works in `returns` and not in an events row is worse than no helpers at all.
|
|
10
|
+
*
|
|
11
|
+
* AsyncLocalStorage instead, bound once where a run begins. It is the platform primitive for
|
|
12
|
+
* exactly this and it is concurrency-safe: two nodes running at once each see their own bag,
|
|
13
|
+
* which a module-level "current package" variable would not give.
|
|
14
|
+
*
|
|
15
|
+
* NOT bound means an expression naming `helpers` fails with `unknown identifier 'helpers'`
|
|
16
|
+
* from the sandbox — loud, and pointing at the expression. There is deliberately no empty-bag
|
|
17
|
+
* fallback: a helper call that silently returned undefined would be the same class of quiet
|
|
18
|
+
* wrong answer as the shallow $ref merge.
|
|
19
|
+
*/
|
|
20
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
21
|
+
import { makeHelpers } from "../../template/SafeExpression.js";
|
|
22
|
+
import type { ComposedNode } from "../compose.js";
|
|
23
|
+
|
|
24
|
+
type Bag = Record<string, (...a: unknown[]) => unknown>;
|
|
25
|
+
|
|
26
|
+
const STORE = new AsyncLocalStorage<Bag>();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Built ONCE per composed node and cached against it.
|
|
30
|
+
*
|
|
31
|
+
* Each helper parses its body eagerly, so rebuilding the bag on every call would re-parse
|
|
32
|
+
* every helper on every request. Keyed by the composed node object, which the loader
|
|
33
|
+
* replaces wholesale when a package reloads, so a stale bag cannot outlive its source.
|
|
34
|
+
*/
|
|
35
|
+
const CACHE = new WeakMap<ComposedNode, Bag>();
|
|
36
|
+
|
|
37
|
+
function bagFor(node: ComposedNode): Bag {
|
|
38
|
+
const hit = CACHE.get(node);
|
|
39
|
+
if (hit) return hit;
|
|
40
|
+
const built = makeHelpers(node.helpers ?? {});
|
|
41
|
+
CACHE.set(node, built);
|
|
42
|
+
return built;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Run `fn` with this node's helpers in scope for every expression it evaluates. */
|
|
46
|
+
export function withHelpers<T>(node: ComposedNode, fn: () => Promise<T>): Promise<T> {
|
|
47
|
+
return STORE.run(bagFor(node), fn);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The bag bound to the current run, or undefined outside one. */
|
|
51
|
+
export function currentHelpers(): Bag | undefined {
|
|
52
|
+
return STORE.getStore();
|
|
53
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ALLOWED_HOSTS BOUNDARY. Its own file because it is the security control.
|
|
3
|
+
*
|
|
4
|
+
* Part of the manifest runtime (DECLARATIVE_NODES.md §2): the manifest DESCRIBES the
|
|
5
|
+
* service, this half COMPUTES it. Split by concern so each piece stays readable.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* THE ALLOWED_HOSTS BOUNDARY.
|
|
9
|
+
*
|
|
10
|
+
* A manifest cannot execute code, but it can say "send this credential to
|
|
11
|
+
* evil.example", and that is exfiltration with no code at all. SECURITY.md bounds a
|
|
12
|
+
* code node by PROVENANCE (the first-party npm scope) and a template expression by
|
|
13
|
+
* having no credentials in scope. A manifest node is neither: it arrives as data yet
|
|
14
|
+
* holds a credential and a URL. This is its boundary.
|
|
15
|
+
*
|
|
16
|
+
* Deny by default. A package that declares no `allowedHosts` reaches nothing.
|
|
17
|
+
*/
|
|
18
|
+
export function assertAllowedHost(
|
|
19
|
+
url: string,
|
|
20
|
+
allowedHosts: string[],
|
|
21
|
+
nodeType: string,
|
|
22
|
+
/**
|
|
23
|
+
* Does THIS call send a credential? Only a call that does is bounded by the host list in
|
|
24
|
+
* the strict sense; see the `"*"` rule below.
|
|
25
|
+
*/
|
|
26
|
+
carriesCredential = true,
|
|
27
|
+
): void {
|
|
28
|
+
// BOTH forms: `host` carries the port, `hostname` never does. A pattern cannot express a
|
|
29
|
+
// port (the schema forbids the colon), so the match below accepts either.
|
|
30
|
+
let host: string;
|
|
31
|
+
let hostname: string;
|
|
32
|
+
try {
|
|
33
|
+
const parsed = new URL(url);
|
|
34
|
+
// `wss:` is https for sockets: TLS from the first byte, and the handshake IS an https
|
|
35
|
+
// request that then upgrades. A duplex node carries its bearer token on that handshake, so
|
|
36
|
+
// this must accept wss and must keep refusing plain `ws:`, which would put the credential
|
|
37
|
+
// on the wire in clear text exactly as `http:` would.
|
|
38
|
+
const secure = parsed.protocol === "https:" || parsed.protocol === "wss:";
|
|
39
|
+
if (!secure && parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1")
|
|
40
|
+
throw new Error(`${nodeType}: refusing a non-https request to ${parsed.protocol}//${parsed.host} — a credential must not travel in clear text`);
|
|
41
|
+
host = parsed.host.toLowerCase();
|
|
42
|
+
hostname = parsed.hostname.toLowerCase();
|
|
43
|
+
} catch (err: any) {
|
|
44
|
+
if (err?.message?.startsWith(nodeType)) throw err;
|
|
45
|
+
throw new Error(`${nodeType}: request url is not a valid URL: ${url}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const allowed = allowedHosts.some((pattern) => {
|
|
49
|
+
/**
|
|
50
|
+
* `"*"` — ANY HOST, and ONLY for a call that sends no credential.
|
|
51
|
+
*
|
|
52
|
+
* Some nodes legitimately fetch a url a person supplies: an image for a model to look
|
|
53
|
+
* at, a document to read. That url cannot be declared in advance, because the whole
|
|
54
|
+
* point is that it is not known until someone types it.
|
|
55
|
+
*
|
|
56
|
+
* Allowing it is safe HERE and nowhere else, because of what this boundary is actually
|
|
57
|
+
* for. SECURITY.md's concern is exfiltration: a manifest saying "POST this credential to
|
|
58
|
+
* evil.example". A call with no `auth` block has no credential to leak — it is an
|
|
59
|
+
* ordinary unauthenticated GET, the same thing any browser does.
|
|
60
|
+
*
|
|
61
|
+
* Two things still bound it. Non-https is already refused above, which puts the cloud
|
|
62
|
+
* metadata endpoint and plaintext internal services out of reach. And the moment a call
|
|
63
|
+
* carries a credential, `"*"` stops matching and the real host list applies — so a
|
|
64
|
+
* tampered manifest cannot move the credential onto the wildcard call.
|
|
65
|
+
*/
|
|
66
|
+
if (pattern === "*") return !carriesCredential;
|
|
67
|
+
/**
|
|
68
|
+
* `**.a.com` — ANY DEPTH. Written with two stars because it is a deliberately weaker
|
|
69
|
+
* claim than one, and the author should have to say so.
|
|
70
|
+
*
|
|
71
|
+
* AWS forced this. Its endpoints are inherently multi-level: `dynamodb.us-east-1.
|
|
72
|
+
* amazonaws.com` is two labels deep and an S3 bucket is three
|
|
73
|
+
* (`bucket.s3.region.amazonaws.com`), because both the region AND the bucket are part
|
|
74
|
+
* of the name. A single-level wildcard cannot express that, and the alternatives were
|
|
75
|
+
* worse: listing every region defeats itself the day AWS adds one, and a bucket name is
|
|
76
|
+
* config, so it cannot be listed at all.
|
|
77
|
+
*
|
|
78
|
+
* Still bounded, and that is the point: `**.amazonaws.com` reaches any AWS host and
|
|
79
|
+
* NOTHING else, so a tampered manifest still cannot post a credential to evil.example.
|
|
80
|
+
*/
|
|
81
|
+
if (pattern.startsWith("**.")) {
|
|
82
|
+
const suffix = pattern.slice(2); // ".amazonaws.com"
|
|
83
|
+
// A non-empty label must precede it, so `**.a.com` does not match a bare `a.com`.
|
|
84
|
+
return host.endsWith(suffix) && host.length > suffix.length;
|
|
85
|
+
}
|
|
86
|
+
if (pattern.startsWith("*.")) {
|
|
87
|
+
const suffix = pattern.slice(1); // ".example.com"
|
|
88
|
+
// One level only: *.a.com matches x.a.com, not x.y.a.com.
|
|
89
|
+
return host.endsWith(suffix) && !host.slice(0, -suffix.length).includes(".");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* EXACT, and a pattern with no port matches ANY port on that host.
|
|
93
|
+
*
|
|
94
|
+
* `parsed.host` carries the port when it is not the scheme's default, while
|
|
95
|
+
* package.schema.json's allowedHosts pattern forbids a colon outright — so a port can
|
|
96
|
+
* never be written down. Comparing the two strings therefore made every non-default
|
|
97
|
+
* port UNREACHABLE, with an error naming a host that looked identical to the declared
|
|
98
|
+
* one: `refusing a request to "127.0.0.1:4106". This package allows "127.0.0.1"`.
|
|
99
|
+
*
|
|
100
|
+
* It stayed hidden because vendors are on https/443, where `host` has no port. The
|
|
101
|
+
* first caller to hit it was a node calling the PLATFORM, which listens on 4106.
|
|
102
|
+
*
|
|
103
|
+
* Nothing is weakened: a port could not be expressed in a pattern, so this restricts
|
|
104
|
+
* exactly what it always restricted, the host. The non-https rule above is unchanged
|
|
105
|
+
* and still bounds what a plaintext call can reach.
|
|
106
|
+
*/
|
|
107
|
+
return host === pattern || hostname === pattern;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (!allowed)
|
|
111
|
+
throw new Error(
|
|
112
|
+
`${nodeType}: refusing a request to "${host}". This package allows ${allowedHosts.length ? allowedHosts.map((h) => `"${h}"`).join(", ") : "NOTHING"}. ` +
|
|
113
|
+
`A manifest may only call hosts its package declared in package.yaml, so a pasted node cannot ship your credentials somewhere you never approved.` +
|
|
114
|
+
(allowedHosts.includes("*") && carriesCredential
|
|
115
|
+
? ` This package does declare "*", but that covers unauthenticated calls only, and THIS call sends a credential.`
|
|
116
|
+
: ""),
|
|
117
|
+
);
|
|
118
|
+
}
|