@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.
- package/CHANGELOG.md +104 -0
- package/README.md +352 -36
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +189 -15
- package/dist/harness/ambient.js +39 -185
- package/dist/harness/blocks/entries.js +47 -0
- package/dist/harness/blocks/state.js +58 -0
- package/dist/harness/blocks/tools.js +274 -0
- package/dist/harness/boot.js +253 -20
- package/dist/harness/builtins.js +15 -11
- package/dist/harness/event-bus.js +105 -10
- package/dist/harness/identity/accounts.js +186 -0
- package/dist/harness/identity/groups.js +14 -0
- package/dist/harness/identity/index.js +13 -6
- package/dist/harness/identity/json-store.js +15 -4
- package/dist/harness/identity/setup-code-check.js +9 -0
- package/dist/harness/identity/tokens.js +28 -5
- package/dist/harness/identity/users.js +111 -12
- package/dist/harness/interactions.js +292 -136
- package/dist/harness/loader.js +271 -17
- package/dist/harness/packages.js +1 -1
- package/dist/harness/reserved-sections.js +21 -0
- package/dist/harness/seam-host.js +80 -6
- package/dist/harness/settings.js +63 -2
- package/dist/harness/stores.js +241 -4
- package/dist/harness/user-services.js +391 -0
- package/dist/harness/user-tools.js +187 -0
- package/dist/harness/workspace.js +61 -57
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +162 -0
- package/dist/sandbox/command-observer.js +59 -0
- package/dist/sandbox/grants.js +159 -0
- package/dist/sandbox/helper.js +29 -0
- package/dist/sandbox/isolation.js +29 -0
- package/dist/sandbox/launch.js +20 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +253 -0
- package/dist/sandbox/pool.js +548 -0
- package/dist/sandbox/protocol.js +167 -0
- package/dist/sandbox/sandboxed-env.js +245 -0
- package/dist/sandbox/unavailable-env.js +49 -0
- package/dist/sandbox/worker.js +281 -0
- package/dist/serve.js +82 -7
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +47 -33
- package/dist/server/blocks/answers.js +94 -0
- package/dist/server/blocks/chat-surfaces.js +393 -0
- package/dist/server/blocks/notes.js +216 -0
- package/dist/server/blocks/summaries.js +328 -0
- package/dist/server/commands.js +5 -0
- package/dist/server/content-parts-lazy.js +13 -0
- package/dist/server/conversation-handler.js +2 -5
- package/dist/server/conversation-ops.js +138 -37
- package/dist/server/helpers.js +18 -6
- package/dist/server/host-bridge.js +40 -20
- package/dist/server/images/cache.js +205 -0
- package/dist/server/images/convert.js +13 -0
- package/dist/server/images/fetch.js +302 -0
- package/dist/server/images/ranges.js +42 -0
- package/dist/server/images/rewrite.js +94 -0
- package/dist/server/images/sign.js +84 -0
- package/dist/server/images/sniff.js +62 -0
- package/dist/server/index.js +144 -4
- package/dist/server/login/flows.js +126 -0
- package/dist/server/login/http.js +31 -0
- package/dist/server/login/resolve.js +133 -0
- package/dist/server/login/setup-claim.js +55 -0
- package/dist/server/login/usernames.js +15 -0
- package/dist/server/ownership.js +13 -0
- package/dist/server/routes/admin.js +58 -0
- package/dist/server/routes/auth.js +88 -2
- package/dist/server/routes/conversations.js +3 -6
- package/dist/server/routes/events.js +28 -3
- package/dist/server/routes/extensions.js +37 -5
- package/dist/server/routes/images.js +267 -0
- package/dist/server/routes/interactions.js +57 -68
- package/dist/server/routes/jobs.js +2 -8
- package/dist/server/routes/login.js +223 -0
- package/dist/server/routes/me.js +90 -0
- package/dist/server/routes/meta.js +15 -0
- package/dist/server/routes/onboarding.js +94 -84
- package/dist/server/routes/settings-sections.js +190 -0
- package/dist/server/routes/settings.js +16 -4
- package/dist/server/routes/submissions.js +1 -1
- package/dist/server/routes/surfaces.js +175 -23
- package/dist/server/routes/users.js +116 -20
- package/dist/server/schema-fixture.js +213 -0
- package/dist/server/session-meta.js +3 -0
- package/dist/server/shutdown.js +11 -4
- package/dist/server/static.js +18 -0
- package/dist/server/surfaces/dedup.js +9 -2
- package/dist/server/surfaces/service.js +181 -13
- package/dist/server/surfaces/settings-surfaces.js +597 -0
- package/dist/server/turns/submit-turn.js +26 -0
- package/dist/server/views.js +16 -0
- package/dist/settings/core-sections.js +532 -0
- package/dist/settings/pending.js +181 -0
- package/dist/settings/registry.js +453 -0
- package/dist/skills/index.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +6 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
- package/node_modules/@sigrun/shared/dist/access.js +11 -4
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
- package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
- package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
- package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
- package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
- package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
- package/node_modules/@sigrun/shared/dist/themes.js +2 -3
- package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/turns.js +53 -3
- package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +11 -6
- package/webui/dist/assets/index-CxB969qp.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-CiYInlLx.css +0 -1
- package/webui/dist/assets/index-JDfOQV9z.js +0 -52
|
@@ -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
|
+
}
|
package/dist/server/views.js
CHANGED
|
@@ -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
|