@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
@@ -11,6 +11,7 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
11
11
  import { join } from "node:path";
12
12
  import { Harness, } from "@earendil-works/pi-durable";
13
13
  import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
14
+ import { effectiveMethods } from "../config.js";
14
15
  import { wireActivity } from "./activity.js";
15
16
  import { createScheduleTask } from "../jobs/task.js";
16
17
  import { createJobsService, jobWireOf } from "../jobs/index.js";
@@ -29,6 +30,7 @@ import { modelGuidanceExtension } from "./sections/model-guidance.js";
29
30
  import { createSkillsExtension } from "../skills/index.js";
30
31
  import { SLASH_COMMANDS } from "../server/commands.js";
31
32
  import { packagePromptRootsOf } from "../commands/templates.js";
33
+ import { blocksToolsExtension } from "./blocks/tools.js";
32
34
  import { codingToolsExtension, searchToolsExtension } from "./tools.js";
33
35
  import { collectProviders, createProviderMergeSink, } from "./providers/index.js";
34
36
  import { EventHub } from "../server/events/hub.js";
@@ -36,10 +38,24 @@ import { providerAuthStatus } from "../server/helpers.js";
36
38
  import { bootRefresh, DEFAULT_REFRESH_TIMEOUT_MS, refreshProvider, } from "./providers/refresh.js";
37
39
  import { startProviderRefresh } from "./providers/scheduler.js";
38
40
  import { fileModelsStore } from "./providers/models-store.js";
39
- import { packagesDirOf } from "./packages.js";
41
+ import { packagesDirOf, selfPackageRoot } from "./packages.js";
42
+ import { resolveHelper } from "../sandbox/helper.js";
43
+ import { launchInJail } from "../sandbox/launch.js";
44
+ import { buildUserPolicy, defaultModuleTrees } from "../sandbox/policy.js";
45
+ import { lookupOsAccount } from "../sandbox/os-account.js";
46
+ import { agentEnv } from "./agent-env.js";
47
+ import { UserServices } from "./user-services.js";
48
+ import { resolveWorkerEntry, sandboxLauncher, WorkerPool } from "../sandbox/pool.js";
49
+ import { IsolationState } from "../sandbox/isolation.js";
50
+ import { probeOsUsers } from "../sandbox/availability.js";
51
+ import { createAdmissionGuard } from "./identity/users.js";
52
+ import { SessionMetaDoc } from "../server/session-meta.js";
53
+ import { probeIsolation, runHelperProbe, selfTestHelper, } from "../sandbox/availability.js";
40
54
  import { resolveBuiltins, } from "./builtins.js";
41
55
  import { createRegistry } from "./registry.js";
56
+ import { isUserEntryName, rewriteSelection, sweepUserSelections, UserToolRegistry, } from "./user-tools.js";
42
57
  import { defaultSettingsDir, SettingsStore } from "./settings.js";
58
+ import { resolveRefreshMinutes, snapshotRestartKeys, } from "../settings/pending.js";
43
59
  import { ensureStoreRoots, resolveStoreRoots, sharedExtensionStores, } from "./stores.js";
44
60
  import { createWorkspaceService, defaultWorkspacePrefs, toHarnessEnv, } from "./workspace.js";
45
61
  /** The idle sweep cadence (spec §2: ONE host-owned 1s interval). */
@@ -93,6 +109,35 @@ export async function openHarness(config, options = {}) {
93
109
  // store-root resolution immediately below, and the provider
94
110
  // collector further down (`providerEnv`).
95
111
  const env = options.env ?? process.env;
112
+ const coreRoot = selfPackageRoot();
113
+ const isolation = await (options.isolationProbe ?? probeIsolation)({
114
+ platform: process.platform,
115
+ helper: coreRoot === undefined ? undefined : resolveHelper(env, coreRoot),
116
+ runProbe: runHelperProbe,
117
+ selfTest: selfTestHelper,
118
+ });
119
+ console.log(`sandbox isolation: ${isolation.available ? `available (Landlock ABI ${isolation.abi})` : `unavailable (${isolation.reason ?? "unknown reason"})`}`);
120
+ if (coreRoot === undefined)
121
+ throw new Error("sandbox worker entry requires the sigrun package root");
122
+ const workerEntry = resolveWorkerEntry(coreRoot);
123
+ const helper = isolation.helper;
124
+ const osUsers = !config.isolation.osUsers
125
+ ? { available: false, status: "off" }
126
+ : options.osUsersProbe !== undefined
127
+ ? await options.osUsersProbe(helper ?? "")
128
+ : helper === undefined || !isolation.available
129
+ ? { available: false, status: "osUsers requires CAP_SETUID" }
130
+ : await probeOsUsers(helper);
131
+ const sandboxPool = new WorkerPool({
132
+ launcher: options.sandboxLauncher ?? ((policy) => {
133
+ if (helper === undefined)
134
+ throw new Error("sandbox helper not found");
135
+ return sandboxLauncher(helper, workerEntry)(policy);
136
+ }),
137
+ idleMinutes: () => settingsStore?.read().server?.isolation?.workerIdleMinutes ?? config.isolation.workerIdleMinutes,
138
+ log: (line) => console.warn(line),
139
+ workerEntry,
140
+ });
96
141
  // Store-root lifecycle (spec §6): every durable root except cache
97
142
  // exists before anything touches disk — storage, the registry's
98
143
  // first install, extension packages — so a fresh deploy (or a PVC
@@ -124,6 +169,33 @@ export async function openHarness(config, options = {}) {
124
169
  // I/O here (each file materializes on its first write), so this is
125
170
  // pure construction over paths boot already owns.
126
171
  const identity = options.identity ?? openIdentityStores(roots);
172
+ const listenHost = options.listenHost ?? env.SIGRUN_LISTEN_HOST ?? "0.0.0.0";
173
+ const methods = effectiveMethods(config.auth, listenHost);
174
+ const isolationState = new IsolationState({
175
+ mode: config.isolation.mode,
176
+ availability: isolation,
177
+ userCount: () => identity.users.list().length,
178
+ log: (line) => console.warn(line),
179
+ });
180
+ // These refusals happen during openHarness, before createServer/listen.
181
+ if (identity.users.list().length >= 2 && methods.length === 0) {
182
+ throw new Error("multi-user requires authentication: configure SIGRUN_AUTH=token or SIGRUN_AUTH=proxy, or remove users until only one remains");
183
+ }
184
+ if ((isolationState.active() || config.isolation.mode === "always") &&
185
+ methods.includes("proxy") &&
186
+ config.auth.proxy.secretHeader === undefined) {
187
+ throw new Error("isolation with proxy authentication requires server.auth.proxy.secretHeader; configure a shared-secret header or disable isolation");
188
+ }
189
+ identity.users.configureAdmission({
190
+ firstWorkspace: config.workspace.path,
191
+ admit: createAdmissionGuard({
192
+ isolation,
193
+ mode: config.isolation.mode,
194
+ methods,
195
+ proxySecretHeader: config.auth.proxy.secretHeader,
196
+ }),
197
+ });
198
+ isolationState.active();
127
199
  // The unclaimed onboarding posture (W1 Task 4, spec §5.8): every
128
200
  // unclaimed boot (re)ensures the setup code under the pod-local cache
129
201
  // root and logs it ONCE — the retrieval flow is `kubectl logs … | grep
@@ -140,8 +212,63 @@ export async function openHarness(config, options = {}) {
140
212
  const code = await ensureSetupCode(identity.cacheDir, env);
141
213
  console.log(`sigrun setup code: ${code}`);
142
214
  }
215
+ const managedPaths = {
216
+ roots,
217
+ storagePath: config.storagePath,
218
+ coreRoot,
219
+ userAreas: (user) => join(roots.dataDir, "users", user),
220
+ };
221
+ const userServices = new UserServices({
222
+ launch: (policy, command, args, serviceEnv) => {
223
+ if (helper === undefined)
224
+ throw new Error("sandbox helper not found");
225
+ return launchInJail(helper, policy, command, args, serviceEnv);
226
+ },
227
+ policyFor: (user) => {
228
+ const record = identity.users.get(user);
229
+ if (record === undefined)
230
+ return { reason: `unknown user "${user}"` };
231
+ let osAccount;
232
+ if (record.osUser !== undefined) {
233
+ if (!osUsers.available)
234
+ return { reason: "osUser requires CAP_SETUID" };
235
+ try {
236
+ osAccount = lookupOsAccount(record.osUser);
237
+ }
238
+ catch {
239
+ return { reason: `OS account not found: ${record.osUser}` };
240
+ }
241
+ if (osAccount === undefined)
242
+ return { reason: `OS account not found: ${record.osUser}` };
243
+ }
244
+ const built = buildUserPolicy(record, {
245
+ managed: managedPaths,
246
+ nodeBinDir: process.execPath,
247
+ moduleTrees: defaultModuleTrees(coreRoot),
248
+ ...(osAccount === undefined ? {} : { osAccount }),
249
+ });
250
+ if (!built.ok)
251
+ return { reason: built.reason };
252
+ if (!isolationState.active()) {
253
+ const serviceEnv = agentEnv(env);
254
+ delete serviceEnv.SSH_AUTH_SOCK;
255
+ serviceEnv.HOME = built.policy.home;
256
+ serviceEnv.TMPDIR = built.policy.tmp;
257
+ serviceEnv.USER = user;
258
+ return { ...built.policy, env: serviceEnv };
259
+ }
260
+ return built.policy;
261
+ },
262
+ isolationActive: () => isolationState.active(),
263
+ now: () => Date.now(),
264
+ managed: managedPaths,
265
+ });
143
266
  const storage = await openNodeSqliteStorage(config.storagePath);
144
267
  const registry = createRegistry();
268
+ // Late-filled by the loader: factories publish through the same registry
269
+ // instance, and selection rewrites read the declared set LIVE.
270
+ const perUserSlugs = [];
271
+ const userTools = new UserToolRegistry(registry, () => perUserSlugs);
145
272
  // The skills convention's package roots (W5 Task 4, spec §9.4): a LATE
146
273
  // array — the extension packages that contribute skill dirs load BELOW
147
274
  // (after this install), and the section renders per REQUEST, so the
@@ -203,6 +330,10 @@ export async function openHarness(config, options = {}) {
203
330
  registry.install(createSkillsExtension(skillsOptions));
204
331
  registry.install(codingToolsExtension);
205
332
  registry.install(searchToolsExtension);
333
+ // The chat-blocks tools (UI-blocks U5 Task 1, spec §8.1): every agent
334
+ // can show and update interactive block surfaces in chat — the same
335
+ // core-owned slot as the coding/search tools, never quarantined.
336
+ registry.install(blocksToolsExtension);
206
337
  // Extension packages load before `Harness.open` reads the registry
207
338
  // (spec §3 boot order). Failures quarantine and boot continues. §3
208
339
  // factory-result producers are retained alongside the outcomes (Task 9e).
@@ -210,17 +341,31 @@ export async function openHarness(config, options = {}) {
210
341
  // loader resolves against FIRST (the settings file's `packages`
211
342
  // declarations install here — see src/harness/packages.ts).
212
343
  const packagesDir = options.packagesDir ?? packagesDirOf(options.settingsDir ?? defaultSettingsDir());
213
- // Built-in extensions (Task 2, pi parity): codemode, MCP and tool
214
- // search ship with core. Resolution needs the settings FILE (the
344
+ // Built-in extensions ship with core. Resolution needs the settings FILE (the
215
345
  // `builtinExtensions` toggles live there), so the store is constructed
216
346
  // HERE — before the loader — and the one instance threads on to the
217
347
  // rest of boot (its `harnessSettings()` call below stays the single
218
- // construction).
219
- const settingsStore = new SettingsStore({ dir: options.settingsDir });
348
+ // construction). The onWrite observer (U4 §13.2) fires `settingsStore`
349
+ // writes' change events through a boot-owned sink assigned below (the
350
+ // hub does not exist yet here — a late-bound closure keeps the store
351
+ // constructor pure while every committed write still publishes).
352
+ const settingsChangedSinks = [];
353
+ const settingsStore = new SettingsStore({
354
+ dir: options.settingsDir,
355
+ onWrite: (keys) => {
356
+ for (const sink of settingsChangedSinks)
357
+ sink("server", keys);
358
+ },
359
+ });
220
360
  // Eager validation (the fail-loud boot posture, matching the
221
361
  // `harnessSettings()` contract below): a malformed settings file
222
362
  // throws HERE even when the caller seam overrides every field.
223
363
  const bootSettings = settingsStore.read();
364
+ // The pending-restart boot snapshot (U4 §13.2): taken ONCE, after the
365
+ // settings load validates and before anything else reads it — the
366
+ // diff base `GET /v1/settings`'s `pendingRestart` reports. Pre-listen
367
+ // by construction: openHarness returns before serve listens.
368
+ const settingsBootSnapshot = snapshotRestartKeys(bootSettings);
224
369
  const builtinsEnabled = options.builtins !== false;
225
370
  // Skipped entirely (no resolution, no log lines) when the caller
226
371
  // turns built-ins off.
@@ -231,7 +376,7 @@ export async function openHarness(config, options = {}) {
231
376
  options.extensionSpecifiers ??
232
377
  config.extensions).map((specifier) => typeof specifier === "string" ? { entry: specifier } : specifier);
233
378
  const builtins = builtinsEnabled
234
- ? resolveBuiltins(bootSettings, configuredInputs, options.extraBuiltins ?? [])
379
+ ? resolveBuiltins(bootSettings, configuredInputs, options.extraBuiltins ?? [], options.builtinResolver)
235
380
  : { load: [], skipped: [] };
236
381
  // The loader input: built-ins FIRST (pi parity — the replacement rule
237
382
  // already removed any built-in a configured entry claims), marked
@@ -274,10 +419,10 @@ export async function openHarness(config, options = {}) {
274
419
  // the cache dir materializes lazily on first `paths.cache` access).
275
420
  // The boot env feeds the admin surface's env-secret listing (the
276
421
  // `SIGRUN_EXT_<SLUG>_` fallbacks — never written down, listed only).
277
- const sharedStores = sharedExtensionStores(roots, env);
422
+ const sharedStores = sharedExtensionStores(roots, env, identity.users);
278
423
  const seamHosts = new Map();
279
424
  const seamKinds = new Map();
280
- const { outcomes: extensionOutcomes, healthProducers: extensionHealth, runSettledBindings, lifecycleBindings, workerBindings, providerContributors, webuiBundles: extensionWebuiBundles, skillRoots: extensionSkillRoots, jobBindings, routeBindings, commandBindings, notifierBindings, channelBindings, gatewayEngine, surfaces: surfaceBindings, } = await loadExtensions(loaderInputs, registry, {
425
+ const { outcomes: extensionOutcomes, healthProducers: extensionHealth, runSettledBindings, settingsChangedBindings, lifecycleBindings, workerBindings, userServices: userServiceBindings, providerContributors, loginProviders, webuiBundles: extensionWebuiBundles, skillRoots: extensionSkillRoots, jobBindings, routeBindings, commandBindings, notifierBindings, channelBindings, gatewayEngine, surfaces: surfaceBindings, settingsSections: settingsSectionBindings, perUserSlugs: loadedPerUserSlugs, } = await loadExtensions(loaderInputs, registry, {
281
426
  packagesDir,
282
427
  hostContextFactory: (extensionName, kinds, priorHost) => {
283
428
  // Two-phase, revised for the provisional identity (provider
@@ -294,7 +439,10 @@ export async function openHarness(config, options = {}) {
294
439
  createSeamHost({ name: extensionName }, kinds, {
295
440
  bindHarness,
296
441
  harness: harnessOf,
297
- }, sharedStores);
442
+ }, sharedStores, {
443
+ declared: (slug) => perUserSlugs.includes(slug),
444
+ set: (slug, user, tools) => userTools.set(slug, user, tools),
445
+ }, userServices);
298
446
  host.rekey({ name: extensionName }, kinds);
299
447
  // File under the final name only, and only once the load
300
448
  // resolved: the PRE-import call must NOT file (its identity is
@@ -307,7 +455,15 @@ export async function openHarness(config, options = {}) {
307
455
  }
308
456
  return host;
309
457
  },
458
+ onQuarantine: (slug) => {
459
+ userTools.removeSlug(slug);
460
+ return userServices.quarantine(slug);
461
+ },
310
462
  });
463
+ perUserSlugs.push(...loadedPerUserSlugs);
464
+ for (const binding of userServiceBindings) {
465
+ userServices.register(binding.extension, binding.services, binding.packageDir);
466
+ }
311
467
  // The late fill (W5 Task 4): the skills extension installed BEFORE the
312
468
  // loader ran, holding an empty package-roots array by reference — the
313
469
  // collected roots land HERE, and the section (which renders per request)
@@ -434,6 +590,7 @@ export async function openHarness(config, options = {}) {
434
590
  // engine loaded (core ships none — `gatewayEngine` is undefined
435
591
  // unless an extension declared one).
436
592
  gatewayLoaded: gatewayEngine !== undefined,
593
+ rewriteUserSelection: (tx, conversationId, from, to) => rewriteSelection(tx, conversationId, from, to, perUserSlugs),
437
594
  };
438
595
  registry.install({
439
596
  name: "sigrun.jobs",
@@ -511,17 +668,39 @@ export async function openHarness(config, options = {}) {
511
668
  // when the resolved interval is greater than zero (0 = the designed
512
669
  // disable, no timer); the timer is unref()ed inside, and `stop()` rides
513
670
  // on the returned handle for the deploy shutdown path.
514
- const stopProviderRefresh = config.providers.refreshIntervalMinutes > 0
515
- ? startProviderRefresh(models, {
516
- intervalMinutes: config.providers.refreshIntervalMinutes,
671
+ // The interval refresh scheduler (with the U4 §13.1 re-arm): the
672
+ // cadence is a LIVE setting — a committed write to
673
+ // `server.providers.refreshIntervalMinutes` re-arms the timer at the
674
+ // NEW interval (stop + start; `0` disables, no timer). The re-arm
675
+ // resolves env over the fresh file value exactly like boot does, so
676
+ // an env-pinned cadence (`SIGRUN_PROVIDER_REFRESH_MINUTES`) stays
677
+ // authoritative across re-arms.
678
+ const armProviderRefresh = () => {
679
+ const minutes = resolveRefreshMinutes(env, settingsStore);
680
+ if (!(minutes > 0))
681
+ return () => { };
682
+ return startProviderRefresh(models, {
683
+ intervalMinutes: minutes,
517
684
  onReadinessChange: observeReadiness,
518
685
  snapshotReadiness,
519
- }).stop
520
- : () => { };
521
- // The static workspace service (spec §4.5): every tool call's ExecutionEnv
522
- // resolves through it, so a future resolver swap changes this wiring's
523
- // inputs and nothing downstream.
524
- const workspaceService = createWorkspaceService();
686
+ }).stop;
687
+ };
688
+ let stopProviderRefresh = armProviderRefresh();
689
+ // SAFETY: `stopProviderRefresh` is `let`-bound for the re-arm swap;
690
+ // the shutdown handle the deploy path calls stays this function (it
691
+ // always stops whatever arm is current).
692
+ const stopCurrentProviderRefresh = () => {
693
+ stopProviderRefresh();
694
+ };
695
+ // Every tool/file/upload/job env resolves through this one decision seam.
696
+ const workspaceService = createWorkspaceService({
697
+ isolation: isolationState,
698
+ osUsers,
699
+ pool: sandboxPool,
700
+ directory: identity.users,
701
+ managed: managedPaths,
702
+ ownerOf: async (conversationId, read, ctx) => (await read.snapshot(SessionMetaDoc, conversationId, ctx))?.owner,
703
+ });
525
704
  const workspacePrefs = defaultWorkspacePrefs(config.workspace.path);
526
705
  const envBuilder = toHarnessEnv(workspaceService, workspacePrefs);
527
706
  // The job runtime's env-builder binding resolves here (the placeholder
@@ -572,7 +751,13 @@ export async function openHarness(config, options = {}) {
572
751
  get stream() {
573
752
  return callerSettings?.stream ?? fileSettings.stream;
574
753
  },
575
- extensions: callerSettings?.extensions,
754
+ get extensions() {
755
+ // Per-user entries are core-computed from SessionMeta.owner,
756
+ // never part of a host default — including an operator-supplied
757
+ // caller list (controller ruling: filter BOTH sources).
758
+ const selected = callerSettings?.extensions ?? registry.snapshot().installed();
759
+ return selected.filter((entry) => !isUserEntryName(entry.name));
760
+ },
576
761
  toolExecution: callerSettings?.toolExecution,
577
762
  compaction: {
578
763
  ...config.compaction,
@@ -580,6 +765,11 @@ export async function openHarness(config, options = {}) {
580
765
  },
581
766
  },
582
767
  }, BACKGROUND_CONTEXT);
768
+ // M2 boot sweep: add each conversation owner's declared entries to every
769
+ // default-edited selection. Exact arrays are never widened. Runs before
770
+ // resume and is idempotent; late tool installs still resolve by name.
771
+ const userToolSweep = await sweepUserSelections(harness, storage, perUserSlugs);
772
+ console.log(`user tools: boot sweep scanned ${userToolSweep.scanned} conversation(s), changed ${userToolSweep.changed}`);
583
773
  // The seam hosts' lazy binding resolves here: every seam call issued
584
774
  // after boot completes finds the harness immediately; one issued from a
585
775
  // post-open async factory path waits on this same promise.
@@ -608,12 +798,35 @@ export async function openHarness(config, options = {}) {
608
798
  idleThresholdMs: options.eventBus?.idleThresholdMs ?? memorySeam.idleThresholdMs,
609
799
  idleSweepMs: options.eventBus?.idleSweepMs ?? IDLE_SWEEP_MS,
610
800
  workerDeadlineMs: options.eventBus?.workerDeadlineMs ?? memorySeam.workerDeadlineMs,
801
+ shutdownSettleWaitMs: options.eventBus?.shutdownSettleWaitMs,
611
802
  // W3 Task 4: the hub's subscription is installed at open —
612
803
  // synchronous, no-blocking-work (the SSE route's filtering side
613
804
  // lane does its own async work). Exposed so the bus's open() can
614
805
  // wire it before any commit lands.
615
806
  onHostEvent: (event) => events.publish(event),
616
807
  });
808
+ // The settings-changed publication (U4 §13.2): ONE seam fanning a
809
+ // committed settings write out to (a) the hub — /v1/events delivers
810
+ // `settings.changed` to admins — and (b) every extension's
811
+ // `onSettingsChanged` through the bus's bounded fan-out (rejections
812
+ // logged, never propagated: §13.2's the-write-still-succeeds rule).
813
+ // The scheduler re-arm rides the same seam: a write touching
814
+ // `server.providers.refreshIntervalMinutes` (a LIVE key, §13.1) stops
815
+ // and re-arms the interval timer at the fresh cadence.
816
+ const publishSettingsChanged = (scope, keys, user) => {
817
+ events.publish({ type: "settings.changed", scope, keys: [...keys] });
818
+ if (keys.includes("server.providers.refreshIntervalMinutes") ||
819
+ keys.includes("server.providers") ||
820
+ keys.includes("server")) {
821
+ stopProviderRefresh();
822
+ stopProviderRefresh = armProviderRefresh();
823
+ }
824
+ if (user !== undefined && typeof scope !== "string") {
825
+ userServices.settingsChanged(scope.extension, user);
826
+ }
827
+ void eventBus.emitSettingsChanged(keys, user);
828
+ };
829
+ settingsChangedSinks.push(publishSettingsChanged);
617
830
  // Activity stamping (UI refresh A1): every settled run stamps its
618
831
  // conversation's `lastActivityAt` (the admission-side stamp lives in the
619
832
  // submissions route). Registered directly after the bus opens — host-owned
@@ -628,6 +841,14 @@ export async function openHarness(config, options = {}) {
628
841
  runSettled: binding.runSettled,
629
842
  });
630
843
  }
844
+ // UI-blocks U4 §13.2: the settings-change hook merges into the same
845
+ // per-extension registration (the fan-out below invokes it).
846
+ for (const binding of settingsChangedBindings) {
847
+ handlersByName.set(binding.extension.name, {
848
+ ...handlersByName.get(binding.extension.name),
849
+ onSettingsChanged: binding.onSettingsChanged,
850
+ });
851
+ }
631
852
  for (const binding of lifecycleBindings) {
632
853
  handlersByName.set(binding.extension.name, {
633
854
  ...handlersByName.get(binding.extension.name),
@@ -824,8 +1045,16 @@ export async function openHarness(config, options = {}) {
824
1045
  harness.resume();
825
1046
  return {
826
1047
  harness,
1048
+ isolation,
1049
+ isolationState,
1050
+ osUsers,
1051
+ sandboxPool,
1052
+ userServices,
827
1053
  storage,
828
1054
  registry,
1055
+ userTools,
1056
+ rewriteUserSelection: (tx, conversationId, from, to) => rewriteSelection(tx, conversationId, from, to, perUserSlugs),
1057
+ userToolSweep,
829
1058
  models,
830
1059
  jobs,
831
1060
  jobRunTotals: () => jobRunTotals,
@@ -835,6 +1064,7 @@ export async function openHarness(config, options = {}) {
835
1064
  webuiBundleCollisions,
836
1065
  envBuilder,
837
1066
  extensionOutcomes,
1067
+ loginProviders,
838
1068
  builtinSkipped: builtinsEnabled ? builtins.skipped : [],
839
1069
  providerHealth,
840
1070
  providerOwnership,
@@ -855,6 +1085,7 @@ export async function openHarness(config, options = {}) {
855
1085
  extensionRoutes: routeBindings,
856
1086
  extensionCommands: commandBindings,
857
1087
  extensionSurfaces: surfaceBindings,
1088
+ extensionSettingsSections: settingsSectionBindings,
858
1089
  notifierBindings,
859
1090
  channelBindings,
860
1091
  gatewayEngine,
@@ -867,6 +1098,8 @@ export async function openHarness(config, options = {}) {
867
1098
  autoNamer,
868
1099
  bootRefresh: bootRefreshDone,
869
1100
  refreshProvider: refreshOneProvider,
870
- stopProviderRefresh,
1101
+ stopProviderRefresh: stopCurrentProviderRefresh,
1102
+ settingsBootSnapshot,
1103
+ publishSettingsChanged,
871
1104
  };
872
1105
  }
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Built-in extensions (Task 2, pi parity): codemode, MCP and tool search
3
- * ship WITH core — present by default, disableable via settings
4
- * `builtinExtensions`, replaceable by a same-named configured extension.
2
+ * Built-in extensions: codemode, MCP, tool search and OAuth ship WITH
3
+ * core — present by default, disableable via settings `builtinExtensions`,
4
+ * replaceable by a same-named configured extension.
5
5
  *
6
- * pi 1.3.0 ships these three as built-ins; a default sigrun install
7
- * mirrors that, so a friend installing sigrun gets pi's tool surface
8
- * without configuring anything.
6
+ * The first three mirror pi 1.3.0's built-in tool surface; OAuth adds
7
+ * sigrun's standard OIDC/OAuth2 login providers without extra setup.
9
8
  *
10
9
  * Resolution root (Ruling 2): built-ins resolve through core's OWN
11
10
  * node_modules via `createRequire(import.meta.url)` — they are core
@@ -24,7 +23,7 @@
24
23
  * (`readPackageName` for path-shaped entries, `parsePackageSpecifier`
25
24
  * otherwise), never by string equality on the raw specifier.
26
25
  *
27
- * Disabled (Ruling 6): `builtinExtensions: {codemode?, mcp?, toolSearch?}`
26
+ * Disabled (Ruling 6): `builtinExtensions: {codemode?, mcp?, toolSearch?, oauth?}`
28
27
  * (each defaulting to `true`, restart-required). A disabled built-in is
29
28
  * absent from `/v1/extensions` entirely; one log line records the skip.
30
29
  *
@@ -39,7 +38,7 @@ import { basename, dirname, isAbsolute, resolve } from "node:path";
39
38
  import { fileURLToPath, pathToFileURL } from "node:url";
40
39
  import { parsePackageSpecifier, readPackageName } from "./packages.js";
41
40
  /**
42
- * The three built-ins, in load order (codemode before tool-search: both
41
+ * The four built-ins, in load order (codemode before tool-search: both
43
42
  * wrap pi-durable's own extensions and neither depends on the other, but
44
43
  * the order pins the tool-collision story should names ever overlap).
45
44
  * Specifiers are the published package names (Task 3) — npm keys a file:
@@ -51,6 +50,8 @@ export const BUILTIN_EXTENSIONS = [
51
50
  { key: "codemode", specifier: "@sigrun/ext-codemode", slug: "codemode" },
52
51
  { key: "mcp", specifier: "@sigrun/ext-mcp", slug: "sigrun-mcp" },
53
52
  { key: "toolSearch", specifier: "@sigrun/ext-tool-search", slug: "tool-search" },
53
+ { key: "gateway", specifier: "@sigrun/ext-gateway", slug: "gateway" },
54
+ { key: "oauth", specifier: "@sigrun/oauth", slug: "oauth" },
54
55
  ];
55
56
  /** The core-anchored require (built-ins resolve from core's own node_modules). */
56
57
  const coreRequire = createRequire(import.meta.url);
@@ -154,7 +155,10 @@ function configuredPackageName(entry) {
154
155
  */
155
156
  export function resolveBuiltins(settings, configured,
156
157
  /** Test seam: additional built-in specifiers (fixtures), resolved like the real ones. */
157
- extra = []) {
158
+ extra = [],
159
+ /** Test seam: replace core resolution to exercise an unresolvable built-in. */
160
+ resolveEntry) {
161
+ const resolve = resolveEntry ?? ((specifier) => builtinEntry(specifier));
158
162
  const toggles = settings.builtinExtensions;
159
163
  const load = [];
160
164
  const skipped = [];
@@ -179,13 +183,13 @@ extra = []) {
179
183
  skipped.push({ key: builtin.key, reason: "replaced", by: replacement });
180
184
  continue;
181
185
  }
182
- load.push(builtinEntry(builtin.specifier));
186
+ load.push(resolve(builtin.specifier, builtinEntry));
183
187
  }
184
188
  // The test-seam extra entries (the boot's `extraBuiltins` option):
185
189
  // resolved exactly like the real built-ins, appended after them,
186
190
  // reported builtin.
187
191
  for (const specifier of extra) {
188
- load.push(builtinEntry(specifier));
192
+ load.push(resolve(specifier, builtinEntry));
189
193
  }
190
194
  return { load, skipped };
191
195
  }