@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.
- package/CHANGELOG.md +136 -0
- package/README.md +352 -36
- package/THIRD_PARTY_NOTICES.md +23 -0
- 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/agent-env.js +62 -0
- 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 -55
- 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 +15 -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 +13 -8
- 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-B4-GYqEb.js +0 -50
- package/webui/dist/assets/index-BiSMljer.css +0 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/harness/settings.js
CHANGED
|
@@ -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
|
package/dist/harness/stores.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|