@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +145 -7
  2. package/README.md +333 -13
  3. package/dist/blocks/build-stamp.json +13 -0
  4. package/dist/blocks/builders.d.ts +57 -0
  5. package/dist/blocks/images.d.ts +26 -0
  6. package/dist/blocks/index.d.ts +20 -0
  7. package/dist/blocks/limits.d.ts +25 -0
  8. package/dist/blocks/placements.d.ts +26 -0
  9. package/dist/blocks/schema.d.ts +38 -0
  10. package/dist/blocks/validate.d.ts +73 -0
  11. package/dist/cli.js +117 -19
  12. package/dist/config.js +21 -2
  13. package/dist/extension.d.ts +48 -8
  14. package/dist/harness/ambient.js +2 -0
  15. package/dist/harness/blocks/state.js +1 -0
  16. package/dist/harness/boot.js +92 -6
  17. package/dist/harness/builtins.js +1 -1
  18. package/dist/harness/context.js +25 -0
  19. package/dist/harness/event-bus.js +25 -14
  20. package/dist/harness/identity/accounts.js +3 -2
  21. package/dist/harness/identity/index.js +2 -1
  22. package/dist/harness/identity/invites.js +26 -9
  23. package/dist/harness/identity/link-sweep.js +47 -0
  24. package/dist/harness/identity/typed-username.js +15 -0
  25. package/dist/harness/identity/users.js +10 -7
  26. package/dist/harness/interactions.js +2 -0
  27. package/dist/harness/loader.js +57 -1
  28. package/dist/harness/migration-snapshot.js +97 -0
  29. package/dist/harness/reports.js +100 -0
  30. package/dist/harness/seam-host.js +23 -1
  31. package/dist/harness/sections/context-files.js +1 -1
  32. package/dist/harness/settings.js +52 -4
  33. package/dist/jobs/docs.js +3 -0
  34. package/dist/jobs/fire.js +2 -0
  35. package/dist/jobs/parked.js +38 -0
  36. package/dist/jobs/task.js +37 -8
  37. package/dist/sandbox/availability.js +69 -20
  38. package/dist/sandbox/launch.js +11 -0
  39. package/dist/sandbox/policy.js +24 -3
  40. package/dist/sandbox/worker.js +4 -24
  41. package/dist/serve.js +162 -40
  42. package/dist/server/auth/ip.js +9 -4
  43. package/dist/server/auth/own-host.js +115 -0
  44. package/dist/server/auth/pipeline.js +93 -28
  45. package/dist/server/auth/proxy-diagnostic.js +60 -0
  46. package/dist/server/auth/proxy-identity.js +134 -0
  47. package/dist/server/auth/proxy-only.js +28 -0
  48. package/dist/server/images/cache.js +13 -5
  49. package/dist/server/index.js +10 -2
  50. package/dist/server/login/resolve.js +88 -26
  51. package/dist/server/notifications.js +21 -4
  52. package/dist/server/routes/admin.js +97 -0
  53. package/dist/server/routes/images.js +16 -9
  54. package/dist/server/routes/login.js +8 -0
  55. package/dist/server/routes/me.js +1 -1
  56. package/dist/server/routes/meta.js +4 -0
  57. package/dist/server/routes/metrics.js +18 -2
  58. package/dist/server/routes/onboarding.js +97 -37
  59. package/dist/server/session-meta.js +1 -0
  60. package/dist/server/shutdown.js +93 -16
  61. package/dist/settings/registry.js +6 -0
  62. package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
  63. package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
  64. package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
  65. package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
  66. package/node_modules/@sigrun/shared/dist/views.d.ts +33 -4
  67. package/node_modules/@sigrun/shared/package.json +5 -1
  68. package/package.json +16 -15
  69. package/webui/dist/assets/{index-CxB969qp.js → index-CEOzj1C4.js} +11 -11
  70. package/webui/dist/index.html +1 -1
  71. package/CONTEXT.md +0 -163
  72. 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
- if (user === undefined || !USERNAME_PATTERN.test(user))
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
- user,
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
- // Proxy claims take identity from the headers, verbatim
153
- // (body identity fields are ignored — the proxy vouched).
154
- grant = { ...proxyIdentity, source: "proxy" };
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: validated.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
- if (!USERNAME_PATTERN.test(grant.user)) {
181
- return { status: 400, error: "invalid_username" };
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
- const invite = validated.user === undefined
211
- ? undefined
212
- : await identity.invites.redeem(validated.code, validated.user);
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: validated.user,
277
+ user: typed.key,
218
278
  ...(validated.name === undefined ? {} : { name: validated.name }),
219
279
  ...(validated.email === undefined ? {} : { email: validated.email }),
220
- admin: invite.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 existing = identity.users.get(proxyIdentity.user);
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
- admin: false,
239
- source: "proxy",
240
- });
241
- return { status: 200, record };
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
@@ -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 (the
6
- * pre-extraction semantics, byte-for-byte).
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, "sigrun shutdown failed");
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 `chat:<channel>` or
884
- * `login:<slug>:<id>`; `subject` is the provider's stable subject
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.2.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.2.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.3.0",
43
- "@earendil-works/pi-ai": "1.3.0",
44
- "@earendil-works/pi-durable": "1.3.0",
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.1",
48
- "@sigrun/ext-gateway": "0.1.1",
49
- "@sigrun/ext-mcp": "0.2.1",
50
- "@sigrun/ext-tool-search": "0.1.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.2.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.2.0",
63
- "@sigrun/sandbox-linux-x64": "0.2.0",
64
- "@sigrun/tui": "0.2.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,