@sigrun/sigrun 0.0.0-stage → 0.1.1
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/CHANGELOG.md +214 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +89 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extension loader (spec §3 boot order, §4.1 deploy-time list).
|
|
3
|
+
*
|
|
4
|
+
* Extensions are EXTERNAL packages (separate repos — the
|
|
5
|
+
* extension-separation ruling). `SIGRUN_EXTENSIONS` carries a comma-separated
|
|
6
|
+
* specifiers (npm package names or resolvable paths); the loader
|
|
7
|
+
* dynamic-imports each, resolves the sigrun extension-package contract, and
|
|
8
|
+
* installs the pi-durable `Extension` into the registry *before*
|
|
9
|
+
* `Harness.open` reads it.
|
|
10
|
+
*
|
|
11
|
+
* A failing extension is QUARANTINED: logged, reported as a degraded surface
|
|
12
|
+
* via the outcome list (which `GET /v1/meta`, Task 9, surfaces as
|
|
13
|
+
* `extensions[].status`), and boot continues — the loader never throws.
|
|
14
|
+
*
|
|
15
|
+
* Extension IDENTITY is the slug (spec §6): each resolved extension's
|
|
16
|
+
* declared name is sanitized ONCE at load (`slugifyName`,
|
|
17
|
+
* src/harness/stores.ts) and the slug replaces the name for everything
|
|
18
|
+
* downstream — the registry key, the seam host's identity, interaction/
|
|
19
|
+
* ambient records, worker names, and health correlation. A declared name
|
|
20
|
+
* that sanitizes to an empty slug, or a slug already claimed by another
|
|
21
|
+
* loaded extension this boot, fails the LOAD (quarantine naming the
|
|
22
|
+
* declared name(s)) — never the first store use mid-run.
|
|
23
|
+
*
|
|
24
|
+
* The sigrun extension-package contract: a package's default export is a
|
|
25
|
+
* factory returning an object with an `extension: Extension` field, plus an
|
|
26
|
+
* optional §3 `health` producer (spec §3 — the memory-backend form; Task
|
|
27
|
+
* 9e retains it for the §5 meta `health.memory` aggregation). A factory
|
|
28
|
+
* result may also carry a `providers` array (`ProviderContribution[]`,
|
|
29
|
+
* provider plugins Task 1): boot feeds every loaded contributor into
|
|
30
|
+
* `collectProviders` after the `options.extensions` seam — and a result
|
|
31
|
+
* carrying `providers` with NO `extension` is a valid PROVIDER-ONLY
|
|
32
|
+
* contributor (the model-plugin shape: no pi-durable surface, identified
|
|
33
|
+
* by its `name` field). A result may likewise carry a `webuiBundles`
|
|
34
|
+
* array (`{name, dir}[]`, build-and-webui Task 6): the loader validates it
|
|
35
|
+
* fail-loud, anchors each `dir` at the extension module's URL, and
|
|
36
|
+
* collects the entries attributed to the slug for boot's
|
|
37
|
+
* contributed-bundle registry — the same contributor-only form applies (a
|
|
38
|
+
* `webuiBundles` result with no `extension` needs a `name`). Factories may
|
|
39
|
+
* return a Promise (the plugins read their scoped settings store inside the
|
|
40
|
+
* factory); the loader awaits the result. Packages that predate the settled
|
|
41
|
+
* contract export a named `extension` directly, so the loader resolves,
|
|
42
|
+
* in order:
|
|
43
|
+
*
|
|
44
|
+
* 1. the default export, if it is a factory returning `{ extension,
|
|
45
|
+
* health? }` or is itself an `Extension` (named `name`) — the supported
|
|
46
|
+
* form,
|
|
47
|
+
* 2. a named `extension` export (DEPRECATED bridge — one warning per
|
|
48
|
+
* load). The pre-contract `createMemory` named-factory bridge was
|
|
49
|
+
* deleted in release-0.1 (its last consumer, the memory package, now
|
|
50
|
+
* exports the factory as default).
|
|
51
|
+
*
|
|
52
|
+
* A factory result still carrying a `submit` pass-through (the retired
|
|
53
|
+
* pre-seam memory wiring) is IGNORED with one warning — never
|
|
54
|
+
* quarantined, so pre-seam packages keep loading.
|
|
55
|
+
*
|
|
56
|
+
* A factory MAY declare an arity-1 signature: the loader then passes the
|
|
57
|
+
* host context (src/harness/seam-host.ts — the interaction/ambient seam
|
|
58
|
+
* handles plus the scoped extension-stores handle `stores` (spec §5:
|
|
59
|
+
* get/set on the extension's OWN secrets section and settings file, and
|
|
60
|
+
* its own raw data/cache/state paths; src/harness/stores.ts). The host
|
|
61
|
+
* is passed only to factories with `factory.length > 0` — a length-0
|
|
62
|
+
* factory may carry a defaulted first parameter that would consume the
|
|
63
|
+
* host as a real dependency — and the factory-result
|
|
64
|
+
* `interactionKinds`/`ambientKinds` string arrays declare which seam
|
|
65
|
+
* kinds/keys the extension writes — validated fail-loud here and
|
|
66
|
+
* consumed by the seam host's write gate and the /v1/meta inventory.
|
|
67
|
+
* Only factory results can carry a health producer or kind declarations
|
|
68
|
+
* (the direct-`Extension` forms have no factory result to read one from).
|
|
69
|
+
* A `health` field that is present but not the expected shape, or kinds
|
|
70
|
+
* that are not string arrays, is a contract violation — quarantined,
|
|
71
|
+
* never silently ignored.
|
|
72
|
+
* A `providers` entry whose `provider` has no string `id` is the same class
|
|
73
|
+
* of violation (a plugin that THINKS it contributed a provider must not
|
|
74
|
+
* silently lose it).
|
|
75
|
+
*
|
|
76
|
+
* The contract is DOCUMENTED as one types-only type surface (W5 Task 1,
|
|
77
|
+
* spec §9.1): `src/extension.ts`, shipped as the `@sigrun/sigrun/extension`
|
|
78
|
+
* subpath (types-only — a runtime import of it fails loud by design).
|
|
79
|
+
* This loader's validated candidate shape is DERIVED from that contract
|
|
80
|
+
* (`CandidateFactoryResult` — reading a key the contract does not declare
|
|
81
|
+
* is a compile error here), and `LoaderReadKeys` compiles only while every
|
|
82
|
+
* factory-result key the loader reads is a key of `SigrunExtensionResult`.
|
|
83
|
+
* The implementation and the contract cannot drift silently.
|
|
84
|
+
*/
|
|
85
|
+
import { validateJobSpec } from "../jobs/spec.js";
|
|
86
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
87
|
+
import { createRequire } from "node:module";
|
|
88
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
89
|
+
import { fileURLToPath } from "node:url";
|
|
90
|
+
import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
|
|
91
|
+
import { RESERVED_CORE_SECTION_KEYS } from "./reserved-sections.js";
|
|
92
|
+
import { slugifyName } from "./stores.js";
|
|
93
|
+
/** Every lifecycle field name, in validation order (mirrors the contract doc). */
|
|
94
|
+
const LIFECYCLE_FIELDS = [
|
|
95
|
+
"conversationCreated",
|
|
96
|
+
"conversationForked",
|
|
97
|
+
"conversationArchived",
|
|
98
|
+
"conversationIdle",
|
|
99
|
+
"harnessShutdown",
|
|
100
|
+
"userDeleted",
|
|
101
|
+
];
|
|
102
|
+
/**
|
|
103
|
+
* The reserved-key membership probe (spec §6.5 R11): a `Set` because the
|
|
104
|
+
* `as const` tuple's `includes` narrows its parameter to the literal
|
|
105
|
+
* union, which a section's `string` key can never satisfy.
|
|
106
|
+
*/
|
|
107
|
+
const RESERVED_SECTION_KEY_SET = new Set(RESERVED_CORE_SECTION_KEYS);
|
|
108
|
+
/** A structural check for "looks like a pi-durable Extension". */
|
|
109
|
+
function isExtension(value) {
|
|
110
|
+
return (typeof value === "object" &&
|
|
111
|
+
value !== null &&
|
|
112
|
+
typeof value.name === "string");
|
|
113
|
+
}
|
|
114
|
+
/** Whether `dir` is an EXISTING directory (never throws). */
|
|
115
|
+
function existingDirectory(dir) {
|
|
116
|
+
try {
|
|
117
|
+
return statSync(dir).isDirectory();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A kinds declaration must be an array of non-empty strings (or absent).
|
|
125
|
+
* The declaration drives the seam host's write gate and the /v1/meta
|
|
126
|
+
* inventory — a malformed one is a contract violation, not a shrug.
|
|
127
|
+
*/
|
|
128
|
+
function validateKinds(value) {
|
|
129
|
+
if (value === undefined)
|
|
130
|
+
return null;
|
|
131
|
+
if (!Array.isArray(value))
|
|
132
|
+
return "kinds declaration must be an array of strings";
|
|
133
|
+
if (value.some((k) => typeof k !== "string" || k.length === 0)) {
|
|
134
|
+
return "kinds declaration must be an array of non-empty strings";
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The lifecycle event names a worker's `wakeOn` may declare (memory-seam
|
|
140
|
+
* Task 5): `harnessShutdown` is excluded — workers stop BEFORE the shutdown
|
|
141
|
+
* handlers run (spec §3 shutdown ordering), so waking on it is meaningless.
|
|
142
|
+
*/
|
|
143
|
+
const WAKE_EVENTS = new Set([
|
|
144
|
+
"conversationCreated",
|
|
145
|
+
"conversationForked",
|
|
146
|
+
"conversationArchived",
|
|
147
|
+
"conversationIdle",
|
|
148
|
+
]);
|
|
149
|
+
/**
|
|
150
|
+
* The `every` retirement message (ruling 12, jobs Task 3): jobs are the
|
|
151
|
+
* only time-based scheduler — an extension that needs a timer declares a
|
|
152
|
+
* job with a `task` action (spec §8.1a).
|
|
153
|
+
*/
|
|
154
|
+
const EVERY_RETIRED = "workers.every was retired — declare a job with a task action";
|
|
155
|
+
/**
|
|
156
|
+
* Validate a factory result's `workers` declaration (memory-seam Task 5,
|
|
157
|
+
* the curator contract, post-`every`-retirement). Absent → undefined (no
|
|
158
|
+
* workers). Present but malformed → throw; the loader quarantines the
|
|
159
|
+
* extension with the thrown message (fail-loud: an extension that THINKS
|
|
160
|
+
* it declared a curator must not silently lose it). Validation order per
|
|
161
|
+
* worker: name, duplicate, run, every-retired, wakeOn,
|
|
162
|
+
* wakeOn-required-non-empty.
|
|
163
|
+
*/
|
|
164
|
+
function validateWorkers(value) {
|
|
165
|
+
if (value === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
if (!Array.isArray(value)) {
|
|
168
|
+
throw new Error("extension factory returned a workers field that is not an array");
|
|
169
|
+
}
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
for (const entry of value) {
|
|
172
|
+
const worker = entry;
|
|
173
|
+
const name = worker?.name;
|
|
174
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
175
|
+
throw new Error("extension factory returned a worker without a name");
|
|
176
|
+
}
|
|
177
|
+
if (seen.has(name)) {
|
|
178
|
+
throw new Error(`extension factory returned a worker with a duplicate worker name "${name}"`);
|
|
179
|
+
}
|
|
180
|
+
seen.add(name);
|
|
181
|
+
if (typeof worker?.run !== "function") {
|
|
182
|
+
throw new Error(`extension factory returned a worker whose worker "${name}" has a run field that is not a function`);
|
|
183
|
+
}
|
|
184
|
+
// Ruling 12: `every` is RETIRED — a worker carrying it quarantines
|
|
185
|
+
// with the retirement message (jobs are the only timer).
|
|
186
|
+
if (worker?.every !== undefined) {
|
|
187
|
+
throw new Error(EVERY_RETIRED);
|
|
188
|
+
}
|
|
189
|
+
if (worker?.wakeOn === undefined ||
|
|
190
|
+
!Array.isArray(worker.wakeOn) ||
|
|
191
|
+
worker.wakeOn.length === 0 ||
|
|
192
|
+
worker.wakeOn.some((event) => typeof event !== "string" || !WAKE_EVENTS.has(event))) {
|
|
193
|
+
if (worker?.wakeOn !== undefined && Array.isArray(worker.wakeOn) && worker.wakeOn.length === 0) {
|
|
194
|
+
throw new Error(`extension factory returned a worker whose worker "${name}" has an empty wakeOn (declare at least one wake event)`);
|
|
195
|
+
}
|
|
196
|
+
if (worker?.wakeOn === undefined) {
|
|
197
|
+
throw new Error("extension factory returned a worker that declares a worker with neither every nor wakeOn");
|
|
198
|
+
}
|
|
199
|
+
throw new Error(`extension factory returned a worker whose worker "${name}" has an invalid wakeOn entry`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return value;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Validate a factory result's `jobs` declaration (jobs §8.3 — the
|
|
206
|
+
* `extension` source). Absent → undefined (no jobs). Present but
|
|
207
|
+
* malformed → throw; the loader quarantines the extension with the
|
|
208
|
+
* thrown message (fail-loud: an extension that THINKS it declared a job
|
|
209
|
+
* must not silently lose it). Each entry is a full `validateJobSpec` run
|
|
210
|
+
* (the §8.2 grammar), and duplicate names within one extension are
|
|
211
|
+
* rejected (they would collide on the `ext:<slug>:<name>` id).
|
|
212
|
+
*/
|
|
213
|
+
function validateJobs(value) {
|
|
214
|
+
if (value === undefined)
|
|
215
|
+
return undefined;
|
|
216
|
+
if (!Array.isArray(value)) {
|
|
217
|
+
throw new Error("extension factory returned a jobs field that is not an array");
|
|
218
|
+
}
|
|
219
|
+
const seen = new Set();
|
|
220
|
+
const specs = [];
|
|
221
|
+
for (const entry of value) {
|
|
222
|
+
const result = validateJobSpec(entry);
|
|
223
|
+
if (!result.ok) {
|
|
224
|
+
throw new Error(`extension factory returned a job whose ${result.field} ${result.message}`);
|
|
225
|
+
}
|
|
226
|
+
if (seen.has(result.spec.name)) {
|
|
227
|
+
throw new Error(`extension factory returned a duplicate job name "${result.spec.name}"`);
|
|
228
|
+
}
|
|
229
|
+
seen.add(result.spec.name);
|
|
230
|
+
specs.push(result.spec);
|
|
231
|
+
}
|
|
232
|
+
return specs;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The `method`/`auth` vocabularies of a `routes` declaration (spec
|
|
236
|
+
* §9.2) — Sets because `includes` on a `const` tuple narrows to the
|
|
237
|
+
* literal union, which an `unknown` field value can never satisfy.
|
|
238
|
+
*/
|
|
239
|
+
const ROUTE_METHODS = new Set([
|
|
240
|
+
"GET",
|
|
241
|
+
"POST",
|
|
242
|
+
"PUT",
|
|
243
|
+
"PATCH",
|
|
244
|
+
"DELETE",
|
|
245
|
+
]);
|
|
246
|
+
const ROUTE_AUTHS = new Set(["user", "admin", "public"]);
|
|
247
|
+
/**
|
|
248
|
+
* Validate a factory result's `routes` declaration (core hardening W5
|
|
249
|
+
* Task 2, spec §9.2). Absent → undefined (no routes). Present but
|
|
250
|
+
* malformed → throw; the loader quarantines the extension with the
|
|
251
|
+
* thrown message (fail-loud: an extension that THINKS it mounted a
|
|
252
|
+
* webhook must not silently lose it). Per entry: `method` in the five
|
|
253
|
+
* verbs, `path` a non-empty RELATIVE string (no leading `/`, no `..` —
|
|
254
|
+
* nothing may escape the `/v1/ext/<slug>/` mount), `auth` one of the
|
|
255
|
+
* three values, `bodyLimit` a positive integer when present, and
|
|
256
|
+
* `handler` a function.
|
|
257
|
+
*/
|
|
258
|
+
function validateRoutes(value) {
|
|
259
|
+
if (value === undefined)
|
|
260
|
+
return undefined;
|
|
261
|
+
if (!Array.isArray(value)) {
|
|
262
|
+
throw new Error("extension factory returned a routes field that is not an array");
|
|
263
|
+
}
|
|
264
|
+
for (const entry of value) {
|
|
265
|
+
const route = entry;
|
|
266
|
+
const describe = () => `route "${typeof route?.path === "string" ? route.path : String(route?.path)}"`;
|
|
267
|
+
if (typeof route?.method !== "string" || !ROUTE_METHODS.has(route.method)) {
|
|
268
|
+
throw new Error(`extension factory returned a ${describe()} whose method is not one of GET, POST, PUT, PATCH, DELETE`);
|
|
269
|
+
}
|
|
270
|
+
if (typeof route?.path !== "string" ||
|
|
271
|
+
route.path === "" ||
|
|
272
|
+
route.path.startsWith("/") ||
|
|
273
|
+
route.path.includes("..")) {
|
|
274
|
+
throw new Error(`extension factory returned a route whose path must be a non-empty relative string (no leading "/", no "..")`);
|
|
275
|
+
}
|
|
276
|
+
if (typeof route?.auth !== "string" || !ROUTE_AUTHS.has(route.auth)) {
|
|
277
|
+
throw new Error(`extension factory returned a ${describe()} whose auth is not one of user, admin, public`);
|
|
278
|
+
}
|
|
279
|
+
if (route?.bodyLimit !== undefined &&
|
|
280
|
+
(typeof route.bodyLimit !== "number" ||
|
|
281
|
+
!Number.isInteger(route.bodyLimit) ||
|
|
282
|
+
route.bodyLimit <= 0)) {
|
|
283
|
+
throw new Error(`extension factory returned a ${describe()} whose bodyLimit is not a positive integer`);
|
|
284
|
+
}
|
|
285
|
+
if (typeof route?.handler !== "function") {
|
|
286
|
+
throw new Error(`extension factory returned a ${describe()} whose handler is not a function`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return value;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The extension command NAME grammar (spec §9.3): `^[a-z][a-z0-9-]*$` —
|
|
293
|
+
* the same shape as every other extension-declared name (workers,
|
|
294
|
+
* bundles), so the composer's `/` menu renders one spelling of a name.
|
|
295
|
+
*/
|
|
296
|
+
const COMMAND_NAME = /^[a-z][a-z0-9-]*$/;
|
|
297
|
+
/**
|
|
298
|
+
* Validate a factory result's `commands` declaration (core hardening W5
|
|
299
|
+
* Task 3, spec §9.3). Absent → undefined (no commands). Present but
|
|
300
|
+
* malformed → throw; the loader quarantines the extension with the
|
|
301
|
+
* thrown message (fail-loud: an extension that THINKS it declared a
|
|
302
|
+
* command must not silently lose it — the providers/health class). Per
|
|
303
|
+
* entry: `name` matching the grammar, `description` a string, optional
|
|
304
|
+
* `args` a string, and `run` a function. Core-name collisions are NOT a
|
|
305
|
+
* validation failure — the catalog builder drops those with a warning
|
|
306
|
+
* (they are a deployment choice, not a broken declaration).
|
|
307
|
+
*/
|
|
308
|
+
function validateCommands(value) {
|
|
309
|
+
if (value === undefined)
|
|
310
|
+
return undefined;
|
|
311
|
+
if (!Array.isArray(value)) {
|
|
312
|
+
throw new Error("extension factory returned a commands field that is not an array");
|
|
313
|
+
}
|
|
314
|
+
for (const entry of value) {
|
|
315
|
+
const command = entry;
|
|
316
|
+
const describe = () => `command "${typeof command?.name === "string" ? command.name : String(command?.name)}"`;
|
|
317
|
+
if (typeof command?.name !== "string" || !COMMAND_NAME.test(command.name)) {
|
|
318
|
+
throw new Error(`extension factory returned a ${describe()} whose name does not match ^[a-z][a-z0-9-]*$`);
|
|
319
|
+
}
|
|
320
|
+
if (typeof command?.description !== "string") {
|
|
321
|
+
throw new Error(`extension factory returned a ${describe()} whose description is missing or not a string`);
|
|
322
|
+
}
|
|
323
|
+
if (command?.args !== undefined && typeof command.args !== "string") {
|
|
324
|
+
throw new Error(`extension factory returned a ${describe()} whose args is not a string`);
|
|
325
|
+
}
|
|
326
|
+
if (typeof command?.run !== "function") {
|
|
327
|
+
throw new Error(`extension factory returned a ${describe()} whose run is not a function`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return value;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Validate a factory result's `notifiers` declaration (core hardening W5
|
|
334
|
+
* Task 6, spec §9.6). Absent → undefined (no contributions). Present but
|
|
335
|
+
* malformed → throw; the loader quarantines the extension with the thrown
|
|
336
|
+
* message (fail-loud: an extension that THINKS it declared a transport
|
|
337
|
+
* must not silently lose it — the providers/health class). Per entry: a
|
|
338
|
+
* non-empty `name` and a `notify` function.
|
|
339
|
+
*/
|
|
340
|
+
function validateNotifiers(value) {
|
|
341
|
+
if (value === undefined)
|
|
342
|
+
return undefined;
|
|
343
|
+
if (!Array.isArray(value)) {
|
|
344
|
+
throw new Error("extension factory returned a notifiers field that is not an array");
|
|
345
|
+
}
|
|
346
|
+
for (const entry of value) {
|
|
347
|
+
const notifier = entry;
|
|
348
|
+
const describe = () => `notifier "${typeof notifier?.name === "string" ? notifier.name : String(notifier?.name)}"`;
|
|
349
|
+
if (typeof notifier?.name !== "string" || notifier.name.length === 0) {
|
|
350
|
+
throw new Error(`extension factory returned a ${describe()} whose name is missing or empty`);
|
|
351
|
+
}
|
|
352
|
+
if (typeof notifier?.notify !== "function") {
|
|
353
|
+
throw new Error(`extension factory returned a ${describe()} whose notify is not a function`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return value;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Validate a factory result's `skills` declaration (core hardening W5
|
|
360
|
+
* Task 4, spec §9.4). Absent → undefined (no contributions). Present but
|
|
361
|
+
* malformed → throw; the loader quarantines the extension with the thrown
|
|
362
|
+
* message (fail-loud: an extension that THINKS it contributed skills must
|
|
363
|
+
* not silently lose them — the providers/health class). Per entry: an
|
|
364
|
+
* absolute path naming an EXISTING directory.
|
|
365
|
+
*/
|
|
366
|
+
function validateSkills(value) {
|
|
367
|
+
if (value === undefined)
|
|
368
|
+
return undefined;
|
|
369
|
+
if (!Array.isArray(value)) {
|
|
370
|
+
throw new Error("extension factory returned a skills field that is not an array");
|
|
371
|
+
}
|
|
372
|
+
for (const entry of value) {
|
|
373
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
374
|
+
throw new Error("extension factory returned a skills entry that is not a non-empty string");
|
|
375
|
+
}
|
|
376
|
+
if (!isAbsolute(entry)) {
|
|
377
|
+
throw new Error(`extension factory returned a skills entry that is not an absolute directory: ${entry}`);
|
|
378
|
+
}
|
|
379
|
+
if (!existingDirectory(entry)) {
|
|
380
|
+
throw new Error(`extension factory returned a skills entry that is not an existing directory: ${entry}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Validate a factory result's `providers` declaration (provider plugins
|
|
387
|
+
* Task 1). Absent → undefined (no contributions). Present but malformed →
|
|
388
|
+
* throw; the loader quarantines the extension with the thrown message
|
|
389
|
+
* (fail-loud: a plugin that THINKS it contributed a provider must not
|
|
390
|
+
* silently lose it). Each entry must be a `ProviderContribution` — an
|
|
391
|
+
* object whose `provider` carries a string `id`, the key the collector
|
|
392
|
+
* merges by and the §5 ownership records read.
|
|
393
|
+
*/
|
|
394
|
+
function validateProviders(value) {
|
|
395
|
+
if (value === undefined)
|
|
396
|
+
return undefined;
|
|
397
|
+
if (!Array.isArray(value)) {
|
|
398
|
+
throw new Error("extension factory returned a providers field that is not an array");
|
|
399
|
+
}
|
|
400
|
+
for (const entry of value) {
|
|
401
|
+
const provider = entry?.provider;
|
|
402
|
+
if (typeof provider !== "object" ||
|
|
403
|
+
provider === null ||
|
|
404
|
+
typeof provider.id !== "string") {
|
|
405
|
+
throw new Error("extension factory returned a providers entry whose provider.id is missing or not a string");
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return value;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The contributed-bundle NAME grammar (spec §4.1: `^[a-z][a-z0-9_-]*$` —
|
|
412
|
+
* a selector value matching it is a contributed-bundle NAME). Kept in
|
|
413
|
+
* sync with `BUNDLE_NAME` in src/server/static.ts by the spec grammar,
|
|
414
|
+
* not a shared import: this module must not pull the server's runtime
|
|
415
|
+
* graph (the loader has no fs/server coupling by contract).
|
|
416
|
+
*/
|
|
417
|
+
const WEBUI_BUNDLE_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
418
|
+
/**
|
|
419
|
+
* Validate a factory result's `webuiBundles` declaration (build-and-webui
|
|
420
|
+
* Task 6). Absent → undefined (no contributions). Present but malformed →
|
|
421
|
+
* throw; the loader quarantines the extension with the thrown message
|
|
422
|
+
* (fail-loud: an extension that THINKS it contributed a bundle must not
|
|
423
|
+
* silently lose it — the providers/health/submit class). Each entry must
|
|
424
|
+
* be a `{name, dir}` object whose `name` matches the contributed-bundle
|
|
425
|
+
* grammar and whose `dir` is a non-empty string; extra fields are inert.
|
|
426
|
+
*/
|
|
427
|
+
function validateWebuiBundles(value) {
|
|
428
|
+
if (value === undefined)
|
|
429
|
+
return undefined;
|
|
430
|
+
if (!Array.isArray(value)) {
|
|
431
|
+
throw new Error("extension factory returned a webuiBundles field that is not an array");
|
|
432
|
+
}
|
|
433
|
+
for (const entry of value) {
|
|
434
|
+
const bundle = entry;
|
|
435
|
+
if (typeof bundle?.name !== "string" ||
|
|
436
|
+
!WEBUI_BUNDLE_NAME.test(bundle.name)) {
|
|
437
|
+
throw new Error("extension factory returned a webuiBundles entry whose name is missing or does not match ^[a-z][a-z0-9_-]*$");
|
|
438
|
+
}
|
|
439
|
+
if (typeof bundle?.dir !== "string" || bundle.dir.length === 0) {
|
|
440
|
+
throw new Error(`extension factory returned a webuiBundles entry "${bundle?.name}" whose dir is missing or not a non-empty string`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return value;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* The one host module whose version must match the host EXACTLY (spec
|
|
447
|
+
* §6.4 R6): an extension's `Extension`/tool objects flow into the HOST's
|
|
448
|
+
* pi-durable registry under structural typing — a different pi-durable
|
|
449
|
+
* is the classic silent-breakage trap (identity never matches, hooks
|
|
450
|
+
* never fire), so any difference at all quarantines. Every other host
|
|
451
|
+
* module only shares the major (a minor/patch difference warns).
|
|
452
|
+
*/
|
|
453
|
+
const EXACT_HOST_MODULES = new Set([
|
|
454
|
+
"@earendil-works/pi-durable",
|
|
455
|
+
]);
|
|
456
|
+
/** The major segment of a semver-ish version string (`"1.2.0"` → `"1"`). */
|
|
457
|
+
function majorSegment(version) {
|
|
458
|
+
return version.split(".")[0] ?? version;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The nearest `package.json` at or above `dir` (bounded walk) — the
|
|
462
|
+
* manifest owning a resolved module ENTRY file. `undefined` at the walk
|
|
463
|
+
* bound or filesystem root.
|
|
464
|
+
*/
|
|
465
|
+
function nearestManifest(dir) {
|
|
466
|
+
let current = dir;
|
|
467
|
+
for (let hops = 0; hops < 6; hops++) {
|
|
468
|
+
const candidate = join(current, "package.json");
|
|
469
|
+
if (existsSync(candidate))
|
|
470
|
+
return candidate;
|
|
471
|
+
const parent = dirname(current);
|
|
472
|
+
if (parent === current)
|
|
473
|
+
return undefined;
|
|
474
|
+
current = parent;
|
|
475
|
+
}
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The nearest `node_modules/<name>/package.json` walking up from `dir` —
|
|
480
|
+
* the package-directory form of Node's own lookup (which copy of `<name>`
|
|
481
|
+
* a module at `dir` resolves). Bounded like every walk here; `undefined`
|
|
482
|
+
* when no ancestor tree carries the package.
|
|
483
|
+
*/
|
|
484
|
+
function nearestModuleManifest(dir, name) {
|
|
485
|
+
let current = dir;
|
|
486
|
+
for (let hops = 0; hops < 10; hops++) {
|
|
487
|
+
const candidate = join(current, "node_modules", ...name.split("/"), "package.json");
|
|
488
|
+
if (existsSync(candidate))
|
|
489
|
+
return candidate;
|
|
490
|
+
const parent = dirname(current);
|
|
491
|
+
if (parent === current)
|
|
492
|
+
return undefined;
|
|
493
|
+
current = parent;
|
|
494
|
+
}
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The version of one host runtime module as an extension's tree resolves
|
|
499
|
+
* it, anchored at the extension ENTRY's directory — the exact resolution
|
|
500
|
+
* the extension's own bare imports use (a nested `node_modules` copy
|
|
501
|
+
* counts, and so does the host's copy when the walk finds nothing
|
|
502
|
+
* nearer). `undefined` when the extension's tree resolves no copy at
|
|
503
|
+
* all: the extension does not depend on the module, and the guard
|
|
504
|
+
* skips it.
|
|
505
|
+
*
|
|
506
|
+
* Two resolution forms, in order: `<name>/package.json` (explicit, and
|
|
507
|
+
* the only form for manifest-less trees), falling back to the module
|
|
508
|
+
* ENTRY + nearest manifest above it — some host modules' exports maps
|
|
509
|
+
* (pi-ai, typebox) do not expose the `./package.json` subpath, which
|
|
510
|
+
* `require.resolve("<name>/package.json")"` reports as
|
|
511
|
+
* ERR_PACKAGE_PATH_NOT_EXPORTED even though the package itself resolves
|
|
512
|
+
* fine; without the fallback those modules would be invisible to the
|
|
513
|
+
* guard, the exact R6 blind spot it exists to close.
|
|
514
|
+
*/
|
|
515
|
+
function moduleVersionFrom(moduleDir, name) {
|
|
516
|
+
// Node's ESM loader realpaths the referrer before resolving ITS bare
|
|
517
|
+
// imports (preserveSymlinks is off by default) — anchor the walk at the
|
|
518
|
+
// realpath'd dir so the tree examined is the tree the extension's own
|
|
519
|
+
// imports resolve from, not the symlink path that named it (a `file:`
|
|
520
|
+
// dir install symlinks node_modules/<pkg> → the checkout).
|
|
521
|
+
let anchor = moduleDir;
|
|
522
|
+
try {
|
|
523
|
+
anchor = realpathSync(moduleDir);
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// Not realpath-able — the literal dir is the best anchor available.
|
|
527
|
+
}
|
|
528
|
+
const req = createRequire(join(anchor, "package.json"));
|
|
529
|
+
let manifestPath;
|
|
530
|
+
try {
|
|
531
|
+
manifestPath = req.resolve(`${name}/package.json`);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
// No package.json subpath from here — either the exports map gates
|
|
535
|
+
// it or the tree carries no copy: the fallbacks below decide.
|
|
536
|
+
}
|
|
537
|
+
if (manifestPath === undefined) {
|
|
538
|
+
try {
|
|
539
|
+
manifestPath = nearestManifest(dirname(req.resolve(name)));
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
// The entry itself is not require-resolvable — pi-ai's exports
|
|
543
|
+
// map carries ONLY `import`/`types` conditions, so createRequire
|
|
544
|
+
// cannot resolve the package AT ALL even though extensions
|
|
545
|
+
// import it fine. Fall back to the node_modules DIRECTORY walk
|
|
546
|
+
// (the package-dir form of Node's lookup); without it pi-ai skew
|
|
547
|
+
// would be invisible to the guard — the exact R6 blind spot.
|
|
548
|
+
manifestPath = nearestModuleManifest(anchor, name);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (manifestPath === undefined)
|
|
552
|
+
return undefined;
|
|
553
|
+
try {
|
|
554
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
555
|
+
return typeof parsed.version === "string" ? parsed.version : undefined;
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* The host-module version guard (spec §6.4 R6, core-hardening Task 5):
|
|
563
|
+
* every host runtime module the extension's tree RESOLVES must agree
|
|
564
|
+
* with the copy the host runs — `@earendil-works/pi-durable` EXACTLY
|
|
565
|
+
* (else quarantine), the others in the major (a minor/patch difference
|
|
566
|
+
* warns, a major difference quarantines). Runs at LOAD, immediately
|
|
567
|
+
* after the import and before anything is keyed, collected, or
|
|
568
|
+
* installed: a skewing extension quarantines with this message and
|
|
569
|
+
* leaves no trace in the registry, the seam hosts, or any binding.
|
|
570
|
+
*
|
|
571
|
+
* @param moduleDir the extension ENTRY's directory (the tree its bare
|
|
572
|
+
* imports resolve from — `extensionModuleDir` above).
|
|
573
|
+
* @throws the quarantine message on a disallowed difference.
|
|
574
|
+
*/
|
|
575
|
+
function checkHostModules(moduleDir) {
|
|
576
|
+
for (const name of HOST_RUNTIME_MODULES) {
|
|
577
|
+
const extVersion = moduleVersionFrom(moduleDir, name);
|
|
578
|
+
if (extVersion === undefined)
|
|
579
|
+
continue; // not a dependency of this extension
|
|
580
|
+
const hostVersion = hostModuleVersion(name);
|
|
581
|
+
if (hostVersion === undefined)
|
|
582
|
+
continue; // no host copy to compare against
|
|
583
|
+
if (extVersion === hostVersion)
|
|
584
|
+
continue;
|
|
585
|
+
if (EXACT_HOST_MODULES.has(name) ||
|
|
586
|
+
majorSegment(extVersion) !== majorSegment(hostVersion)) {
|
|
587
|
+
throw new Error(`host module skew: ${name} resolves to ${extVersion} from ${moduleDir}, host runs ${hostVersion}`);
|
|
588
|
+
}
|
|
589
|
+
console.warn(`host module minor skew: ${name} resolves to ${extVersion} from ${moduleDir}, ` +
|
|
590
|
+
`host runs ${hostVersion} (same major, allowed)`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* The extension module's DIRECTORY — the anchor a contributed bundle's
|
|
595
|
+
* relative `dir` resolves against (build-and-webui Task 6: an
|
|
596
|
+
* npm-installed extension's bundle lives in its own package, so the dir
|
|
597
|
+
* anchors where the MODULE loaded from, never the process cwd). Path-shaped
|
|
598
|
+
* specifiers (file:// URLs, POSIX absolutes — the packages-dir and
|
|
599
|
+
* pre-resolved flows) derive directly; bare specifiers resolve through
|
|
600
|
+
* the loader's own require anchor (the same mechanism
|
|
601
|
+
* `resolveExtensionEntry`'s fallback uses). `undefined` when nothing
|
|
602
|
+
* path-shaped derives — a contributed `dir` then passes through unchanged
|
|
603
|
+
* (absolute dirs always do; `resolve` keeps them verbatim).
|
|
604
|
+
*/
|
|
605
|
+
function extensionModuleDir(loaded) {
|
|
606
|
+
let path;
|
|
607
|
+
if (loaded.startsWith("file://")) {
|
|
608
|
+
try {
|
|
609
|
+
path = fileURLToPath(loaded);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else if (loaded.startsWith("/")) {
|
|
616
|
+
path = loaded;
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
try {
|
|
620
|
+
const resolved = createRequire(import.meta.url).resolve(loaded);
|
|
621
|
+
path = resolved.startsWith("file://")
|
|
622
|
+
? fileURLToPath(resolved)
|
|
623
|
+
: resolved;
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return path === undefined ? undefined : dirname(path);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Call an unknown-typed factory and validate the `{ extension, health? }`
|
|
633
|
+
* result. A `health` field that is present but not the expected shape is a
|
|
634
|
+
* contract violation — thrown, so the loader quarantines the extension
|
|
635
|
+
* (fail-loud: a package that THINKS it reports health must not read as
|
|
636
|
+
* healthy). A `submit` field is IGNORED with a flag (§6.7: the
|
|
637
|
+
* pass-through is retired; the loader warns, never quarantines — old
|
|
638
|
+
* packages keep loading).
|
|
639
|
+
*
|
|
640
|
+
* The factory result may be a Promise (provider plugins read their scoped
|
|
641
|
+
* settings store inside the factory) — awaited before validation. A result
|
|
642
|
+
* carrying `providers` with NO `extension` is a valid provider-only
|
|
643
|
+
* contributor (named by its `name` field); a result with neither is not
|
|
644
|
+
* this contract form at all (returns undefined, falling through to the
|
|
645
|
+
* next resolution candidate).
|
|
646
|
+
*/
|
|
647
|
+
async function resolvedFromFactory(factory, hostContext) {
|
|
648
|
+
if (typeof factory !== "function")
|
|
649
|
+
return undefined;
|
|
650
|
+
// Arity gate: the host context goes ONLY to factories that declare the
|
|
651
|
+
// arity-1 signature (factory.length > 0). A length-0 factory with a
|
|
652
|
+
// defaulted first parameter (createMemory(backend = new Backend()))
|
|
653
|
+
// consumes its first argument as a real dependency — passing the host
|
|
654
|
+
// unconditionally landed it in that slot and broke the extension at
|
|
655
|
+
// runtime (memory: every submit 500ed on `bindRecallSource is not a
|
|
656
|
+
// function`). Extra arguments are inert only for factories that read
|
|
657
|
+
// none; `length` is how the contract declares it.
|
|
658
|
+
const result = factory.length > 0
|
|
659
|
+
? factory(hostContext)
|
|
660
|
+
: factory();
|
|
661
|
+
// Provider plugins may resolve asynchronously; `await` passes a
|
|
662
|
+
// synchronous result through unchanged.
|
|
663
|
+
const awaited = await result;
|
|
664
|
+
if (typeof awaited !== "object" || awaited === null)
|
|
665
|
+
return undefined;
|
|
666
|
+
// The candidate is keyed by the PUBLIC contract (CandidateFactoryResult):
|
|
667
|
+
// reading a field the contract does not declare is a compile error here,
|
|
668
|
+
// and the `LoaderReadKeys` assertion (above ResolvedExtension) pins the
|
|
669
|
+
// full read list to the contract's keys.
|
|
670
|
+
const candidate = awaited;
|
|
671
|
+
const providers = validateProviders(candidate.providers);
|
|
672
|
+
const webuiBundles = validateWebuiBundles(candidate.webuiBundles);
|
|
673
|
+
const jobs = validateJobs(candidate.jobs);
|
|
674
|
+
const routes = validateRoutes(candidate.routes);
|
|
675
|
+
const commands = validateCommands(candidate.commands);
|
|
676
|
+
const skills = validateSkills(candidate.skills);
|
|
677
|
+
const notifiers = validateNotifiers(candidate.notifiers);
|
|
678
|
+
const extension = isExtension(candidate.extension)
|
|
679
|
+
? candidate.extension
|
|
680
|
+
: undefined;
|
|
681
|
+
if (extension === undefined) {
|
|
682
|
+
// Contributor-only result: valid when ANY contribution field is
|
|
683
|
+
// present (`providers`, `webuiBundles`); otherwise this factory
|
|
684
|
+
// result is not the contract form (fall through to the next
|
|
685
|
+
// resolution candidate). The contributor's name is its
|
|
686
|
+
// collision-record identity — without one there is nothing to
|
|
687
|
+
// attribute the contributions to, which is a contract violation.
|
|
688
|
+
if (providers === undefined && webuiBundles === undefined) {
|
|
689
|
+
return undefined;
|
|
690
|
+
}
|
|
691
|
+
if (typeof candidate.name !== "string" || candidate.name.length === 0) {
|
|
692
|
+
throw new Error("extension factory returned a contributor-only result without a name");
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (candidate.health !== undefined &&
|
|
696
|
+
typeof candidate.health !== "function") {
|
|
697
|
+
throw new Error("extension factory returned a health field that is not a function");
|
|
698
|
+
}
|
|
699
|
+
if (candidate.runSettled !== undefined &&
|
|
700
|
+
typeof candidate.runSettled !== "function") {
|
|
701
|
+
throw new Error("extension factory returned a runSettled field that is not a function");
|
|
702
|
+
}
|
|
703
|
+
// The five lifecycle fields (memory-seam Task 4) validate identically:
|
|
704
|
+
// present-but-not-a-function is a contract violation — the extension
|
|
705
|
+
// THINKS it listens to lifecycle events and must not silently miss them.
|
|
706
|
+
const lifecycle = {};
|
|
707
|
+
let anyLifecycle = false;
|
|
708
|
+
for (const field of LIFECYCLE_FIELDS) {
|
|
709
|
+
const value = candidate[field];
|
|
710
|
+
if (value === undefined)
|
|
711
|
+
continue;
|
|
712
|
+
if (typeof value !== "function") {
|
|
713
|
+
throw new Error(`extension factory returned a ${field} field that is not a function`);
|
|
714
|
+
}
|
|
715
|
+
lifecycle[field] = value;
|
|
716
|
+
anyLifecycle = true;
|
|
717
|
+
}
|
|
718
|
+
const kindsProblem = validateKinds(candidate.interactionKinds) ??
|
|
719
|
+
validateKinds(candidate.ambientKinds);
|
|
720
|
+
if (kindsProblem !== null)
|
|
721
|
+
throw new Error(kindsProblem);
|
|
722
|
+
// `workers` is a contract key — the candidate read needs no cast.
|
|
723
|
+
const workers = validateWorkers(candidate.workers);
|
|
724
|
+
return {
|
|
725
|
+
// Identity: the extension when one was carried, else the
|
|
726
|
+
// provider-only contributor's validated `name`.
|
|
727
|
+
...(extension !== undefined
|
|
728
|
+
? { extension }
|
|
729
|
+
: { name: candidate.name }),
|
|
730
|
+
...(candidate.health === undefined
|
|
731
|
+
? {}
|
|
732
|
+
: { health: candidate.health }),
|
|
733
|
+
...(candidate.submit === undefined
|
|
734
|
+
? {}
|
|
735
|
+
: { retiredSubmit: true }),
|
|
736
|
+
...(candidate.runSettled === undefined
|
|
737
|
+
? {}
|
|
738
|
+
: { runSettled: candidate.runSettled }),
|
|
739
|
+
...(anyLifecycle
|
|
740
|
+
? {
|
|
741
|
+
// SAFETY: every value under `lifecycle` passed `typeof === "function"`
|
|
742
|
+
// against its LIFECYCLE_FIELDS key above, so the object matches
|
|
743
|
+
// ExtensionLifecycleHandlers key-for-key and value-for-value.
|
|
744
|
+
lifecycle: lifecycle,
|
|
745
|
+
}
|
|
746
|
+
: {}),
|
|
747
|
+
...(candidate.interactionKinds === undefined
|
|
748
|
+
? {}
|
|
749
|
+
: { interactionKinds: candidate.interactionKinds }),
|
|
750
|
+
...(candidate.ambientKinds === undefined
|
|
751
|
+
? {}
|
|
752
|
+
: { ambientKinds: candidate.ambientKinds }),
|
|
753
|
+
...(workers === undefined ? {} : { workers }),
|
|
754
|
+
...(providers === undefined ? {} : { providers }),
|
|
755
|
+
...(webuiBundles === undefined ? {} : { webuiBundles }),
|
|
756
|
+
...(jobs === undefined ? {} : { jobs }),
|
|
757
|
+
...(routes === undefined ? {} : { routes }),
|
|
758
|
+
...(commands === undefined ? {} : { commands }),
|
|
759
|
+
...(skills === undefined ? {} : { skills }),
|
|
760
|
+
...(notifiers === undefined ? {} : { notifiers }),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Resolve one imported module to the extension the loader installs, per the
|
|
765
|
+
* contract order documented on this module, retaining a factory-result §3
|
|
766
|
+
* health producer when one was carried. Async: the factory result may be a
|
|
767
|
+
* Promise (settings-reading provider plugins).
|
|
768
|
+
*/
|
|
769
|
+
export async function resolveExtension(module, hostContext = undefined) {
|
|
770
|
+
// 1. Default export: factory returning `{ extension, health? }`, or the
|
|
771
|
+
// Extension itself — the supported forms.
|
|
772
|
+
const defaultExport = module.default;
|
|
773
|
+
if (isExtension(defaultExport))
|
|
774
|
+
return { extension: defaultExport };
|
|
775
|
+
const fromDefault = await resolvedFromFactory(defaultExport, hostContext);
|
|
776
|
+
if (fromDefault !== undefined)
|
|
777
|
+
return fromDefault;
|
|
778
|
+
// 2. Named `extension` export (DEPRECATED bridge for the pre-contract
|
|
779
|
+
// packages — one warning per load at the caller). The pre-contract
|
|
780
|
+
// `createMemory` named-factory bridge was deleted in release-0.1; its
|
|
781
|
+
// last consumer exports the factory as default now.
|
|
782
|
+
if (isExtension(module.extension)) {
|
|
783
|
+
return { extension: module.extension, resolvedVia: "extension" };
|
|
784
|
+
}
|
|
785
|
+
return undefined;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* The PROVISIONAL host identity for the pre-import factory call
|
|
789
|
+
* (provider plugins Task 9, review P1): the slug derived from the LOAD
|
|
790
|
+
* SPECIFIER — the package's own manifest name when the entry is
|
|
791
|
+
* path-shaped (packages-dir installs, git checkouts, dev checkouts:
|
|
792
|
+
* the nearest `package.json` at or above the entry, never read across
|
|
793
|
+
* a `node_modules` boundary — the packages root's own manifest is not
|
|
794
|
+
* an extension's identity), else the specifier's name part
|
|
795
|
+
* (`slugifyName` folds scoped names and `@version`s; an `npm:` install
|
|
796
|
+
* prefix is stripped). `""` when nothing survives (the pre-fix
|
|
797
|
+
* placeholder shape — an absent settings file reads as `{}` either
|
|
798
|
+
* way, but `""` never invents a wrong-but-plausible scope).
|
|
799
|
+
*
|
|
800
|
+
* Why a provisional at all: a factory that reads scoped settings
|
|
801
|
+
* DURING its body (sigrun-pareto's `baseUrl` override) runs BEFORE the
|
|
802
|
+
* declared name is known; with the `""` placeholder its read hit
|
|
803
|
+
* `settings/.json` and the override was silently inert. Pre-keying on
|
|
804
|
+
* the specifier-derived slug makes the factory-time read resolve the
|
|
805
|
+
* real package's file whenever the extension's declared name matches
|
|
806
|
+
* its package name — the documented requirement for provider plugins
|
|
807
|
+
* that read settings at factory time. A mismatching declared name
|
|
808
|
+
* still re-keys to the declared slug after resolution; only its
|
|
809
|
+
* factory-time settings reads saw the provisional scope.
|
|
810
|
+
*/
|
|
811
|
+
function provisionalSlugFor(loaded) {
|
|
812
|
+
let absolute;
|
|
813
|
+
if (loaded.startsWith("file://")) {
|
|
814
|
+
try {
|
|
815
|
+
absolute = fileURLToPath(loaded);
|
|
816
|
+
}
|
|
817
|
+
catch {
|
|
818
|
+
// Not a usable file URL — fall through to the specifier form.
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
else if (loaded.startsWith("/") ||
|
|
822
|
+
loaded.startsWith("./") ||
|
|
823
|
+
loaded.startsWith("../")) {
|
|
824
|
+
absolute = resolve(loaded);
|
|
825
|
+
}
|
|
826
|
+
if (absolute !== undefined) {
|
|
827
|
+
// Bounded walk from the entry itself (a directory entry carries
|
|
828
|
+
// its manifest at the top) up its ancestors.
|
|
829
|
+
let dir = absolute;
|
|
830
|
+
for (let hops = 0; hops < 6; hops++) {
|
|
831
|
+
if (basename(dir) === "node_modules")
|
|
832
|
+
return "";
|
|
833
|
+
const name = readPackageName(dir);
|
|
834
|
+
if (name !== undefined) {
|
|
835
|
+
const slug = slugifyName(name);
|
|
836
|
+
if (slug !== "")
|
|
837
|
+
return slug;
|
|
838
|
+
}
|
|
839
|
+
const parent = dirname(dir);
|
|
840
|
+
if (parent === dir)
|
|
841
|
+
return "";
|
|
842
|
+
dir = parent;
|
|
843
|
+
}
|
|
844
|
+
return "";
|
|
845
|
+
}
|
|
846
|
+
return slugifyName(loaded.replace(/^npm:/, ""));
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Scan the installed registry snapshot for tool names registered by more
|
|
850
|
+
* than one extension, and `console.warn` each collision (Task 7, spec
|
|
851
|
+
* slice 4): pi-durable resolves tools by NAME (`resolveAgent` composes a
|
|
852
|
+
* name→tool map in install order, so the LAST registration wins and the
|
|
853
|
+
* earlier one is shadowed silently). The scan changes nothing — last-wins
|
|
854
|
+
* is pi-durable's behavior — it only makes the shadowing visible at boot,
|
|
855
|
+
* which is the surface a deployer needs to notice a co-installed
|
|
856
|
+
* replacement extension (the slice-4 replacement contract is "do not
|
|
857
|
+
* co-install", so any collision here is a configuration mistake).
|
|
858
|
+
*/
|
|
859
|
+
function warnToolNameCollisions(registry) {
|
|
860
|
+
const byName = new Map();
|
|
861
|
+
for (const { extension, tool } of registry.snapshot().tools()) {
|
|
862
|
+
const owners = byName.get(tool.name) ?? [];
|
|
863
|
+
owners.push({ extension: extension.name });
|
|
864
|
+
byName.set(tool.name, owners);
|
|
865
|
+
}
|
|
866
|
+
for (const [toolName, owners] of byName) {
|
|
867
|
+
if (owners.length < 2)
|
|
868
|
+
continue;
|
|
869
|
+
const names = owners.map((owner) => owner.extension).join(", ");
|
|
870
|
+
console.warn(`sigrun: duplicate tool name "${toolName}" registered by extensions ` +
|
|
871
|
+
`[${names}]; the last-listed registration wins (pi-durable resolves ` +
|
|
872
|
+
`tool names last-selected-wins) — do not co-install extensions ` +
|
|
873
|
+
`that register the same tool name`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Load and install every configured extension, quarantining failures.
|
|
878
|
+
*
|
|
879
|
+
* @param specifiers package specifiers from `SIGRUN_EXTENSIONS` (npm names,
|
|
880
|
+
* absolute/relative paths, or `file://` URLs) — or the test seam's explicit
|
|
881
|
+
* list.
|
|
882
|
+
* @param registry the application-owned registry; install publishes at once,
|
|
883
|
+
* so extensions land before `Harness.open` reads the registry.
|
|
884
|
+
* @returns the outcome list (one entry per specifier, in order — never
|
|
885
|
+
* empty-entry gaps; boot stores this for `GET /v1/meta`, Task 9) plus the
|
|
886
|
+
* §3 health producers and submit pass-throughs retained from factory
|
|
887
|
+
* results, paired with their extensions for identity correlation (Task 9e,
|
|
888
|
+
* Task 26a).
|
|
889
|
+
*/
|
|
890
|
+
export async function loadExtensions(specifiers, registry, options = {}) {
|
|
891
|
+
// Normalize: the caller may hand pre-resolved entries (the boot flow,
|
|
892
|
+
// syncPackages consumer) or raw specifiers (the test seam, env-only
|
|
893
|
+
// deploys). Each becomes {entry, label, skills} — label drives the warning
|
|
894
|
+
// name exactly as specifiers did; an ABSENT entry with skills is the
|
|
895
|
+
// skills-only resolution (§9.4).
|
|
896
|
+
const entries = specifiers.map((spec) => typeof spec === "string"
|
|
897
|
+
? { name: undefined, entry: spec, skills: undefined, builtin: undefined, coreResolved: undefined, packageDir: undefined }
|
|
898
|
+
: { name: spec.name, entry: spec.entry, skills: spec.skills, builtin: spec.builtin, coreResolved: spec.coreResolved, packageDir: spec.packageDir });
|
|
899
|
+
const outcomes = [];
|
|
900
|
+
const providerContributors = [];
|
|
901
|
+
const webuiBundles = [];
|
|
902
|
+
const healthProducers = [];
|
|
903
|
+
const runSettledBindings = [];
|
|
904
|
+
const lifecycleBindings = [];
|
|
905
|
+
const workerBindings = [];
|
|
906
|
+
// Jobs Task 3: declared jobs, attributed to the extension slug (the
|
|
907
|
+
// `ext:<slug>:<name>` id's namespace).
|
|
908
|
+
const jobBindings = [];
|
|
909
|
+
// HTTP routes (W5 Task 2), attributed to the slug like the job bindings.
|
|
910
|
+
const routeBindings = [];
|
|
911
|
+
// Slash commands (W5 Task 3), attributed the same way.
|
|
912
|
+
const commandBindings = [];
|
|
913
|
+
// Notifier transports (W5 Task 6, spec §9.6), attributed the same way —
|
|
914
|
+
// the server's notifications surface delivers to every collected entry.
|
|
915
|
+
const notifierBindings = [];
|
|
916
|
+
// Skill roots (W5 Task 4, spec §9.4): every skill dir a loaded package
|
|
917
|
+
// contributed — skills-only resolutions, entry-bearing packages' manifest
|
|
918
|
+
// skills, and factory-result `skills` fields — in load order, attributed
|
|
919
|
+
// to the extension slug. Boot threads this to the skills section's
|
|
920
|
+
// package-roots option.
|
|
921
|
+
const skillRoots = [];
|
|
922
|
+
// Slug → declared name, for collision rejection at load (this boot's
|
|
923
|
+
// loaded set only — quarantined extensions never claim a slug).
|
|
924
|
+
const slugs = new Map();
|
|
925
|
+
for (const { name: label, entry, skills, builtin, coreResolved, packageDir } of entries) {
|
|
926
|
+
const display = label ?? entry ?? "skills-only";
|
|
927
|
+
try {
|
|
928
|
+
// Skills-only resolution (§9.4): the package declared skill dirs and
|
|
929
|
+
// has NO loadable entry — nothing is imported. The outcome records
|
|
930
|
+
// `ok` under the slug of the package name; the dirs (validated as
|
|
931
|
+
// existing directories — the sync filtered, a violation here is a
|
|
932
|
+
// contract break) append to skillRoots. Inside the per-entry try so
|
|
933
|
+
// a bad declaration QUARANTINES instead of aborting the boot.
|
|
934
|
+
if (entry === undefined) {
|
|
935
|
+
if (skills === undefined || skills.length === 0) {
|
|
936
|
+
throw new Error("a pre-resolved input carried neither an entry nor skill directories");
|
|
937
|
+
}
|
|
938
|
+
const skillsOnlyName = label ?? "skills-only";
|
|
939
|
+
const slug = slugifyName(skillsOnlyName);
|
|
940
|
+
if (slug === "") {
|
|
941
|
+
throw new Error(`skills-only package name "${skillsOnlyName}" sanitizes to an empty slug (nothing survives [a-z0-9_-] folding); refusing to load`);
|
|
942
|
+
}
|
|
943
|
+
for (const dir of skills) {
|
|
944
|
+
if (!existingDirectory(dir)) {
|
|
945
|
+
throw new Error(`skills: contributed directory ${dir} does not exist (or is not a directory)`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
// All-or-nothing (the quarantined-contributions invariant): the
|
|
949
|
+
// dirs append only once every one validated.
|
|
950
|
+
for (const dir of skills) {
|
|
951
|
+
skillRoots.push({ extension: slug, dir });
|
|
952
|
+
}
|
|
953
|
+
outcomes.push({ name: slug, status: "ok" });
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
// Pre-resolved entries (the boot flow) import verbatim;
|
|
957
|
+
// raw specifiers (the test seam) resolve through the
|
|
958
|
+
// packages dir first, then bare import resolution.
|
|
959
|
+
const loaded = coreResolved === true
|
|
960
|
+
? entry
|
|
961
|
+
: label !== undefined
|
|
962
|
+
? entry
|
|
963
|
+
: (options.packagesDir === undefined
|
|
964
|
+
? entry
|
|
965
|
+
: (resolveExtensionEntry(entry, options.packagesDir) ?? entry));
|
|
966
|
+
// Resolution order (the packages-dir ruling): the deployment's
|
|
967
|
+
// packages dir FIRST (what `packages[]` installed wins over
|
|
968
|
+
// anything the image bundles), then bare import resolution
|
|
969
|
+
// (paths, file:// URLs, dev checkouts, app-tree packages). The
|
|
970
|
+
// packages dir is an unrelated node_modules tree — Node's
|
|
971
|
+
// upward walk never finds it from sigrun's own imports, so it
|
|
972
|
+
// must be consulted explicitly.
|
|
973
|
+
// A resolved entry ending in `.ts` is rejected BEFORE the import
|
|
974
|
+
// (build-and-webui Task 3, BREAKING): the built server ships no
|
|
975
|
+
// loader — a `.ts` entry is a from-source artifact whose behavior
|
|
976
|
+
// depends on the host node's type-stripping flags (newer node
|
|
977
|
+
// loads it, older node crashes with ERR_UNKNOWN_FILE_EXTENSION).
|
|
978
|
+
// Sources must ship a BUILT entry; the quarantine names it.
|
|
979
|
+
if (/\.ts$/.test(loaded)) {
|
|
980
|
+
throw new Error(`the resolved entry is a TypeScript source (${loaded}) — build the package and ship its built entry (dist/index.js); the built server loads JavaScript`);
|
|
981
|
+
}
|
|
982
|
+
const module = (await import(loaded));
|
|
983
|
+
// Host-module version guard (spec §6.4 R6): the module tree the
|
|
984
|
+
// extension's bare imports resolve from must agree with the copy
|
|
985
|
+
// the host runs — checked immediately after the import, BEFORE any
|
|
986
|
+
// host keying or contract resolution, so a skewing extension leaves
|
|
987
|
+
// no trace (no seam host, no bindings, no registry entry). A throw
|
|
988
|
+
// here rides the existing per-extension try (quarantine, boot
|
|
989
|
+
// continues) exactly like the reserved-section check below.
|
|
990
|
+
const moduleDir = extensionModuleDir(loaded);
|
|
991
|
+
if (moduleDir !== undefined)
|
|
992
|
+
checkHostModules(moduleDir);
|
|
993
|
+
// Two-phase host: the factory receives the host at CALL time, but
|
|
994
|
+
// the extension name and kinds it declares are only known from its
|
|
995
|
+
// RESULT. The loader therefore invokes the host factory TWICE with
|
|
996
|
+
// the SAME instance: once PRE-import with the PROVISIONAL identity
|
|
997
|
+
// (the slug derived from the load specifier — `provisionalSlugFor`;
|
|
998
|
+
// "" when nothing derives) so a factory that reads scoped settings
|
|
999
|
+
// during its body resolves the real package's store (provider
|
|
1000
|
+
// plugins Task 9 P1 — the pre-fix "" placeholder made sigrun-pareto's
|
|
1001
|
+
// `baseUrl` override silently inert), and once post-resolution with
|
|
1002
|
+
// the declared name + kinds PLUS the captured instance — boot's
|
|
1003
|
+
// factory re-keys that SAME host object IN PLACE (SeamHost.rekey),
|
|
1004
|
+
// so the instance the factory captured becomes the final host and
|
|
1005
|
+
// its write gate sees the declared kinds from then on. The
|
|
1006
|
+
// provisional normally EQUALS the resolved slug (extension name ==
|
|
1007
|
+
// package name); when it differs the re-key still corrects the
|
|
1008
|
+
// scope — only factory-time settings reads of such an extension
|
|
1009
|
+
// saw the provisional scope (documented: read scoped settings at
|
|
1010
|
+
// factory time only when the plugin name matches its package name).
|
|
1011
|
+
let host;
|
|
1012
|
+
if (options.hostContextFactory !== undefined) {
|
|
1013
|
+
host = options.hostContextFactory(provisionalSlugFor(loaded), {});
|
|
1014
|
+
}
|
|
1015
|
+
const resolved = await resolveExtension(module, host);
|
|
1016
|
+
if (resolved === undefined) {
|
|
1017
|
+
throw new Error(`does not satisfy the sigrun extension-package contract ` +
|
|
1018
|
+
`(expected a default-export factory returning { extension })`);
|
|
1019
|
+
}
|
|
1020
|
+
// Task 3 — the slug IS the extension identity from here down: ONE
|
|
1021
|
+
// sanitization point, computed from the DECLARED name at load and
|
|
1022
|
+
// applied before the seam host is re-keyed and before the registry
|
|
1023
|
+
// install — every downstream surface (registry key, seam-host
|
|
1024
|
+
// identity, interaction/ambient records, worker names, health
|
|
1025
|
+
// correlation) sees the slug, never the declared name. Rejections
|
|
1026
|
+
// ride the LOAD (Review Focus 2): an empty slug or a collision
|
|
1027
|
+
// quarantines HERE naming the declared name(s), never the first
|
|
1028
|
+
// store use mid-run.
|
|
1029
|
+
const declared = resolved.extension !== undefined
|
|
1030
|
+
? resolved.extension.name
|
|
1031
|
+
: resolved.name;
|
|
1032
|
+
if (declared === undefined) {
|
|
1033
|
+
// Unreachable by construction (provider-only results validate
|
|
1034
|
+
// `name`; every other form carries an `extension`) — but the
|
|
1035
|
+
// identity rule fails loud rather than slugifying `undefined`.
|
|
1036
|
+
throw new Error("factory result carried neither an extension nor a contributor name");
|
|
1037
|
+
}
|
|
1038
|
+
const slug = slugifyName(declared);
|
|
1039
|
+
if (slug === "") {
|
|
1040
|
+
throw new Error(`extension name "${declared}" sanitizes to an empty slug ` +
|
|
1041
|
+
"(nothing survives [a-z0-9_-] folding); refusing to load");
|
|
1042
|
+
}
|
|
1043
|
+
const collides = slugs.get(slug);
|
|
1044
|
+
if (collides !== undefined) {
|
|
1045
|
+
throw new Error(`slug collision: extension "${declared}" sanitizes to ` +
|
|
1046
|
+
`"${slug}", already claimed by "${collides}" — extension ` +
|
|
1047
|
+
"identities must be unique after sanitization");
|
|
1048
|
+
}
|
|
1049
|
+
// Rename at the ONE point: a shallow copy with the slug as `name`
|
|
1050
|
+
// (the Extension contract is readonly; the declared object is
|
|
1051
|
+
// never mutated). Every binding below pairs with THIS object so
|
|
1052
|
+
// boot's identity correlation (health/submit/memory-owner
|
|
1053
|
+
// matching) stays exact.
|
|
1054
|
+
const extension = resolved.extension === undefined
|
|
1055
|
+
? undefined
|
|
1056
|
+
: { ...resolved.extension, name: slug };
|
|
1057
|
+
// Reserved core section keys (spec §6.5 R11): a section under one
|
|
1058
|
+
// of them is a LOAD-TIME quarantine, checked HERE — after the slug
|
|
1059
|
+
// is final and BEFORE anything is collected. The registry would
|
|
1060
|
+
// otherwise accept the duplicate (sections are last-writer-wins
|
|
1061
|
+
// across extensions), silently replacing a core surface; the
|
|
1062
|
+
// existing catch quarantines with this message instead, leaving
|
|
1063
|
+
// nothing installed (no registry entry), no post-resolution host
|
|
1064
|
+
// re-key (boot's seamHosts never files the entry), and no
|
|
1065
|
+
// provider/health/submit/runSettled/lifecycle/worker/webui
|
|
1066
|
+
// binding collected. Boot's post-install walk over
|
|
1067
|
+
// RESERVED_CORE_SECTION_OWNERS stays as a core-only assertion.
|
|
1068
|
+
const reservedSectionKey = extension?.sections?.find((section) => RESERVED_SECTION_KEY_SET.has(section.key))?.key;
|
|
1069
|
+
if (reservedSectionKey !== undefined) {
|
|
1070
|
+
throw new Error(`extension "${slug}" declares reserved core section "${reservedSectionKey}"`);
|
|
1071
|
+
}
|
|
1072
|
+
// Skill contributions (W5 Task 4), validated HERE — before the
|
|
1073
|
+
// install/outcome, so a bad declaration quarantines with nothing
|
|
1074
|
+
// installed and exactly one outcome (the same point as every
|
|
1075
|
+
// other fail-loud contract check). The factory-result dirs were
|
|
1076
|
+
// already validated at read time (validateSkills, all-or-nothing);
|
|
1077
|
+
// the MANIFEST dirs (the sync's resolution, existing by
|
|
1078
|
+
// construction) are re-checked because a hand-built pre-resolved
|
|
1079
|
+
// input skips the sync.
|
|
1080
|
+
const contributed = [...(resolved.skills ?? []), ...(skills ?? [])];
|
|
1081
|
+
for (const dir of contributed) {
|
|
1082
|
+
if (!existingDirectory(dir)) {
|
|
1083
|
+
throw new Error(`skills: contributed directory ${dir} does not exist (or is not a directory)`);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
// The post-resolution call re-keys the captured host instance in
|
|
1087
|
+
// place with the slug + declared kinds (boot's
|
|
1088
|
+
// hostContextFactory; see src/harness/boot.ts) — the instance is
|
|
1089
|
+
// handed back explicitly as `priorHost`, so even a provisional ≠
|
|
1090
|
+
// resolved mismatch re-keys the SAME instance. No second host is
|
|
1091
|
+
// ever created — a rebuilt instance would orphan every inbound
|
|
1092
|
+
// handler the factory registered on the host it captured.
|
|
1093
|
+
if (options.hostContextFactory !== undefined) {
|
|
1094
|
+
const kinds = {
|
|
1095
|
+
...(resolved.interactionKinds === undefined
|
|
1096
|
+
? {}
|
|
1097
|
+
: { interaction: resolved.interactionKinds }),
|
|
1098
|
+
...(resolved.ambientKinds === undefined
|
|
1099
|
+
? {}
|
|
1100
|
+
: { ambient: resolved.ambientKinds }),
|
|
1101
|
+
};
|
|
1102
|
+
options.hostContextFactory(slug, kinds, host);
|
|
1103
|
+
}
|
|
1104
|
+
if (extension !== undefined) {
|
|
1105
|
+
registry.install(extension);
|
|
1106
|
+
}
|
|
1107
|
+
// Only a LOADED extension or contributor claims its slug — a
|
|
1108
|
+
// later stage's quarantine (never loaded) leaves the slug free
|
|
1109
|
+
// for the next boot's specifier list.
|
|
1110
|
+
slugs.set(slug, declared);
|
|
1111
|
+
// The manifest version for the §7.7 inventory: read off the
|
|
1112
|
+
// module's own directory (a bare entry with no manifest stays
|
|
1113
|
+
// versionless — the field is optional by design).
|
|
1114
|
+
const versionDir = packageDir ?? extensionModuleDir(loaded);
|
|
1115
|
+
const version = versionDir === undefined ? undefined : readPackageVersion(versionDir);
|
|
1116
|
+
outcomes.push({
|
|
1117
|
+
name: slug,
|
|
1118
|
+
status: "ok",
|
|
1119
|
+
...(version === undefined ? {} : { version }),
|
|
1120
|
+
...(builtin === true ? { builtin: true } : {}),
|
|
1121
|
+
});
|
|
1122
|
+
if (extension !== undefined && resolved.health !== undefined) {
|
|
1123
|
+
healthProducers.push({
|
|
1124
|
+
extension,
|
|
1125
|
+
health: resolved.health,
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
// §6.7 retirements, warned ONCE at load (never quarantined): a
|
|
1129
|
+
// result resolved through the named-export bridge, and a result still
|
|
1130
|
+
// carrying the retired submit pass-through. Both keep loading —
|
|
1131
|
+
// the pre-contract `createMemory` named-factory bridge was deleted
|
|
1132
|
+
// in release-0.1 (its last consumer exports default now), and the
|
|
1133
|
+
// submit is simply ignored (the memory seam owns capture now).
|
|
1134
|
+
if (resolved.resolvedVia !== undefined) {
|
|
1135
|
+
console.warn(`extension ${display}: named-export factory "${resolved.resolvedVia}" is deprecated — export it as default`);
|
|
1136
|
+
}
|
|
1137
|
+
if (resolved.retiredSubmit === true) {
|
|
1138
|
+
console.warn(`extension "${slug}": the submit pass-through was retired; ignoring`);
|
|
1139
|
+
}
|
|
1140
|
+
if (extension !== undefined && resolved.runSettled !== undefined) {
|
|
1141
|
+
runSettledBindings.push({
|
|
1142
|
+
extension,
|
|
1143
|
+
runSettled: resolved.runSettled,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
if (extension !== undefined && resolved.lifecycle !== undefined) {
|
|
1147
|
+
lifecycleBindings.push({
|
|
1148
|
+
extension,
|
|
1149
|
+
handlers: resolved.lifecycle,
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
if (extension !== undefined && resolved.workers !== undefined) {
|
|
1153
|
+
workerBindings.push({
|
|
1154
|
+
extension,
|
|
1155
|
+
workers: resolved.workers,
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
if (resolved.jobs !== undefined) {
|
|
1159
|
+
jobBindings.push({ extension: slug, jobs: resolved.jobs });
|
|
1160
|
+
}
|
|
1161
|
+
if (resolved.routes !== undefined) {
|
|
1162
|
+
routeBindings.push({ extension: slug, routes: resolved.routes });
|
|
1163
|
+
}
|
|
1164
|
+
if (resolved.commands !== undefined) {
|
|
1165
|
+
commandBindings.push({ extension: slug, commands: resolved.commands });
|
|
1166
|
+
}
|
|
1167
|
+
if (resolved.notifiers !== undefined) {
|
|
1168
|
+
notifierBindings.push({ extension: slug, notifiers: resolved.notifiers });
|
|
1169
|
+
}
|
|
1170
|
+
if (resolved.providers !== undefined) {
|
|
1171
|
+
providerContributors.push({
|
|
1172
|
+
name: slug,
|
|
1173
|
+
providers: resolved.providers,
|
|
1174
|
+
...(resolved.health === undefined
|
|
1175
|
+
? {}
|
|
1176
|
+
: { health: resolved.health }),
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
// Webui-bundle contributions (build-and-webui Task 6): each entry
|
|
1180
|
+
// is attributed to the extension slug and its `dir` anchored at
|
|
1181
|
+
// the MODULE's directory — resolved HERE, the one point the module
|
|
1182
|
+
// URL is known (an npm-installed extension's bundle lives in its
|
|
1183
|
+
// own package, never the server cwd). Absolute dirs pass through
|
|
1184
|
+
// `resolve` verbatim; the server owns the index.html check at
|
|
1185
|
+
// serve time (the loader has no fs coupling by contract).
|
|
1186
|
+
if (resolved.webuiBundles !== undefined) {
|
|
1187
|
+
const moduleDir = extensionModuleDir(loaded);
|
|
1188
|
+
for (const entry of resolved.webuiBundles) {
|
|
1189
|
+
webuiBundles.push({
|
|
1190
|
+
name: entry.name,
|
|
1191
|
+
dir: moduleDir === undefined || isAbsolute(entry.dir)
|
|
1192
|
+
? entry.dir
|
|
1193
|
+
: resolve(moduleDir, entry.dir),
|
|
1194
|
+
extension: slug,
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// Skill contributions (W5 Task 4): the validated combined list
|
|
1199
|
+
// (factory-result + manifest dirs — validated above, before the
|
|
1200
|
+
// install), attributed to the extension slug, in load order.
|
|
1201
|
+
for (const dir of contributed) {
|
|
1202
|
+
skillRoots.push({ extension: slug, dir });
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
catch (cause) {
|
|
1206
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1207
|
+
console.warn(`extension quarantined: ${display} failed to load: ${message}`);
|
|
1208
|
+
outcomes.push({
|
|
1209
|
+
name: display,
|
|
1210
|
+
status: "quarantined",
|
|
1211
|
+
error: message,
|
|
1212
|
+
...(builtin === true ? { builtin: true } : {}),
|
|
1213
|
+
});
|
|
1214
|
+
// A quarantined extension contributes no health producer or submit
|
|
1215
|
+
// binding: the §3 surfaces are reported through
|
|
1216
|
+
// `extensions[].status`, never double-reported.
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
// After all installs: surface tool-name collisions across extensions
|
|
1220
|
+
// (last-wins is pi-durable's resolution — unchanged, only warned).
|
|
1221
|
+
warnToolNameCollisions(registry);
|
|
1222
|
+
return {
|
|
1223
|
+
outcomes,
|
|
1224
|
+
providerContributors,
|
|
1225
|
+
webuiBundles,
|
|
1226
|
+
skillRoots,
|
|
1227
|
+
healthProducers,
|
|
1228
|
+
runSettledBindings,
|
|
1229
|
+
lifecycleBindings,
|
|
1230
|
+
workerBindings,
|
|
1231
|
+
jobBindings,
|
|
1232
|
+
routeBindings,
|
|
1233
|
+
commandBindings,
|
|
1234
|
+
notifierBindings,
|
|
1235
|
+
};
|
|
1236
|
+
}
|