@sigrun/sigrun 0.2.0 → 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 +145 -7
- package/README.md +333 -13
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/cli.js +117 -19
- package/dist/config.js +21 -2
- package/dist/extension.d.ts +48 -8
- package/dist/harness/ambient.js +2 -0
- package/dist/harness/blocks/state.js +1 -0
- package/dist/harness/boot.js +92 -6
- package/dist/harness/builtins.js +1 -1
- 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 +23 -1
- package/dist/harness/sections/context-files.js +1 -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/sandbox/availability.js +69 -20
- package/dist/sandbox/launch.js +11 -0
- package/dist/sandbox/policy.js +24 -3
- package/dist/sandbox/worker.js +4 -24
- package/dist/serve.js +162 -40
- package/dist/server/auth/ip.js +9 -4
- package/dist/server/auth/own-host.js +115 -0
- package/dist/server/auth/pipeline.js +93 -28
- 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/images/cache.js +13 -5
- package/dist/server/index.js +10 -2
- package/dist/server/login/resolve.js +88 -26
- package/dist/server/notifications.js +21 -4
- package/dist/server/routes/admin.js +97 -0
- package/dist/server/routes/images.js +16 -9
- package/dist/server/routes/login.js +8 -0
- package/dist/server/routes/me.js +1 -1
- package/dist/server/routes/meta.js +4 -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 +12 -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 +33 -4
- package/node_modules/@sigrun/shared/package.json +5 -1
- package/package.json +16 -15
- package/webui/dist/assets/{index-CxB969qp.js → index-CEOzj1C4.js} +11 -11
- package/webui/dist/index.html +1 -1
- package/CONTEXT.md +0 -163
- package/dist/server/login/usernames.js +0 -15
|
@@ -17,7 +17,12 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
17
17
|
import { withClaimLock } from "../auth/claim-lock.js";
|
|
18
18
|
import { ipAllowed } from "../auth/ip.js";
|
|
19
19
|
import { isLoopbackHost } from "../auth/loopback.js";
|
|
20
|
+
import { ownHostPeerPermitted } from "../auth/own-host.js";
|
|
21
|
+
import { previewProxyUser, provisionProxyUser } from "../auth/proxy-identity.js";
|
|
22
|
+
import { ProxyRejectReason, warnProxyRejected, } from "../auth/proxy-diagnostic.js";
|
|
23
|
+
import { requiresProxyVouch } from "../auth/proxy-only.js";
|
|
20
24
|
import { readSetupCode, clearSetupCode } from "../../harness/identity/index.js";
|
|
25
|
+
import { normalizeTypedUsername } from "../../harness/identity/typed-username.js";
|
|
21
26
|
import { setupCodeMatches } from "../../harness/identity/setup-code-check.js";
|
|
22
27
|
import { AdmissionRefusedError } from "../../harness/identity/users.js";
|
|
23
28
|
import { stampSessionOwner } from "../helpers.js";
|
|
@@ -28,8 +33,6 @@ function headerOf(request, name) {
|
|
|
28
33
|
const single = Array.isArray(value) ? value[0] : value;
|
|
29
34
|
return single?.trim() || undefined;
|
|
30
35
|
}
|
|
31
|
-
/** The username rule (spec §5.8, shared with the directory's own check). */
|
|
32
|
-
const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
33
36
|
/** Constant-time string equality (the pipeline's discipline). */
|
|
34
37
|
function timingSafeStringEqual(a, b) {
|
|
35
38
|
const left = Buffer.from(a, "utf8");
|
|
@@ -52,8 +55,24 @@ function timingSafeStringEqual(a, b) {
|
|
|
52
55
|
function resolveProxyIdentity(request, ctx) {
|
|
53
56
|
if (!ctx.methods.includes("proxy"))
|
|
54
57
|
return undefined;
|
|
58
|
+
// The rejection diagnostic (proxy-trust C1): while the instance is
|
|
59
|
+
// UNCLAIMED, every rejection branch reports WHY via the shared,
|
|
60
|
+
// throttled warn — these routes sit on the public path the pipeline
|
|
61
|
+
// never visits, so without this their proxy rejections are silent.
|
|
62
|
+
// Once claimed the onboarding surface no longer needs the signal.
|
|
63
|
+
const diagnose = !ctx.opened.identity.users.isClaimed();
|
|
55
64
|
const { proxy } = ctx.config.auth;
|
|
56
65
|
if (!ipAllowed(request.socket.remoteAddress, proxy.trustedProxies)) {
|
|
66
|
+
if (diagnose)
|
|
67
|
+
warnProxyRejected(request, ProxyRejectReason.UNTRUSTED);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
// C5: the same own-host discipline as the pipeline — an own-host
|
|
71
|
+
// peer reached through a range without the shared secret never
|
|
72
|
+
// vouches (any local process could claim the first admin).
|
|
73
|
+
if (!ownHostPeerPermitted(request.socket.remoteAddress, proxy.trustedProxies, proxy.secret !== undefined && proxy.secretHeader !== undefined)) {
|
|
74
|
+
if (diagnose)
|
|
75
|
+
warnProxyRejected(request, ProxyRejectReason.OWN_HOST);
|
|
57
76
|
return undefined;
|
|
58
77
|
}
|
|
59
78
|
if (proxy.secret !== undefined && proxy.secretHeader !== undefined) {
|
|
@@ -62,16 +81,32 @@ function resolveProxyIdentity(request, ctx) {
|
|
|
62
81
|
delete request.headers[name];
|
|
63
82
|
if (presented === undefined ||
|
|
64
83
|
!timingSafeStringEqual(presented, proxy.secret)) {
|
|
84
|
+
if (diagnose) {
|
|
85
|
+
warnProxyRejected(request, ProxyRejectReason.secretReason(proxy.secretHeader));
|
|
86
|
+
}
|
|
65
87
|
return undefined;
|
|
66
88
|
}
|
|
67
89
|
}
|
|
68
90
|
const user = headerOf(request, proxy.userHeader);
|
|
69
|
-
|
|
91
|
+
// An absent OR EMPTY (headerOf trims to undefined) user header is the
|
|
92
|
+
// missing-header reason; a present-but-unrepresentable claim is the
|
|
93
|
+
// unusable-claim reason. Neither line carries the claimed VALUE.
|
|
94
|
+
if (user === undefined) {
|
|
95
|
+
if (diagnose) {
|
|
96
|
+
warnProxyRejected(request, ProxyRejectReason.missingHeaderReason(proxy.userHeader.toLowerCase()));
|
|
97
|
+
}
|
|
70
98
|
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const preview = previewProxyUser(ctx.opened.identity, user);
|
|
101
|
+
if (preview === undefined) {
|
|
102
|
+
if (diagnose)
|
|
103
|
+
warnProxyRejected(request, ProxyRejectReason.UNUSABLE);
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
71
106
|
const name = headerOf(request, proxy.nameHeader);
|
|
72
107
|
const email = headerOf(request, proxy.emailHeader);
|
|
73
108
|
return {
|
|
74
|
-
|
|
109
|
+
...preview,
|
|
75
110
|
...(name === undefined ? {} : { name }),
|
|
76
111
|
...(email === undefined ? {} : { email }),
|
|
77
112
|
};
|
|
@@ -132,6 +167,15 @@ export function registerOnboardingRoutes(app, ctx) {
|
|
|
132
167
|
}
|
|
133
168
|
const proxyIdentity = resolveProxyIdentity(request, ctx);
|
|
134
169
|
const loopback = isLoopbackHost(ctx.listenHost);
|
|
170
|
+
// C2: a proxy-only non-loopback server creates accounts ONLY
|
|
171
|
+
// through a proxy vouch — refuse BEFORE code evaluation, so even
|
|
172
|
+
// a valid setup code or invite code cannot mint an account
|
|
173
|
+
// unvouched on this posture. Mixed [proxy, token] and loopback
|
|
174
|
+
// binds keep the code paths.
|
|
175
|
+
if (requiresProxyVouch(ctx.methods, ctx.listenHost) &&
|
|
176
|
+
proxyIdentity === undefined) {
|
|
177
|
+
return await reply.code(403).send({ error: "proxy_identity_required" });
|
|
178
|
+
}
|
|
135
179
|
// The code's validity is evaluated ONCE, BEFORE the mutex: the winner
|
|
136
180
|
// clears the setup-code file inside its critical section, so a claim
|
|
137
181
|
// that loses the race must still be recognizable as having held a
|
|
@@ -149,25 +193,29 @@ export function registerOnboardingRoutes(app, ctx) {
|
|
|
149
193
|
// code ("the first invite" — same shape, admin grant).
|
|
150
194
|
let grant;
|
|
151
195
|
if (proxyIdentity !== undefined) {
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
196
|
+
// Body identity fields are ignored — the proxy vouched. This
|
|
197
|
+
// route already holds the claim lock, so call the lock-free
|
|
198
|
+
// resolver body, never the pipeline entry.
|
|
199
|
+
const resolved = await provisionProxyUser(identity, proxyIdentity.subject, {
|
|
200
|
+
...(proxyIdentity.name === undefined ? {} : { name: proxyIdentity.name }),
|
|
201
|
+
...(proxyIdentity.email === undefined ? {} : { email: proxyIdentity.email }),
|
|
202
|
+
}, { provision: true, admin: true });
|
|
203
|
+
if (resolved.kind === "invalid") {
|
|
204
|
+
return { status: 400, error: "invalid_username" };
|
|
205
|
+
}
|
|
206
|
+
if (resolved.kind === "not_provisioned") {
|
|
207
|
+
return { status: 409, error: "already_claimed" };
|
|
208
|
+
}
|
|
209
|
+
grant = resolved.record;
|
|
155
210
|
}
|
|
156
|
-
else if (loopback) {
|
|
211
|
+
else if (loopback || codeWasSetup) {
|
|
157
212
|
if (validated.user === undefined)
|
|
158
213
|
return { status: 400, error: "invalid_body" };
|
|
214
|
+
const typed = normalizeTypedUsername(validated.user, (key) => identity.users.get(key) !== undefined);
|
|
215
|
+
if (!typed.ok)
|
|
216
|
+
return { status: typed.error === "invalid_username" ? 400 : 409, error: typed.error };
|
|
159
217
|
grant = {
|
|
160
|
-
user:
|
|
161
|
-
...(validated.name === undefined ? {} : { name: validated.name }),
|
|
162
|
-
...(validated.email === undefined ? {} : { email: validated.email }),
|
|
163
|
-
source: "cli",
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
else if (codeWasSetup) {
|
|
167
|
-
if (validated.user === undefined)
|
|
168
|
-
return { status: 400, error: "invalid_body" };
|
|
169
|
-
grant = {
|
|
170
|
-
user: validated.user,
|
|
218
|
+
user: typed.key,
|
|
171
219
|
...(validated.name === undefined ? {} : { name: validated.name }),
|
|
172
220
|
...(validated.email === undefined ? {} : { email: validated.email }),
|
|
173
221
|
source: "cli",
|
|
@@ -177,10 +225,8 @@ export function registerOnboardingRoutes(app, ctx) {
|
|
|
177
225
|
// Wrong or absent code where one is required.
|
|
178
226
|
return { status: 403, error: "invalid_code" };
|
|
179
227
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
const record = await identity.users.add({
|
|
228
|
+
// The proxy resolver already added and linked its record.
|
|
229
|
+
const record = "createdAt" in grant ? grant : await identity.users.add({
|
|
184
230
|
user: grant.user,
|
|
185
231
|
...(grant.name === undefined ? {} : { name: grant.name }),
|
|
186
232
|
...(grant.email === undefined ? {} : { email: grant.email }),
|
|
@@ -207,17 +253,31 @@ export function registerOnboardingRoutes(app, ctx) {
|
|
|
207
253
|
return { status: 409, error: "already_claimed" };
|
|
208
254
|
}
|
|
209
255
|
if (validated.code !== undefined) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
256
|
+
if (validated.user === undefined) {
|
|
257
|
+
return { status: 403, error: "invalid_code" };
|
|
258
|
+
}
|
|
259
|
+
const typed = normalizeTypedUsername(validated.user, (key) => identity.users.get(key) !== undefined);
|
|
260
|
+
if (!typed.ok && typed.error === "invalid_username") {
|
|
261
|
+
return { status: 400, error: typed.error };
|
|
262
|
+
}
|
|
263
|
+
// Validate before revealing a collision, but never consume
|
|
264
|
+
// the invite until every refusal check has passed.
|
|
265
|
+
const invite = await identity.invites.peek(validated.code);
|
|
213
266
|
if (invite === undefined) {
|
|
214
267
|
return { status: 403, error: "invalid_code" };
|
|
215
268
|
}
|
|
269
|
+
if (!typed.ok)
|
|
270
|
+
return { status: 409, error: typed.error };
|
|
271
|
+
identity.users.checkAdmission();
|
|
272
|
+
const redeemed = await identity.invites.redeem(validated.code, typed.key);
|
|
273
|
+
if (redeemed === undefined) {
|
|
274
|
+
return { status: 403, error: "invalid_code" };
|
|
275
|
+
}
|
|
216
276
|
const record = await identity.users.add({
|
|
217
|
-
user:
|
|
277
|
+
user: typed.key,
|
|
218
278
|
...(validated.name === undefined ? {} : { name: validated.name }),
|
|
219
279
|
...(validated.email === undefined ? {} : { email: validated.email }),
|
|
220
|
-
admin:
|
|
280
|
+
admin: redeemed.admin,
|
|
221
281
|
source: "invite",
|
|
222
282
|
});
|
|
223
283
|
return { status: 200, record };
|
|
@@ -228,17 +288,17 @@ export function registerOnboardingRoutes(app, ctx) {
|
|
|
228
288
|
if (proxyIdentity === undefined) {
|
|
229
289
|
return { status: 409, error: "already_claimed" };
|
|
230
290
|
}
|
|
231
|
-
const
|
|
232
|
-
if (existing !== undefined)
|
|
233
|
-
return { status: 200, record: existing };
|
|
234
|
-
const record = await identity.users.add({
|
|
235
|
-
user: proxyIdentity.user,
|
|
291
|
+
const resolved = await provisionProxyUser(identity, proxyIdentity.subject, {
|
|
236
292
|
...(proxyIdentity.name === undefined ? {} : { name: proxyIdentity.name }),
|
|
237
293
|
...(proxyIdentity.email === undefined ? {} : { email: proxyIdentity.email }),
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
294
|
+
},
|
|
295
|
+
// A codeless onboarding claim has always auto-provisioned the
|
|
296
|
+
// vouched identity, independent of the pipeline's policy.
|
|
297
|
+
{ provision: true, admin: false });
|
|
298
|
+
if (resolved.kind !== "user") {
|
|
299
|
+
return { status: 409, error: "already_claimed" };
|
|
300
|
+
}
|
|
301
|
+
return { status: 200, record: resolved.record };
|
|
242
302
|
}
|
|
243
303
|
catch (error) {
|
|
244
304
|
if (error instanceof AdmissionRefusedError) {
|
|
@@ -49,6 +49,7 @@ export const SessionMetaDoc = defineDoc({
|
|
|
49
49
|
kind: "sigrun.session-meta",
|
|
50
50
|
version: 1,
|
|
51
51
|
scope: "conversation",
|
|
52
|
+
checkpointWhen: () => false,
|
|
52
53
|
history: "latest",
|
|
53
54
|
fork: "current",
|
|
54
55
|
// Defensive copy (the alias-free-ownership pattern): the tracker takes
|
package/dist/server/shutdown.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* The ordered shutdown (gateway C2's extraction of serve.ts's inline
|
|
3
3
|
* `shutdown`): eight steps, each in its own try/catch so a failure never
|
|
4
4
|
* skips the remaining steps, with every failure collected into one
|
|
5
|
-
* `AggregateError("sigrun shutdown failed")` after ALL steps ran
|
|
6
|
-
*
|
|
5
|
+
* `AggregateError("sigrun shutdown failed")` after ALL steps ran. Every
|
|
6
|
+
* asynchronous stage has a fixed cap; reaching one abandons only that wait,
|
|
7
|
+
* logs, and continues, so the composed shutdown has a fixed worst case.
|
|
7
8
|
*
|
|
8
9
|
* The order (spec §8.0 — the gateway lifecycle slots between the server
|
|
9
10
|
* drain and the event-bus flush):
|
|
@@ -28,6 +29,82 @@
|
|
|
28
29
|
* Does NOT exit the process — the caller owns exit policy (the deploy
|
|
29
30
|
* entry exits 0/1; the CLI's `run` returns to its exit code).
|
|
30
31
|
*/
|
|
32
|
+
import { SHUTDOWN_SETTLE_WAIT_MS, WORKER_SHUTDOWN_AWAIT_MS, } from "../harness/event-bus.js";
|
|
33
|
+
/** Every ordered stage has a bound; these are the bounds used by the budget. */
|
|
34
|
+
export const END_STREAMS_SHUTDOWN_CAP_MS = 0;
|
|
35
|
+
export const CLOSE_APP_SHUTDOWN_CAP_MS = 10_000;
|
|
36
|
+
export const GATEWAY_STOP_INTAKE_SHUTDOWN_CAP_MS = 10_000;
|
|
37
|
+
export const GATEWAY_STOP_SHUTDOWN_CAP_MS = 10_000;
|
|
38
|
+
export const SANDBOX_WORKERS_SHUTDOWN_CAP_MS = 5_000;
|
|
39
|
+
export const PROVIDER_REFRESH_SHUTDOWN_CAP_MS = 2_000;
|
|
40
|
+
/**
|
|
41
|
+
* Measured against a real 1.4.0 boot over a copy of the durable-v1 fixture
|
|
42
|
+
* in test/shutdown-order.test.ts (0.6ms on the implementation run). The test
|
|
43
|
+
* asserts this cap is at least four times that measurement, so a slow but
|
|
44
|
+
* healthy close never gets converted into a counted restart by a guessed cap.
|
|
45
|
+
*/
|
|
46
|
+
export const CLOSE_HARNESS_SHUTDOWN_CAP_MS = 10_000;
|
|
47
|
+
/**
|
|
48
|
+
* The event bus's bounded stages: worker stop + the initial drain + the
|
|
49
|
+
* live-run settle wait AND the final drain that can start just before its
|
|
50
|
+
* deadline + all extension handlers under one shared deadline. The settle
|
|
51
|
+
* wait is a per-boot test override; the production budget uses its default.
|
|
52
|
+
* This bounds each drain WAIT, not work a caller can ride on an already
|
|
53
|
+
* running side lane: the grace holds when the side lane is idle at shutdown
|
|
54
|
+
* (the quiet admission window). An outer boundedStage wrap or stopping
|
|
55
|
+
* enqueue at shutdownSequence entry is a deferred structural option.
|
|
56
|
+
*/
|
|
57
|
+
export function eventBusShutdownCapMs(config) {
|
|
58
|
+
return (WORKER_SHUTDOWN_AWAIT_MS +
|
|
59
|
+
config.hookDeadlineMs +
|
|
60
|
+
SHUTDOWN_SETTLE_WAIT_MS +
|
|
61
|
+
config.hookDeadlineMs +
|
|
62
|
+
config.hookDeadlineMs);
|
|
63
|
+
}
|
|
64
|
+
/** The fixed worst case of the complete ordered shutdown, in milliseconds. */
|
|
65
|
+
export function shutdownBudgetMs(config) {
|
|
66
|
+
return (END_STREAMS_SHUTDOWN_CAP_MS +
|
|
67
|
+
CLOSE_APP_SHUTDOWN_CAP_MS +
|
|
68
|
+
GATEWAY_STOP_INTAKE_SHUTDOWN_CAP_MS +
|
|
69
|
+
eventBusShutdownCapMs(config) +
|
|
70
|
+
GATEWAY_STOP_SHUTDOWN_CAP_MS +
|
|
71
|
+
SANDBOX_WORKERS_SHUTDOWN_CAP_MS +
|
|
72
|
+
PROVIDER_REFRESH_SHUTDOWN_CAP_MS +
|
|
73
|
+
CLOSE_HARNESS_SHUTDOWN_CAP_MS);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Await one stage without allowing a wedged implementation to outlive its
|
|
77
|
+
* budget share. Abandonment is logged; the next ordered stage still runs.
|
|
78
|
+
*/
|
|
79
|
+
async function boundedStage(name, capMs, run) {
|
|
80
|
+
let timer;
|
|
81
|
+
const deadline = new Promise((resolve) => {
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
console.error(`sigrun: shutdown stage ${name} exceeded ${capMs}ms — abandoning the wait`);
|
|
84
|
+
resolve();
|
|
85
|
+
}, capMs);
|
|
86
|
+
});
|
|
87
|
+
let abandoned = false;
|
|
88
|
+
const stage = Promise.resolve().then(run);
|
|
89
|
+
// The race can abandon a stage that later rejects. Observe that rejection
|
|
90
|
+
// here so it is logged rather than becoming an unhandled rejection. An
|
|
91
|
+
// early rejection already propagated to createShutdown's failure collector,
|
|
92
|
+
// so it must not also be reported as an abandonment.
|
|
93
|
+
stage.catch((error) => {
|
|
94
|
+
if (abandoned) {
|
|
95
|
+
console.error(`sigrun: shutdown stage ${name} rejected after abandonment:`, error);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
await Promise.race([stage, deadline.then(() => {
|
|
100
|
+
abandoned = true;
|
|
101
|
+
})]);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
if (timer !== undefined)
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
31
108
|
/**
|
|
32
109
|
* Build the ordered shutdown over the eight steps. Failures from ANY step
|
|
33
110
|
* are collected (the remaining steps still run) and thrown together as
|
|
@@ -41,52 +118,52 @@ export function createShutdown(steps) {
|
|
|
41
118
|
steps.endStreams();
|
|
42
119
|
}
|
|
43
120
|
catch (error) {
|
|
44
|
-
failures.push(error);
|
|
121
|
+
failures.push({ stage: "endStreams", error });
|
|
45
122
|
}
|
|
46
123
|
try {
|
|
47
|
-
await steps.closeApp();
|
|
124
|
+
await boundedStage("closeApp", CLOSE_APP_SHUTDOWN_CAP_MS, () => steps.closeApp());
|
|
48
125
|
}
|
|
49
126
|
catch (error) {
|
|
50
|
-
failures.push(error);
|
|
127
|
+
failures.push({ stage: "closeApp", error });
|
|
51
128
|
}
|
|
52
129
|
try {
|
|
53
|
-
await steps.gateway.stopIntake();
|
|
130
|
+
await boundedStage("gateway.stopIntake", GATEWAY_STOP_INTAKE_SHUTDOWN_CAP_MS, () => steps.gateway.stopIntake());
|
|
54
131
|
}
|
|
55
132
|
catch (error) {
|
|
56
|
-
failures.push(error);
|
|
133
|
+
failures.push({ stage: "gateway.stopIntake", error });
|
|
57
134
|
}
|
|
58
135
|
try {
|
|
59
136
|
await steps.eventBusShutdown();
|
|
60
137
|
}
|
|
61
138
|
catch (error) {
|
|
62
|
-
failures.push(error);
|
|
139
|
+
failures.push({ stage: "eventBusShutdown", error });
|
|
63
140
|
}
|
|
64
141
|
try {
|
|
65
|
-
await steps.gateway.stop();
|
|
142
|
+
await boundedStage("gateway.stop", GATEWAY_STOP_SHUTDOWN_CAP_MS, () => steps.gateway.stop());
|
|
66
143
|
}
|
|
67
144
|
catch (error) {
|
|
68
|
-
failures.push(error);
|
|
145
|
+
failures.push({ stage: "gateway.stop", error });
|
|
69
146
|
}
|
|
70
147
|
try {
|
|
71
|
-
await steps.stopSandboxWorkers();
|
|
148
|
+
await boundedStage("stopSandboxWorkers", SANDBOX_WORKERS_SHUTDOWN_CAP_MS, () => steps.stopSandboxWorkers());
|
|
72
149
|
}
|
|
73
150
|
catch (error) {
|
|
74
|
-
failures.push(error);
|
|
151
|
+
failures.push({ stage: "stopSandboxWorkers", error });
|
|
75
152
|
}
|
|
76
153
|
try {
|
|
77
154
|
await steps.stopProviderRefresh();
|
|
78
155
|
}
|
|
79
156
|
catch (error) {
|
|
80
|
-
failures.push(error);
|
|
157
|
+
failures.push({ stage: "stopProviderRefresh", error });
|
|
81
158
|
}
|
|
82
159
|
try {
|
|
83
|
-
await steps.closeHarness();
|
|
160
|
+
await boundedStage("closeHarness", CLOSE_HARNESS_SHUTDOWN_CAP_MS, () => steps.closeHarness());
|
|
84
161
|
}
|
|
85
162
|
catch (error) {
|
|
86
|
-
failures.push(error);
|
|
163
|
+
failures.push({ stage: "closeHarness", error });
|
|
87
164
|
}
|
|
88
165
|
if (failures.length > 0) {
|
|
89
|
-
throw new AggregateError(failures,
|
|
166
|
+
throw new AggregateError(failures.map(({ error }) => error), `sigrun shutdown failed: ${failures.map(({ stage }) => stage).join(", ")}`);
|
|
90
167
|
}
|
|
91
168
|
};
|
|
92
169
|
}
|
|
@@ -290,6 +290,12 @@ export const CORE_SETTING_KEYS = [
|
|
|
290
290
|
section: "advanced",
|
|
291
291
|
reason: "compaction policy resolves into the boot harness settings",
|
|
292
292
|
},
|
|
293
|
+
{
|
|
294
|
+
key: "server.compaction.modelOverrides",
|
|
295
|
+
apply: "restart",
|
|
296
|
+
section: "advanced",
|
|
297
|
+
reason: "compaction policy resolves into the boot harness settings",
|
|
298
|
+
},
|
|
293
299
|
{
|
|
294
300
|
key: "server.memorySeam.hookDeadlineMs",
|
|
295
301
|
apply: "restart",
|
|
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
|
+
### Added
|
|
10
|
+
- The `./usernames` export for shared username derivation and validation.
|
|
11
|
+
|
|
12
|
+
## [0.3.0] - 2026-10-29
|
|
13
|
+
### Added
|
|
14
|
+
- `ServerSettings.compaction.modelOverrides` declares per-model compaction
|
|
15
|
+
policy overrides in the shared settings wire type.
|
|
16
|
+
|
|
17
|
+
## [0.2.1] - 2026-10-28
|
|
18
|
+
### Added
|
|
19
|
+
- Additive listener metadata on the meta response: host, port, and
|
|
20
|
+
measured address families.
|
|
9
21
|
|
|
10
22
|
## [0.2.0] - 2026-10-27
|
|
11
23
|
### Added
|
|
@@ -136,6 +136,20 @@ export interface ServerSettings {
|
|
|
136
136
|
readonly reserveTokens?: number;
|
|
137
137
|
readonly keepRecentTokens?: number;
|
|
138
138
|
readonly backgroundTokens?: number;
|
|
139
|
+
/**
|
|
140
|
+
* Per-model compaction policy overrides, keyed by the exact
|
|
141
|
+
* `provider/modelId` string pi-durable composes — a chain-shaped
|
|
142
|
+
* model id doubles the prefix (e.g. `bifrost/bifrost/<chain>`).
|
|
143
|
+
* Each field replaces the harness-wide one for that model, so a
|
|
144
|
+
* small-window model can sit alongside the large-window default.
|
|
145
|
+
* Boot-time lifetime, like the rest of compaction.
|
|
146
|
+
*/
|
|
147
|
+
readonly modelOverrides?: Readonly<Record<string, Partial<{
|
|
148
|
+
enabled: boolean;
|
|
149
|
+
reserveTokens: number;
|
|
150
|
+
keepRecentTokens: number;
|
|
151
|
+
backgroundTokens: number;
|
|
152
|
+
}>>>;
|
|
139
153
|
};
|
|
140
154
|
/**
|
|
141
155
|
* The memory-seam bounds (memory-seam Task 6), mirroring the
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const MAX_USERNAME_LENGTH = 128;
|
|
2
|
+
export declare const MAX_SUFFIX_ATTEMPTS = 10000;
|
|
3
|
+
export declare class UsernameExhaustedError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export declare function deriveUsername(input: string): string | undefined;
|
|
6
|
+
export declare function isUsernameKey(s: string): boolean;
|
|
7
|
+
export declare function foldSubject(s: string): string;
|
|
8
|
+
export declare function withSuffix(base: string, taken: (key: string) => boolean): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const MAX_USERNAME_LENGTH = 128;
|
|
2
|
+
export const MAX_SUFFIX_ATTEMPTS = 10_000;
|
|
3
|
+
const COMBINING_MARKS = /\p{M}/gu;
|
|
4
|
+
const AT_SIGNS = /@/g;
|
|
5
|
+
const INVALID_CHARACTERS = /[^a-z0-9._-]/g;
|
|
6
|
+
const LEADING_NON_ALPHANUMERIC = /^[^a-z0-9]+/;
|
|
7
|
+
const USERNAME_KEY = /^[a-z0-9][a-z0-9._-]*$/;
|
|
8
|
+
export class UsernameExhaustedError extends Error {
|
|
9
|
+
}
|
|
10
|
+
export function deriveUsername(input) {
|
|
11
|
+
const derived = input
|
|
12
|
+
.normalize("NFKD")
|
|
13
|
+
.replace(COMBINING_MARKS, "")
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(AT_SIGNS, "_")
|
|
16
|
+
.replace(INVALID_CHARACTERS, "-")
|
|
17
|
+
.replace(LEADING_NON_ALPHANUMERIC, "")
|
|
18
|
+
.slice(0, MAX_USERNAME_LENGTH);
|
|
19
|
+
return derived === "" ? undefined : derived;
|
|
20
|
+
}
|
|
21
|
+
export function isUsernameKey(s) {
|
|
22
|
+
return s.length <= MAX_USERNAME_LENGTH && USERNAME_KEY.test(s);
|
|
23
|
+
}
|
|
24
|
+
export function foldSubject(s) {
|
|
25
|
+
return s.normalize("NFC").toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
export function withSuffix(base, taken) {
|
|
28
|
+
if (!taken(base)) {
|
|
29
|
+
return base;
|
|
30
|
+
}
|
|
31
|
+
for (let suffix = 2; suffix <= MAX_SUFFIX_ATTEMPTS; suffix += 1) {
|
|
32
|
+
const suffixText = `-${suffix}`;
|
|
33
|
+
const candidate = `${base.slice(0, MAX_USERNAME_LENGTH - suffixText.length)}${suffixText}`;
|
|
34
|
+
if (!taken(candidate)) {
|
|
35
|
+
return candidate;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new UsernameExhaustedError(`No available username for "${base}"`);
|
|
39
|
+
}
|
|
@@ -403,11 +403,38 @@ export type WorkerStatus = {
|
|
|
403
403
|
readonly lastError?: string;
|
|
404
404
|
};
|
|
405
405
|
/** The `GET /v1/meta` payload (spec §5; the health block is Task 9e). */
|
|
406
|
+
/**
|
|
407
|
+
* The measured listener facts (release-readiness Task 1, serving spec §2):
|
|
408
|
+
* captured ONCE when the listener goes live (never probed per request) and
|
|
409
|
+
* retained on the server context; `GET /v1/meta` projects them verbatim.
|
|
410
|
+
*/
|
|
411
|
+
export interface ListenerInfo {
|
|
412
|
+
/** The effective host string supplied to `listen` — never a resolved peer. */
|
|
413
|
+
readonly host: string;
|
|
414
|
+
/** The configured/selected port the listener bound. */
|
|
415
|
+
readonly port: number;
|
|
416
|
+
/**
|
|
417
|
+
* The address families the listener accepts, in stable `ipv4`, `ipv6`
|
|
418
|
+
* order. Literal binds report the bound socket's family; the `::`
|
|
419
|
+
* wildcard reports exactly the loopback families a startup reachability
|
|
420
|
+
* check connected on (measured, never inferred from the wildcard
|
|
421
|
+
* spelling). Empty on a server that never listened (a hand-built test
|
|
422
|
+
* server projecting intent only).
|
|
423
|
+
*/
|
|
424
|
+
readonly families: readonly ("ipv4" | "ipv6")[];
|
|
425
|
+
}
|
|
406
426
|
export interface MetaResponse {
|
|
407
427
|
/** The sigrun server's own version (the package manifest). */
|
|
408
428
|
readonly version: string;
|
|
409
429
|
readonly harnessVersion: string;
|
|
410
430
|
readonly schemaVersion: string;
|
|
431
|
+
/**
|
|
432
|
+
* The listener facts (release-readiness Task 1, serving spec §2). A
|
|
433
|
+
* REQUIRED field on this type; OLDER SERVERS (≤0.2.0) omit it on the
|
|
434
|
+
* wire, so clients consuming it must tolerate its absence during
|
|
435
|
+
* mixed-version upgrades.
|
|
436
|
+
*/
|
|
437
|
+
readonly listener: ListenerInfo;
|
|
411
438
|
readonly extensions: readonly {
|
|
412
439
|
readonly name: string;
|
|
413
440
|
readonly status: string;
|
|
@@ -811,7 +838,9 @@ export interface OnboardingState {
|
|
|
811
838
|
readonly methods: readonly ("token" | "proxy")[];
|
|
812
839
|
readonly codeRequired: boolean;
|
|
813
840
|
readonly proxyIdentity?: {
|
|
841
|
+
/** The would-be directory key; `subject` is the raw proxy header. */
|
|
814
842
|
readonly user: string;
|
|
843
|
+
readonly subject: string;
|
|
815
844
|
readonly name?: string;
|
|
816
845
|
readonly email?: string;
|
|
817
846
|
};
|
|
@@ -880,9 +909,9 @@ export interface TokenCreateResponse {
|
|
|
880
909
|
}
|
|
881
910
|
/**
|
|
882
911
|
* One external identity linked to a directory user (multi-user §11.3):
|
|
883
|
-
* the `accounts.json` record verbatim. `provider` is `
|
|
884
|
-
* `login:<slug>:<id>`; `subject` is the provider's
|
|
885
|
-
* (numeric id, `+E.164`, email-shaped — opaque to sigrun).
|
|
912
|
+
* the `accounts.json` record verbatim. `provider` is `proxy`,
|
|
913
|
+
* `chat:<channel>`, or `login:<slug>:<id>`; `subject` is the provider's
|
|
914
|
+
* stable subject (numeric id, `+E.164`, email-shaped — opaque to sigrun).
|
|
886
915
|
*
|
|
887
916
|
* Served to extensions as `host.accounts` and to users as
|
|
888
917
|
* `GET /v1/auth/accounts`.
|
|
@@ -892,7 +921,7 @@ export interface AccountLinkWire {
|
|
|
892
921
|
readonly subject: string;
|
|
893
922
|
readonly user: string;
|
|
894
923
|
readonly linkedAt: number;
|
|
895
|
-
readonly via: "login" | "self" | "pairing" | "admin";
|
|
924
|
+
readonly via: "login" | "self" | "pairing" | "admin" | "proxy";
|
|
896
925
|
}
|
|
897
926
|
/**
|
|
898
927
|
* The `GET /v1/auth/accounts` payload (multi-user §11.3): the caller's
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigrun/shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "sigrun shared leaf package \u2014 theme tokens, turn/conversation shaping, and the channel wire types (pure leaf, zero dependencies)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"types": "./dist/themes.d.ts",
|
|
9
9
|
"default": "./dist/themes.js"
|
|
10
10
|
},
|
|
11
|
+
"./usernames": {
|
|
12
|
+
"types": "./dist/usernames.d.ts",
|
|
13
|
+
"default": "./dist/usernames.js"
|
|
14
|
+
},
|
|
11
15
|
"./conversations": {
|
|
12
16
|
"types": "./dist/conversations.d.ts",
|
|
13
17
|
"default": "./dist/conversations.js"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigrun/sigrun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Durable personal assistant on pi-durable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"test": "vitest run",
|
|
31
31
|
"typecheck": "tsc --noEmit",
|
|
32
32
|
"build:shared": "npm --workspace @sigrun/shared run build",
|
|
33
|
-
"build": "npm run build:shared && tsc -p tsconfig.build.json && tsc -p tsconfig.extension.json && node -e \"require('node:fs').rmSync('dist/extension.js',{force:true})\"",
|
|
33
|
+
"build": "npm run build:shared && tsc -p tsconfig.build.json && tsc -p tsconfig.extension.json && node scripts/stamp-block-build.mjs && node -e \"require('node:fs').rmSync('dist/extension.js',{force:true})\"",
|
|
34
34
|
"build:webui": "npm --workspace @sigrun/webui run build",
|
|
35
35
|
"typecheck:webui": "npm --workspace @sigrun/webui run typecheck",
|
|
36
36
|
"test:e2e": "npm run build:webui && npm --workspace @sigrun/webui run test:e2e",
|
|
@@ -39,19 +39,20 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@aws-sdk/client-s3": "^3.1154.0",
|
|
42
|
-
"@earendil-works/chord": "1.
|
|
43
|
-
"@earendil-works/pi-ai": "1.
|
|
44
|
-
"@earendil-works/pi-durable": "1.
|
|
42
|
+
"@earendil-works/chord": "1.4.0",
|
|
43
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
44
|
+
"@earendil-works/pi-durable": "1.4.0",
|
|
45
45
|
"@earendil-works/pi-tui": "^1.3.0",
|
|
46
46
|
"@fastify/static": "^10.1.7",
|
|
47
|
-
"@sigrun/ext-codemode": "0.1.
|
|
48
|
-
"@sigrun/ext-gateway": "0.1.
|
|
49
|
-
"@sigrun/ext-mcp": "0.2.
|
|
50
|
-
"@sigrun/ext-tool-search": "0.1.
|
|
47
|
+
"@sigrun/ext-codemode": "0.1.2",
|
|
48
|
+
"@sigrun/ext-gateway": "0.1.4",
|
|
49
|
+
"@sigrun/ext-mcp": "0.2.2",
|
|
50
|
+
"@sigrun/ext-tool-search": "0.1.2",
|
|
51
51
|
"@sigrun/oauth": "0.1.1",
|
|
52
|
-
"@sigrun/shared": "0.
|
|
52
|
+
"@sigrun/shared": "0.3.0",
|
|
53
53
|
"croner": "^10.0.1",
|
|
54
54
|
"fastify": "^5.12.7",
|
|
55
|
+
"semver": "^7.8.5",
|
|
55
56
|
"sharp": "^0.35.5",
|
|
56
57
|
"typebox": "1.3.27"
|
|
57
58
|
},
|
|
@@ -59,12 +60,13 @@
|
|
|
59
60
|
"@sigrun/shared"
|
|
60
61
|
],
|
|
61
62
|
"optionalDependencies": {
|
|
62
|
-
"@sigrun/sandbox-linux-arm64": "0.
|
|
63
|
-
"@sigrun/sandbox-linux-x64": "0.
|
|
64
|
-
"@sigrun/tui": "0.
|
|
63
|
+
"@sigrun/sandbox-linux-arm64": "0.3.0",
|
|
64
|
+
"@sigrun/sandbox-linux-x64": "0.3.0",
|
|
65
|
+
"@sigrun/tui": "0.3.0"
|
|
65
66
|
},
|
|
66
67
|
"devDependencies": {
|
|
67
68
|
"@types/node": "^26.6.4",
|
|
69
|
+
"@types/semver": "^7.8.0",
|
|
68
70
|
"fast-check": "^4.10.2",
|
|
69
71
|
"tsx": "^4.23.15",
|
|
70
72
|
"typescript": "^7.0.2",
|
|
@@ -90,8 +92,7 @@
|
|
|
90
92
|
"THIRD_PARTY_NOTICES.md",
|
|
91
93
|
"README.md",
|
|
92
94
|
"LICENSE",
|
|
93
|
-
"CHANGELOG.md"
|
|
94
|
-
"CONTEXT.md"
|
|
95
|
+
"CHANGELOG.md"
|
|
95
96
|
],
|
|
96
97
|
"allowScripts": {
|
|
97
98
|
"@google/genai@2.21.0": true,
|