@sigrun/sigrun 0.1.5 → 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 (128) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +352 -36
  3. package/THIRD_PARTY_NOTICES.md +23 -0
  4. package/dist/blocks/schema.js +9 -2
  5. package/dist/blocks/validate.js +57 -8
  6. package/dist/cli.js +26 -2
  7. package/dist/config.js +67 -1
  8. package/dist/extension.d.ts +189 -15
  9. package/dist/harness/agent-env.js +62 -0
  10. package/dist/harness/ambient.js +39 -185
  11. package/dist/harness/blocks/entries.js +47 -0
  12. package/dist/harness/blocks/state.js +58 -0
  13. package/dist/harness/blocks/tools.js +274 -0
  14. package/dist/harness/boot.js +253 -20
  15. package/dist/harness/builtins.js +15 -11
  16. package/dist/harness/event-bus.js +105 -10
  17. package/dist/harness/identity/accounts.js +186 -0
  18. package/dist/harness/identity/groups.js +14 -0
  19. package/dist/harness/identity/index.js +13 -6
  20. package/dist/harness/identity/json-store.js +15 -4
  21. package/dist/harness/identity/setup-code-check.js +9 -0
  22. package/dist/harness/identity/tokens.js +28 -5
  23. package/dist/harness/identity/users.js +111 -12
  24. package/dist/harness/interactions.js +292 -136
  25. package/dist/harness/loader.js +271 -17
  26. package/dist/harness/packages.js +1 -1
  27. package/dist/harness/reserved-sections.js +21 -0
  28. package/dist/harness/seam-host.js +80 -6
  29. package/dist/harness/settings.js +63 -2
  30. package/dist/harness/stores.js +241 -4
  31. package/dist/harness/user-services.js +391 -0
  32. package/dist/harness/user-tools.js +187 -0
  33. package/dist/harness/workspace.js +61 -55
  34. package/dist/jobs/fire.js +10 -16
  35. package/dist/sandbox/availability.js +162 -0
  36. package/dist/sandbox/command-observer.js +59 -0
  37. package/dist/sandbox/grants.js +159 -0
  38. package/dist/sandbox/helper.js +29 -0
  39. package/dist/sandbox/isolation.js +29 -0
  40. package/dist/sandbox/launch.js +20 -0
  41. package/dist/sandbox/os-account.js +49 -0
  42. package/dist/sandbox/policy.js +253 -0
  43. package/dist/sandbox/pool.js +548 -0
  44. package/dist/sandbox/protocol.js +167 -0
  45. package/dist/sandbox/sandboxed-env.js +245 -0
  46. package/dist/sandbox/unavailable-env.js +49 -0
  47. package/dist/sandbox/worker.js +281 -0
  48. package/dist/serve.js +82 -7
  49. package/dist/server/auth/access-principal.js +16 -0
  50. package/dist/server/auth/pipeline.js +47 -33
  51. package/dist/server/blocks/answers.js +94 -0
  52. package/dist/server/blocks/chat-surfaces.js +393 -0
  53. package/dist/server/blocks/notes.js +216 -0
  54. package/dist/server/blocks/summaries.js +328 -0
  55. package/dist/server/commands.js +5 -0
  56. package/dist/server/content-parts-lazy.js +13 -0
  57. package/dist/server/conversation-handler.js +2 -5
  58. package/dist/server/conversation-ops.js +138 -37
  59. package/dist/server/helpers.js +18 -6
  60. package/dist/server/host-bridge.js +40 -20
  61. package/dist/server/images/cache.js +205 -0
  62. package/dist/server/images/convert.js +13 -0
  63. package/dist/server/images/fetch.js +302 -0
  64. package/dist/server/images/ranges.js +42 -0
  65. package/dist/server/images/rewrite.js +94 -0
  66. package/dist/server/images/sign.js +84 -0
  67. package/dist/server/images/sniff.js +62 -0
  68. package/dist/server/index.js +144 -4
  69. package/dist/server/login/flows.js +126 -0
  70. package/dist/server/login/http.js +31 -0
  71. package/dist/server/login/resolve.js +133 -0
  72. package/dist/server/login/setup-claim.js +55 -0
  73. package/dist/server/login/usernames.js +15 -0
  74. package/dist/server/ownership.js +13 -0
  75. package/dist/server/routes/admin.js +58 -0
  76. package/dist/server/routes/auth.js +88 -2
  77. package/dist/server/routes/conversations.js +3 -6
  78. package/dist/server/routes/events.js +28 -3
  79. package/dist/server/routes/extensions.js +37 -5
  80. package/dist/server/routes/images.js +267 -0
  81. package/dist/server/routes/interactions.js +57 -68
  82. package/dist/server/routes/jobs.js +2 -8
  83. package/dist/server/routes/login.js +223 -0
  84. package/dist/server/routes/me.js +90 -0
  85. package/dist/server/routes/meta.js +15 -0
  86. package/dist/server/routes/onboarding.js +94 -84
  87. package/dist/server/routes/settings-sections.js +190 -0
  88. package/dist/server/routes/settings.js +16 -4
  89. package/dist/server/routes/submissions.js +1 -1
  90. package/dist/server/routes/surfaces.js +175 -23
  91. package/dist/server/routes/users.js +116 -20
  92. package/dist/server/schema-fixture.js +213 -0
  93. package/dist/server/session-meta.js +3 -0
  94. package/dist/server/shutdown.js +11 -4
  95. package/dist/server/static.js +18 -0
  96. package/dist/server/surfaces/dedup.js +9 -2
  97. package/dist/server/surfaces/service.js +181 -13
  98. package/dist/server/surfaces/settings-surfaces.js +597 -0
  99. package/dist/server/turns/submit-turn.js +26 -0
  100. package/dist/server/views.js +16 -0
  101. package/dist/settings/core-sections.js +532 -0
  102. package/dist/settings/pending.js +181 -0
  103. package/dist/settings/registry.js +453 -0
  104. package/dist/skills/index.js +6 -0
  105. package/node_modules/@sigrun/shared/CHANGELOG.md +15 -0
  106. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  107. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  108. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  109. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  110. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  111. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  112. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  113. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  114. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  115. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  116. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  117. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  118. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  119. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  120. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  121. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  122. package/node_modules/@sigrun/shared/package.json +1 -1
  123. package/package.json +13 -8
  124. package/webui/dist/assets/index-CxB969qp.js +55 -0
  125. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  126. package/webui/dist/index.html +2 -2
  127. package/webui/dist/assets/index-B4-GYqEb.js +0 -50
  128. package/webui/dist/assets/index-BiSMljer.css +0 -1
@@ -307,7 +307,10 @@ const MeterBlock = Type.Object({
307
307
  const LogBlock = Type.Object({
308
308
  ...BlockBase,
309
309
  type: Type.Literal("log"),
310
- lines: Type.Ref("Binding"),
310
+ // Literal lines are legal (ambient placement requires them — the
311
+ // ambient envelope carries no data document); placements that resolve
312
+ // bindings keep the binding form.
313
+ lines: Type.Union([Type.Ref("Binding"), Type.Array(Type.Ref("JsonValue"))]),
311
314
  follow: Type.Optional(Type.Boolean()),
312
315
  wrap: Type.Optional(Type.Boolean()),
313
316
  tones: Type.Optional(Type.Record(Type.String(), Type.Ref("Tone"))),
@@ -374,7 +377,10 @@ const BulkAction = Type.Object({
374
377
  const CollectionBlock = Type.Object({
375
378
  ...BlockBase,
376
379
  type: Type.Literal("collection"),
377
- items: Type.Ref("Binding"),
380
+ // Literal rows are legal (ambient placement requires them — the
381
+ // ambient envelope carries no data document); placements that resolve
382
+ // bindings keep the binding form.
383
+ items: Type.Union([Type.Ref("Binding"), Type.Array(Type.Ref("JsonValue"))]),
378
384
  item: AnyObject,
379
385
  display: Type.Unsafe({
380
386
  type: "string",
@@ -445,6 +451,7 @@ const NumberField = Type.Object({
445
451
  const ToggleField = Type.Object({
446
452
  ...FieldBase,
447
453
  type: Type.Literal("toggle"),
454
+ default: Type.Optional(Type.Boolean()),
448
455
  });
449
456
  const ChoiceField = Type.Object({
450
457
  ...FieldBase,
@@ -142,9 +142,22 @@ function checkActionButtons(ctx, buttons, path) {
142
142
  checkAction(ctx, button.action, joinPath(bPath, "action"));
143
143
  });
144
144
  }
145
- /** Relative `./` bindings are legal only inside a card template (§4.2). */
145
+ /**
146
+ * Bindings outside a card template (§4.2): relative `./` bindings are
147
+ * legal only inside an item template. In the AMBIENT placement, every
148
+ * binding (absolute or relative) outside an item template is rejected —
149
+ * the ambient envelope carries no data document, so a bound prop has
150
+ * nothing to resolve against and renders blank (the 2026-10-24 ruling's
151
+ * items rule, extended to every bindable prop).
152
+ */
146
153
  function checkBinding(ctx, binding, path) {
147
- if (!ctx.itemTemplate && binding.$.startsWith("./")) {
154
+ if (ctx.itemTemplate)
155
+ return;
156
+ if (ctx.placement === "ambient") {
157
+ push(ctx, joinPath(path, "$"), `ambient surfaces carry literal content only (binding "${binding.$}" outside a collection item template)`);
158
+ return;
159
+ }
160
+ if (binding.$.startsWith("./")) {
148
161
  push(ctx, joinPath(path, "$"), `relative binding "${binding.$}" is only valid inside a collection item template`);
149
162
  }
150
163
  }
@@ -152,10 +165,12 @@ function checkBinding(ctx, binding, path) {
152
165
  * Any relative `./` binding on this node (§4.2): every prop value is
153
166
  * scanned — the binding-only props (`items`, `lines`, `nodes`, `rows`,
154
167
  * `series[].data`, `submitLabel`, `copy.text`) bind just like the
155
- * bindable literals, so a hardcoded prop list would miss them. Absolute
156
- * `/` bindings stay legal everywhere; relative ones only inside item
157
- * templates. `skip` names props whose own walk already reported (the
158
- * structural literals above carry their more precise message).
168
+ * bindable literals, so a hardcoded prop list would miss them. Outside
169
+ * ambient, absolute `/` bindings stay legal everywhere and relative
170
+ * ones only inside item templates; in ambient, every binding outside an
171
+ * item template fails (checkBinding). `skip` names props whose own walk
172
+ * already reported (the structural literals above carry their more
173
+ * precise message).
159
174
  */
160
175
  function checkNodeRelativeBindings(ctx, node, path, skip) {
161
176
  if (ctx.itemTemplate)
@@ -282,6 +297,35 @@ function checkBlock(ctx, node, path, rootDepth) {
282
297
  checkSchema(ctx, validator, node, path);
283
298
  // collection-specific rules
284
299
  if (type === "collection") {
300
+ // Controller ruling (2026-10-24): bindings are OUT of ambient
301
+ // placement — the ambient envelope carries no data document, so a
302
+ // bound `items` has nothing to resolve against. Literal rows only.
303
+ // (Reversible: extending the envelope with a data slot remains open.)
304
+ if (ctx.placement === "ambient" && isBinding(node.items)) {
305
+ // checkBinding's ambient branch fires for this prop too; the
306
+ // more specific collection wording wins, so skip `items` below.
307
+ push(ctx, joinPath(path, "items"), "ambient collections carry literal rows (items must not be a binding)");
308
+ }
309
+ // Ambient: bindables nested inside structural props are still
310
+ // bound nowhere (the no-data-document ruling). `groups`/`columns`
311
+ // sit in the structural skip set, so the generic binding walk
312
+ // never reaches their `title`/`label` bindables — walk them here.
313
+ if (ctx.placement === "ambient") {
314
+ const nestedBindables = [
315
+ ["groups", "title"],
316
+ ["columns", "label"],
317
+ ];
318
+ for (const [listProp, leafProp] of nestedBindables) {
319
+ const list = node[listProp];
320
+ if (!Array.isArray(list))
321
+ continue;
322
+ list.forEach((entry, i) => {
323
+ if (isRec(entry) && isBinding(entry[leafProp])) {
324
+ checkBinding(ctx, entry[leafProp], joinPath(joinPath(joinPath(path, listProp), String(i)), leafProp));
325
+ }
326
+ });
327
+ }
328
+ }
285
329
  const display = node.display;
286
330
  if (typeof display === "string" &&
287
331
  rules.collectionDisplays &&
@@ -350,9 +394,14 @@ function checkBlock(ctx, node, path, rootDepth) {
350
394
  if (!check.ok)
351
395
  push(ctx, joinPath(path, "src"), check.message);
352
396
  }
353
- // bindable props: every relative `./` binding on the node (§4.2)
397
+ // bindable props: every relative `./` binding on the node (§4.2).
398
+ // `items` joins the skip set in ambient so the collection-specific
399
+ // message above stays the single error at its path.
354
400
  const structural = STRUCTURAL_LITERALS[type] ?? [];
355
- checkNodeRelativeBindings(ctx, node, path, new Set(structural));
401
+ const skip = new Set(structural);
402
+ if (type === "collection" && ctx.placement === "ambient")
403
+ skip.add("items");
404
+ checkNodeRelativeBindings(ctx, node, path, skip);
356
405
  // onActivate carries an Action (§6.1): same rules as button actions
357
406
  if (node.onActivate !== undefined) {
358
407
  checkAction(ctx, node.onActivate, joinPath(path, "onActivate"));
package/dist/cli.js CHANGED
@@ -49,6 +49,12 @@ import { Writable } from "node:stream";
49
49
  import { resolveStoreRoots, writeFileAtomic } from "./harness/stores.js";
50
50
  import { openIdentityStores } from "./harness/identity/index.js";
51
51
  import { pathToFileURL } from "node:url";
52
+ import { effectiveMethods, loadConfig } from "./config.js";
53
+ import { probeIsolation, runHelperProbe, selfTestHelper } from "./sandbox/availability.js";
54
+ import { SettingsStore } from "./harness/settings.js";
55
+ import { resolveHelper } from "./sandbox/helper.js";
56
+ import { selfPackageRoot } from "./harness/packages.js";
57
+ import { createAdmissionGuard } from "./harness/identity/users.js";
52
58
  import { serve } from "./serve.js";
53
59
  import { ACCENT_NAMES, PALETTE_NAMES, isAccentName, isPaletteName, } from "@sigrun/shared/themes";
54
60
  export const USAGE_TEXT = `usage: sigrun [--user <name>]
@@ -643,7 +649,7 @@ async function runProviderCommand(parsed, env, io, fetchImpl, readSecret) {
643
649
  * secret exists only at create time);
644
650
  * - `token revoke` fails loud on an unknown id.
645
651
  */
646
- async function runIdentityCommand(parsed, env, io) {
652
+ async function runIdentityCommand(parsed, env, io, isolationProbe) {
647
653
  const stores = openIdentityStores(resolveStoreRoots(env));
648
654
  if (parsed.command === "token") {
649
655
  switch (parsed.sub) {
@@ -679,6 +685,24 @@ async function runIdentityCommand(parsed, env, io) {
679
685
  }
680
686
  switch (parsed.sub) {
681
687
  case "add": {
688
+ const config = loadConfig(env, new SettingsStore({ dir: defaultConfigDir(env) }).read());
689
+ const coreRoot = selfPackageRoot();
690
+ const isolation = await isolationProbe({
691
+ platform: process.platform,
692
+ helper: coreRoot === undefined ? undefined : resolveHelper(env, coreRoot),
693
+ runProbe: runHelperProbe,
694
+ selfTest: selfTestHelper,
695
+ });
696
+ const methods = effectiveMethods(config.auth, "127.0.0.1");
697
+ stores.users.configureAdmission({
698
+ firstWorkspace: config.workspace.path,
699
+ admit: createAdmissionGuard({
700
+ isolation,
701
+ mode: config.isolation.mode,
702
+ methods,
703
+ proxySecretHeader: config.auth.proxy.secretHeader,
704
+ }),
705
+ });
682
706
  await stores.users.add({
683
707
  user: parsed.user,
684
708
  admin: parsed.admin ?? false,
@@ -850,7 +874,7 @@ export async function cliMain(argv, env = process.env, deps, io = {
850
874
  case "token":
851
875
  case "user": {
852
876
  // The identity commands (Task 7): stores directly, server or not.
853
- return await runIdentityCommand(parsed, env, io);
877
+ return await runIdentityCommand(parsed, env, io, realDeps.probeIsolation ?? probeIsolation);
854
878
  }
855
879
  case "provider": {
856
880
  // The provider commands (release 0.1 Task 2b): API clients over
package/dist/config.js CHANGED
@@ -148,6 +148,7 @@ export const DEFAULT_PROVIDER_REFRESH_MINUTES = 15;
148
148
  * defaulted — they exist only when env provides them.
149
149
  */
150
150
  export const DEFAULT_AUTH = {
151
+ sessionDays: 30,
151
152
  methods: undefined,
152
153
  proxy: {
153
154
  trustedProxies: [],
@@ -323,6 +324,40 @@ function parseDev(env) {
323
324
  return false;
324
325
  throw new Error(`SIGRUN_DEV must be "true" or "false" (or 1/0), got ${JSON.stringify(raw)}`);
325
326
  }
327
+ /** Parse `SIGRUN_ISOLATION`; unset/empty lets the file/default tier apply. */
328
+ function parseIsolationMode(env) {
329
+ const raw = env.SIGRUN_ISOLATION;
330
+ if (raw === undefined || raw === "")
331
+ return undefined;
332
+ const mode = raw.trim().toLowerCase();
333
+ if (mode === "auto" || mode === "always" || mode === "off")
334
+ return mode;
335
+ throw new Error(`SIGRUN_ISOLATION must be "auto", "always", or "off", got ${JSON.stringify(raw)}`);
336
+ }
337
+ function parseIsolation(env, server) {
338
+ return {
339
+ mode: parseIsolationMode(env) ?? server?.mode ?? "auto",
340
+ osUsers: server?.osUsers ?? false,
341
+ workerIdleMinutes: server?.workerIdleMinutes ?? 10,
342
+ };
343
+ }
344
+ /**
345
+ * Parse `SIGRUN_SUPERVISED` (UI-blocks U4 Task 2, spec §13.3): the
346
+ * `parseDev` grammar exactly ("true"/"false"/1/0, strict otherwise —
347
+ * the env contract is the deploy's promise that a restart policy exists,
348
+ * so a fuzzy parse would lie about the failure mode).
349
+ */
350
+ function parseSupervised(env) {
351
+ const raw = env.SIGRUN_SUPERVISED;
352
+ if (raw === undefined || raw === "")
353
+ return undefined;
354
+ const lowered = raw.trim().toLowerCase();
355
+ if (lowered === "true" || lowered === "1")
356
+ return true;
357
+ if (lowered === "false" || lowered === "0")
358
+ return false;
359
+ throw new Error(`SIGRUN_SUPERVISED must be "true" or "false" (or 1/0), got ${JSON.stringify(raw)}`);
360
+ }
326
361
  /**
327
362
  * Parse `SIGRUN_AUTH`: a csv of `token`/`proxy` (order preserved,
328
363
  * duplicates dropped), `none` ALONE as the empty list (authentication
@@ -398,6 +433,7 @@ function parseAuth(env, server) {
398
433
  const token = env.SIGRUN_API_TOKEN?.trim() || undefined;
399
434
  const secret = env.SIGRUN_PROXY_SECRET?.trim() || undefined;
400
435
  return {
436
+ sessionDays: server?.sessionDays ?? DEFAULT_AUTH.sessionDays,
401
437
  methods,
402
438
  proxy: {
403
439
  trustedProxies,
@@ -730,11 +766,15 @@ export function resolvedServerSection(config) {
730
766
  proxySection !== undefined ||
731
767
  config.auth.adminGroups.length > 0 ||
732
768
  config.auth.allowedHosts !== undefined ||
733
- config.auth.allowedOrigins !== undefined
769
+ config.auth.allowedOrigins !== undefined ||
770
+ config.auth.sessionDays !== DEFAULT_AUTH.sessionDays
734
771
  ? {
735
772
  ...(config.auth.methods === undefined
736
773
  ? {}
737
774
  : { methods: [...config.auth.methods] }),
775
+ ...(config.auth.sessionDays === DEFAULT_AUTH.sessionDays
776
+ ? {}
777
+ : { sessionDays: config.auth.sessionDays }),
738
778
  ...(proxySection === undefined ? {} : { proxy: proxySection }),
739
779
  ...(config.auth.adminGroups.length > 0
740
780
  ? { adminGroups: [...config.auth.adminGroups] }
@@ -765,6 +805,7 @@ export function resolvedServerSection(config) {
765
805
  ...(webui === undefined ? {} : { webui }),
766
806
  compaction: { ...config.compaction },
767
807
  memorySeam: { ...config.memorySeam },
808
+ isolation: { ...config.isolation },
768
809
  providers: { ...config.providers },
769
810
  ...(auth === undefined ? {} : { auth }),
770
811
  ...(metrics === undefined ? {} : { metrics }),
@@ -803,6 +844,7 @@ export function resolvedServerSection(config) {
803
844
  * | `SIGRUN_IDLE_THRESHOLD` | `memorySeam.idleThresholdMs` |
804
845
  * | `SIGRUN_WORKER_DEADLINE` | `memorySeam.workerDeadlineMs` |
805
846
  * | `SIGRUN_QUEUE_MAX` | `memorySeam.queueMax` |
847
+ * | `SIGRUN_ISOLATION` | `isolation.mode` |
806
848
  * | `SIGRUN_PROVIDER_REFRESH_MINUTES` | `providers.refreshIntervalMinutes` |
807
849
  * | `SIGRUN_BACKUP_*` | `backup` |
808
850
  * | `SIGRUN_MAX_UPLOAD_BYTES` | `maxUploadBytes` |
@@ -859,6 +901,9 @@ export function envServerSection(env) {
859
901
  });
860
902
  if (memorySeam !== undefined)
861
903
  section.memorySeam = memorySeam;
904
+ const isolationMode = parseIsolationMode(env);
905
+ if (isolationMode !== undefined)
906
+ section.isolation = { mode: isolationMode };
862
907
  const refreshIntervalMinutes = parseNonNegativeIntEnv(env, PROVIDER_REFRESH_MINUTES_ENV);
863
908
  if (refreshIntervalMinutes !== undefined) {
864
909
  section.providers = { refreshIntervalMinutes };
@@ -877,6 +922,12 @@ export function envServerSection(env) {
877
922
  const dev = parseDev(env);
878
923
  if (dev !== undefined)
879
924
  section.dev = dev;
925
+ // The supervised flag (§13.3) — the same bootstrap rule as dev:
926
+ // projected only when the env var is SET (a defaulted flag is never
927
+ // persisted; the deploy's restart posture is env intent, not state).
928
+ const supervised = parseSupervised(env);
929
+ if (supervised !== undefined)
930
+ section.supervised = supervised;
880
931
  return section;
881
932
  }
882
933
  /**
@@ -950,8 +1001,18 @@ export function effectiveServerSection(env, fileServer, config) {
950
1001
  "memorySeam",
951
1002
  "queueMax",
952
1003
  ]);
1004
+ emit("auth.sessionDays", config.auth.sessionDays, ["auth", "sessionDays"]);
1005
+ emit("isolation.mode", config.isolation.mode, ["isolation", "mode"]);
1006
+ emit("isolation.osUsers", config.isolation.osUsers, ["isolation", "osUsers"]);
1007
+ emit("isolation.workerIdleMinutes", config.isolation.workerIdleMinutes, [
1008
+ "isolation",
1009
+ "workerIdleMinutes",
1010
+ ]);
953
1011
  emit("providers.refreshIntervalMinutes", config.providers.refreshIntervalMinutes, ["providers", "refreshIntervalMinutes"]);
954
1012
  emit("dev", config.dev, ["dev"]);
1013
+ // U4 §13.3: the supervised posture rides the effective block too —
1014
+ // the settings surface's restart affordance reads one place.
1015
+ emit("supervised", config.supervised, ["supervised"]);
955
1016
  if (config.backup?.target === "path") {
956
1017
  emit("backup.path", config.backup.path, ["backup", "path"]);
957
1018
  emit("backup.staleAfterHours", config.backupStaleAfterHours, ["backup", "staleAfterHours"]);
@@ -1093,6 +1154,7 @@ export function loadConfig(env, settingsFile) {
1093
1154
  backupStaleAfterHours: server?.backup?.staleAfterHours ?? DEFAULT_BACKUP_STALE_AFTER_HOURS,
1094
1155
  compaction: parseCompaction(env, server?.compaction),
1095
1156
  memorySeam: parseMemorySeam(env, server?.memorySeam),
1157
+ isolation: parseIsolation(env, server?.isolation),
1096
1158
  providers: {
1097
1159
  refreshIntervalMinutes: parseRefreshIntervalMinutes(env, server?.providers?.refreshIntervalMinutes),
1098
1160
  },
@@ -1101,6 +1163,10 @@ export function loadConfig(env, settingsFile) {
1101
1163
  // The development flag (§14.2/§14.4): strict env parse over the
1102
1164
  // file's `server.dev`, default false.
1103
1165
  dev: parseDev(env) ?? server?.dev ?? false,
1166
+ // The supervised posture (§13.3): env over file over false. Only
1167
+ // the chart and compose set the env; a file-set flag serves a
1168
+ // hand-rolled systemd/supervisord deploy (same restart promise).
1169
+ supervised: parseSupervised(env) ?? server?.supervised ?? false,
1104
1170
  // The jobs default timezone (§8.2): env over file over the process
1105
1171
  // TZ, validated loud (a bad name would otherwise crash the scheduler
1106
1172
  // at evaluation time instead of failing boot).
@@ -35,12 +35,12 @@
35
35
  import type { AssistantMessage, Provider } from "@earendil-works/pi-ai";
36
36
  import type { ConversationDocToken, ConversationId, Cursor, EntryId, EntryQuery, EntryRecord, Extension, InputSubmissionDraft, JsonObject, ModelRef, Page, SubmissionId } from "@earendil-works/pi-durable";
37
37
  import type { Access, Sharing } from "@sigrun/shared/access";
38
- import type { AmbientBody, AmbientPlace, AmbientRecord } from "@sigrun/shared/ambient";
38
+ import type { AmbientPlace, AmbientRecord } from "@sigrun/shared/ambient";
39
39
  import type { Action, DataOps, IconName, Tree } from "@sigrun/shared/blocks";
40
40
  import type { InteractionAnswer, InteractionRecord } from "@sigrun/shared/interactions";
41
41
  import type { JobSpec } from "@sigrun/shared/jobs";
42
- import type { ConversationSummary, ConversationViewWire, HealthStatus, ModelEntry, SessionMeta, SlashCommand, SubmissionRecordWire, ThinkingLevelWire, UserWire as DirectoryUserWire } from "@sigrun/shared/views";
43
- export type { Access, AmbientRecord, DirectoryUserWire, InteractionRecord, JobSpec, ModelEntry, Sharing, SlashCommand, };
42
+ import type { AccountLinkWire, ConversationSummary, ConversationViewWire, HealthStatus, ModelEntry, SessionMeta, SlashCommand, SubmissionRecordWire, ThinkingLevelWire, UserWire as DirectoryUserWire } from "@sigrun/shared/views";
43
+ export type { Access, AccountLinkWire, AmbientRecord, DirectoryUserWire, InteractionRecord, JobSpec, ModelEntry, Sharing, SlashCommand, };
44
44
  type JsonParams = JsonObject;
45
45
  type JsonParamValue = JsonParams[string];
46
46
  /**
@@ -63,6 +63,8 @@ export interface HandlerContext {
63
63
  */
64
64
  export type RunSettledEvent = {
65
65
  readonly conversationId: ConversationId;
66
+ /** The conversation owner at delivery; absent when the conversation is ownerless. */
67
+ readonly owner?: string;
66
68
  readonly inputs: readonly SubmissionId[];
67
69
  /** Text blocks of each input's entry (`pi.user`, or an expanded input's
68
70
  * `sigrun.user.expanded` — the ADMITTED text), concatenated;
@@ -100,19 +102,24 @@ export type MemoryEventApi = {
100
102
  export type ExtensionLifecyclePayload = {
101
103
  conversationCreated: {
102
104
  readonly conversationId: ConversationId;
105
+ readonly owner?: string;
103
106
  };
104
107
  conversationForked: {
105
108
  readonly childId: ConversationId;
106
109
  readonly parentId: ConversationId;
107
110
  readonly at: EntryId;
111
+ /** The CHILD's owner at delivery; absent when the child is ownerless. */
112
+ readonly owner?: string;
108
113
  };
109
114
  conversationArchived: {
110
115
  readonly conversationId: ConversationId;
111
116
  readonly archived: boolean;
117
+ readonly owner?: string;
112
118
  };
113
119
  conversationIdle: {
114
120
  readonly conversationId: ConversationId;
115
121
  readonly idleMs: number;
122
+ readonly owner?: string;
116
123
  };
117
124
  /** Gateway C6: one terminal interaction transition — the payload mirrors
118
125
  * the record's terminal envelope fields. */
@@ -121,6 +128,11 @@ export type ExtensionLifecyclePayload = {
121
128
  readonly callId: string;
122
129
  readonly kind: string;
123
130
  readonly status: "answered" | "skipped" | "expired" | "cancelled";
131
+ /** The answering user (C5 attribution), when a human answered. */
132
+ readonly answeredByUser?: string;
133
+ /** The STORED (masked-by-construction) answer values — render-safe. */
134
+ readonly answer?: Readonly<Record<string, unknown>>;
135
+ readonly owner?: string;
124
136
  };
125
137
  };
126
138
  /** A factory-result `runSettled` handler: called once per settled run, deadline-bounded. */
@@ -151,6 +163,17 @@ export type SigrunLifecycleHandlers = {
151
163
  readonly harnessShutdown?: ExtensionHarnessShutdownHandler;
152
164
  /** The user-removal transfer event; direct fan-out, not commit-derived. */
153
165
  readonly userDeleted?: ExtensionUserDeletedHandler;
166
+ /**
167
+ * UI-blocks U4 (spec §13.2): called after a settings write COMMITS —
168
+ * the keys are the flattened paths the write touched (core keys in
169
+ * the §13.1 dotted vocabulary, this extension's own top-level keys
170
+ * for a `settings/<slug>.json` write). Fired for BOTH scopes: a
171
+ * server-scope write notifies every loaded extension; an extension's
172
+ * own write notifies it (and every other extension — settings are
173
+ * shared operator surface). Errors are caught and logged; the write
174
+ * already succeeded and stays successful.
175
+ */
176
+ onSettingsChanged?(keys: readonly string[], user?: string): void | Promise<void>;
154
177
  };
155
178
  /**
156
179
  * The lifecycle event names a worker's `wakeOn` may declare.
@@ -182,6 +205,31 @@ export interface ExtensionWorker {
182
205
  /** The work; rejects (or overruns the worker deadline) to fail the run. */
183
206
  run(context: HandlerContext, wake?: WorkerWake): Promise<void>;
184
207
  }
208
+ /** The command and extra environment/read rights for one per-user process. */
209
+ export interface ServiceSpec {
210
+ readonly command: string;
211
+ readonly args: string[];
212
+ readonly env?: Record<string, string>;
213
+ readonly cwd?: string;
214
+ readonly readPaths?: string[];
215
+ }
216
+ /** One factory-declared long-lived per-user service. */
217
+ export interface UserService {
218
+ readonly name: string;
219
+ start(user: string, ctx: HandlerContext): Promise<ServiceSpec | undefined>;
220
+ readonly idleMinutes?: number;
221
+ }
222
+ /** A shared-stdio attachment to a running per-user service. */
223
+ export interface ServiceHandle {
224
+ readonly stdin: WritableStream<Uint8Array>;
225
+ readonly stdout: ReadableStream<Uint8Array>;
226
+ readonly stderr: ReadableStream<Uint8Array>;
227
+ readonly exited: Promise<{
228
+ code: number | null;
229
+ signal: string | null;
230
+ }>;
231
+ release(): void;
232
+ }
185
233
  /** Three-state per-provider readiness: not configured, degraded, or ready. */
186
234
  export type ProviderReadinessStatus = "not_configured" | "degraded" | "ready";
187
235
  /** One readiness report: the status plus optional operator-facing detail. */
@@ -207,6 +255,42 @@ export interface ProviderContribution {
207
255
  /** The per-provider readiness tracker (`/v1/auth/check` reports it verbatim). */
208
256
  readonly readiness?: ReadinessTracker;
209
257
  }
258
+ /** Core-owned state for one external login, with provider-private scratch data. */
259
+ export type LoginFlow = {
260
+ state: string;
261
+ callbackUrl: string;
262
+ data: Record<string, string>;
263
+ };
264
+ /** The identity proof a login provider returns after its callback. */
265
+ export type LoginResult = {
266
+ subject: string;
267
+ profile: {
268
+ username?: string;
269
+ name?: string;
270
+ email?: string;
271
+ emailVerified?: boolean;
272
+ };
273
+ groups?: string[];
274
+ };
275
+ /** One extension identity provider; core owns the flow, state and callback. */
276
+ export interface LoginProviderContribution {
277
+ readonly id: string;
278
+ readonly label: string;
279
+ readonly icon?: string;
280
+ /** Provider-declared provisioning policy, enforced by core. */
281
+ readonly policy?: {
282
+ readonly autoProvision?: boolean;
283
+ readonly allowedGroups?: string[];
284
+ readonly allowedDomains?: string[];
285
+ };
286
+ begin(flow: LoginFlow, ctx: HandlerContext): Promise<{
287
+ redirectUrl: string;
288
+ }>;
289
+ complete(flow: LoginFlow, callback: {
290
+ query: Record<string, string>;
291
+ body?: unknown;
292
+ }, ctx: HandlerContext): Promise<LoginResult>;
293
+ }
210
294
  /** One contributed webui bundle: a selection name plus the bundle root directory. */
211
295
  export interface WebuiBundleContribution {
212
296
  /** The selection key (`server.webui.bundle`); must match `^[a-z][a-z0-9_-]*$`. */
@@ -231,7 +315,9 @@ export interface Principal {
231
315
  readonly admin: boolean;
232
316
  /** True for the `SIGRUN_API_TOKEN` service principal. */
233
317
  readonly service: boolean;
234
- readonly method: "none" | "token" | "proxy" | "host";
318
+ readonly method: "none" | "token" | "session" | "proxy" | "host";
319
+ /** The login provider that issued a session principal. */
320
+ readonly provider?: string;
235
321
  }
236
322
  /** One extension HTTP route, mounted at `/v1/ext/<slug>/<path>`. */
237
323
  export interface ExtensionRouteContribution {
@@ -564,10 +650,12 @@ export type ActionResult = {
564
650
  };
565
651
  /**
566
652
  * One declared surface (spec §7.1, verbatim shape): the placement is
567
- * the generic parameter — `"view"` or `"panel"` (the settings sections
568
- * of §13.3 are a separate workstream).
653
+ * the generic parameter — `"view"`, `"panel"`, or `"settings"` (the
654
+ * §13.3 settings sections adapt to settings-placement surfaces; the
655
+ * host builds those — extensions declare `SettingsSectionDef`s, never
656
+ * `SurfaceDef<"settings">` directly).
569
657
  */
570
- export interface SurfaceDef<P extends "view" | "panel"> {
658
+ export interface SurfaceDef<P extends "view" | "panel" | "settings"> {
571
659
  /** Unique within the extension. */
572
660
  readonly id: string;
573
661
  readonly title: string;
@@ -577,22 +665,51 @@ export interface SurfaceDef<P extends "view" | "panel"> {
577
665
  readonly sharedData?: boolean;
578
666
  /** Channels refetch data on this interval while visible (min 5000). */
579
667
  readonly refreshMs?: number;
580
- /** §7.3; default: canonical JSON of params. */
581
- instanceKey?(params: JsonParams): string;
668
+ /** §7.3; default: canonical JSON of params. The principal argument
669
+ * lets principal-owned surfaces isolate their revision/action identity. */
670
+ instanceKey?(params: JsonParams, principal: Principal): string;
582
671
  render(ctx: SurfaceContext): Promise<SurfaceRenderResult>;
583
672
  data?(ctx: SurfaceContext): Promise<SurfaceDataResult>;
584
673
  readonly actions?: Readonly<Record<string, (ctx: SurfaceContext, payload: JsonParamValue) => Promise<ActionResult>>>;
585
674
  }
586
675
  /**
587
- * The `surfaces` field of a factory result (spec §7.1): declared views
588
- * and panels. Ids are unique across BOTH lists (one namespace — the
589
- * loader enforces it).
676
+ * One declared settings section (spec §13.3, verbatim shape): a static
677
+ * `settings`-placement form bound to the extension's settings object
678
+ * (`settings/<slug>.json`). The host adapts it to a surface under the
679
+ * extension's own `/v1/ext/<slug>/surfaces/` mount — core's own
680
+ * sections use the same renderer, under the reserved `core` slug.
681
+ */
682
+ export interface SettingsSectionDef {
683
+ /** Unique within the extension. */
684
+ readonly id: string;
685
+ readonly title: string;
686
+ readonly icon?: IconName;
687
+ /** A root `form` block; the placement is `settings` (spec §5.7). */
688
+ readonly form: Tree;
689
+ /** Declared secrets, rendered as `secret` fields backed by the §7.7 secrets routes. */
690
+ readonly secrets?: readonly {
691
+ key: string;
692
+ label: string;
693
+ help?: string;
694
+ }[];
695
+ /** Only admins see and edit it; default true for shared sections.
696
+ * A user-scope section is visible to every signed-in user unless this
697
+ * is explicitly true. */
698
+ readonly admin?: boolean;
699
+ /** `"user"` reads/writes the calling user's settings; absent/`"server"`
700
+ * keeps the extension's shared settings file. */
701
+ readonly scope?: "server" | "user";
702
+ }
703
+ /**
704
+ * The `surfaces` field of a factory result (spec §7.1): declared views,
705
+ * panels, and settings sections. Ids are unique across ALL THREE lists
706
+ * (one namespace — the loader enforces it).
590
707
  */
591
708
  export interface SurfacesContribution {
592
709
  readonly views?: readonly SurfaceDef<"view">[];
593
710
  readonly panels?: readonly SurfaceDef<"panel">[];
594
- /** §13.3 settings sections — a separate workstream; declared for shape stability. */
595
- readonly settings?: readonly never[];
711
+ /** §13.3 settings sections (UI-blocks U4 Task 3). */
712
+ readonly settings?: readonly SettingsSectionDef[];
596
713
  }
597
714
  /** One wire part of a `host.conversations.submit` input array. */
598
715
  export type SubmissionInputPart = {
@@ -620,8 +737,38 @@ export type WhenBusyMode = NonNullable<InputSubmissionDraft["whenBusy"]>;
620
737
  * factory body.
621
738
  */
622
739
  export interface SigrunHost {
740
+ /**
741
+ * Publish tools for ONE user (multi-user M2). Only factory results that
742
+ * declared `perUserTools: true` may call this; other extensions throw
743
+ * `perUserTools not declared`. An empty list uninstalls the user's entry.
744
+ * Core wraps every tool with a committed-owner check at execute time.
745
+ */
746
+ readonly tools: {
747
+ setUserTools(user: string, tools: readonly import("@earendil-works/pi-durable").ToolRegistration[]): void;
748
+ };
749
+ /** Connect to one declared per-user service; core owns its process lifecycle. */
750
+ readonly services: {
751
+ connect(name: string, user: string, ctx: HandlerContext): Promise<ServiceHandle>;
752
+ };
623
753
  /** The scoped extension-stores handle (your secrets/settings sections + raw data/cache/state paths). */
624
754
  readonly stores: {
755
+ /** One directory user's secrets/settings/paths, still scoped to your slug. */
756
+ user(user: string): {
757
+ readonly secrets: {
758
+ get(key: string): Promise<string | undefined>;
759
+ set(key: string, value: string): Promise<void>;
760
+ delete(key: string): Promise<void>;
761
+ };
762
+ readonly settings: {
763
+ get(): Promise<Record<string, unknown>>;
764
+ set(value: Record<string, unknown>): Promise<void>;
765
+ };
766
+ readonly paths: {
767
+ readonly data: string;
768
+ readonly state: string;
769
+ readonly cache: string;
770
+ };
771
+ };
625
772
  /** Your section of secrets.json. */
626
773
  readonly secrets: {
627
774
  get(key: string): Promise<string | undefined>;
@@ -662,7 +809,7 @@ export interface SigrunHost {
662
809
  readonly maxRows?: number;
663
810
  readonly overflow?: "drop" | "wrap" | "summarize";
664
811
  readonly refreshMs?: number;
665
- readonly body?: AmbientBody;
812
+ readonly body?: Tree;
666
813
  readonly details?: JsonObject;
667
814
  }, context: HandlerContext): Promise<void>;
668
815
  /** Clear one ambient surface (empty replacement; keeps ownership). */
@@ -729,6 +876,8 @@ export interface SigrunHost {
729
876
  configure(id: ConversationId, opts: {
730
877
  readonly model?: string;
731
878
  readonly thinking?: ThinkingLevelWire;
879
+ /** Exact extension selection; another user's `<slug>@<user>` entry is rejected. */
880
+ readonly extensions?: readonly string[];
732
881
  }): Promise<void>;
733
882
  /** Admit one manual compaction task (gateway C4, the compact route's op). */
734
883
  compact(id: ConversationId): Promise<void>;
@@ -782,6 +931,25 @@ export interface SigrunHost {
782
931
  list(): Promise<readonly DirectoryUserWire[]>;
783
932
  get(user: string): Promise<DirectoryUserWire | undefined>;
784
933
  };
934
+ /**
935
+ * External identities linked to directory users (multi-user §11.3): the
936
+ * one table for chat senders (`chat:<channel>`) and login subjects
937
+ * (`login:<slug>:<id>`), shared with the gateway.
938
+ *
939
+ * Core owns the file — extensions go through this surface, never
940
+ * `accounts.json` directly. One link per `(provider, subject)`: a
941
+ * re-link to the same user is a no-op returning the existing record, a
942
+ * link to a DIFFERENT user rejects with an `Error` whose message starts
943
+ * `account_link_conflict:` (the message never names the current
944
+ * holder — extensions match the prefix, as they do `command_not_found:`).
945
+ * An unknown user rejects `unknown user "<user>"`.
946
+ */
947
+ readonly accounts: {
948
+ lookup(provider: string, subject: string): Promise<AccountLinkWire | undefined>;
949
+ link(provider: string, subject: string, user: string, via: AccountLinkWire["via"]): Promise<AccountLinkWire>;
950
+ unlink(provider: string, subject: string): Promise<boolean>;
951
+ listForUser(user: string): Promise<readonly AccountLinkWire[]>;
952
+ };
785
953
  /**
786
954
  * Resolve one principal's access to one shared resource (the whole
787
955
  * sharing contract for extension-owned records — core stores nothing
@@ -841,12 +1009,18 @@ export interface SigrunHost {
841
1009
  * begins reading each when its task lands.
842
1010
  */
843
1011
  export interface SigrunExtensionResult extends SigrunLifecycleHandlers {
1012
+ /** Long-lived per-user sibling processes, launched and supervised by core. */
1013
+ readonly userServices?: readonly UserService[];
1014
+ /** Declare the per-user tool surface (`host.tools.setUserTools`). */
1015
+ readonly perUserTools?: boolean;
844
1016
  /** The pi-durable `Extension` to install; its declared name is the slug source. */
845
1017
  readonly extension?: Extension;
846
1018
  /** The contributor-only identity (required when contributing without an `extension`). */
847
1019
  readonly name?: string;
848
1020
  /** Provider contributions, merged into the boot model catalog (last-registered-wins, collisions recorded). */
849
1021
  readonly providers?: readonly ProviderContribution[];
1022
+ /** External identity providers; core qualifies ids as `login:<slug>:<id>`. */
1023
+ readonly loginProviders?: readonly LoginProviderContribution[];
850
1024
  /** Webui-bundle contributions; each `dir` anchors at the extension ENTRY's directory. */
851
1025
  readonly webuiBundles?: readonly WebuiBundleContribution[];
852
1026
  /** The surface's health producer, aggregated into `/v1/meta` `health` (any `"degraded"` degrades). */