@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
@@ -17,7 +17,7 @@
17
17
  * extension; a throw later (tool execute) surfaces to the caller.
18
18
  */
19
19
  import { AmbientDoc, AmbientOwnersDoc, validateAmbient, } from "./ambient.js";
20
- import { InteractionDoc, InteractionsIndexDoc, applyAnswer, indexPending, indexSettled, validateInteraction, } from "./interactions.js";
20
+ import { InteractionDoc, InteractionsIndexDoc, applyAnswer, asInteractionRecord, indexPending, indexSettled, sweepSecretDelivery, takeSecretDelivery, validateInteraction, } from "./interactions.js";
21
21
  import { scopedExtensionStores, } from "./stores.js";
22
22
  /** One seam violation. Name carries the surface ("interaction"/"ambient"). */
23
23
  export class SeamError extends Error {
@@ -36,7 +36,7 @@ export class SeamError extends Error {
36
36
  * boot-built store trio (one per boot, from the resolved roots); the
37
37
  * host scopes it to its identity's slug — `rekey` re-scopes in place.
38
38
  */
39
- export function createSeamHost(identity, kinds, bind, sharedStores) {
39
+ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools, services) {
40
40
  const interactionKinds = new Set(kinds.interaction ?? []);
41
41
  const ambientKinds = new Set(kinds.ambient ?? []);
42
42
  // The scoped stores view, keyed on the CURRENT identity — the same
@@ -45,6 +45,19 @@ export function createSeamHost(identity, kinds, bind, sharedStores) {
45
45
  // factory-captured host object re-scopes IN PLACE (never a second
46
46
  // host, never a stale view).
47
47
  let stores = scopedExtensionStores(sharedStores, identity.name);
48
+ const userStores = (user) => {
49
+ const scoped = stores.user(user);
50
+ return {
51
+ ...scoped,
52
+ settings: {
53
+ get: scoped.settings.get,
54
+ set: async (value) => {
55
+ await scoped.settings.set(value);
56
+ services?.settingsChanged(identity.name, user);
57
+ },
58
+ },
59
+ };
60
+ };
48
61
  async function harness(context) {
49
62
  const h = await bind.harness();
50
63
  // The caller's context gates the wait: an aborted request/tool call
@@ -69,7 +82,22 @@ export function createSeamHost(identity, kinds, bind, sharedStores) {
69
82
  };
70
83
  return {
71
84
  get stores() {
72
- return stores;
85
+ return { ...stores, user: userStores };
86
+ },
87
+ tools: {
88
+ setUserTools(user, tools) {
89
+ if (perUserTools?.declared(identity.name) !== true) {
90
+ throw new Error("perUserTools not declared");
91
+ }
92
+ perUserTools.set(identity.name, user, tools);
93
+ },
94
+ },
95
+ services: {
96
+ connect: (name, user, ctx) => {
97
+ if (services === undefined)
98
+ return Promise.reject(new Error("user services unavailable"));
99
+ return services.connect(identity.name, name, user, ctx);
100
+ },
73
101
  },
74
102
  rekey(nextIdentity, nextKinds) {
75
103
  // In place, by design: the factory already captured THIS object, so
@@ -111,8 +139,9 @@ export function createSeamHost(identity, kinds, bind, sharedStores) {
111
139
  return false;
112
140
  // Gateway C5: a host caller naming a user answers AS that human
113
141
  // (the route's path — the same applyAnswer line); without a user
114
- // the extension answered itself.
115
- const applied = applyAnswer(draft, answer, opts?.user === undefined ? "extension" : "human", Date.now(), opts?.user);
142
+ // the extension answered itself. The conversationId rides the opts
143
+ // so applyAnswer can record a secret delivery (Review Focus 4).
144
+ const applied = applyAnswer(draft, answer, opts?.user === undefined ? "extension" : "human", Date.now(), opts?.user, { conversationId });
116
145
  if (applied) {
117
146
  const index = await tx.doc(InteractionsIndexDoc);
118
147
  indexSettled(index, conversationId, callId);
@@ -122,21 +151,49 @@ export function createSeamHost(identity, kinds, bind, sharedStores) {
122
151
  },
123
152
  async settleInteraction(conversationId, callId, status, context) {
124
153
  const h = await harness(context);
154
+ // The sweep (Review Focus 4) is INSIDE the commit, applied only on
155
+ // the terminal transition: sweeping before the pending check would
156
+ // delete a delivery that still belongs to a live answer record
157
+ // (a no-op settle on an already-answered record must not eat the
158
+ // winner's unread secret).
125
159
  return await h.commit(async (tx) => {
126
160
  const draft = await tx.doc(InteractionDoc, conversationId, callId, callId);
127
161
  if (draft.status !== "pending")
128
162
  return false;
163
+ // SAFETY: the tracked-draft write pattern mutates through a
164
+ // structural record view; every key exists by construction.
129
165
  const mutable = draft;
130
166
  mutable.status = status;
131
167
  mutable.settledAt = Date.now();
132
168
  const index = await tx.doc(InteractionsIndexDoc);
133
169
  indexSettled(index, conversationId, callId);
170
+ sweepSecretDelivery(conversationId, callId);
134
171
  return true;
135
172
  }, context);
136
173
  },
137
174
  async snapshotInteraction(conversationId, callId, context) {
138
175
  const h = await harness(context);
139
- return await h.snapshot(InteractionDoc, conversationId, callId, context);
176
+ const stored = await h.snapshot(InteractionDoc, conversationId, callId, context);
177
+ if (stored === undefined)
178
+ return undefined;
179
+ const record = asInteractionRecord(stored);
180
+ // An unanswered record cannot have a delivery — and taking before
181
+ // this check would DELETE a delivery in flight in the
182
+ // record-then-commit-visible window (the poll returns the pending
183
+ // record and the true value is lost). Check the record FIRST.
184
+ if (record.answer === undefined)
185
+ return record;
186
+ // Review Focus 4 (the extension-only read): a secret delivery for
187
+ // this record merges its TRUE values back ONCE — the take is
188
+ // one-shot, so a second read sees the stored MASK. HTTP GETs never
189
+ // come through here (they serve the stored record verbatim).
190
+ const delivery = takeSecretDelivery(conversationId, callId);
191
+ if (delivery === undefined)
192
+ return record;
193
+ return {
194
+ ...record,
195
+ answer: { values: delivery.values },
196
+ };
140
197
  },
141
198
  async setAmbient(conversationId, record, context) {
142
199
  if (!ambientKinds.has(record.key)) {
@@ -166,6 +223,9 @@ export function createSeamHost(identity, kinds, bind, sharedStores) {
166
223
  const problem = validateAmbient(full);
167
224
  if (problem !== null)
168
225
  throw new SeamError("ambient", problem);
226
+ // SAFETY: `full` carries a validated block tree as its body
227
+ // (validateAmbient's validateTree gate just passed); the stored
228
+ // mirror's body differs only in the structural JSON naming.
169
229
  Object.assign(draft, full);
170
230
  }, context);
171
231
  },
@@ -250,6 +310,20 @@ export function createSeamHost(identity, kinds, bind, sharedStores) {
250
310
  return (await hostBridge()).users.get(user);
251
311
  },
252
312
  },
313
+ accounts: {
314
+ async lookup(provider, subject) {
315
+ return (await hostBridge()).accounts.lookup(provider, subject);
316
+ },
317
+ async link(provider, subject, user, via) {
318
+ return (await hostBridge()).accounts.link(provider, subject, user, via);
319
+ },
320
+ async unlink(provider, subject) {
321
+ return (await hostBridge()).accounts.unlink(provider, subject);
322
+ },
323
+ async listForUser(user) {
324
+ return (await hostBridge()).accounts.listForUser(user);
325
+ },
326
+ },
253
327
  access: {
254
328
  async accessOf(principal, resource) {
255
329
  return (await hostBridge()).access.accessOf(principal, resource);
@@ -53,6 +53,7 @@ import { dirname, join } from "node:path";
53
53
  import { resolveStoreRoots } from "./stores.js";
54
54
  import { isAbsoluteHttpUrl } from "../config.js";
55
55
  import { isIanaTimeZone, validateJobSpec } from "../jobs/spec.js";
56
+ import { classifyPatch, } from "../settings/pending.js";
56
57
  /** The queue modes pi-durable accepts (its `QueueMode`). */
57
58
  const QUEUE_MODES = ["all", "one-at-a-time"];
58
59
  /**
@@ -367,8 +368,8 @@ function validate(value) {
367
368
  if (builtins === undefined) {
368
369
  throw new SettingsValidationError("builtinExtensions", "must be an object");
369
370
  }
370
- checkKeys("builtinExtensions.", builtins, ["codemode", "mcp", "toolSearch"]);
371
- for (const key of ["codemode", "mcp", "toolSearch"]) {
371
+ checkKeys("builtinExtensions.", builtins, ["codemode", "mcp", "toolSearch", "gateway", "oauth"]);
372
+ for (const key of ["codemode", "mcp", "toolSearch", "gateway", "oauth"]) {
372
373
  if (builtins[key] !== undefined && typeof builtins[key] !== "boolean") {
373
374
  throw new SettingsValidationError(`builtinExtensions.${key}`, "must be a boolean");
374
375
  }
@@ -410,10 +411,12 @@ function validateServer(server) {
410
411
  "webuiBundle",
411
412
  "compaction",
412
413
  "memorySeam",
414
+ "isolation",
413
415
  "providers",
414
416
  "auth",
415
417
  "metrics",
416
418
  "dev",
419
+ "supervised",
417
420
  "backup",
418
421
  ]);
419
422
  // The jobs default timezone (§8.2): an IANA name — `Intl` accepts
@@ -538,6 +541,30 @@ function validateServer(server) {
538
541
  }
539
542
  }
540
543
  }
544
+ if (record.isolation !== undefined) {
545
+ const isolation = asRecord(record.isolation);
546
+ if (isolation === undefined) {
547
+ throw new SettingsValidationError("server.isolation", "must be an object");
548
+ }
549
+ checkKeys("server.isolation.", isolation, [
550
+ "mode",
551
+ "osUsers",
552
+ "workerIdleMinutes",
553
+ ]);
554
+ if (isolation.mode !== undefined &&
555
+ isolation.mode !== "auto" &&
556
+ isolation.mode !== "always" &&
557
+ isolation.mode !== "off") {
558
+ throw new SettingsValidationError("server.isolation.mode", 'must be "auto", "always", or "off"');
559
+ }
560
+ if (isolation.osUsers !== undefined && typeof isolation.osUsers !== "boolean") {
561
+ throw new SettingsValidationError("server.isolation.osUsers", "must be a boolean");
562
+ }
563
+ if (isolation.workerIdleMinutes !== undefined &&
564
+ !isPositiveInt(isolation.workerIdleMinutes)) {
565
+ throw new SettingsValidationError("server.isolation.workerIdleMinutes", "must be a positive integer");
566
+ }
567
+ }
541
568
  if (record.providers !== undefined) {
542
569
  const providers = asRecord(record.providers);
543
570
  if (providers === undefined) {
@@ -556,11 +583,15 @@ function validateServer(server) {
556
583
  }
557
584
  checkKeys("server.auth.", auth, [
558
585
  "methods",
586
+ "sessionDays",
559
587
  "proxy",
560
588
  "adminGroups",
561
589
  "allowedHosts",
562
590
  "allowedOrigins",
563
591
  ]);
592
+ if (auth.sessionDays !== undefined && !isPositiveInt(auth.sessionDays)) {
593
+ throw new SettingsValidationError("server.auth.sessionDays", "must be a positive integer");
594
+ }
564
595
  if (auth.methods !== undefined) {
565
596
  if (!Array.isArray(auth.methods) ||
566
597
  auth.methods.some((m) => m !== "token" && m !== "proxy")) {
@@ -616,6 +647,11 @@ function validateServer(server) {
616
647
  if (record.dev !== undefined && typeof record.dev !== "boolean") {
617
648
  throw new SettingsValidationError("server.dev", "must be a boolean");
618
649
  }
650
+ // The supervised flag (U4 §13.3) — same shape.
651
+ if (record.supervised !== undefined &&
652
+ typeof record.supervised !== "boolean") {
653
+ throw new SettingsValidationError("server.supervised", "must be a boolean");
654
+ }
619
655
  if (record.backup !== undefined) {
620
656
  const backup = asRecord(record.backup);
621
657
  if (backup === undefined) {
@@ -699,8 +735,10 @@ function checkKeys(prefix, record, allowed, reason = "is not a settable field")
699
735
  */
700
736
  export class SettingsStore {
701
737
  path;
738
+ #onWrite;
702
739
  constructor(options = {}) {
703
740
  this.path = settingsPath(options.dir ?? defaultSettingsDir());
741
+ this.#onWrite = options.onWrite;
704
742
  }
705
743
  /** The current settings; absent file = `{}` (all defaults). */
706
744
  read() {
@@ -1025,6 +1063,29 @@ export class SettingsStore {
1025
1063
  writeFileSync(this.path, `${JSON.stringify(file, null, "\t")}\n`);
1026
1064
  return file;
1027
1065
  }
1066
+ /**
1067
+ * The settings-change write (UI-blocks U4 Task 1, spec §13.2): the
1068
+ * PATCH body merged + validated + persisted (exactly `update()`),
1069
+ * THEN classified against the apply registry. Returns which keys
1070
+ * applied live vs. which are pending a restart, plus the effective
1071
+ * file; the committed-write observer fires after the write lands.
1072
+ *
1073
+ * @throws SettingsValidationError naming the offending field.
1074
+ */
1075
+ apply(patch) {
1076
+ const outcome = classifyPatch(patch);
1077
+ const file = this.update(patch);
1078
+ this.#onWrite?.(outcome.keys);
1079
+ // SAFETY: the intersection is structural — `update` returns the
1080
+ // validated `SettingsFile`, and `ApplyOutcome`'s three fields are
1081
+ // freshly created here, so no field collision can occur.
1082
+ return {
1083
+ ...file,
1084
+ keys: outcome.keys,
1085
+ applied: outcome.applied,
1086
+ pending: outcome.pending,
1087
+ };
1088
+ }
1028
1089
  /**
1029
1090
  * A live `HarnessSettings` object for `openHarness`: every property
1030
1091
  * reads the file AT ACCESS TIME, so pi-durable's per-resolution
@@ -36,8 +36,10 @@
36
36
  import { randomUUID } from "node:crypto";
37
37
  import { homedir } from "node:os";
38
38
  import { mkdirSync } from "node:fs";
39
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
40
- import { join } from "node:path";
39
+ import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
40
+ import { dirname, join, sep } from "node:path";
41
+ import { MutationQueue, invalidateJsonCache, readJson, writeJson, } from "./identity/json-store.js";
42
+ import { UserDirectory } from "./identity/users.js";
41
43
  /**
42
44
  * The tier-3 defaults (spec §4): `~/.config/sigrun`,
43
45
  * `~/.local/share/sigrun`, `~/.cache/sigrun`, `~/.local/state/sigrun`.
@@ -154,7 +156,10 @@ export async function ensureCacheDir(roots, slug) {
154
156
  }
155
157
  /** The synchronous mkdir core behind {@link ensureCacheDir}. */
156
158
  function ensureCacheDirAt(roots, slug) {
157
- const dir = join(roots.cacheDir, slug);
159
+ return ensureDirAt(join(roots.cacheDir, slug));
160
+ }
161
+ /** The synchronous mkdir core behind lazy path getters. */
162
+ function ensureDirAt(dir) {
158
163
  try {
159
164
  mkdirSync(dir, { recursive: true });
160
165
  }
@@ -354,14 +359,101 @@ export function resolveSecretFor(slug, section, key, env) {
354
359
  }
355
360
  /** The `secrets.json` file name at the config root (spec §3 map). */
356
361
  const SECRETS_FILE_NAME = "secrets.json";
362
+ /**
363
+ * One user's secrets file (multi-user M2): the same object-map discipline
364
+ * as the shared SecretsStore, plus the identity helpers' mtime reload and
365
+ * MutationQueue. A queued write re-reads the CURRENT file inside its
366
+ * mutation, so sibling sections survive concurrent sets.
367
+ */
368
+ /** One FIFO per secrets file, shared by independently constructed views. */
369
+ const userSecretMutations = new Map();
370
+ export class UserSecretsStore {
371
+ path;
372
+ mutations;
373
+ constructor(path) {
374
+ this.path = path;
375
+ this.mutations = userSecretMutations.get(path) ?? new MutationQueue();
376
+ userSecretMutations.set(path, this.mutations);
377
+ }
378
+ readAll() {
379
+ const value = readJson(this.path, {});
380
+ if (!isPlainObject(value)) {
381
+ throw new Error(`sigrun: ${this.path} must contain a JSON object`);
382
+ }
383
+ return value;
384
+ }
385
+ readSection(slug) {
386
+ const section = this.readAll()[slug];
387
+ return isPlainObject(section) ? section : {};
388
+ }
389
+ writeSection(slug, section) {
390
+ return this.mutate(slug, (draft) => {
391
+ for (const key of Object.keys(draft))
392
+ delete draft[key];
393
+ Object.assign(draft, section);
394
+ });
395
+ }
396
+ /** One serialized read-modify-write; validation runs INSIDE the queue. */
397
+ mutate(slug, update) {
398
+ return this.mutations.run(async () => {
399
+ const all = this.readAll();
400
+ const current = all[slug];
401
+ const draft = isPlainObject(current) ? { ...current } : {};
402
+ update(draft);
403
+ all[slug] = draft;
404
+ // The parent is created only on the first actual secret write.
405
+ await ensureDir(dirname(this.path));
406
+ // writeJson -> writeFileAtomic(..., 0600), on create AND rewrite.
407
+ await writeJson(this.path, all);
408
+ });
409
+ }
410
+ }
411
+ /** In-memory only: active userDeleted fan-out windows (Task 5 owns begin/end). */
412
+ const deletingUsers = new Map();
413
+ /**
414
+ * Mark one user's deletion fan-out active. The returned idempotent end hook
415
+ * releases only this window; nested fan-outs cannot clear each other early.
416
+ * No marker is persisted and no catch-all allowMissing flag exists.
417
+ */
418
+ export function beginUserDeletion(user) {
419
+ deletingUsers.set(user, (deletingUsers.get(user) ?? 0) + 1);
420
+ let active = true;
421
+ return () => {
422
+ if (!active)
423
+ return;
424
+ active = false;
425
+ const remaining = (deletingUsers.get(user) ?? 1) - 1;
426
+ if (remaining <= 0)
427
+ deletingUsers.delete(user);
428
+ else
429
+ deletingUsers.set(user, remaining);
430
+ };
431
+ }
432
+ /** Resolve one directory user, or the explicit deletion-window exception. */
433
+ function requireStoreUser(users, user) {
434
+ if (users.get(user) === undefined && !deletingUsers.has(user)) {
435
+ throw new Error(`unknown user "${user}"`);
436
+ }
437
+ }
357
438
  /** Build the shared trio from resolved roots (cheap, no I/O). */
358
- export function sharedExtensionStores(roots, env = {}) {
439
+ export function sharedExtensionStores(roots, env = {}, users) {
359
440
  const settings = new ExtensionSettingsStore(join(roots.configDir, SETTINGS_DIR_NAME));
360
441
  const secrets = new SecretsStore(join(roots.configDir, SECRETS_FILE_NAME));
442
+ // The optional fallback keeps this low-level factory usable without an
443
+ // identity bundle; production boot always passes its LIVE directory.
444
+ const directory = users ?? new UserDirectory(join(roots.configDir, "users.json"));
361
445
  return {
362
446
  secrets,
363
447
  settings,
364
448
  roots,
449
+ users: directory,
450
+ removeUserData: (user) => removeUserData(roots, user),
451
+ userExtensionStores: (slug, user) => userExtensionStores(roots, directory, slug, user),
452
+ userSecretKeys: async (slug, user) => {
453
+ requireStoreUser(directory, user);
454
+ const store = new UserSecretsStore(join(roots.configDir, USER_SECRETS_DIR, `${user}.json`));
455
+ return Object.keys(store.readSection(slug));
456
+ },
365
457
  settingsFor: (slug) => ({
366
458
  get: () => settings.read(slug),
367
459
  set: (value) => settings.write(slug, value),
@@ -421,6 +513,7 @@ export function sharedExtensionStores(roots, env = {}) {
421
513
  */
422
514
  export function scopedExtensionStores(shared, slug) {
423
515
  return {
516
+ user: (user) => shared.userExtensionStores(slug, user),
424
517
  secrets: {
425
518
  async get(key) {
426
519
  const section = await shared.secrets.readSection(slug);
@@ -448,3 +541,147 @@ export function scopedExtensionStores(shared, slug) {
448
541
  },
449
542
  };
450
543
  }
544
+ /** The per-user file/dir layout (multi-user M2 §10.2, verbatim). */
545
+ const USER_SECRETS_DIR = "user-secrets";
546
+ const USER_SETTINGS_DIR = "user-settings";
547
+ /** Whether a filesystem error is just an absent cleanup candidate. */
548
+ function isEnoent(error) {
549
+ return error.code === "ENOENT";
550
+ }
551
+ /** Delete one cache user dir only when every existing parent component is
552
+ * a real directory inside the cache root. Symlink parents are skipped, so
553
+ * recursive rm can never follow one outside the root.
554
+ */
555
+ async function removeCacheUserDir(cacheRoot, slug, user) {
556
+ const parentComponents = [slug, "users"];
557
+ let resolvedParent = cacheRoot;
558
+ for (const component of parentComponents) {
559
+ const candidate = join(resolvedParent, component);
560
+ let stats;
561
+ try {
562
+ stats = await lstat(candidate);
563
+ }
564
+ catch (error) {
565
+ if (isEnoent(error))
566
+ return;
567
+ throw error;
568
+ }
569
+ if (!stats.isDirectory() || stats.isSymbolicLink())
570
+ return;
571
+ const resolved = await realpath(candidate);
572
+ if (resolved !== cacheRoot && !resolved.startsWith(cacheRoot + sep))
573
+ return;
574
+ resolvedParent = resolved;
575
+ }
576
+ const target = join(resolvedParent, user);
577
+ try {
578
+ const stats = await lstat(target);
579
+ // Deleting the symlink itself is confined; its target is untouched.
580
+ if (!stats.isDirectory() && !stats.isSymbolicLink())
581
+ return;
582
+ }
583
+ catch (error) {
584
+ if (isEnoent(error))
585
+ return;
586
+ throw error;
587
+ }
588
+ await rm(target, { recursive: true, force: true });
589
+ }
590
+ /** Core's §10.6 step-5 cleanup, after userDeleted and directory removal.
591
+ * Cache entries are enumerated because per-user registry entries may already
592
+ * have uninstalled slugs. Data/state deliberately remain extension-owned.
593
+ */
594
+ async function removeUserData(roots, user) {
595
+ await rm(join(roots.configDir, USER_SECRETS_DIR, `${user}.json`), { force: true });
596
+ await rm(join(roots.configDir, USER_SETTINGS_DIR, user), { recursive: true, force: true });
597
+ let cacheRoot;
598
+ let entries;
599
+ try {
600
+ cacheRoot = await realpath(roots.cacheDir);
601
+ entries = await readdir(cacheRoot, { withFileTypes: true });
602
+ }
603
+ catch (error) {
604
+ if (isEnoent(error))
605
+ return;
606
+ throw error;
607
+ }
608
+ await Promise.all(entries.flatMap((entry) => entry.isDirectory() && !entry.isSymbolicLink()
609
+ ? [removeCacheUserDir(cacheRoot, entry.name, user)]
610
+ : []));
611
+ }
612
+ /** Settings use sync mtime reload + queued atomic writes (0644, like shared settings). */
613
+ const userSettingQueues = new Map();
614
+ const userSettingMutations = {
615
+ run(path, op) {
616
+ const queue = userSettingQueues.get(path) ?? new MutationQueue();
617
+ userSettingQueues.set(path, queue);
618
+ return queue.run(op);
619
+ },
620
+ };
621
+ function readUserSettings(path) {
622
+ const value = readJson(path, {});
623
+ if (!isPlainObject(value)) {
624
+ throw new Error(`sigrun: ${path} must contain a JSON object`);
625
+ }
626
+ return value;
627
+ }
628
+ async function writeUserSettings(path, value) {
629
+ try {
630
+ await writeFileAtomic(path, serializeJson(value), 0o644);
631
+ }
632
+ finally {
633
+ invalidateJsonCache(path);
634
+ }
635
+ }
636
+ /** One user/slug view; user validity is checked at EVERY operation, not cached. */
637
+ // Directory usernames are validated at creation, and extension slugs are
638
+ // loader-sanitized; the joins below therefore cannot escape the store roots.
639
+ // The deleting marker is the ONLY missing-record exception, and its release
640
+ // immediately re-enables checks on handles already captured during fan-out.
641
+ export function userExtensionStores(roots, users, slug, user) {
642
+ requireStoreUser(users, user);
643
+ const secrets = new UserSecretsStore(join(roots.configDir, USER_SECRETS_DIR, `${user}.json`));
644
+ const rawPath = (root) => {
645
+ requireStoreUser(users, user);
646
+ return ensureDirAt(join(root, slug, "users", user));
647
+ };
648
+ return {
649
+ secrets: {
650
+ async get(key) {
651
+ requireStoreUser(users, user);
652
+ const value = secrets.readSection(slug)[key];
653
+ return value === undefined || value === null ? undefined : String(value);
654
+ },
655
+ async set(key, value) {
656
+ await secrets.mutate(slug, (section) => {
657
+ requireStoreUser(users, user);
658
+ section[key] = value;
659
+ });
660
+ },
661
+ async delete(key) {
662
+ await secrets.mutate(slug, (section) => {
663
+ requireStoreUser(users, user);
664
+ delete section[key];
665
+ });
666
+ },
667
+ },
668
+ settings: {
669
+ async get() {
670
+ requireStoreUser(users, user);
671
+ return readUserSettings(join(roots.configDir, USER_SETTINGS_DIR, user, `${slug}.json`));
672
+ },
673
+ async set(value) {
674
+ await userSettingMutations.run(join(roots.configDir, USER_SETTINGS_DIR, user, `${slug}.json`), async () => {
675
+ requireStoreUser(users, user);
676
+ await ensureDir(join(roots.configDir, USER_SETTINGS_DIR, user));
677
+ await writeUserSettings(join(roots.configDir, USER_SETTINGS_DIR, user, `${slug}.json`), value);
678
+ });
679
+ },
680
+ },
681
+ paths: {
682
+ get data() { return rawPath(roots.dataDir); },
683
+ get state() { return rawPath(roots.stateDir); },
684
+ get cache() { return rawPath(roots.cacheDir); },
685
+ },
686
+ };
687
+ }