@sigrun/sigrun 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +352 -36
  3. package/dist/blocks/schema.js +9 -2
  4. package/dist/blocks/validate.js +57 -8
  5. package/dist/cli.js +26 -2
  6. package/dist/config.js +67 -1
  7. package/dist/extension.d.ts +189 -15
  8. package/dist/harness/ambient.js +39 -185
  9. package/dist/harness/blocks/entries.js +47 -0
  10. package/dist/harness/blocks/state.js +58 -0
  11. package/dist/harness/blocks/tools.js +274 -0
  12. package/dist/harness/boot.js +253 -20
  13. package/dist/harness/builtins.js +15 -11
  14. package/dist/harness/event-bus.js +105 -10
  15. package/dist/harness/identity/accounts.js +186 -0
  16. package/dist/harness/identity/groups.js +14 -0
  17. package/dist/harness/identity/index.js +13 -6
  18. package/dist/harness/identity/json-store.js +15 -4
  19. package/dist/harness/identity/setup-code-check.js +9 -0
  20. package/dist/harness/identity/tokens.js +28 -5
  21. package/dist/harness/identity/users.js +111 -12
  22. package/dist/harness/interactions.js +292 -136
  23. package/dist/harness/loader.js +271 -17
  24. package/dist/harness/packages.js +1 -1
  25. package/dist/harness/reserved-sections.js +21 -0
  26. package/dist/harness/seam-host.js +80 -6
  27. package/dist/harness/settings.js +63 -2
  28. package/dist/harness/stores.js +241 -4
  29. package/dist/harness/user-services.js +391 -0
  30. package/dist/harness/user-tools.js +187 -0
  31. package/dist/harness/workspace.js +61 -57
  32. package/dist/jobs/fire.js +10 -16
  33. package/dist/sandbox/availability.js +162 -0
  34. package/dist/sandbox/command-observer.js +59 -0
  35. package/dist/sandbox/grants.js +159 -0
  36. package/dist/sandbox/helper.js +29 -0
  37. package/dist/sandbox/isolation.js +29 -0
  38. package/dist/sandbox/launch.js +20 -0
  39. package/dist/sandbox/os-account.js +49 -0
  40. package/dist/sandbox/policy.js +253 -0
  41. package/dist/sandbox/pool.js +548 -0
  42. package/dist/sandbox/protocol.js +167 -0
  43. package/dist/sandbox/sandboxed-env.js +245 -0
  44. package/dist/sandbox/unavailable-env.js +49 -0
  45. package/dist/sandbox/worker.js +281 -0
  46. package/dist/serve.js +82 -7
  47. package/dist/server/auth/access-principal.js +16 -0
  48. package/dist/server/auth/pipeline.js +47 -33
  49. package/dist/server/blocks/answers.js +94 -0
  50. package/dist/server/blocks/chat-surfaces.js +393 -0
  51. package/dist/server/blocks/notes.js +216 -0
  52. package/dist/server/blocks/summaries.js +328 -0
  53. package/dist/server/commands.js +5 -0
  54. package/dist/server/content-parts-lazy.js +13 -0
  55. package/dist/server/conversation-handler.js +2 -5
  56. package/dist/server/conversation-ops.js +138 -37
  57. package/dist/server/helpers.js +18 -6
  58. package/dist/server/host-bridge.js +40 -20
  59. package/dist/server/images/cache.js +205 -0
  60. package/dist/server/images/convert.js +13 -0
  61. package/dist/server/images/fetch.js +302 -0
  62. package/dist/server/images/ranges.js +42 -0
  63. package/dist/server/images/rewrite.js +94 -0
  64. package/dist/server/images/sign.js +84 -0
  65. package/dist/server/images/sniff.js +62 -0
  66. package/dist/server/index.js +144 -4
  67. package/dist/server/login/flows.js +126 -0
  68. package/dist/server/login/http.js +31 -0
  69. package/dist/server/login/resolve.js +133 -0
  70. package/dist/server/login/setup-claim.js +55 -0
  71. package/dist/server/login/usernames.js +15 -0
  72. package/dist/server/ownership.js +13 -0
  73. package/dist/server/routes/admin.js +58 -0
  74. package/dist/server/routes/auth.js +88 -2
  75. package/dist/server/routes/conversations.js +3 -6
  76. package/dist/server/routes/events.js +28 -3
  77. package/dist/server/routes/extensions.js +37 -5
  78. package/dist/server/routes/images.js +267 -0
  79. package/dist/server/routes/interactions.js +57 -68
  80. package/dist/server/routes/jobs.js +2 -8
  81. package/dist/server/routes/login.js +223 -0
  82. package/dist/server/routes/me.js +90 -0
  83. package/dist/server/routes/meta.js +15 -0
  84. package/dist/server/routes/onboarding.js +94 -84
  85. package/dist/server/routes/settings-sections.js +190 -0
  86. package/dist/server/routes/settings.js +16 -4
  87. package/dist/server/routes/submissions.js +1 -1
  88. package/dist/server/routes/surfaces.js +175 -23
  89. package/dist/server/routes/users.js +116 -20
  90. package/dist/server/schema-fixture.js +213 -0
  91. package/dist/server/session-meta.js +3 -0
  92. package/dist/server/shutdown.js +11 -4
  93. package/dist/server/static.js +18 -0
  94. package/dist/server/surfaces/dedup.js +9 -2
  95. package/dist/server/surfaces/service.js +181 -13
  96. package/dist/server/surfaces/settings-surfaces.js +597 -0
  97. package/dist/server/turns/submit-turn.js +26 -0
  98. package/dist/server/views.js +16 -0
  99. package/dist/settings/core-sections.js +532 -0
  100. package/dist/settings/pending.js +181 -0
  101. package/dist/settings/registry.js +453 -0
  102. package/dist/skills/index.js +6 -0
  103. package/node_modules/@sigrun/shared/CHANGELOG.md +6 -0
  104. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  105. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  106. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  107. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  108. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  109. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  110. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  111. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  112. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  113. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  114. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  115. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  116. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  117. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  118. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  119. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  120. package/node_modules/@sigrun/shared/package.json +1 -1
  121. package/package.json +11 -6
  122. package/webui/dist/assets/index-CxB969qp.js +55 -0
  123. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  124. package/webui/dist/index.html +2 -2
  125. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  126. package/webui/dist/assets/index-JDfOQV9z.js +0 -52
@@ -84,12 +84,13 @@
84
84
  */
85
85
  import { ICON_NAMES } from "@sigrun/shared/blocks";
86
86
  import { validateJobSpec } from "../jobs/spec.js";
87
+ import { validateTree } from "../blocks/validate.js";
87
88
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
88
89
  import { createRequire } from "node:module";
89
90
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
90
91
  import { fileURLToPath } from "node:url";
91
92
  import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageAssetsDir, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
92
- import { RESERVED_CORE_SECTION_KEYS } from "./reserved-sections.js";
93
+ import { RESERVED_CORE_SECTION_KEYS, RESERVED_EXTENSION_SLUGS, } from "./reserved-sections.js";
93
94
  import { slugifyName } from "./stores.js";
94
95
  /** Every lifecycle field name, in validation order (mirrors the contract doc). */
95
96
  const LIFECYCLE_FIELDS = [
@@ -107,6 +108,8 @@ const LIFECYCLE_FIELDS = [
107
108
  * union, which a section's `string` key can never satisfy.
108
109
  */
109
110
  const RESERVED_SECTION_KEY_SET = new Set(RESERVED_CORE_SECTION_KEYS);
111
+ /** Reserved store-root directory names may not become extension identities. */
112
+ const RESERVED_EXTENSION_SLUG_SET = new Set(RESERVED_EXTENSION_SLUGS);
110
113
  /** A structural check for "looks like a pi-durable Extension". */
111
114
  function isExtension(value) {
112
115
  return (typeof value === "object" &&
@@ -213,6 +216,27 @@ function validateWorkers(value) {
213
216
  * (the §8.2 grammar), and duplicate names within one extension are
214
217
  * rejected (they would collide on the `ext:<slug>:<name>` id).
215
218
  */
219
+ function validateUserServices(value) {
220
+ if (value === undefined)
221
+ return undefined;
222
+ if (!Array.isArray(value))
223
+ throw new Error("extension factory returned a userServices field that is not an array");
224
+ const names = new Set();
225
+ for (const candidate of value) {
226
+ const service = candidate;
227
+ if (typeof service?.name !== "string" || service.name === "")
228
+ throw new Error("extension factory returned a user service without a name");
229
+ if (names.has(service.name))
230
+ throw new Error(`extension factory returned a duplicate user service name "${service.name}"`);
231
+ names.add(service.name);
232
+ if (typeof service.start !== "function")
233
+ throw new Error(`extension factory returned a user service "${service.name}" whose start is not a function`);
234
+ if (service.idleMinutes !== undefined && (typeof service.idleMinutes !== "number" || !Number.isFinite(service.idleMinutes) || service.idleMinutes <= 0)) {
235
+ throw new Error(`extension factory returned a user service "${service.name}" with an invalid idleMinutes`);
236
+ }
237
+ }
238
+ return value;
239
+ }
216
240
  function validateJobs(value) {
217
241
  if (value === undefined)
218
242
  return undefined;
@@ -242,6 +266,117 @@ function validateJobs(value) {
242
266
  const ICON_NAME_SET = new Set(ICON_NAMES);
243
267
  /** The refreshMs floor (spec §7.1): channels refetch no faster than 5 s. */
244
268
  const MIN_REFRESH_MS = 5000;
269
+ /**
270
+ * The `settings` placement's field-name walk (UI-blocks U4 Task 3):
271
+ * every field of a settings form must be a TOP-LEVEL key of the
272
+ * extension's settings object (spec §13.3 — the form binds to
273
+ * `settings/<slug>.json`; a dotted or nested name would write a shape
274
+ * the verbatim settings routes cannot show).
275
+ */
276
+ function* walkFormFields(root) {
277
+ const stack = [root];
278
+ while (stack.length > 0) {
279
+ const node = stack.pop();
280
+ if (Array.isArray(node)) {
281
+ stack.push(...node);
282
+ continue;
283
+ }
284
+ if (typeof node !== "object" || node === null)
285
+ continue;
286
+ const record = node;
287
+ for (const key of ["children", "fields", "root", "main", "sidebar", "detail"]) {
288
+ if (record[key] !== undefined)
289
+ stack.push(record[key]);
290
+ }
291
+ if (record.block !== undefined)
292
+ stack.push(record.block);
293
+ if (record.item !== undefined)
294
+ stack.push(record.item);
295
+ if (typeof record.name === "string" && typeof record.type === "string") {
296
+ yield record;
297
+ }
298
+ }
299
+ }
300
+ /**
301
+ * Validate a factory result's `surfaces.settings` list (UI-blocks U4
302
+ * Task 3, spec §13.3). Each entry: non-empty string `id`, `title`,
303
+ * `icon` (optional, in the closed set), `form` a Tree validating
304
+ * against the `settings` placement with a root `form` block, every
305
+ * field name a TOP-LEVEL key (no dots), optional `secrets` entries
306
+ * `{key, label, help?}`, and `admin` a boolean when present. The
307
+ * `save` handler name is core-owned (the adapter's), so `onSubmit`
308
+ * names outside `{save, submit}` are rejected (an extension thinking
309
+ * it owns its save handler would silently never fire).
310
+ */
311
+ function validateSettingsSections(value) {
312
+ if (value === undefined)
313
+ return undefined;
314
+ if (!Array.isArray(value)) {
315
+ throw new Error("extension factory returned a surfaces.settings field that is not an array");
316
+ }
317
+ const defs = [];
318
+ const seen = new Set();
319
+ for (const entry of value) {
320
+ const def = entry;
321
+ if (typeof def?.id !== "string" || def.id.length === 0) {
322
+ throw new Error("extension factory returned a settings section whose id is missing or not a non-empty string");
323
+ }
324
+ if (seen.has(def.id)) {
325
+ throw new Error(`extension factory returned a duplicate settings section id "${def.id}"`);
326
+ }
327
+ seen.add(def.id);
328
+ if (typeof def?.title !== "string" || def.title.length === 0) {
329
+ throw new Error(`extension factory returned a settings section "${def.id}" whose title is missing or not a non-empty string`);
330
+ }
331
+ if (def?.icon !== undefined &&
332
+ (typeof def.icon !== "string" || !ICON_NAME_SET.has(def.icon))) {
333
+ throw new Error(`extension factory returned a settings section "${def.id}" with unknown icon "${String(def?.icon)}"`);
334
+ }
335
+ if (def?.admin !== undefined && typeof def.admin !== "boolean") {
336
+ throw new Error(`extension factory returned a settings section "${def.id}" whose admin is not a boolean`);
337
+ }
338
+ if (def?.scope !== undefined && def.scope !== "server" && def.scope !== "user") {
339
+ throw new Error(`extension factory returned a settings section "${def.id}" whose scope is not "server" or "user"`);
340
+ }
341
+ if (def?.secrets !== undefined) {
342
+ if (!Array.isArray(def.secrets)) {
343
+ throw new Error(`extension factory returned a settings section "${def.id}" whose secrets is not an array`);
344
+ }
345
+ for (const secret of def.secrets) {
346
+ const entry2 = secret;
347
+ if (typeof entry2?.key !== "string" || entry2.key.length === 0) {
348
+ throw new Error(`extension factory returned a settings section "${def.id}" whose secrets entry is missing a key`);
349
+ }
350
+ if (typeof entry2?.label !== "string" || entry2.label.length === 0) {
351
+ throw new Error(`extension factory returned a settings section "${def.id}" whose secrets entry "${String(entry2?.key)}" is missing a label`);
352
+ }
353
+ if (entry2?.help !== undefined && typeof entry2.help !== "string") {
354
+ throw new Error(`extension factory returned a settings section "${def.id}" whose secrets entry "${entry2.key}" carries a non-string help`);
355
+ }
356
+ }
357
+ }
358
+ if (typeof def?.form !== "object" || def.form === null) {
359
+ throw new Error(`extension factory returned a settings section "${def.id}" whose form is missing or not a Tree`);
360
+ }
361
+ const validation = validateTree(def.form, {
362
+ placement: "settings",
363
+ handlers: new Set(["save", "submit"]),
364
+ });
365
+ if (!validation.ok) {
366
+ throw new Error(`extension factory returned a settings section "${def.id}" whose form is invalid: ` +
367
+ validation.errors.map((err) => `${err.path}: ${err.message}`).join("; "));
368
+ }
369
+ for (const field of walkFormFields(validation.tree.root)) {
370
+ if (typeof field.name !== "string" || field.name.length === 0)
371
+ continue;
372
+ if (field.name.includes(".") || field.name.includes("/")) {
373
+ throw new Error(`extension factory returned a settings section "${def.id}" whose field "${field.name}" is not a top-level settings key (no dots or slashes)`);
374
+ }
375
+ }
376
+ defs.push(entry);
377
+ }
378
+ return defs;
379
+ }
245
380
  /**
246
381
  * Validate a factory result's `surfaces` declaration (UI-blocks U3
247
382
  * Task 1, spec §7.1/§15). Absent → undefined (no surfaces). Present
@@ -255,10 +390,10 @@ const MIN_REFRESH_MS = 5000;
255
390
  * whose every handler is a function (message names the action),
256
391
  * `refreshMs ≥ 5000` when present, `data()` a function when
257
392
  * `refreshMs` is set (a data refetch would otherwise re-run `render`,
258
- * §7.1), `render` a function, and NO `settings` key (reserved — the
259
- * §13.3 sections are a separate workstream). Returns the
260
- * FLATTENED defs (views then panels, declaration order) attributed to
261
- * the slug at collection.
393
+ * §7.1), `render` a function — plus the `settings` list (§13.3, U4
394
+ * Task 3), validated by `validateSettingsSections`. Returns the
395
+ * FLATTENED defs (views then panels, declaration order) and the
396
+ * settings sections, attributed to the slug at collection.
262
397
  */
263
398
  function validateSurfaces(value) {
264
399
  if (value === undefined)
@@ -266,16 +401,12 @@ function validateSurfaces(value) {
266
401
  if (typeof value !== "object" || Array.isArray(value)) {
267
402
  throw new Error("extension factory returned a surfaces field that is not an object");
268
403
  }
269
- // The settings sections key is RESERVED (spec §7.1 names it; §13.3
270
- // owns it): this workstream cannot serve it, and a JS package
271
- // declaring one must not lose it silently — quarantine loud, naming
272
- // the workstream (TS consumers are already stopped by the contract's
273
- // `readonly never[]`).
274
- if (value.settings !== undefined) {
275
- throw new Error("extension factory returned a surfaces.settings declaration — settings sections are a separate workstream (they land in U4)");
276
- }
404
+ // §13.3 settings sections (UI-blocks U4 Task 3): validated with the
405
+ // settings placement; a malformed list quarantines like every other
406
+ // malformed surfaces member.
407
+ const settings = validateSettingsSections(value.settings);
277
408
  const defs = [];
278
- const seen = new Set();
409
+ const seen = new Set(settings?.map((def) => def.id) ?? []);
279
410
  for (const list of ["views", "panels"]) {
280
411
  const entries = value[list];
281
412
  if (entries === undefined)
@@ -340,7 +471,7 @@ function validateSurfaces(value) {
340
471
  defs.push(entry);
341
472
  }
342
473
  }
343
- return defs;
474
+ return { defs, ...(settings === undefined ? {} : { settings }) };
344
475
  }
345
476
  const ROUTE_METHODS = new Set([
346
477
  "GET",
@@ -944,9 +1075,15 @@ async function resolvedFromFactory(factory, hostContext) {
944
1075
  // and the `LoaderReadKeys` assertion (above ResolvedExtension) pins the
945
1076
  // full read list to the contract's keys.
946
1077
  const candidate = awaited;
1078
+ if (candidate.perUserTools !== undefined &&
1079
+ typeof candidate.perUserTools !== "boolean") {
1080
+ throw new Error("extension factory returned a perUserTools field that is not a boolean");
1081
+ }
947
1082
  const providers = validateProviders(candidate.providers);
1083
+ const loginProviders = validateLoginProviders(candidate.loginProviders);
948
1084
  const webuiBundles = validateWebuiBundles(candidate.webuiBundles);
949
1085
  const jobs = validateJobs(candidate.jobs);
1086
+ const userServices = validateUserServices(candidate.userServices);
950
1087
  const routes = validateRoutes(candidate.routes);
951
1088
  const commands = validateCommands(candidate.commands);
952
1089
  const skills = validateSkills(candidate.skills);
@@ -968,8 +1105,10 @@ async function resolvedFromFactory(factory, hostContext) {
968
1105
  // collision-record identity — without one there is nothing to
969
1106
  // attribute the contributions to, which is a contract violation.
970
1107
  if (providers === undefined &&
1108
+ loginProviders === undefined &&
971
1109
  webuiBundles === undefined &&
972
- channels === undefined) {
1110
+ channels === undefined &&
1111
+ userServices === undefined) {
973
1112
  return undefined;
974
1113
  }
975
1114
  if (typeof candidate.name !== "string" || candidate.name.length === 0) {
@@ -1003,9 +1142,20 @@ async function resolvedFromFactory(factory, hostContext) {
1003
1142
  validateKinds(candidate.ambientKinds);
1004
1143
  if (kindsProblem !== null)
1005
1144
  throw new Error(kindsProblem);
1145
+ // The settings-change hook (U4 §13.2): present-but-not-a-function is
1146
+ // the same contract violation class as the lifecycle fields — an
1147
+ // extension that THINKS it observes settings changes must not
1148
+ // silently miss them.
1149
+ const onSettingsChanged = candidate.onSettingsChanged;
1150
+ if (onSettingsChanged !== undefined && typeof onSettingsChanged !== "function") {
1151
+ throw new Error("extension factory returned an onSettingsChanged field that is not a function");
1152
+ }
1006
1153
  // `workers` is a contract key — the candidate read needs no cast.
1007
1154
  const workers = validateWorkers(candidate.workers);
1008
1155
  return {
1156
+ ...(candidate.perUserTools === undefined
1157
+ ? {}
1158
+ : { perUserTools: candidate.perUserTools }),
1009
1159
  // Identity: the extension when one was carried, else the
1010
1160
  // provider-only contributor's validated `name`.
1011
1161
  ...(extension !== undefined
@@ -1020,6 +1170,13 @@ async function resolvedFromFactory(factory, hostContext) {
1020
1170
  ...(candidate.runSettled === undefined
1021
1171
  ? {}
1022
1172
  : { runSettled: candidate.runSettled }),
1173
+ ...(onSettingsChanged === undefined
1174
+ ? {}
1175
+ : {
1176
+ // SAFETY: the typeof-function check above is the validation —
1177
+ // the cast supplies the contract's parameter type only.
1178
+ onSettingsChanged: onSettingsChanged,
1179
+ }),
1023
1180
  ...(anyLifecycle
1024
1181
  ? {
1025
1182
  // SAFETY: every value under `lifecycle` passed `typeof === "function"`
@@ -1036,17 +1193,54 @@ async function resolvedFromFactory(factory, hostContext) {
1036
1193
  : { ambientKinds: candidate.ambientKinds }),
1037
1194
  ...(workers === undefined ? {} : { workers }),
1038
1195
  ...(providers === undefined ? {} : { providers }),
1196
+ ...(loginProviders === undefined ? {} : { loginProviders }),
1039
1197
  ...(webuiBundles === undefined ? {} : { webuiBundles }),
1040
1198
  ...(jobs === undefined ? {} : { jobs }),
1199
+ ...(userServices === undefined ? {} : { userServices }),
1041
1200
  ...(routes === undefined ? {} : { routes }),
1042
1201
  ...(commands === undefined ? {} : { commands }),
1043
1202
  ...(skills === undefined ? {} : { skills }),
1044
1203
  ...(notifiers === undefined ? {} : { notifiers }),
1045
1204
  ...(channels === undefined ? {} : { channels }),
1046
1205
  ...(gateway === undefined ? {} : { gateway }),
1047
- ...(surfaces === undefined ? {} : { surfaces }),
1206
+ ...(surfaces === undefined ? {} : { surfaces: surfaces.defs }),
1207
+ ...(surfaces?.settings === undefined
1208
+ ? {}
1209
+ : { settingsSections: surfaces.settings }),
1048
1210
  };
1049
1211
  }
1212
+ /** Validate and retain login providers; all-or-nothing for one extension. */
1213
+ function validateLoginProviders(value) {
1214
+ if (value === undefined)
1215
+ return undefined;
1216
+ if (!Array.isArray(value)) {
1217
+ throw new Error("extension factory returned a loginProviders field that is not an array");
1218
+ }
1219
+ const ids = new Set();
1220
+ for (const candidate of value) {
1221
+ if (typeof candidate !== "object" || candidate === null) {
1222
+ throw new Error("extension factory returned a loginProviders entry that is not an object");
1223
+ }
1224
+ const entry = candidate;
1225
+ if (typeof entry.id !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(entry.id)) {
1226
+ throw new Error("extension factory returned a loginProviders entry with an invalid id");
1227
+ }
1228
+ if (ids.has(entry.id)) {
1229
+ throw new Error(`extension factory returned duplicate login provider id "${entry.id}"`);
1230
+ }
1231
+ ids.add(entry.id);
1232
+ if (typeof entry.label !== "string") {
1233
+ throw new Error("extension factory returned a loginProviders entry whose label is not a string");
1234
+ }
1235
+ if (entry.icon !== undefined && typeof entry.icon !== "string") {
1236
+ throw new Error("extension factory returned a loginProviders entry whose icon is not a string");
1237
+ }
1238
+ if (typeof entry.begin !== "function" || typeof entry.complete !== "function") {
1239
+ throw new Error("extension factory returned a loginProviders entry whose begin/complete is not a function");
1240
+ }
1241
+ }
1242
+ return value;
1243
+ }
1050
1244
  /**
1051
1245
  * Resolve one imported module to the extension the loader installs, per the
1052
1246
  * contract order documented on this module, retaining a factory-result §3
@@ -1184,12 +1378,15 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1184
1378
  ? { name: undefined, entry: spec, skills: undefined, builtin: undefined, coreResolved: undefined, packageDir: undefined }
1185
1379
  : { name: spec.name, entry: spec.entry, skills: spec.skills, builtin: spec.builtin, coreResolved: spec.coreResolved, packageDir: spec.packageDir });
1186
1380
  const outcomes = [];
1381
+ const perUserSlugs = [];
1187
1382
  const providerContributors = [];
1188
1383
  const webuiBundles = [];
1189
1384
  const healthProducers = [];
1190
1385
  const runSettledBindings = [];
1386
+ const settingsChangedBindings = [];
1191
1387
  const lifecycleBindings = [];
1192
1388
  const workerBindings = [];
1389
+ const userServiceBindings = [];
1193
1390
  // Jobs Task 3: declared jobs, attributed to the extension slug (the
1194
1391
  // `ext:<slug>:<name>` id's namespace).
1195
1392
  const jobBindings = [];
@@ -1205,6 +1402,8 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1205
1402
  // them. Collected only for LOADED extensions (a colliding adapter never
1206
1403
  // reaches here — the channelIds check below throws first).
1207
1404
  const channelBindings = [];
1405
+ // Login providers (multi-user M3b): flat, slug-qualified, in load order.
1406
+ const loginProviders = [];
1208
1407
  // The gateway engine (gateway C2, spec §8.0): the ONE-engine rule — the
1209
1408
  // first loaded declarer wins; a later result declaring `gateway`
1210
1409
  // quarantines naming the first slug (an engine owns supervision over the
@@ -1213,6 +1412,8 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1213
1412
  let gatewayEngine;
1214
1413
  // Surfaces (U3 Task 1): the flattened validated defs, slug-attributed.
1215
1414
  const surfaceBindings = [];
1415
+ // Settings sections (U4 Task 3, spec §13.3): validated, slug-attributed.
1416
+ const settingsSectionBindings = [];
1216
1417
  // Skill roots (W5 Task 4, spec §9.4): every skill dir a loaded package
1217
1418
  // contributed — skills-only resolutions, entry-bearing packages' manifest
1218
1419
  // skills, and factory-result `skills` fields — in load order, attributed
@@ -1231,6 +1432,7 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1231
1432
  const channelIds = new Map();
1232
1433
  for (const { name: label, entry, skills, builtin, coreResolved, packageDir } of entries) {
1233
1434
  const display = label ?? entry ?? "skills-only";
1435
+ let quarantineSlug = slugifyName(label ?? entry ?? "");
1234
1436
  try {
1235
1437
  // Skills-only resolution (§9.4): the package declared skill dirs and
1236
1438
  // has NO loadable entry — nothing is imported. The outcome records
@@ -1343,10 +1545,26 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1343
1545
  throw new Error("factory result carried neither an extension nor a contributor name");
1344
1546
  }
1345
1547
  const slug = slugifyName(declared);
1548
+ quarantineSlug = slug;
1346
1549
  if (slug === "") {
1347
1550
  throw new Error(`extension name "${declared}" sanitizes to an empty slug ` +
1348
1551
  "(nothing survives [a-z0-9_-] folding); refusing to load");
1349
1552
  }
1553
+ // The `core` slug is RESERVED (UI-blocks U4 Task 3, spec §13.4):
1554
+ // core's own settings sections serve under it, so an extension
1555
+ // claiming the slug would shadow them (and every other
1556
+ // core-served `/v1/ext/core/` surface).
1557
+ if (slug === "core") {
1558
+ throw new Error(`extension name "${declared}" sanitizes to the reserved slug "core" ` +
1559
+ "(core's own settings sections use it); refusing to load");
1560
+ }
1561
+ // Multi-user M1: core-owned store-root directories are not available
1562
+ // as extension identities. Check after the slug is final, before
1563
+ // any contribution is collected (the same load-time quarantine as
1564
+ // the reserved section-key check below).
1565
+ if (RESERVED_EXTENSION_SLUG_SET.has(slug)) {
1566
+ throw new Error(`reserved extension slug "${slug}"`);
1567
+ }
1350
1568
  const collides = slugs.get(slug);
1351
1569
  if (collides !== undefined) {
1352
1570
  throw new Error(`slug collision: extension "${declared}" sanitizes to ` +
@@ -1430,6 +1648,8 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1430
1648
  if (extension !== undefined) {
1431
1649
  registry.install(extension);
1432
1650
  }
1651
+ if (resolved.perUserTools === true)
1652
+ perUserSlugs.push(slug);
1433
1653
  // Only a LOADED extension or contributor claims its slug — a
1434
1654
  // later stage's quarantine (never loaded) leaves the slug free
1435
1655
  // for the next boot's specifier list.
@@ -1478,6 +1698,13 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1478
1698
  runSettled: resolved.runSettled,
1479
1699
  });
1480
1700
  }
1701
+ if (extension !== undefined &&
1702
+ resolved.onSettingsChanged !== undefined) {
1703
+ settingsChangedBindings.push({
1704
+ extension,
1705
+ onSettingsChanged: resolved.onSettingsChanged,
1706
+ });
1707
+ }
1481
1708
  if (extension !== undefined && resolved.lifecycle !== undefined) {
1482
1709
  lifecycleBindings.push({
1483
1710
  extension,
@@ -1493,6 +1720,13 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1493
1720
  if (resolved.jobs !== undefined) {
1494
1721
  jobBindings.push({ extension: slug, jobs: resolved.jobs });
1495
1722
  }
1723
+ if (resolved.userServices !== undefined) {
1724
+ userServiceBindings.push({
1725
+ extension: slug,
1726
+ services: resolved.userServices,
1727
+ packageDir: realpathSync(packageDir ?? extensionModuleDir(loaded) ?? "."),
1728
+ });
1729
+ }
1496
1730
  if (resolved.routes !== undefined) {
1497
1731
  routeBindings.push({ extension: slug, routes: resolved.routes });
1498
1732
  }
@@ -1520,6 +1754,20 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1520
1754
  ...(declaredAssets === undefined ? {} : { assetsDir: declaredAssets }),
1521
1755
  });
1522
1756
  }
1757
+ if (resolved.settingsSections !== undefined) {
1758
+ settingsSectionBindings.push({
1759
+ extension: slug,
1760
+ defs: resolved.settingsSections,
1761
+ });
1762
+ }
1763
+ if (resolved.loginProviders !== undefined) {
1764
+ for (const contribution of resolved.loginProviders) {
1765
+ loginProviders.push({
1766
+ provider: `login:${slug}:${contribution.id}`,
1767
+ contribution,
1768
+ });
1769
+ }
1770
+ }
1523
1771
  if (resolved.providers !== undefined) {
1524
1772
  providerContributors.push({
1525
1773
  name: slug,
@@ -1556,6 +1804,7 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1556
1804
  }
1557
1805
  }
1558
1806
  catch (cause) {
1807
+ await options.onQuarantine?.(quarantineSlug);
1559
1808
  const message = cause instanceof Error ? cause.message : String(cause);
1560
1809
  console.warn(`extension quarantined: ${display} failed to load: ${message}`);
1561
1810
  outcomes.push({
@@ -1574,11 +1823,15 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1574
1823
  warnToolNameCollisions(registry);
1575
1824
  return {
1576
1825
  outcomes,
1826
+ userServices: userServiceBindings,
1827
+ perUserSlugs,
1577
1828
  providerContributors,
1829
+ loginProviders,
1578
1830
  webuiBundles,
1579
1831
  skillRoots,
1580
1832
  healthProducers,
1581
1833
  runSettledBindings,
1834
+ settingsChangedBindings,
1582
1835
  lifecycleBindings,
1583
1836
  workerBindings,
1584
1837
  jobBindings,
@@ -1588,5 +1841,6 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1588
1841
  channelBindings,
1589
1842
  gatewayEngine,
1590
1843
  surfaces: surfaceBindings,
1844
+ settingsSections: settingsSectionBindings,
1591
1845
  };
1592
1846
  }
@@ -121,7 +121,7 @@ export function hostModuleVersion(name) {
121
121
  * install resolves through its OWN root, which is this same dir).
122
122
  * `undefined` when no ancestor carries the manifest.
123
123
  */
124
- function selfPackageRoot() {
124
+ export function selfPackageRoot() {
125
125
  let dir = dirname(fileURLToPath(import.meta.url));
126
126
  for (let hops = 0; hops < 10; hops++) {
127
127
  const manifest = join(dir, "package.json");
@@ -22,3 +22,24 @@ export const RESERVED_CORE_SECTION_KEYS = [
22
22
  "tool-guidance",
23
23
  "execution-guidance",
24
24
  ];
25
+ /**
26
+ * Core-owned directory names directly under a store root (multi-user M1).
27
+ * The loader refuses these as extension identities before collecting any
28
+ * contribution. This list also feeds grant validation, which deliberately
29
+ * treats the data-side entries as CORE data dirs, not foreign extension
30
+ * dirs — see src/sandbox/grants.ts.
31
+ */
32
+ export const RESERVED_EXTENSION_SLUGS = [
33
+ "users",
34
+ "workspace",
35
+ "models",
36
+ "sandbox",
37
+ "settings",
38
+ "user-settings",
39
+ "user-secrets",
40
+ "skills",
41
+ "prompts",
42
+ "packages",
43
+ "git",
44
+ "core",
45
+ ];