@sigrun/sigrun 0.2.1 → 0.3.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 +50 -0
- package/README.md +234 -1
- package/dist/cli.js +117 -19
- package/dist/config.js +21 -2
- package/dist/extension.d.ts +7 -1
- package/dist/harness/ambient.js +2 -0
- package/dist/harness/blocks/state.js +1 -0
- package/dist/harness/boot.js +86 -6
- package/dist/harness/context.js +25 -0
- package/dist/harness/event-bus.js +25 -14
- package/dist/harness/identity/accounts.js +3 -2
- package/dist/harness/identity/index.js +2 -1
- package/dist/harness/identity/invites.js +26 -9
- package/dist/harness/identity/link-sweep.js +47 -0
- package/dist/harness/identity/typed-username.js +15 -0
- package/dist/harness/identity/users.js +10 -7
- package/dist/harness/interactions.js +2 -0
- package/dist/harness/loader.js +57 -1
- package/dist/harness/migration-snapshot.js +97 -0
- package/dist/harness/reports.js +100 -0
- package/dist/harness/seam-host.js +11 -1
- package/dist/harness/settings.js +52 -4
- package/dist/jobs/docs.js +3 -0
- package/dist/jobs/fire.js +2 -0
- package/dist/jobs/parked.js +38 -0
- package/dist/jobs/task.js +37 -8
- package/dist/serve.js +23 -14
- package/dist/server/auth/ip.js +9 -4
- package/dist/server/auth/own-host.js +115 -0
- package/dist/server/auth/pipeline.js +64 -48
- package/dist/server/auth/proxy-diagnostic.js +60 -0
- package/dist/server/auth/proxy-identity.js +134 -0
- package/dist/server/auth/proxy-only.js +28 -0
- package/dist/server/login/resolve.js +88 -26
- package/dist/server/routes/admin.js +97 -0
- package/dist/server/routes/login.js +8 -0
- package/dist/server/routes/metrics.js +18 -2
- package/dist/server/routes/onboarding.js +97 -37
- package/dist/server/session-meta.js +1 -0
- package/dist/server/shutdown.js +93 -16
- package/dist/settings/registry.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +7 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
- package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +6 -4
- package/node_modules/@sigrun/shared/package.json +5 -1
- package/package.json +14 -12
- package/webui/dist/assets/{index-SO2W4jjk.js → index-CEOzj1C4.js} +11 -11
- package/webui/dist/index.html +1 -1
- package/dist/server/login/usernames.js +0 -15
package/dist/extension.d.ts
CHANGED
|
@@ -139,7 +139,13 @@ export type ExtensionLifecyclePayload = {
|
|
|
139
139
|
export type ExtensionRunSettledHandler = (event: RunSettledEvent, api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
|
|
140
140
|
/** A factory-result lifecycle handler for one event, deadline-bounded. */
|
|
141
141
|
export type ExtensionLifecycleHandler<K extends keyof ExtensionLifecyclePayload> = (event: ExtensionLifecyclePayload[K], api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
|
|
142
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* A factory-result `harnessShutdown` handler (no event payload — shutdown is
|
|
144
|
+
* conversation-less). All extension handlers run CONCURRENTLY under one
|
|
145
|
+
* shared hook deadline, in no guaranteed relative execution or commit order.
|
|
146
|
+
* Handlers may still commit; a wedged handler is abandoned without undoing
|
|
147
|
+
* other handlers' durable commits.
|
|
148
|
+
*/
|
|
143
149
|
export type ExtensionHarnessShutdownHandler = (api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
|
|
144
150
|
/**
|
|
145
151
|
* The `userDeleted` payload (core hardening W1): which directory user
|
package/dist/harness/ambient.js
CHANGED
|
@@ -42,6 +42,7 @@ export const AmbientDoc = defineDocFamily({
|
|
|
42
42
|
kind: "sigrun.ambient",
|
|
43
43
|
version: 1,
|
|
44
44
|
scope: "conversation",
|
|
45
|
+
checkpointWhen: () => false,
|
|
45
46
|
history: "latest",
|
|
46
47
|
fork: "current",
|
|
47
48
|
family: true,
|
|
@@ -57,6 +58,7 @@ export const AmbientOwnersDoc = defineDoc({
|
|
|
57
58
|
kind: "sigrun.ambient.owners",
|
|
58
59
|
version: 1,
|
|
59
60
|
scope: "session",
|
|
61
|
+
checkpointWhen: () => false,
|
|
60
62
|
initial: () => ({ owners: {} }),
|
|
61
63
|
});
|
|
62
64
|
const PLACES = new Set(["status", "footer", "panel"]);
|
package/dist/harness/boot.js
CHANGED
|
@@ -12,10 +12,14 @@ import { join } from "node:path";
|
|
|
12
12
|
import { Harness, } from "@earendil-works/pi-durable";
|
|
13
13
|
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
|
|
14
14
|
import { effectiveMethods } from "../config.js";
|
|
15
|
+
import { requiresProxyVouch } from "../server/auth/proxy-only.js";
|
|
15
16
|
import { wireActivity } from "./activity.js";
|
|
17
|
+
import { snapshotBeforeMigration } from "./migration-snapshot.js";
|
|
16
18
|
import { createScheduleTask } from "../jobs/task.js";
|
|
17
19
|
import { createJobsService, jobWireOf } from "../jobs/index.js";
|
|
18
20
|
import { reconcileJobs } from "../jobs/reconcile.js";
|
|
21
|
+
import { applyParkedJobs } from "../jobs/parked.js";
|
|
22
|
+
import { createDurableReports } from "./reports.js";
|
|
19
23
|
import { registerJobObservers } from "../jobs/observe.js";
|
|
20
24
|
import { JobDoc, JobRunDoc, listRevisions } from "../jobs/docs.js";
|
|
21
25
|
import { EventBus } from "./event-bus.js";
|
|
@@ -49,6 +53,7 @@ import { resolveWorkerEntry, sandboxLauncher, WorkerPool } from "../sandbox/pool
|
|
|
49
53
|
import { IsolationState } from "../sandbox/isolation.js";
|
|
50
54
|
import { probeOsUsers } from "../sandbox/availability.js";
|
|
51
55
|
import { createAdmissionGuard } from "./identity/users.js";
|
|
56
|
+
import { sweepAccountLinks } from "./identity/link-sweep.js";
|
|
52
57
|
import { SessionMetaDoc } from "../server/session-meta.js";
|
|
53
58
|
import { probeIsolation, runHelperProbe, selfTestHelper, } from "../sandbox/availability.js";
|
|
54
59
|
import { resolveBuiltins, } from "./builtins.js";
|
|
@@ -169,6 +174,25 @@ export async function openHarness(config, options = {}) {
|
|
|
169
174
|
// I/O here (each file materializes on its first write), so this is
|
|
170
175
|
// pure construction over paths boot already owns.
|
|
171
176
|
const identity = options.identity ?? openIdentityStores(roots);
|
|
177
|
+
// The account-link sweep (username-derivation Task 8): once at boot,
|
|
178
|
+
// before serving — dangling links are removed, proxy-source crash
|
|
179
|
+
// orphans are logged with their repair command (never auto-repaired:
|
|
180
|
+
// the original proxy username is not derivable from the key).
|
|
181
|
+
// BEST-EFFORT posture: a sweep failure (e.g. a corrupt accounts.json)
|
|
182
|
+
// must not crash-loop a serving deployment; the stores' own readers
|
|
183
|
+
// still fail loud on the request paths if the file is truly corrupt.
|
|
184
|
+
try {
|
|
185
|
+
const linkSweep = await sweepAccountLinks(identity.users, identity.accounts);
|
|
186
|
+
if (linkSweep.removedDangling > 0) {
|
|
187
|
+
console.log(`identity: removed ${linkSweep.removedDangling} dangling account link(s)`);
|
|
188
|
+
}
|
|
189
|
+
for (const orphan of linkSweep.orphans) {
|
|
190
|
+
console.log(`identity: proxy user "${orphan}" has no proxy link; repair with: sigrun user link ${orphan} --proxy <username>`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
console.warn(`identity: account-link sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
195
|
+
}
|
|
172
196
|
// The caller-supplied host wins (serving spec §1: `serve()` resolves
|
|
173
197
|
// the ONE effective host — options.host → SIGRUN_LISTEN_HOST →
|
|
174
198
|
// DEFAULT_LISTEN_HOST — and passes it here). The env read below is
|
|
@@ -209,8 +233,10 @@ export async function openHarness(config, options = {}) {
|
|
|
209
233
|
// is 0600 + atomic, and the cache root is never a backup section).
|
|
210
234
|
// `SIGRUN_SETUP_CODE` pins the value instead (the k8s Secret shape).
|
|
211
235
|
// Claimed boots (every identity-seeded test, every post-onboarding
|
|
212
|
-
// deploy) write nothing.
|
|
213
|
-
|
|
236
|
+
// deploy) write nothing. A proxy-only non-loopback boot writes and
|
|
237
|
+
// logs NOTHING either (C2): account creation there is vouch-only, so
|
|
238
|
+
// a setup code would be an unvouched account-creation path.
|
|
239
|
+
if (!identity.users.isClaimed() && !requiresProxyVouch(methods, listenHost)) {
|
|
214
240
|
// `identity.cacheDir`, not `roots.cacheDir`: the claim route reads
|
|
215
241
|
// `readSetupCode(identity.cacheDir)`, and an injected identity
|
|
216
242
|
// bundle (the test seam) may root elsewhere than the resolved
|
|
@@ -269,7 +295,18 @@ export async function openHarness(config, options = {}) {
|
|
|
269
295
|
now: () => Date.now(),
|
|
270
296
|
managed: managedPaths,
|
|
271
297
|
});
|
|
272
|
-
const storage = await
|
|
298
|
+
const storage = await (async () => {
|
|
299
|
+
// D4: preserve the last schema the previous release can read BEFORE
|
|
300
|
+
// openNodeSqliteStorage applies its one-way durable migration. The
|
|
301
|
+
// injected clock keeps snapshots deterministic in tests; a needed
|
|
302
|
+
// snapshot failure aborts boot here, before any migration write.
|
|
303
|
+
await snapshotBeforeMigration(config.storagePath, {
|
|
304
|
+
env,
|
|
305
|
+
now: options.now ?? (() => new Date()),
|
|
306
|
+
log: (line) => console.log(line),
|
|
307
|
+
});
|
|
308
|
+
return await openNodeSqliteStorage(config.storagePath);
|
|
309
|
+
})();
|
|
273
310
|
const registry = createRegistry();
|
|
274
311
|
// Late-filled by the loader: factories publish through the same registry
|
|
275
312
|
// instance, and selection rewrites read the declared set LIVE.
|
|
@@ -660,6 +697,25 @@ export async function openHarness(config, options = {}) {
|
|
|
660
697
|
onReadinessChange: observeReadiness,
|
|
661
698
|
snapshotReadiness,
|
|
662
699
|
});
|
|
700
|
+
// Per-model compaction overrides (D9): after the boot refresh the
|
|
701
|
+
// catalog is as complete as boot gets, so an override key matching no
|
|
702
|
+
// known model is almost always a typo (or a stale chain name). Warn
|
|
703
|
+
// once per unmatched key — never an error: catalogs move and an
|
|
704
|
+
// unmatched override is simply inert. pi-durable's own key composition
|
|
705
|
+
// is `${provider}/${modelId}`, so a chain-shaped model id yields a
|
|
706
|
+
// doubled prefix (e.g. "bifrost/bifrost/<chain>").
|
|
707
|
+
void bootRefreshDone.then(() => {
|
|
708
|
+
const overrides = config.compaction.modelOverrides ??
|
|
709
|
+
bootSettings.server?.compaction?.modelOverrides;
|
|
710
|
+
if (overrides === undefined)
|
|
711
|
+
return;
|
|
712
|
+
const known = new Set(models.getModels().map((model) => `${model.provider}/${model.id}`));
|
|
713
|
+
for (const key of Object.keys(overrides)) {
|
|
714
|
+
if (!known.has(key)) {
|
|
715
|
+
console.warn(`compaction override "${key}" matches no known model`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
});
|
|
663
719
|
// The boot-wired single-provider refresh (W3 Task 6): the route
|
|
664
720
|
// surface's refresh — the same bound policy `resolveServedModelRefreshing`
|
|
665
721
|
// runs on a miss, with the same readiness observation so a provider a
|
|
@@ -724,15 +780,23 @@ export async function openHarness(config, options = {}) {
|
|
|
724
780
|
const fileSettings = settingsStore.harnessSettings();
|
|
725
781
|
const callerSettings = options.settings;
|
|
726
782
|
// The ONE boot clock (options.now ?? real): the activity stamper AND
|
|
727
|
-
// the harness clock share it (the schedule task's `
|
|
728
|
-
//
|
|
729
|
-
// tests' time-injection lever).
|
|
783
|
+
// the harness clock share it (the schedule task's `sleepUntil`
|
|
784
|
+
// deadlines live on the harness clock; pinning the seam pins both —
|
|
785
|
+
// the jobs tests' time-injection lever).
|
|
730
786
|
const clockNow = options.now ?? (() => new Date());
|
|
787
|
+
// D5: reports may arrive inside Harness.open. The collector logs/counts
|
|
788
|
+
// synchronously and queues parks; job patches drain after binding below.
|
|
789
|
+
const reports = createDurableReports({
|
|
790
|
+
log: (line) => console.error(line),
|
|
791
|
+
onReport: options.onReport,
|
|
792
|
+
});
|
|
731
793
|
const harness = await Harness.open(storage, {
|
|
732
794
|
models,
|
|
733
795
|
registry,
|
|
734
796
|
env: envBuilder,
|
|
735
797
|
now: () => clockNow().getTime(),
|
|
798
|
+
setTimer: options.setTimer,
|
|
799
|
+
onReport: reports.onReport,
|
|
736
800
|
// Context-management policy (Task 8): config's `SIGRUN_COMPACTION_*`
|
|
737
801
|
// tuning, with any caller-set field winning over it. Upstream merges
|
|
738
802
|
// the result over `DEFAULT_COMPACTION_POLICY` per field. Task 8a: the
|
|
@@ -744,6 +808,14 @@ export async function openHarness(config, options = {}) {
|
|
|
744
808
|
// snapshots the file's boot-time values, silently freezing the
|
|
745
809
|
// queue modes and clobbering the caller seam (the round-1 defects).
|
|
746
810
|
// `extensions`/`toolExecution` have no file backing — plain passthrough.
|
|
811
|
+
// Compaction (D9): ONLY `modelOverrides` comes from the file here —
|
|
812
|
+
// it has no env spelling, so this adds a file tier without touching
|
|
813
|
+
// the four env-backed knobs' precedence (env over file, resolved by
|
|
814
|
+
// loadConfig). serve already folds the file into config; the
|
|
815
|
+
// conditional spread keeps a config-resolved map authoritative and
|
|
816
|
+
// covers direct `openHarness` boots. Read ONCE (boot-time), so a
|
|
817
|
+
// PATCH is restart-pending exactly like the other compaction keys.
|
|
818
|
+
// Order: caller seam > config (env over file) > this file fallback.
|
|
747
819
|
settings: {
|
|
748
820
|
get steeringMode() {
|
|
749
821
|
return callerSettings?.steeringMode ?? fileSettings.steeringMode;
|
|
@@ -767,6 +839,10 @@ export async function openHarness(config, options = {}) {
|
|
|
767
839
|
toolExecution: callerSettings?.toolExecution,
|
|
768
840
|
compaction: {
|
|
769
841
|
...config.compaction,
|
|
842
|
+
...(config.compaction.modelOverrides === undefined &&
|
|
843
|
+
bootSettings.server?.compaction?.modelOverrides !== undefined
|
|
844
|
+
? { modelOverrides: bootSettings.server.compaction.modelOverrides }
|
|
845
|
+
: {}),
|
|
770
846
|
...callerSettings?.compaction,
|
|
771
847
|
},
|
|
772
848
|
},
|
|
@@ -786,6 +862,9 @@ export async function openHarness(config, options = {}) {
|
|
|
786
862
|
// JobsService — happens after this point.
|
|
787
863
|
jobRuntimeDeps.harness = harness;
|
|
788
864
|
jobRuntimeDeps.storage = storage;
|
|
865
|
+
// Drain the restart-limit parks reported during open before scheduling
|
|
866
|
+
// resumes. A failed status patch is logged and dropped by applyParkedJobs.
|
|
867
|
+
await applyParkedJobs(harness, reports.takeParked(), BACKGROUND_CONTEXT);
|
|
789
868
|
// The memory-seam event bus (Tasks 2-4): subscribe BEFORE `resume()` so
|
|
790
869
|
// recovery settles (faulted/orphaned submissions the scheduler drains)
|
|
791
870
|
// are observed like any other commit. Extension handlers register under
|
|
@@ -1063,6 +1142,7 @@ export async function openHarness(config, options = {}) {
|
|
|
1063
1142
|
userToolSweep,
|
|
1064
1143
|
models,
|
|
1065
1144
|
jobs,
|
|
1145
|
+
reports,
|
|
1066
1146
|
jobRunTotals: () => jobRunTotals,
|
|
1067
1147
|
defaultModel,
|
|
1068
1148
|
providerCollisions: sink.collisions,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize the extension contract's structural invocation context to the
|
|
3
|
+
* full chord Context required by pi-durable 1.4.0.
|
|
4
|
+
*
|
|
5
|
+
* Extensions deliberately depend only on HandlerContext (`abortSignal`), so
|
|
6
|
+
* they may pass a plain object. Durable publishes read telemetry and other
|
|
7
|
+
* context values, however; forwarding that literal would make the first
|
|
8
|
+
* entry/submission write fail at the host boundary. Deriving from
|
|
9
|
+
* BACKGROUND_CONTEXT supplies the runtime defaults while preserving caller
|
|
10
|
+
* cancellation. A real Context passes through BY IDENTITY so its telemetry
|
|
11
|
+
* parent and application values are never discarded.
|
|
12
|
+
*/
|
|
13
|
+
import { BACKGROUND_CONTEXT, withAbortSignal, } from "@earendil-works/chord/context";
|
|
14
|
+
/** Upgrade a structural handler context, or retain an existing chord context. */
|
|
15
|
+
export function toDurableContext(context) {
|
|
16
|
+
const candidate = context;
|
|
17
|
+
if (typeof candidate.telemetry === "object" &&
|
|
18
|
+
candidate.telemetry !== null &&
|
|
19
|
+
typeof candidate.value === "function") {
|
|
20
|
+
return context;
|
|
21
|
+
}
|
|
22
|
+
return context.abortSignal === undefined
|
|
23
|
+
? BACKGROUND_CONTEXT
|
|
24
|
+
: withAbortSignal(context.abortSignal, BACKGROUND_CONTEXT);
|
|
25
|
+
}
|
|
@@ -64,6 +64,7 @@ import { SessionMetaDoc } from "../server/session-meta.js";
|
|
|
64
64
|
import { EXPANDED_ENTRY_KIND } from "../commands/expand.js";
|
|
65
65
|
import { InteractionDoc, TERMINAL as INTERACTION_TERMINAL } from "./interactions.js";
|
|
66
66
|
import { AmbientDoc } from "./ambient.js";
|
|
67
|
+
import { toDurableContext } from "./context.js";
|
|
67
68
|
import { beginUserDeletion } from "./stores.js";
|
|
68
69
|
/** Project a SessionMeta doc value into the tracked basis. */
|
|
69
70
|
function metaBasisOf(value) {
|
|
@@ -84,9 +85,9 @@ function metaBasisOf(value) {
|
|
|
84
85
|
return basis;
|
|
85
86
|
}
|
|
86
87
|
/** How long shutdown waits for in-flight worker runs before abandoning (spec §3). */
|
|
87
|
-
const WORKER_SHUTDOWN_AWAIT_MS = 30_000;
|
|
88
|
+
export const WORKER_SHUTDOWN_AWAIT_MS = 30_000;
|
|
88
89
|
/** The default last-admission window (gateway spec §8.13). */
|
|
89
|
-
const SHUTDOWN_SETTLE_WAIT_MS = 10_000;
|
|
90
|
+
export const SHUTDOWN_SETTLE_WAIT_MS = 10_000;
|
|
90
91
|
/** Short pause between live-run checks while the commit side lane works. */
|
|
91
92
|
const SHUTDOWN_SETTLE_POLL_MS = 10;
|
|
92
93
|
/**
|
|
@@ -398,10 +399,13 @@ export class EventBus {
|
|
|
398
399
|
* 3. drain pending settles and wait (bounded) for placed live runs —
|
|
399
400
|
* a run settling during this last admission window delivers before
|
|
400
401
|
* the shutdown flush; a wedged run logs its conversation ids;
|
|
401
|
-
* 4. deliver `harnessShutdown` to every extension
|
|
402
|
-
*
|
|
403
|
-
* open in this window (the seal is `Harness.close`, the
|
|
404
|
-
* next step), which is exactly the flush window the spec
|
|
402
|
+
* 4. deliver `harnessShutdown` to every extension CONCURRENTLY under
|
|
403
|
+
* ONE shared hook deadline (D6). Handlers may commit — admission is
|
|
404
|
+
* still open in this window (the seal is `Harness.close`, the
|
|
405
|
+
* caller's next step), which is exactly the flush window the spec
|
|
406
|
+
* promises — but their relative execution and commit order is not
|
|
407
|
+
* guaranteed. A wedged handler is abandoned at the shared deadline;
|
|
408
|
+
* other handlers' completed commits remain durable;
|
|
405
409
|
* 5. close the bus (idempotent; boot's `subscribeClose` wiring makes a
|
|
406
410
|
* later `harness.close` a no-op here).
|
|
407
411
|
*/
|
|
@@ -418,11 +422,18 @@ export class EventBus {
|
|
|
418
422
|
// harnessShutdown carries no conversation: the conversation-scoped
|
|
419
423
|
// reads resolve empty and `document` serves any conversation.
|
|
420
424
|
const api = this.#apiFor(undefined);
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
425
|
+
const registered = [...this.#extensions.entries()].filter(([, handlers]) => handlers.harnessShutdown !== undefined);
|
|
426
|
+
const calls = registered.map(([name, handlers]) => this.#bounded(`extension ${name} harnessShutdown`, () => handlers.harnessShutdown(api, context)));
|
|
427
|
+
let timer;
|
|
428
|
+
const sharedDeadline = new Promise((resolve) => {
|
|
429
|
+
timer = setTimeout(resolve, Math.max(0, this.#hookDeadlineMs));
|
|
430
|
+
});
|
|
431
|
+
try {
|
|
432
|
+
await Promise.race([Promise.allSettled(calls), sharedDeadline]);
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
if (timer !== undefined)
|
|
436
|
+
clearTimeout(timer);
|
|
426
437
|
}
|
|
427
438
|
this.close();
|
|
428
439
|
}
|
|
@@ -1266,15 +1277,15 @@ export class EventBus {
|
|
|
1266
1277
|
return {
|
|
1267
1278
|
entry: async () => undefined,
|
|
1268
1279
|
entries: async () => ({ items: [] }),
|
|
1269
|
-
document: ((token, id, context) => harness.snapshot(token, id, context)),
|
|
1280
|
+
document: ((token, id, context) => harness.snapshot(token, id, toDurableContext(context))),
|
|
1270
1281
|
};
|
|
1271
1282
|
}
|
|
1272
1283
|
return {
|
|
1273
1284
|
entry: (id, context) => this.#readEntry(conversationId, id, context),
|
|
1274
1285
|
entries: (query, limit, cursor, context) => this.#entriesOf(conversationId, query, limit, cursor, context),
|
|
1275
1286
|
// The snapshot overloads are generic per call; the narrowed shape
|
|
1276
|
-
// carries one conversation-doc read
|
|
1277
|
-
document: ((token, id, context) => harness.snapshot(token, id, context)),
|
|
1287
|
+
// carries one conversation-doc read with the D1 context upgrade.
|
|
1288
|
+
document: ((token, id, context) => harness.snapshot(token, id, toDurableContext(context))),
|
|
1278
1289
|
};
|
|
1279
1290
|
}
|
|
1280
1291
|
/** `Conversation.entries` over the settled conversation (read failures resolve empty). */
|
|
@@ -35,6 +35,7 @@ const VIAS = new Set([
|
|
|
35
35
|
"self",
|
|
36
36
|
"pairing",
|
|
37
37
|
"admin",
|
|
38
|
+
"proxy",
|
|
38
39
|
]);
|
|
39
40
|
/**
|
|
40
41
|
* A `(provider, subject)` already linked to a different user. The
|
|
@@ -52,8 +53,8 @@ export class AccountLinkConflictError extends Error {
|
|
|
52
53
|
this.subject = subject;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
|
-
/** `chat:<channel>` / `login:<slug>:<id>` — lowercase, no spaces. */
|
|
56
|
-
const PROVIDER_PATTERN = /^(login|chat):[a-z0-9][a-z0-9._:-]
|
|
56
|
+
/** `proxy` / `chat:<channel>` / `login:<slug>:<id>` — lowercase, no spaces. */
|
|
57
|
+
const PROVIDER_PATTERN = /^(proxy|(login|chat):[a-z0-9][a-z0-9._:-]*)$/;
|
|
57
58
|
/** A subject is opaque, non-empty, and bounded. */
|
|
58
59
|
const SUBJECT_MAX_LENGTH = 512;
|
|
59
60
|
/**
|
|
@@ -21,7 +21,8 @@ import { AccountStore, AccountLinkConflictError, } from "./accounts.js";
|
|
|
21
21
|
import { TokenStore, generateToken, hashToken, } from "./tokens.js";
|
|
22
22
|
import { InviteStore, DEFAULT_INVITE_EXPIRY_MS, } from "./invites.js";
|
|
23
23
|
import { ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, } from "./setup-code.js";
|
|
24
|
-
|
|
24
|
+
import { sweepAccountLinks, } from "./link-sweep.js";
|
|
25
|
+
export { UserDirectory, effectiveAdmin, storedGroups, AccountStore, AccountLinkConflictError, TokenStore, generateToken, hashToken, InviteStore, DEFAULT_INVITE_EXPIRY_MS, ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, sweepAccountLinks, };
|
|
25
26
|
/** The file names each store owns under its root. */
|
|
26
27
|
const USERS_FILE = "users.json";
|
|
27
28
|
const TOKENS_FILE = "api-tokens.json";
|
|
@@ -33,8 +33,17 @@ function matchesCode(record, digest) {
|
|
|
33
33
|
return false;
|
|
34
34
|
return timingSafeEqual(Buffer.from(record.sha256, "hex"), Buffer.from(digest, "hex"));
|
|
35
35
|
}
|
|
36
|
+
/** Locate the valid, unused row for one code, or undefined without effects. */
|
|
37
|
+
function validInviteIndex(records, code, now) {
|
|
38
|
+
const digest = hashToken(code);
|
|
39
|
+
const index = records.findIndex((record) => matchesCode(record, digest));
|
|
40
|
+
if (index === -1)
|
|
41
|
+
return -1;
|
|
42
|
+
const record = records[index];
|
|
43
|
+
return record.usedBy === undefined && now <= record.expiresAt ? index : -1;
|
|
44
|
+
}
|
|
36
45
|
/**
|
|
37
|
-
* The file-backed invite store. `redeem` is the single mutator;
|
|
46
|
+
* The file-backed invite store. `redeem` is the single mutator; all three
|
|
38
47
|
* methods serialize on the store's {@link MutationQueue}.
|
|
39
48
|
*/
|
|
40
49
|
export class InviteStore {
|
|
@@ -66,6 +75,19 @@ export class InviteStore {
|
|
|
66
75
|
return { id: record.id, code, expiresAt: record.expiresAt };
|
|
67
76
|
});
|
|
68
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Validate a code without consuming it: the same digest, single-use, and
|
|
80
|
+
* expiry rules as {@link redeem}, returning its grant while leaving the row
|
|
81
|
+
* untouched. Callers must still redeem last; this is a preflight, not a
|
|
82
|
+
* reservation against another writer.
|
|
83
|
+
*/
|
|
84
|
+
peek(code, now = Date.now()) {
|
|
85
|
+
return this.mutations.run(async () => {
|
|
86
|
+
const records = this.list();
|
|
87
|
+
const index = validInviteIndex(records, code, now);
|
|
88
|
+
return index === -1 ? undefined : { admin: records[index].admin };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
69
91
|
/**
|
|
70
92
|
* Redeem one code for `user`: undefined when no record matches the
|
|
71
93
|
* digest, when the row is already used (single-use, forever), or
|
|
@@ -77,15 +99,10 @@ export class InviteStore {
|
|
|
77
99
|
redeem(code, user, now = Date.now()) {
|
|
78
100
|
return this.mutations.run(async () => {
|
|
79
101
|
const records = this.list();
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
const record = index === -1 ? undefined : records[index];
|
|
83
|
-
if (record === undefined)
|
|
84
|
-
return undefined;
|
|
85
|
-
if (record.usedBy !== undefined)
|
|
86
|
-
return undefined;
|
|
87
|
-
if (now > record.expiresAt)
|
|
102
|
+
const index = validInviteIndex(records, code, now);
|
|
103
|
+
if (index === -1)
|
|
88
104
|
return undefined;
|
|
105
|
+
const record = records[index];
|
|
89
106
|
records[index] = { ...record, usedBy: user };
|
|
90
107
|
await writeJson(this.path, records);
|
|
91
108
|
return { admin: record.admin };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The boot-time account-link sweep (username-derivation Task 8, spec
|
|
3
|
+
* §11.3): runs ONCE at boot, after the identity stores open and before
|
|
4
|
+
* the listener binds — never on the request path, and never inside the
|
|
5
|
+
* claim lock (boot is single-threaded before serving).
|
|
6
|
+
*
|
|
7
|
+
* Two repair classes:
|
|
8
|
+
*
|
|
9
|
+
* - DANGLING LINKS: an `accounts.json` row whose `user` no longer
|
|
10
|
+
* exists (a hand-edited file, or a crash between a user deletion and
|
|
11
|
+
* its link cleanup). The sweep REMOVES them — the link names an
|
|
12
|
+
* identity that cannot resolve, so keeping it only preserves a
|
|
13
|
+
* conflict block against a future re-add.
|
|
14
|
+
*
|
|
15
|
+
* - CRASH ORPHANS: a `source === "proxy"` user with NO proxy link
|
|
16
|
+
* (Task 3's crash window: the user row landed, the link write never
|
|
17
|
+
* did). The sweep LISTS them, never repairs: the original proxy
|
|
18
|
+
* username is not derivable from the directory key (collisions
|
|
19
|
+
* suffix it), so the operator repairs with
|
|
20
|
+
* `sigrun user link <key> --proxy <username>`.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Remove links whose user is gone and list proxy-source users with no
|
|
24
|
+
* proxy link. After the initial reads and the serialized unlinks, the
|
|
25
|
+
* orphan pass deliberately RE-READS accounts.json so a removed link
|
|
26
|
+
* cannot leave stale state in the orphan list.
|
|
27
|
+
*/
|
|
28
|
+
export async function sweepAccountLinks(users, accounts) {
|
|
29
|
+
const known = new Set(users.list().map((record) => record.user));
|
|
30
|
+
const links = accounts.list();
|
|
31
|
+
const dangling = links.filter((link) => !known.has(link.user));
|
|
32
|
+
let removedDangling = 0;
|
|
33
|
+
for (const link of dangling) {
|
|
34
|
+
if (await accounts.unlink(link.provider, link.subject)) {
|
|
35
|
+
removedDangling += 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const linkedProxyUsers = new Set(accounts
|
|
39
|
+
.list()
|
|
40
|
+
.filter((link) => link.provider === "proxy")
|
|
41
|
+
.map((link) => link.user));
|
|
42
|
+
const orphans = users
|
|
43
|
+
.list()
|
|
44
|
+
.filter((record) => record.source === "proxy" && !linkedProxyUsers.has(record.user))
|
|
45
|
+
.map((record) => record.user);
|
|
46
|
+
return { removedDangling, orphans };
|
|
47
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve an operator-typed username to its canonical key. Typed entry
|
|
3
|
+
* points refuse collisions rather than silently suffixing them; the caller
|
|
4
|
+
* owns the surrounding claim/admission locking and supplies `taken`.
|
|
5
|
+
*/
|
|
6
|
+
import { deriveUsername } from "@sigrun/shared/usernames";
|
|
7
|
+
/** Derive a typed username, refusing unusable input and existing keys. */
|
|
8
|
+
export function normalizeTypedUsername(input, taken) {
|
|
9
|
+
const key = deriveUsername(input);
|
|
10
|
+
if (key === undefined)
|
|
11
|
+
return { ok: false, error: "invalid_username" };
|
|
12
|
+
if (taken(key))
|
|
13
|
+
return { ok: false, error: "username_taken" };
|
|
14
|
+
return { ok: true, key };
|
|
15
|
+
}
|
|
@@ -11,9 +11,8 @@
|
|
|
11
11
|
* after the first add (`isClaimed`); `admin` is a property of the USER
|
|
12
12
|
* (tokens carry their user's rights — never their own admin flag).
|
|
13
13
|
*/
|
|
14
|
+
import { isUsernameKey } from "@sigrun/shared/usernames";
|
|
14
15
|
import { MutationQueue, readRecords, writeJson } from "./json-store.js";
|
|
15
|
-
/** The username rule (spec §5.8): lowercase-first, then `[a-z0-9._-]`. */
|
|
16
|
-
const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
17
16
|
/**
|
|
18
17
|
* Strip the retired C8 field at every write boundary. A legacy on-disk
|
|
19
18
|
* value is readable for compatibility, but never survives another write
|
|
@@ -61,9 +60,13 @@ export class UserDirectory {
|
|
|
61
60
|
async writeRecords(records) {
|
|
62
61
|
await writeJson(this.path, records.map(withoutLegacyGroupAdmin));
|
|
63
62
|
}
|
|
64
|
-
/** Run the
|
|
63
|
+
/** Run the shared admission guard before consuming an invite. Writes nothing. */
|
|
65
64
|
checkAdmission() {
|
|
66
|
-
this.
|
|
65
|
+
this.checkAdmissionFor(this.list());
|
|
66
|
+
}
|
|
67
|
+
checkAdmissionFor(records) {
|
|
68
|
+
if (records.length >= 1)
|
|
69
|
+
this.options.admit?.(records.length);
|
|
67
70
|
}
|
|
68
71
|
/** Every user, insertion order (the first add is the onboarding claim). */
|
|
69
72
|
list() {
|
|
@@ -88,15 +91,15 @@ export class UserDirectory {
|
|
|
88
91
|
*/
|
|
89
92
|
add(user) {
|
|
90
93
|
return this.mutations.run(async () => {
|
|
91
|
-
if (!
|
|
92
|
-
throw new Error(`invalid username "${user.user}": must match ^[a-z0-9][a-z0-9._-]
|
|
94
|
+
if (!isUsernameKey(user.user)) {
|
|
95
|
+
throw new Error(`invalid username "${user.user}": must match ^[a-z0-9][a-z0-9._-]*$ and be at most 128 characters`);
|
|
93
96
|
}
|
|
94
97
|
const records = this.list();
|
|
95
98
|
if (records.some((record) => record.user === user.user)) {
|
|
96
99
|
throw new Error(`user "${user.user}" already exists`);
|
|
97
100
|
}
|
|
98
101
|
if (records.length >= 1) {
|
|
99
|
-
this.
|
|
102
|
+
this.checkAdmissionFor(records);
|
|
100
103
|
if (records.length === 1 && records[0].workspace === undefined && this.options.firstWorkspace !== undefined) {
|
|
101
104
|
records[0] = { ...records[0], workspace: this.options.firstWorkspace };
|
|
102
105
|
}
|
|
@@ -62,6 +62,7 @@ export const InteractionDoc = defineDocFamily({
|
|
|
62
62
|
kind: "sigrun.interaction",
|
|
63
63
|
version: 1,
|
|
64
64
|
scope: "conversation",
|
|
65
|
+
checkpointWhen: () => false,
|
|
65
66
|
history: "latest",
|
|
66
67
|
fork: "current",
|
|
67
68
|
family: true,
|
|
@@ -76,6 +77,7 @@ export const InteractionsIndexDoc = defineDoc({
|
|
|
76
77
|
kind: "sigrun.interactions.index",
|
|
77
78
|
version: 1,
|
|
78
79
|
scope: "session",
|
|
80
|
+
checkpointWhen: () => false,
|
|
79
81
|
initial: () => ({ pending: {} }),
|
|
80
82
|
});
|
|
81
83
|
/** Terminal statuses: answering one of these is a no-op (first wins).
|
package/dist/harness/loader.js
CHANGED
|
@@ -89,6 +89,7 @@ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
|
89
89
|
import { createRequire } from "node:module";
|
|
90
90
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
91
91
|
import { fileURLToPath } from "node:url";
|
|
92
|
+
import { satisfies, validRange } from "semver";
|
|
92
93
|
import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageAssetsDir, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
|
|
93
94
|
import { RESERVED_CORE_SECTION_KEYS, RESERVED_EXTENSION_SLUGS, } from "./reserved-sections.js";
|
|
94
95
|
import { slugifyName } from "./stores.js";
|
|
@@ -997,6 +998,57 @@ function checkHostModules(moduleDir) {
|
|
|
997
998
|
`host runs ${hostVersion} (same major, allowed)`);
|
|
998
999
|
}
|
|
999
1000
|
}
|
|
1001
|
+
/**
|
|
1002
|
+
* The Earendil host modules whose DECLARED peer ranges the loader checks.
|
|
1003
|
+
* Other peers (typebox and @sigrun/sigrun) have different compatibility
|
|
1004
|
+
* contracts and are deliberately outside this guard.
|
|
1005
|
+
*/
|
|
1006
|
+
const DECLARED_PEER_MODULES = [
|
|
1007
|
+
"@earendil-works/pi-durable",
|
|
1008
|
+
"@earendil-works/chord",
|
|
1009
|
+
"@earendil-works/pi-ai",
|
|
1010
|
+
];
|
|
1011
|
+
/**
|
|
1012
|
+
* The declared-peer guard (pi-durable 1.4.0 D7): after the resolved-version
|
|
1013
|
+
* guard, reject an Earendil peer range that excludes the running host.
|
|
1014
|
+
* Non-semver specifiers (file:, link:, git:, URLs, workspace:) and wildcards
|
|
1015
|
+
* pass; a missing or unreadable manifest also passes because the guard never
|
|
1016
|
+
* quarantines what it cannot read.
|
|
1017
|
+
*
|
|
1018
|
+
* @param moduleDir the extension ENTRY's directory; the owning package
|
|
1019
|
+
* manifest is the nearest manifest at or above it.
|
|
1020
|
+
* @returns the quarantine reason, or undefined when the declaration admits
|
|
1021
|
+
* the host or cannot be evaluated.
|
|
1022
|
+
*/
|
|
1023
|
+
function checkDeclaredPeers(moduleDir) {
|
|
1024
|
+
const manifestPath = nearestManifest(moduleDir);
|
|
1025
|
+
if (manifestPath === undefined)
|
|
1026
|
+
return undefined;
|
|
1027
|
+
let peers;
|
|
1028
|
+
try {
|
|
1029
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
1030
|
+
if (typeof parsed.peerDependencies !== "object" ||
|
|
1031
|
+
parsed.peerDependencies === null ||
|
|
1032
|
+
Array.isArray(parsed.peerDependencies)) {
|
|
1033
|
+
return undefined;
|
|
1034
|
+
}
|
|
1035
|
+
peers = parsed.peerDependencies;
|
|
1036
|
+
}
|
|
1037
|
+
catch {
|
|
1038
|
+
return undefined;
|
|
1039
|
+
}
|
|
1040
|
+
for (const name of DECLARED_PEER_MODULES) {
|
|
1041
|
+
const range = peers[name];
|
|
1042
|
+
if (typeof range !== "string" || validRange(range) === null)
|
|
1043
|
+
continue;
|
|
1044
|
+
const hostVersion = hostModuleVersion(name);
|
|
1045
|
+
if (hostVersion !== undefined &&
|
|
1046
|
+
!satisfies(hostVersion, range)) {
|
|
1047
|
+
return `peer ${name} ${range} excludes host ${hostVersion}`;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return undefined;
|
|
1051
|
+
}
|
|
1000
1052
|
/**
|
|
1001
1053
|
* The extension module's DIRECTORY — the anchor a contributed bundle's
|
|
1002
1054
|
* relative `dir` resolves against (build-and-webui Task 6: an
|
|
@@ -1497,8 +1549,12 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1497
1549
|
// here rides the existing per-extension try (quarantine, boot
|
|
1498
1550
|
// continues) exactly like the reserved-section check below.
|
|
1499
1551
|
const moduleDir = extensionModuleDir(loaded);
|
|
1500
|
-
if (moduleDir !== undefined)
|
|
1552
|
+
if (moduleDir !== undefined) {
|
|
1501
1553
|
checkHostModules(moduleDir);
|
|
1554
|
+
const peerReason = checkDeclaredPeers(moduleDir);
|
|
1555
|
+
if (peerReason !== undefined)
|
|
1556
|
+
throw new Error(peerReason);
|
|
1557
|
+
}
|
|
1502
1558
|
// Two-phase host: the factory receives the host at CALL time, but
|
|
1503
1559
|
// the extension name and kinds it declares are only known from its
|
|
1504
1560
|
// RESULT. The loader therefore invokes the host factory TWICE with
|