@sigrun/sigrun 0.1.6 → 0.2.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.
Files changed (137) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +445 -42
  3. package/dist/blocks/build-stamp.json +13 -0
  4. package/dist/blocks/builders.d.ts +57 -0
  5. package/dist/blocks/images.d.ts +26 -0
  6. package/dist/blocks/index.d.ts +20 -0
  7. package/dist/blocks/limits.d.ts +25 -0
  8. package/dist/blocks/placements.d.ts +26 -0
  9. package/dist/blocks/schema.d.ts +38 -0
  10. package/dist/blocks/schema.js +9 -2
  11. package/dist/blocks/validate.d.ts +73 -0
  12. package/dist/blocks/validate.js +57 -8
  13. package/dist/cli.js +26 -2
  14. package/dist/config.js +67 -1
  15. package/dist/extension.d.ts +230 -22
  16. package/dist/harness/ambient.js +39 -185
  17. package/dist/harness/blocks/entries.js +47 -0
  18. package/dist/harness/blocks/state.js +58 -0
  19. package/dist/harness/blocks/tools.js +274 -0
  20. package/dist/harness/boot.js +259 -20
  21. package/dist/harness/builtins.js +15 -11
  22. package/dist/harness/event-bus.js +105 -10
  23. package/dist/harness/identity/accounts.js +186 -0
  24. package/dist/harness/identity/groups.js +14 -0
  25. package/dist/harness/identity/index.js +13 -6
  26. package/dist/harness/identity/json-store.js +15 -4
  27. package/dist/harness/identity/setup-code-check.js +9 -0
  28. package/dist/harness/identity/tokens.js +28 -5
  29. package/dist/harness/identity/users.js +111 -12
  30. package/dist/harness/interactions.js +292 -136
  31. package/dist/harness/loader.js +271 -17
  32. package/dist/harness/packages.js +1 -1
  33. package/dist/harness/reserved-sections.js +21 -0
  34. package/dist/harness/seam-host.js +92 -6
  35. package/dist/harness/sections/context-files.js +1 -1
  36. package/dist/harness/settings.js +63 -2
  37. package/dist/harness/stores.js +241 -4
  38. package/dist/harness/user-services.js +391 -0
  39. package/dist/harness/user-tools.js +187 -0
  40. package/dist/harness/workspace.js +61 -57
  41. package/dist/jobs/fire.js +10 -16
  42. package/dist/sandbox/availability.js +211 -0
  43. package/dist/sandbox/command-observer.js +59 -0
  44. package/dist/sandbox/grants.js +159 -0
  45. package/dist/sandbox/helper.js +29 -0
  46. package/dist/sandbox/isolation.js +29 -0
  47. package/dist/sandbox/launch.js +31 -0
  48. package/dist/sandbox/os-account.js +49 -0
  49. package/dist/sandbox/policy.js +274 -0
  50. package/dist/sandbox/pool.js +548 -0
  51. package/dist/sandbox/protocol.js +167 -0
  52. package/dist/sandbox/sandboxed-env.js +245 -0
  53. package/dist/sandbox/unavailable-env.js +49 -0
  54. package/dist/sandbox/worker.js +261 -0
  55. package/dist/serve.js +213 -25
  56. package/dist/server/auth/access-principal.js +16 -0
  57. package/dist/server/auth/pipeline.js +99 -36
  58. package/dist/server/blocks/answers.js +94 -0
  59. package/dist/server/blocks/chat-surfaces.js +393 -0
  60. package/dist/server/blocks/notes.js +216 -0
  61. package/dist/server/blocks/summaries.js +328 -0
  62. package/dist/server/commands.js +5 -0
  63. package/dist/server/content-parts-lazy.js +13 -0
  64. package/dist/server/conversation-handler.js +2 -5
  65. package/dist/server/conversation-ops.js +138 -37
  66. package/dist/server/helpers.js +18 -6
  67. package/dist/server/host-bridge.js +40 -20
  68. package/dist/server/images/cache.js +213 -0
  69. package/dist/server/images/convert.js +13 -0
  70. package/dist/server/images/fetch.js +302 -0
  71. package/dist/server/images/ranges.js +42 -0
  72. package/dist/server/images/rewrite.js +94 -0
  73. package/dist/server/images/sign.js +84 -0
  74. package/dist/server/images/sniff.js +62 -0
  75. package/dist/server/index.js +152 -4
  76. package/dist/server/login/flows.js +126 -0
  77. package/dist/server/login/http.js +31 -0
  78. package/dist/server/login/resolve.js +133 -0
  79. package/dist/server/login/setup-claim.js +55 -0
  80. package/dist/server/login/usernames.js +15 -0
  81. package/dist/server/notifications.js +21 -4
  82. package/dist/server/ownership.js +13 -0
  83. package/dist/server/routes/admin.js +58 -0
  84. package/dist/server/routes/auth.js +88 -2
  85. package/dist/server/routes/conversations.js +3 -6
  86. package/dist/server/routes/events.js +28 -3
  87. package/dist/server/routes/extensions.js +37 -5
  88. package/dist/server/routes/images.js +274 -0
  89. package/dist/server/routes/interactions.js +57 -68
  90. package/dist/server/routes/jobs.js +2 -8
  91. package/dist/server/routes/login.js +223 -0
  92. package/dist/server/routes/me.js +90 -0
  93. package/dist/server/routes/meta.js +19 -0
  94. package/dist/server/routes/onboarding.js +94 -84
  95. package/dist/server/routes/settings-sections.js +190 -0
  96. package/dist/server/routes/settings.js +16 -4
  97. package/dist/server/routes/submissions.js +1 -1
  98. package/dist/server/routes/surfaces.js +175 -23
  99. package/dist/server/routes/users.js +116 -20
  100. package/dist/server/schema-fixture.js +213 -0
  101. package/dist/server/session-meta.js +3 -0
  102. package/dist/server/shutdown.js +11 -4
  103. package/dist/server/static.js +18 -0
  104. package/dist/server/surfaces/dedup.js +9 -2
  105. package/dist/server/surfaces/service.js +181 -13
  106. package/dist/server/surfaces/settings-surfaces.js +597 -0
  107. package/dist/server/turns/submit-turn.js +26 -0
  108. package/dist/server/views.js +16 -0
  109. package/dist/settings/core-sections.js +532 -0
  110. package/dist/settings/pending.js +181 -0
  111. package/dist/settings/registry.js +453 -0
  112. package/dist/skills/index.js +6 -0
  113. package/node_modules/@sigrun/shared/CHANGELOG.md +11 -0
  114. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  115. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  116. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  117. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  118. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  119. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  120. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  121. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  122. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  123. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  124. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  125. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  126. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  127. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  128. package/node_modules/@sigrun/shared/dist/views.d.ts +202 -12
  129. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  130. package/node_modules/@sigrun/shared/package.json +1 -1
  131. package/package.json +13 -9
  132. package/webui/dist/assets/index-SO2W4jjk.js +55 -0
  133. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  134. package/webui/dist/index.html +2 -2
  135. package/CONTEXT.md +0 -163
  136. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  137. 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,39 @@ 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
+ // The caller-supplied host wins (serving spec §1: `serve()` resolves
173
+ // the ONE effective host — options.host → SIGRUN_LISTEN_HOST →
174
+ // DEFAULT_LISTEN_HOST — and passes it here). The env read below is
175
+ // NOT a second source on the serve path (the option is always set
176
+ // there); it remains for direct `openHarness` callers that never
177
+ // bind a listener (tests) and must derive the same auth posture.
178
+ const listenHost = options.listenHost ?? env.SIGRUN_LISTEN_HOST ?? "0.0.0.0";
179
+ const methods = effectiveMethods(config.auth, listenHost);
180
+ const isolationState = new IsolationState({
181
+ mode: config.isolation.mode,
182
+ availability: isolation,
183
+ userCount: () => identity.users.list().length,
184
+ log: (line) => console.warn(line),
185
+ });
186
+ // These refusals happen during openHarness, before createServer/listen.
187
+ if (identity.users.list().length >= 2 && methods.length === 0) {
188
+ throw new Error("multi-user requires authentication: configure SIGRUN_AUTH=token or SIGRUN_AUTH=proxy, or remove users until only one remains");
189
+ }
190
+ if ((isolationState.active() || config.isolation.mode === "always") &&
191
+ methods.includes("proxy") &&
192
+ config.auth.proxy.secretHeader === undefined) {
193
+ throw new Error("isolation with proxy authentication requires server.auth.proxy.secretHeader; configure a shared-secret header or disable isolation");
194
+ }
195
+ identity.users.configureAdmission({
196
+ firstWorkspace: config.workspace.path,
197
+ admit: createAdmissionGuard({
198
+ isolation,
199
+ mode: config.isolation.mode,
200
+ methods,
201
+ proxySecretHeader: config.auth.proxy.secretHeader,
202
+ }),
203
+ });
204
+ isolationState.active();
127
205
  // The unclaimed onboarding posture (W1 Task 4, spec §5.8): every
128
206
  // unclaimed boot (re)ensures the setup code under the pod-local cache
129
207
  // root and logs it ONCE — the retrieval flow is `kubectl logs … | grep
@@ -140,8 +218,63 @@ export async function openHarness(config, options = {}) {
140
218
  const code = await ensureSetupCode(identity.cacheDir, env);
141
219
  console.log(`sigrun setup code: ${code}`);
142
220
  }
221
+ const managedPaths = {
222
+ roots,
223
+ storagePath: config.storagePath,
224
+ coreRoot,
225
+ userAreas: (user) => join(roots.dataDir, "users", user),
226
+ };
227
+ const userServices = new UserServices({
228
+ launch: (policy, command, args, serviceEnv) => {
229
+ if (helper === undefined)
230
+ throw new Error("sandbox helper not found");
231
+ return launchInJail(helper, policy, command, args, serviceEnv);
232
+ },
233
+ policyFor: (user) => {
234
+ const record = identity.users.get(user);
235
+ if (record === undefined)
236
+ return { reason: `unknown user "${user}"` };
237
+ let osAccount;
238
+ if (record.osUser !== undefined) {
239
+ if (!osUsers.available)
240
+ return { reason: "osUser requires CAP_SETUID" };
241
+ try {
242
+ osAccount = lookupOsAccount(record.osUser);
243
+ }
244
+ catch {
245
+ return { reason: `OS account not found: ${record.osUser}` };
246
+ }
247
+ if (osAccount === undefined)
248
+ return { reason: `OS account not found: ${record.osUser}` };
249
+ }
250
+ const built = buildUserPolicy(record, {
251
+ managed: managedPaths,
252
+ nodeBinDir: process.execPath,
253
+ moduleTrees: defaultModuleTrees(coreRoot),
254
+ ...(osAccount === undefined ? {} : { osAccount }),
255
+ });
256
+ if (!built.ok)
257
+ return { reason: built.reason };
258
+ if (!isolationState.active()) {
259
+ const serviceEnv = agentEnv(env);
260
+ delete serviceEnv.SSH_AUTH_SOCK;
261
+ serviceEnv.HOME = built.policy.home;
262
+ serviceEnv.TMPDIR = built.policy.tmp;
263
+ serviceEnv.USER = user;
264
+ return { ...built.policy, env: serviceEnv };
265
+ }
266
+ return built.policy;
267
+ },
268
+ isolationActive: () => isolationState.active(),
269
+ now: () => Date.now(),
270
+ managed: managedPaths,
271
+ });
143
272
  const storage = await openNodeSqliteStorage(config.storagePath);
144
273
  const registry = createRegistry();
274
+ // Late-filled by the loader: factories publish through the same registry
275
+ // instance, and selection rewrites read the declared set LIVE.
276
+ const perUserSlugs = [];
277
+ const userTools = new UserToolRegistry(registry, () => perUserSlugs);
145
278
  // The skills convention's package roots (W5 Task 4, spec §9.4): a LATE
146
279
  // array — the extension packages that contribute skill dirs load BELOW
147
280
  // (after this install), and the section renders per REQUEST, so the
@@ -203,6 +336,10 @@ export async function openHarness(config, options = {}) {
203
336
  registry.install(createSkillsExtension(skillsOptions));
204
337
  registry.install(codingToolsExtension);
205
338
  registry.install(searchToolsExtension);
339
+ // The chat-blocks tools (UI-blocks U5 Task 1, spec §8.1): every agent
340
+ // can show and update interactive block surfaces in chat — the same
341
+ // core-owned slot as the coding/search tools, never quarantined.
342
+ registry.install(blocksToolsExtension);
206
343
  // Extension packages load before `Harness.open` reads the registry
207
344
  // (spec §3 boot order). Failures quarantine and boot continues. §3
208
345
  // factory-result producers are retained alongside the outcomes (Task 9e).
@@ -210,17 +347,31 @@ export async function openHarness(config, options = {}) {
210
347
  // loader resolves against FIRST (the settings file's `packages`
211
348
  // declarations install here — see src/harness/packages.ts).
212
349
  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
350
+ // Built-in extensions ship with core. Resolution needs the settings FILE (the
215
351
  // `builtinExtensions` toggles live there), so the store is constructed
216
352
  // HERE — before the loader — and the one instance threads on to the
217
353
  // rest of boot (its `harnessSettings()` call below stays the single
218
- // construction).
219
- const settingsStore = new SettingsStore({ dir: options.settingsDir });
354
+ // construction). The onWrite observer (U4 §13.2) fires `settingsStore`
355
+ // writes' change events through a boot-owned sink assigned below (the
356
+ // hub does not exist yet here — a late-bound closure keeps the store
357
+ // constructor pure while every committed write still publishes).
358
+ const settingsChangedSinks = [];
359
+ const settingsStore = new SettingsStore({
360
+ dir: options.settingsDir,
361
+ onWrite: (keys) => {
362
+ for (const sink of settingsChangedSinks)
363
+ sink("server", keys);
364
+ },
365
+ });
220
366
  // Eager validation (the fail-loud boot posture, matching the
221
367
  // `harnessSettings()` contract below): a malformed settings file
222
368
  // throws HERE even when the caller seam overrides every field.
223
369
  const bootSettings = settingsStore.read();
370
+ // The pending-restart boot snapshot (U4 §13.2): taken ONCE, after the
371
+ // settings load validates and before anything else reads it — the
372
+ // diff base `GET /v1/settings`'s `pendingRestart` reports. Pre-listen
373
+ // by construction: openHarness returns before serve listens.
374
+ const settingsBootSnapshot = snapshotRestartKeys(bootSettings);
224
375
  const builtinsEnabled = options.builtins !== false;
225
376
  // Skipped entirely (no resolution, no log lines) when the caller
226
377
  // turns built-ins off.
@@ -231,7 +382,7 @@ export async function openHarness(config, options = {}) {
231
382
  options.extensionSpecifiers ??
232
383
  config.extensions).map((specifier) => typeof specifier === "string" ? { entry: specifier } : specifier);
233
384
  const builtins = builtinsEnabled
234
- ? resolveBuiltins(bootSettings, configuredInputs, options.extraBuiltins ?? [])
385
+ ? resolveBuiltins(bootSettings, configuredInputs, options.extraBuiltins ?? [], options.builtinResolver)
235
386
  : { load: [], skipped: [] };
236
387
  // The loader input: built-ins FIRST (pi parity — the replacement rule
237
388
  // already removed any built-in a configured entry claims), marked
@@ -274,10 +425,10 @@ export async function openHarness(config, options = {}) {
274
425
  // the cache dir materializes lazily on first `paths.cache` access).
275
426
  // The boot env feeds the admin surface's env-secret listing (the
276
427
  // `SIGRUN_EXT_<SLUG>_` fallbacks — never written down, listed only).
277
- const sharedStores = sharedExtensionStores(roots, env);
428
+ const sharedStores = sharedExtensionStores(roots, env, identity.users);
278
429
  const seamHosts = new Map();
279
430
  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, {
431
+ 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
432
  packagesDir,
282
433
  hostContextFactory: (extensionName, kinds, priorHost) => {
283
434
  // Two-phase, revised for the provisional identity (provider
@@ -294,7 +445,10 @@ export async function openHarness(config, options = {}) {
294
445
  createSeamHost({ name: extensionName }, kinds, {
295
446
  bindHarness,
296
447
  harness: harnessOf,
297
- }, sharedStores);
448
+ }, sharedStores, {
449
+ declared: (slug) => perUserSlugs.includes(slug),
450
+ set: (slug, user, tools) => userTools.set(slug, user, tools),
451
+ }, userServices);
298
452
  host.rekey({ name: extensionName }, kinds);
299
453
  // File under the final name only, and only once the load
300
454
  // resolved: the PRE-import call must NOT file (its identity is
@@ -307,7 +461,15 @@ export async function openHarness(config, options = {}) {
307
461
  }
308
462
  return host;
309
463
  },
464
+ onQuarantine: (slug) => {
465
+ userTools.removeSlug(slug);
466
+ return userServices.quarantine(slug);
467
+ },
310
468
  });
469
+ perUserSlugs.push(...loadedPerUserSlugs);
470
+ for (const binding of userServiceBindings) {
471
+ userServices.register(binding.extension, binding.services, binding.packageDir);
472
+ }
311
473
  // The late fill (W5 Task 4): the skills extension installed BEFORE the
312
474
  // loader ran, holding an empty package-roots array by reference — the
313
475
  // collected roots land HERE, and the section (which renders per request)
@@ -434,6 +596,7 @@ export async function openHarness(config, options = {}) {
434
596
  // engine loaded (core ships none — `gatewayEngine` is undefined
435
597
  // unless an extension declared one).
436
598
  gatewayLoaded: gatewayEngine !== undefined,
599
+ rewriteUserSelection: (tx, conversationId, from, to) => rewriteSelection(tx, conversationId, from, to, perUserSlugs),
437
600
  };
438
601
  registry.install({
439
602
  name: "sigrun.jobs",
@@ -511,17 +674,39 @@ export async function openHarness(config, options = {}) {
511
674
  // when the resolved interval is greater than zero (0 = the designed
512
675
  // disable, no timer); the timer is unref()ed inside, and `stop()` rides
513
676
  // on the returned handle for the deploy shutdown path.
514
- const stopProviderRefresh = config.providers.refreshIntervalMinutes > 0
515
- ? startProviderRefresh(models, {
516
- intervalMinutes: config.providers.refreshIntervalMinutes,
677
+ // The interval refresh scheduler (with the U4 §13.1 re-arm): the
678
+ // cadence is a LIVE setting — a committed write to
679
+ // `server.providers.refreshIntervalMinutes` re-arms the timer at the
680
+ // NEW interval (stop + start; `0` disables, no timer). The re-arm
681
+ // resolves env over the fresh file value exactly like boot does, so
682
+ // an env-pinned cadence (`SIGRUN_PROVIDER_REFRESH_MINUTES`) stays
683
+ // authoritative across re-arms.
684
+ const armProviderRefresh = () => {
685
+ const minutes = resolveRefreshMinutes(env, settingsStore);
686
+ if (!(minutes > 0))
687
+ return () => { };
688
+ return startProviderRefresh(models, {
689
+ intervalMinutes: minutes,
517
690
  onReadinessChange: observeReadiness,
518
691
  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();
692
+ }).stop;
693
+ };
694
+ let stopProviderRefresh = armProviderRefresh();
695
+ // SAFETY: `stopProviderRefresh` is `let`-bound for the re-arm swap;
696
+ // the shutdown handle the deploy path calls stays this function (it
697
+ // always stops whatever arm is current).
698
+ const stopCurrentProviderRefresh = () => {
699
+ stopProviderRefresh();
700
+ };
701
+ // Every tool/file/upload/job env resolves through this one decision seam.
702
+ const workspaceService = createWorkspaceService({
703
+ isolation: isolationState,
704
+ osUsers,
705
+ pool: sandboxPool,
706
+ directory: identity.users,
707
+ managed: managedPaths,
708
+ ownerOf: async (conversationId, read, ctx) => (await read.snapshot(SessionMetaDoc, conversationId, ctx))?.owner,
709
+ });
525
710
  const workspacePrefs = defaultWorkspacePrefs(config.workspace.path);
526
711
  const envBuilder = toHarnessEnv(workspaceService, workspacePrefs);
527
712
  // The job runtime's env-builder binding resolves here (the placeholder
@@ -572,7 +757,13 @@ export async function openHarness(config, options = {}) {
572
757
  get stream() {
573
758
  return callerSettings?.stream ?? fileSettings.stream;
574
759
  },
575
- extensions: callerSettings?.extensions,
760
+ get extensions() {
761
+ // Per-user entries are core-computed from SessionMeta.owner,
762
+ // never part of a host default — including an operator-supplied
763
+ // caller list (controller ruling: filter BOTH sources).
764
+ const selected = callerSettings?.extensions ?? registry.snapshot().installed();
765
+ return selected.filter((entry) => !isUserEntryName(entry.name));
766
+ },
576
767
  toolExecution: callerSettings?.toolExecution,
577
768
  compaction: {
578
769
  ...config.compaction,
@@ -580,6 +771,11 @@ export async function openHarness(config, options = {}) {
580
771
  },
581
772
  },
582
773
  }, BACKGROUND_CONTEXT);
774
+ // M2 boot sweep: add each conversation owner's declared entries to every
775
+ // default-edited selection. Exact arrays are never widened. Runs before
776
+ // resume and is idempotent; late tool installs still resolve by name.
777
+ const userToolSweep = await sweepUserSelections(harness, storage, perUserSlugs);
778
+ console.log(`user tools: boot sweep scanned ${userToolSweep.scanned} conversation(s), changed ${userToolSweep.changed}`);
583
779
  // The seam hosts' lazy binding resolves here: every seam call issued
584
780
  // after boot completes finds the harness immediately; one issued from a
585
781
  // post-open async factory path waits on this same promise.
@@ -608,12 +804,35 @@ export async function openHarness(config, options = {}) {
608
804
  idleThresholdMs: options.eventBus?.idleThresholdMs ?? memorySeam.idleThresholdMs,
609
805
  idleSweepMs: options.eventBus?.idleSweepMs ?? IDLE_SWEEP_MS,
610
806
  workerDeadlineMs: options.eventBus?.workerDeadlineMs ?? memorySeam.workerDeadlineMs,
807
+ shutdownSettleWaitMs: options.eventBus?.shutdownSettleWaitMs,
611
808
  // W3 Task 4: the hub's subscription is installed at open —
612
809
  // synchronous, no-blocking-work (the SSE route's filtering side
613
810
  // lane does its own async work). Exposed so the bus's open() can
614
811
  // wire it before any commit lands.
615
812
  onHostEvent: (event) => events.publish(event),
616
813
  });
814
+ // The settings-changed publication (U4 §13.2): ONE seam fanning a
815
+ // committed settings write out to (a) the hub — /v1/events delivers
816
+ // `settings.changed` to admins — and (b) every extension's
817
+ // `onSettingsChanged` through the bus's bounded fan-out (rejections
818
+ // logged, never propagated: §13.2's the-write-still-succeeds rule).
819
+ // The scheduler re-arm rides the same seam: a write touching
820
+ // `server.providers.refreshIntervalMinutes` (a LIVE key, §13.1) stops
821
+ // and re-arms the interval timer at the fresh cadence.
822
+ const publishSettingsChanged = (scope, keys, user) => {
823
+ events.publish({ type: "settings.changed", scope, keys: [...keys] });
824
+ if (keys.includes("server.providers.refreshIntervalMinutes") ||
825
+ keys.includes("server.providers") ||
826
+ keys.includes("server")) {
827
+ stopProviderRefresh();
828
+ stopProviderRefresh = armProviderRefresh();
829
+ }
830
+ if (user !== undefined && typeof scope !== "string") {
831
+ userServices.settingsChanged(scope.extension, user);
832
+ }
833
+ void eventBus.emitSettingsChanged(keys, user);
834
+ };
835
+ settingsChangedSinks.push(publishSettingsChanged);
617
836
  // Activity stamping (UI refresh A1): every settled run stamps its
618
837
  // conversation's `lastActivityAt` (the admission-side stamp lives in the
619
838
  // submissions route). Registered directly after the bus opens — host-owned
@@ -628,6 +847,14 @@ export async function openHarness(config, options = {}) {
628
847
  runSettled: binding.runSettled,
629
848
  });
630
849
  }
850
+ // UI-blocks U4 §13.2: the settings-change hook merges into the same
851
+ // per-extension registration (the fan-out below invokes it).
852
+ for (const binding of settingsChangedBindings) {
853
+ handlersByName.set(binding.extension.name, {
854
+ ...handlersByName.get(binding.extension.name),
855
+ onSettingsChanged: binding.onSettingsChanged,
856
+ });
857
+ }
631
858
  for (const binding of lifecycleBindings) {
632
859
  handlersByName.set(binding.extension.name, {
633
860
  ...handlersByName.get(binding.extension.name),
@@ -824,8 +1051,16 @@ export async function openHarness(config, options = {}) {
824
1051
  harness.resume();
825
1052
  return {
826
1053
  harness,
1054
+ isolation,
1055
+ isolationState,
1056
+ osUsers,
1057
+ sandboxPool,
1058
+ userServices,
827
1059
  storage,
828
1060
  registry,
1061
+ userTools,
1062
+ rewriteUserSelection: (tx, conversationId, from, to) => rewriteSelection(tx, conversationId, from, to, perUserSlugs),
1063
+ userToolSweep,
829
1064
  models,
830
1065
  jobs,
831
1066
  jobRunTotals: () => jobRunTotals,
@@ -835,6 +1070,7 @@ export async function openHarness(config, options = {}) {
835
1070
  webuiBundleCollisions,
836
1071
  envBuilder,
837
1072
  extensionOutcomes,
1073
+ loginProviders,
838
1074
  builtinSkipped: builtinsEnabled ? builtins.skipped : [],
839
1075
  providerHealth,
840
1076
  providerOwnership,
@@ -855,6 +1091,7 @@ export async function openHarness(config, options = {}) {
855
1091
  extensionRoutes: routeBindings,
856
1092
  extensionCommands: commandBindings,
857
1093
  extensionSurfaces: surfaceBindings,
1094
+ extensionSettingsSections: settingsSectionBindings,
858
1095
  notifierBindings,
859
1096
  channelBindings,
860
1097
  gatewayEngine,
@@ -867,6 +1104,8 @@ export async function openHarness(config, options = {}) {
867
1104
  autoNamer,
868
1105
  bootRefresh: bootRefreshDone,
869
1106
  refreshProvider: refreshOneProvider,
870
- stopProviderRefresh,
1107
+ stopProviderRefresh: stopCurrentProviderRefresh,
1108
+ settingsBootSnapshot,
1109
+ publishSettingsChanged,
871
1110
  };
872
1111
  }
@@ -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 five 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
  }