@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
@@ -0,0 +1,597 @@
1
+ /**
2
+ * The settings-section surface adapter (UI-blocks U4 Task 3, spec
3
+ * §13.3): turns one extension-declared `SettingsSectionDef` into a
4
+ * `SurfaceDef<"settings">` the surface service serves under the
5
+ * extension's own `/v1/ext/<slug>/surfaces/<id>` mount — core's own
6
+ * sections (Task 4) use the same renderer under the reserved `core`
7
+ * slug, so core and extensions render settings from ONE code path.
8
+ *
9
+ * - `render` returns the declared form (validated at load with the
10
+ * `settings` placement) with one appended `secret` field per declared
11
+ * secret (spec §13.4 — a declaration without a field never renders),
12
+ * plus `data` = the current `settings/<slug>.json` object minus
13
+ * secret-typed field values (`settings`) and the declared secrets'
14
+ * statuses (`secrets`: `stored` / `env` / absent — the channels'
15
+ * secret field renders those as `set (stored)` / `set (env)` / `not
16
+ * set`; values NEVER appear, spec §7.7's never-read-back rule).
17
+ * - The `save` action takes `{rev, values}`: a stale `rev` rejects
18
+ * `{ok: false, error: {message: "changed by someone else"}}`
19
+ * (Review Focus 3); the values validate against the form's fields
20
+ * (unknown keys, wrong types, min/max/required violations reject
21
+ * field-scoped and write nothing); a valid save MERGES into the
22
+ * stored object (unknown stored keys survive — Review Focus 5),
23
+ * writes `settings/<slug>.json`, and fires the §13.2 change path
24
+ * with the top-level keys it touched.
25
+ * - Access: `admin` sections (the default) are `write` for superusers
26
+ * and admins and `none` for everyone else (the route maps `none` to
27
+ * 404 `surface_not_found`); `admin: false` sections are `write` for
28
+ * every authenticated principal.
29
+ */
30
+ import { isSuperuser } from "../auth/principal.js";
31
+ import { pendingRestart, } from "../../settings/pending.js";
32
+ import { instanceKey as paramsInstanceKey } from "./instance.js";
33
+ /** A user-scope surface requires a live directory user, exactly like /v1/me. */
34
+ export class NamedUserRequiredError extends Error {
35
+ constructor() {
36
+ super("named_user_required");
37
+ this.name = "NamedUserRequiredError";
38
+ }
39
+ }
40
+ /** Parse the save payload: `{rev, values}` or undefined (bad shape). */
41
+ function parseSavePayload(payload) {
42
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
43
+ return undefined;
44
+ }
45
+ const record = payload;
46
+ if (typeof record.rev !== "number" ||
47
+ !Number.isInteger(record.rev) ||
48
+ record.rev < 0) {
49
+ return undefined;
50
+ }
51
+ if (typeof record.values !== "object" ||
52
+ record.values === null ||
53
+ Array.isArray(record.values)) {
54
+ return undefined;
55
+ }
56
+ return { rev: record.rev, values: record.values };
57
+ }
58
+ function isRec(value) {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
60
+ }
61
+ /**
62
+ * Collect the field entries of one section's form: every node carrying a
63
+ * string `name` and a `type` in the field set, recursively (the loader
64
+ * validated the tree — the walk only reads `children`/`fields`/`root`/
65
+ * `item` and yields records; sections inside forms nest fields).
66
+ */
67
+ function collectFormFields(tree) {
68
+ const fields = [];
69
+ const stack = [tree];
70
+ while (stack.length > 0) {
71
+ const node = stack.pop();
72
+ if (Array.isArray(node)) {
73
+ stack.push(...node);
74
+ continue;
75
+ }
76
+ if (!isRec(node))
77
+ continue;
78
+ for (const key of ["children", "fields", "root", "item"]) {
79
+ if (node[key] !== undefined)
80
+ stack.push(node[key]);
81
+ }
82
+ if (isRec(node.block))
83
+ stack.push(node.block);
84
+ if (typeof node.name === "string" && typeof node.type === "string") {
85
+ const type = node.type;
86
+ if (type !== "section" &&
87
+ type !== "form" &&
88
+ type !== "page" &&
89
+ type !== "stack") {
90
+ // SAFETY: the loader validated the tree against the settings
91
+ // placement, so every name-carrying non-container node is a
92
+ // field; the cast narrows the record to the field shape.
93
+ fields.push(node);
94
+ }
95
+ }
96
+ }
97
+ return fields;
98
+ }
99
+ /**
100
+ * Validate one value against one field (the server-side half of the
101
+ * channel's client-side check): required, type, min/max,
102
+ * minLength/maxLength, pattern. Returns the violation message or null.
103
+ */
104
+ function validateFieldValue(field, value) {
105
+ if (value === undefined || value === null || value === "") {
106
+ if (field.required === true)
107
+ return `${field.name} is required`;
108
+ return null;
109
+ }
110
+ switch (field.type) {
111
+ case "text":
112
+ case "longtext":
113
+ case "secret":
114
+ case "date":
115
+ case "datetime":
116
+ if (typeof value !== "string")
117
+ return `${field.name} must be a string`;
118
+ if (field.minLength !== undefined && value.length < field.minLength) {
119
+ return `${field.name} must be at least ${field.minLength} characters`;
120
+ }
121
+ if (field.maxLength !== undefined && value.length > field.maxLength) {
122
+ return `${field.name} must be at most ${field.maxLength} characters`;
123
+ }
124
+ if (field.pattern !== undefined && !new RegExp(field.pattern).test(value)) {
125
+ return `${field.name} does not match ${field.pattern}`;
126
+ }
127
+ return null;
128
+ case "number": {
129
+ if (typeof value !== "number")
130
+ return `${field.name} must be a number`;
131
+ if (field.min !== undefined && value < field.min) {
132
+ return `${field.name} must be at least ${field.min}`;
133
+ }
134
+ if (field.max !== undefined && value > field.max) {
135
+ return `${field.name} must be at most ${field.max}`;
136
+ }
137
+ return null;
138
+ }
139
+ case "toggle":
140
+ if (typeof value !== "boolean")
141
+ return `${field.name} must be a boolean`;
142
+ return null;
143
+ case "choice": {
144
+ if (Array.isArray(value)) {
145
+ if (!value.every((entry) => typeof entry === "string")) {
146
+ return `${field.name} must be a list of option ids`;
147
+ }
148
+ return null;
149
+ }
150
+ if (typeof value !== "string")
151
+ return `${field.name} must be an option id`;
152
+ return null;
153
+ }
154
+ case "daterange":
155
+ case "file":
156
+ // Never reached (the settings placement excludes both); keep the
157
+ // switch total for the compiler.
158
+ return null;
159
+ default:
160
+ return null;
161
+ }
162
+ }
163
+ /**
164
+ * Per-slug write serialization (§13.3's read-merge-write): every
165
+ * section of one slug shares `settings/<slug>.json`, so the chains
166
+ * live at module scope keyed by slug (a chain is ordering, not data —
167
+ * a stale chain from another server instance is inert). Without it,
168
+ * two interleaved saves from two sections of one extension silently
169
+ * drop each other's keys.
170
+ */
171
+ const writeChains = new Map();
172
+ function enqueueWrite(slug, work) {
173
+ const run = (writeChains.get(slug) ?? Promise.resolve()).then(work);
174
+ writeChains.set(slug, run.then(() => undefined, () => undefined));
175
+ return run;
176
+ }
177
+ /**
178
+ * Append one `secret` field per declared secret to the form's root
179
+ * children (the tree shape is a plain JSON object — spec §4.1). A
180
+ * non-object root or one without a children array passes through
181
+ * verbatim (the loader already validated the settings placement).
182
+ */
183
+ function withAppendedSecretFields(form, appended) {
184
+ if (appended.length === 0)
185
+ return form;
186
+ const root = form.root;
187
+ if (!isRec(root))
188
+ return form;
189
+ const children = Array.isArray(root.children) ? root.children : [];
190
+ const fields = appended.map((secret) => ({
191
+ type: "secret",
192
+ name: secret.key,
193
+ label: secret.label,
194
+ ...(secret.help === undefined ? {} : { help: secret.help }),
195
+ }));
196
+ // SAFETY: root is a plain JSON record from the loader-validated
197
+ // settings placement (its root is a form block); adding a children
198
+ // array of secret-field objects keeps it a valid Block tree. The
199
+ // `Block` union type can't express the property-addition spread, so
200
+ // the rebuild narrows through `unknown`.
201
+ return {
202
+ ...form,
203
+ root: { ...root, children: [...children, ...fields] },
204
+ };
205
+ }
206
+ /** The env knob mirroring one core key, when one exists (the
207
+ * General/Providers sections' "env wins" notes). Only the keys the
208
+ * core forms render are listed. */
209
+ const ENV_OVERRIDES = {
210
+ defaultModel: "SIGRUN_DEFAULT_MODEL",
211
+ "defaultProvider.baseUrl": "OPENAI_BASE_URL",
212
+ "server.port": "SIGRUN_PORT",
213
+ "server.publicUrl": "SIGRUN_PUBLIC_URL",
214
+ "server.timezone": "SIGRUN_TZ",
215
+ "server.providers.refreshIntervalMinutes": "SIGRUN_PROVIDER_REFRESH_MINUTES",
216
+ "server.compaction.enabled": "SIGRUN_COMPACTION",
217
+ };
218
+ /** Read one flattened dotted path out of a nested record (the value
219
+ * is a JSON value by the settings file's validation). */
220
+ function readDotted(source, path) {
221
+ let node = source;
222
+ for (const key of path.split(".")) {
223
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
224
+ return undefined;
225
+ }
226
+ node = node[key];
227
+ }
228
+ return node;
229
+ }
230
+ /** Write one flattened dotted path into a nested patch record. */
231
+ function writeDotted(target, path, value) {
232
+ const keys = path.split(".");
233
+ let node = target;
234
+ for (let i = 0; i < keys.length - 1; i++) {
235
+ const key = keys[i];
236
+ if (key === undefined)
237
+ return;
238
+ const next = node[key];
239
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
240
+ node[key] = {};
241
+ }
242
+ node = node[key];
243
+ }
244
+ const last = keys[keys.length - 1];
245
+ if (last !== undefined)
246
+ node[last] = value;
247
+ }
248
+ /** The `meta` block the core sections' renders carry: the pending
249
+ * restart diff (the callout's key list), the env-override map for
250
+ * the rendered keys, and the supervised flag. */
251
+ function pendingContext(core) {
252
+ const file = core.settings.read();
253
+ const env = {};
254
+ for (const [key, envName] of Object.entries(ENV_OVERRIDES)) {
255
+ if (core.env[envName] !== undefined)
256
+ env[key] = envName;
257
+ }
258
+ return {
259
+ pendingRestart: [...pendingRestart(file, core.bootSnapshot)],
260
+ env,
261
+ supervised: core.env.SIGRUN_SUPERVISED === "1" || core.env.SIGRUN_SUPERVISED === "true",
262
+ };
263
+ }
264
+ /**
265
+ * Adapt one declared settings section to a `SurfaceDef<"settings">`.
266
+ * The def closes over the slug + section id (the surface service keys
267
+ * instances by `(slug, id, params-key)`; settings sections take NO
268
+ * params, so the default `instanceKey({})` applies).
269
+ */
270
+ export function settingsSurfaceFor(slug, section, options) {
271
+ const { stores, publishSettingsChanged, service } = options;
272
+ const userScope = section.scope === "user";
273
+ // User sections are personal by default. An explicit admin: true keeps
274
+ // the admin gate; shared sections retain their original default-true rule.
275
+ const admin = userScope ? section.admin === true : section.admin !== false;
276
+ const namedUser = (principal) => {
277
+ const user = principal.user;
278
+ if (user === undefined || stores.users.get(user) === undefined) {
279
+ throw new NamedUserRequiredError();
280
+ }
281
+ return user;
282
+ };
283
+ const fields = collectFormFields(section.form.root);
284
+ // Secret-typed fields NEVER land in the plaintext settings file
285
+ // (§5.6/§13.4 write-only): saves route them to the secrets store and
286
+ // reads exclude them from `data.settings`.
287
+ const secretFieldNames = new Set(fields
288
+ .filter((field) => field.type === "secret")
289
+ .map((field) => field.name));
290
+ // Declared secrets whose keys are not already covered by a form
291
+ // field get one appended `secret` field each (a declaration must
292
+ // render — spec §13.4). The field NAME is the secret key, so the
293
+ // save below routes it to the secrets store.
294
+ const declared = section.secrets ?? [];
295
+ const appended = declared.filter((secret) => !secretFieldNames.has(secret.key));
296
+ const form = withAppendedSecretFields(section.form, appended);
297
+ const allSecretFieldNames = new Set([
298
+ ...secretFieldNames,
299
+ ...appended.map((secret) => secret.key),
300
+ ]);
301
+ const accessFor = (principal) => {
302
+ if (userScope)
303
+ namedUser(principal);
304
+ if (admin) {
305
+ return isSuperuser(principal) || principal.admin ? "write" : "none";
306
+ }
307
+ return "write";
308
+ };
309
+ // ─── Core sections (U4 Task 4): the same renderer, over the core ──
310
+ // SettingsStore (dotted names land at nested paths, apply-typed).
311
+ const core = options.core;
312
+ /** The core read: the SettingsStore's effective values at the
313
+ * fields' dotted names, plus the boot/meta block the pending
314
+ * callout + version line read. */
315
+ const readCoreData = () => {
316
+ if (options.core === undefined) {
317
+ throw new Error("core settings section adapted without core deps");
318
+ }
319
+ // SAFETY: SettingsFile is a plain validated JSON document; the
320
+ // JsonObject narrowing is the read-side projection the field walk
321
+ // consumes (readDotted reads unknown values out of it).
322
+ const file = options.core.settings.read();
323
+ const settings = {};
324
+ for (const field of fields) {
325
+ const value = readDotted(file, field.name);
326
+ if (value !== undefined)
327
+ settings[field.name] = value;
328
+ }
329
+ const { pendingRestart, env, supervised } = pendingContext(options.core);
330
+ return {
331
+ settings,
332
+ meta: {
333
+ pendingRestart,
334
+ env,
335
+ supervised,
336
+ },
337
+ // The Tools section's default-tools editor binds its options
338
+ // here (the boot registry's tool names — the roster can only
339
+ // come from the server; an empty literal would render a dead
340
+ // checkbox list). The binding's shape is ChoiceOption[]
341
+ // (`{id, label}` rows, spec §5.6).
342
+ toolRoster: options.core.toolRoster.map((name) => ({ id: name, label: name })),
343
+ };
344
+ };
345
+ /** The core save: fold the dotted field names into one core-store
346
+ * patch (longtext fields parse raw JSON — the Tools/Advanced raw
347
+ * object fields), apply it through the store, and fire the §13.2
348
+ * path with the flattened key paths the write touched. */
349
+ const saveCore = async (values) => {
350
+ if (options.core === undefined) {
351
+ throw new Error("core settings section adapted without core deps");
352
+ }
353
+ // SAFETY: SettingsFile is a plain validated JSON document; the
354
+ // read here is the pre-patch state the default-skip below keys on.
355
+ const current = options.core.settings.read();
356
+ let patch = {};
357
+ for (const [name, raw] of Object.entries(values)) {
358
+ const field = fields.find((candidate) => candidate.name === name);
359
+ if (field === undefined) {
360
+ return { ok: false, error: { message: `unknown field: ${name}`, field: name } };
361
+ }
362
+ // A submitted value EQUAL to the field's declared default with
363
+ // NO stored key is the untouched state: the channels
364
+ // initialize an absent default-ON toggle to the default and
365
+ // submit every field, so an unedited default-on toggle would
366
+ // otherwise pin an explicit key a fresh install never had.
367
+ // Skipping keeps absent = default; explicitly flipping OFF the
368
+ // default writes the key, and re-enabling a stored `false`
369
+ // writes the `true` (the key is present, so no skip).
370
+ if (field.default !== undefined &&
371
+ raw === field.default &&
372
+ readDotted(current, name) === undefined) {
373
+ continue;
374
+ }
375
+ // Empty-string values CLEAR the key (the nullable fields'
376
+ // "(server default)" affordance): null rides the store's
377
+ // delete-key merge.
378
+ let value = raw;
379
+ if (field.type === "longtext") {
380
+ if (typeof raw === "string" && raw.trim() !== "") {
381
+ let parsed;
382
+ try {
383
+ parsed = JSON.parse(raw);
384
+ }
385
+ catch (cause) {
386
+ return {
387
+ ok: false,
388
+ error: {
389
+ message: `${name}: invalid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
390
+ field: name,
391
+ },
392
+ };
393
+ }
394
+ value = parsed;
395
+ }
396
+ }
397
+ // An empty STRING is the unset affordance (the nullable
398
+ // fields' "(server default)" gesture): it CLEARS the key —
399
+ // `null` rides the store's delete-key merge (settings.ts:
400
+ // a null value clears the key; a null sub-field clears that
401
+ // sub-field). A clear of a key nothing STORES is a no-op and
402
+ // drops out here: the strict store validation reads a null
403
+ // under a number-only sub-section as "must be a number", so a
404
+ // full-form save on a fresh install (every nullable field
405
+ // empty) would otherwise reject on never-present keys.
406
+ if (raw === "") {
407
+ if (readDotted(current, name) !== undefined) {
408
+ writeDotted(patch, name, null);
409
+ }
410
+ continue;
411
+ }
412
+ writeDotted(patch, name, value);
413
+ }
414
+ try {
415
+ const outcome = options.core.settings.apply(patch);
416
+ // The §13.2 publish rides the store's onWrite sink (boot wired
417
+ // it); the save only reports. `outcome` carries the touched
418
+ // flattened paths; nothing else to do here.
419
+ void outcome;
420
+ }
421
+ catch (cause) {
422
+ return {
423
+ ok: false,
424
+ error: {
425
+ message: cause instanceof Error ? cause.message : String(cause),
426
+ },
427
+ };
428
+ }
429
+ return { ok: true };
430
+ };
431
+ const readData = async (principal) => {
432
+ const user = userScope ? namedUser(principal) : undefined;
433
+ const userStores = user === undefined
434
+ ? undefined
435
+ : stores.userExtensionStores(slug, user);
436
+ const stored = (userStores === undefined
437
+ ? await stores.settingsFor(slug).get()
438
+ : await userStores.settings.get());
439
+ // Secret-typed field values never render (§5.6/§13.4 write-only —
440
+ // the plaintext settings file shouldn't carry them either, but an
441
+ // older version or a hand edit may have left one behind). Secret
442
+ // STATUSES live at the field's own pointer under the form bind
443
+ // (`settings/<name>`) as the channels' contract strings
444
+ // (`stored`/`env`; an unset key is absent and renders "not set")
445
+ // — both channels resolve a secret field's status at
446
+ // `<bind>/<name>`. Values NEVER appear (§5.6/§7.7).
447
+ const settings = {};
448
+ for (const [name, value] of Object.entries(stored)) {
449
+ if (!allSecretFieldNames.has(name))
450
+ settings[name] = value;
451
+ }
452
+ const secrets = {};
453
+ if (section.secrets !== undefined) {
454
+ const listed = user === undefined
455
+ ? await stores.secretKeys(slug)
456
+ : (await stores.userSecretKeys(slug, user)).map((key) => ({
457
+ key,
458
+ source: "stored",
459
+ }));
460
+ const byKey = new Map(listed.map((entry) => [entry.key, entry.source]));
461
+ for (const secret of section.secrets) {
462
+ const source = byKey.get(secret.key);
463
+ if (source !== undefined) {
464
+ secrets[secret.key] = source;
465
+ settings[secret.key] = source;
466
+ }
467
+ }
468
+ }
469
+ return { settings, secrets };
470
+ };
471
+ return {
472
+ id: section.id,
473
+ title: section.title,
474
+ icon: section.icon ?? "key",
475
+ placement: "settings",
476
+ // Personal settings are distinct surface instances per caller. The
477
+ // scoped key feeds revision state, action dedup, watchers, and upload
478
+ // caps alike; shared sections keep the default params-only identity.
479
+ ...(userScope
480
+ ? {
481
+ instanceKey: (params, principal) => `${namedUser(principal)}\u0000${paramsInstanceKey(params)}`,
482
+ }
483
+ : {}),
484
+ // Settings reads do not advance the concurrency rev (the save
485
+ // action's optimistic check compares against it; §7.3). User-scope
486
+ // data is still resolved from the calling principal on every read.
487
+ sharedData: true,
488
+ render: async (ctx) => {
489
+ const access = accessFor(ctx.principal);
490
+ return {
491
+ tree: form,
492
+ data: access === "none"
493
+ ? {}
494
+ : core === undefined
495
+ ? await readData(ctx.principal)
496
+ : readCoreData(),
497
+ access,
498
+ };
499
+ },
500
+ actions: {
501
+ save: async (ctx, payload) => {
502
+ const user = userScope ? namedUser(ctx.principal) : undefined;
503
+ const userStores = user === undefined
504
+ ? undefined
505
+ : stores.userExtensionStores(slug, user);
506
+ const parsed = parseSavePayload(payload);
507
+ if (parsed === undefined) {
508
+ return { ok: false, error: { message: "save expects {rev, values}" } };
509
+ }
510
+ // Optimistic concurrency (Review Focus 3): the rev the caller's
511
+ // data read was based on must still be the instance's current
512
+ // rev. `ctx.key` is the instance key the service resolved this
513
+ // request under — never a hardcoded one (a params-carrying
514
+ // request must not bypass the check).
515
+ const rev = service.currentRev(slug, section.id, ctx.key);
516
+ if (parsed.rev !== rev) {
517
+ return {
518
+ ok: false,
519
+ error: { message: "changed by someone else" },
520
+ };
521
+ }
522
+ // The core sections save through the core store (the field
523
+ // fold + apply classification live in saveCore).
524
+ if (core !== undefined) {
525
+ return await saveCore(parsed.values);
526
+ }
527
+ // Validate every submitted value against the form's fields
528
+ // (appended secret fields validate as string secrets);
529
+ // unknown submitted keys reject (the form owns the write
530
+ // surface; unknown STORED keys survive untouched below).
531
+ const byName = new Map([
532
+ ...fields.map((field) => [field.name, field]),
533
+ ...appended.map((secret) => [secret.key, { name: secret.key, type: "secret" }]),
534
+ ]);
535
+ for (const [name, value] of Object.entries(parsed.values)) {
536
+ const field = byName.get(name);
537
+ if (field === undefined) {
538
+ return { ok: false, error: { message: `unknown field: ${name}`, field: name } };
539
+ }
540
+ const violation = validateFieldValue(field, value);
541
+ if (violation !== null) {
542
+ return { ok: false, error: { message: violation, field: name } };
543
+ }
544
+ }
545
+ // Split secret-typed values from plain settings values: an
546
+ // empty/absent secret submission means "unchanged" (an
547
+ // untouched write-only field submits nothing meaningful —
548
+ // §5.6); a non-empty string writes the secrets store, never
549
+ // the plaintext settings file.
550
+ const secretWrites = [];
551
+ const plainValues = {};
552
+ for (const [name, value] of Object.entries(parsed.values)) {
553
+ if (allSecretFieldNames.has(name)) {
554
+ if (typeof value === "string" && value !== "") {
555
+ secretWrites.push({ key: name, value });
556
+ }
557
+ continue;
558
+ }
559
+ plainValues[name] = value;
560
+ }
561
+ // Merge into the stored object (unknown stored keys survive —
562
+ // Review Focus 5), write, and fire the §13.2 change path with
563
+ // the top-level keys the write touched. The per-slug promise
564
+ // chain serializes read-merge-writes across this slug's
565
+ // sections so interleaved saves can't drop each other's keys.
566
+ return enqueueWrite(user === undefined ? slug : `${slug}\u0000${user}`, async () => {
567
+ for (const write of secretWrites) {
568
+ if (userStores === undefined) {
569
+ await stores.setSecret(slug, write.key, write.value);
570
+ }
571
+ else {
572
+ await userStores.secrets.set(write.key, write.value);
573
+ }
574
+ }
575
+ const stored = userStores === undefined
576
+ ? await stores.settingsFor(slug).get()
577
+ : await userStores.settings.get();
578
+ const next = { ...stored, ...plainValues };
579
+ // A secret-typed field that previously leaked into the
580
+ // settings file is dropped by the next save (it lives in
581
+ // the secrets store from now on).
582
+ for (const name of allSecretFieldNames) {
583
+ delete next[name];
584
+ }
585
+ if (userStores === undefined) {
586
+ await stores.settingsFor(slug).set(next);
587
+ }
588
+ else {
589
+ await userStores.settings.set(next);
590
+ }
591
+ publishSettingsChanged({ extension: slug }, Object.keys(parsed.values), user);
592
+ return { ok: true };
593
+ });
594
+ },
595
+ },
596
+ };
597
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The turn-admission helper (UI-blocks U5 Task 3, spec §8.3 "Where"):
3
+ * the one prelude every turn starter runs BEFORE its input submission —
4
+ * the §8.3 change note.
5
+ *
6
+ * `submitOp` (src/server/conversation-ops.ts — the composer route and
7
+ * `host.conversations.submit`) inlines this prelude itself; `fireJob`
8
+ * (src/jobs/fire.ts) submits directly and calls {@link noteThen} at both
9
+ * prompt-action sites. The grep-style pin in
10
+ * test/submit-turn-callers.test.ts keeps the `type: "input"` call-site
11
+ * list to exactly those three, so a new turn starter can never skip the
12
+ * note silently.
13
+ */
14
+ import { writePendingNote } from "../blocks/notes.js";
15
+ /**
16
+ * Write the conversation's pending change note, if any, then run
17
+ * `submitInput` (the caller's input admission). The note precedes the
18
+ * input: when the conversation is busy both queue in `pi.inbox`, and
19
+ * pi-durable places every queued write before any queued user item at
20
+ * each boundary — the note reaches the model first, a steer included.
21
+ * Jobs never pass `skipEntry` (a job never answers a block).
22
+ */
23
+ export async function noteThen(harness, conversation, submitInput, ctx) {
24
+ await writePendingNote(harness, conversation, undefined, ctx);
25
+ return await submitInput();
26
+ }
@@ -58,6 +58,22 @@ function deriveSteer(view) {
58
58
  const queued = Array.isArray(items) ? items.length : 0;
59
59
  return { busy, queued };
60
60
  }
61
+ /**
62
+ * One `accounts.json` record in wire form (multi-user M3a, spec §11.3).
63
+ * The fields are copied explicitly rather than spread: a future
64
+ * store-only field must not leak onto the wire by accident. Lives here
65
+ * with the other wire mappers (`serializeConversationView`); the route
66
+ * serves it and the host bridge hands the same shape to extensions.
67
+ */
68
+ export function accountLinkWire(link) {
69
+ return {
70
+ provider: link.provider,
71
+ subject: link.subject,
72
+ user: link.user,
73
+ linkedAt: link.linkedAt,
74
+ via: link.via,
75
+ };
76
+ }
61
77
  /**
62
78
  * Serialize one conversation view onto the wire: the upstream view plus
63
79
  * the derived `steer` field. A shallow spread (upstream frames are