@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +234 -1
  3. package/dist/cli.js +117 -19
  4. package/dist/config.js +21 -2
  5. package/dist/extension.d.ts +7 -1
  6. package/dist/harness/ambient.js +2 -0
  7. package/dist/harness/blocks/state.js +1 -0
  8. package/dist/harness/boot.js +86 -6
  9. package/dist/harness/context.js +25 -0
  10. package/dist/harness/event-bus.js +25 -14
  11. package/dist/harness/identity/accounts.js +3 -2
  12. package/dist/harness/identity/index.js +2 -1
  13. package/dist/harness/identity/invites.js +26 -9
  14. package/dist/harness/identity/link-sweep.js +47 -0
  15. package/dist/harness/identity/typed-username.js +15 -0
  16. package/dist/harness/identity/users.js +10 -7
  17. package/dist/harness/interactions.js +2 -0
  18. package/dist/harness/loader.js +57 -1
  19. package/dist/harness/migration-snapshot.js +97 -0
  20. package/dist/harness/reports.js +100 -0
  21. package/dist/harness/seam-host.js +11 -1
  22. package/dist/harness/settings.js +52 -4
  23. package/dist/jobs/docs.js +3 -0
  24. package/dist/jobs/fire.js +2 -0
  25. package/dist/jobs/parked.js +38 -0
  26. package/dist/jobs/task.js +37 -8
  27. package/dist/serve.js +23 -14
  28. package/dist/server/auth/ip.js +9 -4
  29. package/dist/server/auth/own-host.js +115 -0
  30. package/dist/server/auth/pipeline.js +64 -48
  31. package/dist/server/auth/proxy-diagnostic.js +60 -0
  32. package/dist/server/auth/proxy-identity.js +134 -0
  33. package/dist/server/auth/proxy-only.js +28 -0
  34. package/dist/server/login/resolve.js +88 -26
  35. package/dist/server/routes/admin.js +97 -0
  36. package/dist/server/routes/login.js +8 -0
  37. package/dist/server/routes/metrics.js +18 -2
  38. package/dist/server/routes/onboarding.js +97 -37
  39. package/dist/server/session-meta.js +1 -0
  40. package/dist/server/shutdown.js +93 -16
  41. package/dist/settings/registry.js +6 -0
  42. package/node_modules/@sigrun/shared/CHANGELOG.md +7 -0
  43. package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
  44. package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
  45. package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
  46. package/node_modules/@sigrun/shared/dist/views.d.ts +6 -4
  47. package/node_modules/@sigrun/shared/package.json +5 -1
  48. package/package.json +14 -12
  49. package/webui/dist/assets/{index-SO2W4jjk.js → index-CEOzj1C4.js} +11 -11
  50. package/webui/dist/index.html +1 -1
  51. package/dist/server/login/usernames.js +0 -15
@@ -18,12 +18,24 @@
18
18
  * graceful-shutdown + `process.exit(0)`) owns the exit — the route
19
19
  * never calls exit itself.
20
20
  *
21
+ * D5 parked-task recovery (pi-durable 1.4.0, Task B6):
22
+ * `GET /v1/admin/tasks?state=blocked` lists blocked tasks from
23
+ * `harness.inspect()` (id, kind, reason, conversationId, owner from the
24
+ * conversation's SessionMeta when present); `POST /v1/admin/tasks/:id/retry`
25
+ * returns 404 unknown_task / 409 not_parked unless the task is blocked
26
+ * with reason restart_limit, else calls `harness.retryTask(id)` → 204.
27
+ * The inspect-first check is load-bearing: retryTask rejects an unknown
28
+ * id but SILENTLY no-ops for a task that isn't parked. Both endpoints
29
+ * are admin-only (403 forbidden).
30
+ *
21
31
  * Durability (Review Focus 4): the restart rides the SAME ordered
22
32
  * shutdown as the SIGTERM path — the event bus drains pending settles
23
33
  * and the harness close seals storage, so a run in flight at restart
24
34
  * has its submission durable before exit; pi-durable resumes it after
25
35
  * the supervisor brings the process back.
26
36
  */
37
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
38
+ import { SessionMetaDoc } from "../session-meta.js";
27
39
  /** Register the `/v1/admin` routes. */
28
40
  export function registerAdminRoutes(app, ctx) {
29
41
  app.post("/v1/admin/restart", async (request, reply) => {
@@ -55,4 +67,89 @@ export function registerAdminRoutes(app, ctx) {
55
67
  });
56
68
  return await reply.code(202).send({ restarting: true });
57
69
  });
70
+ // D5 parked-task recovery. `GET /v1/admin/tasks?state=blocked` lists
71
+ // blocked tasks from `harness.inspect()`; `POST /v1/admin/tasks/:id/retry`
72
+ // re-runs a restart-parked task. Both admin-only (403 before any
73
+ // inspection, so a non-admin learns nothing about the task graph).
74
+ app.get("/v1/admin/tasks", async (request, reply) => {
75
+ if (!request.principal.admin) {
76
+ return await reply.code(403).send({ error: "forbidden" });
77
+ }
78
+ const query = request.query;
79
+ // Only the blocked projection exists (the recovery surface); any
80
+ // other state filter is a fail-loud 400, same posture as the
81
+ // conversations list's strict booleans.
82
+ if (query.state !== undefined && query.state !== "blocked") {
83
+ return await reply.code(400).send({ error: "invalid_field", field: "state" });
84
+ }
85
+ const inspection = await ctx.opened.harness.inspect(BACKGROUND_CONTEXT);
86
+ const blocked = inspection.tasks.filter((task) => task.state.kind === "blocked");
87
+ // Owner: the conversation's SessionMeta.owner when it has one.
88
+ // Reads are snapshots (never materialize the doc); a raced read
89
+ // degrades to an omitted owner, not a failed list.
90
+ const owners = new Map();
91
+ for (const task of blocked) {
92
+ const conversationId = Number(task.record.conversationId);
93
+ if (owners.has(conversationId))
94
+ continue;
95
+ try {
96
+ const meta = await ctx.opened.harness.snapshot(SessionMetaDoc, task.record.conversationId, BACKGROUND_CONTEXT);
97
+ owners.set(conversationId, meta?.owner);
98
+ }
99
+ catch (error) {
100
+ console.error("admin tasks: SessionMeta snapshot failed:", error);
101
+ owners.set(conversationId, undefined);
102
+ }
103
+ }
104
+ // Extension: task names are unique across installed extensions, so
105
+ // ONE registry snapshot resolves the owning extension. Built-in
106
+ // generation/tool/compaction tasks have no installed extension and
107
+ // correctly omit the field.
108
+ const extensionsByTask = new Map();
109
+ for (const extension of ctx.opened.registry.snapshot().installed()) {
110
+ for (const task of extension.tasks ?? []) {
111
+ extensionsByTask.set(task.definition.name, extension.name);
112
+ }
113
+ }
114
+ const tasks = blocked.map((task) => {
115
+ const conversationId = Number(task.record.conversationId);
116
+ const owner = owners.get(conversationId);
117
+ const extension = extensionsByTask.get(task.record.kind);
118
+ return {
119
+ id: Number(task.record.id),
120
+ kind: task.record.kind,
121
+ reason: task.state.reason,
122
+ conversationId,
123
+ ...(owner === undefined ? {} : { owner }),
124
+ ...(extension === undefined ? {} : { extension }),
125
+ };
126
+ });
127
+ return await reply.send({ tasks });
128
+ });
129
+ app.post("/v1/admin/tasks/:id/retry", async (request, reply) => {
130
+ if (!request.principal.admin) {
131
+ return await reply.code(403).send({ error: "forbidden" });
132
+ }
133
+ const { id } = request.params;
134
+ const taskId = Number(id);
135
+ if (!Number.isInteger(taskId) || taskId < 0 || String(taskId) !== id) {
136
+ return await reply.code(404).send({ error: "unknown_task" });
137
+ }
138
+ // Check inspect() FIRST: pi-durable's retryTask rejects an unknown
139
+ // id but SILENTLY no-ops for a task that isn't parked — the 409
140
+ // must come from the inspection, not from retryTask.
141
+ const inspection = await ctx.opened.harness.inspect(BACKGROUND_CONTEXT);
142
+ const task = inspection.tasks.find((candidate) => String(candidate.record.id) === id);
143
+ if (task === undefined) {
144
+ return await reply.code(404).send({ error: "unknown_task" });
145
+ }
146
+ if (task.state.kind !== "blocked" || task.state.reason !== "restart_limit") {
147
+ return await reply.code(409).send({ error: "not_parked" });
148
+ }
149
+ // For a schedule task the job's status fields return to `waiting`
150
+ // on the task's next committed state (the task's own phases patch
151
+ // the record) — the route deliberately does NOT hand-patch the job.
152
+ await ctx.opened.harness.retryTask(task.record.id, BACKGROUND_CONTEXT);
153
+ return await reply.code(204).send();
154
+ });
58
155
  }
@@ -1,4 +1,5 @@
1
1
  import { ipAllowed } from "../auth/ip.js";
2
+ import { requiresProxyVouch } from "../auth/proxy-only.js";
2
3
  import { bearerOf, resolveToken, sessionCookieOf } from "../auth/pipeline.js";
3
4
  import { LoginFlows } from "../login/flows.js";
4
5
  import { clearSetupCode, readSetupCode } from "../../harness/identity/setup-code.js";
@@ -191,6 +192,13 @@ export function registerLoginRoutes(app, ctx, options = {}) {
191
192
  // The webui client requests JSON so fetch never follows a redirect
192
193
  // into an HTML document. Existing form callers keep the 302 contract.
193
194
  const asJson = request.headers.accept?.includes("application/json") === true;
195
+ // C2: this route creates an account from a parked external-identity
196
+ // flow plus a setup/invite code — it carries NO proxy identity, so
197
+ // on a proxy-only non-loopback bind it ALWAYS refuses, before any
198
+ // code evaluation or account provisioning.
199
+ if (requiresProxyVouch(ctx.methods, ctx.listenHost)) {
200
+ return browserError(reply, 403, "proxy_identity_required", asJson);
201
+ }
194
202
  const pending = flows.takePending(record.key);
195
203
  if (pending === undefined)
196
204
  return browserError(reply, 400, "login_expired", asJson);
@@ -55,12 +55,14 @@
55
55
  * type is `text/plain; version=0.0.4; charset=utf-8`.
56
56
  */
57
57
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
58
+ import { REPORT_TYPES } from "../../harness/reports.js";
58
59
  import { readBackupMarker } from "../../backup/marker.js";
59
60
  import { scanJobs } from "../../jobs/docs.js";
60
61
  import { providerAuthStatus } from "../helpers.js";
61
62
  import { checkStorage } from "../readiness.js";
62
63
  /** The exposition content type (spec §6.9's format rule). */
63
64
  const EXPOSITION_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
65
+ const BLOCKED_REASONS = ["missing_task", "task_too_old", "migration_failed", "restart_limit"];
64
66
  /** Escape one label value per the exposition format: `\` → `\\`,
65
67
  * `"` → `\"`, newline → `\n`. */
66
68
  function escapeLabelValue(value) {
@@ -121,18 +123,28 @@ export function registerMetricsRoutes(app, ctx) {
121
123
  // run is live — the alert's `> 1800` must not key on absence).
122
124
  const oldestMs = opened.eventBus.oldestLiveAdmissionMs();
123
125
  family("sigrun_inflight_submission_oldest_seconds", "Age of the oldest in-flight submission's admission (0 when none; in-process clock — a pre-restart run is invisible until it settles)", [{ labels: {}, value: Math.round((oldestMs ?? 0) / 1000) }]);
124
- // Live tasks by scheduler state kind. The full five-kind
125
- // vocabulary is emitted (0s included) for label stability.
126
+ // Live tasks by scheduler state kind. The full six-kind
127
+ // vocabulary is emitted (0s included) for label stability
128
+ // (1.4.0 adds `sleeping` — the durable-sleep state).
126
129
  const taskStatuses = {
127
130
  running: 0,
128
131
  ready: 0,
129
132
  waiting: 0,
133
+ sleeping: 0,
130
134
  completing: 0,
131
135
  blocked: 0,
132
136
  };
137
+ const blockedReasons = Object.fromEntries(BLOCKED_REASONS.map((reason) => [reason, 0]));
133
138
  for (const task of inspection.tasks) {
134
139
  taskStatuses[task.state.kind] = (taskStatuses[task.state.kind] ?? 0) + 1;
140
+ if (task.state.kind === "blocked") {
141
+ blockedReasons[task.state.reason] = (blockedReasons[task.state.reason] ?? 0) + 1;
142
+ }
135
143
  }
144
+ family("sigrun_tasks_blocked", "Live tasks blocked by scheduler reason", Object.entries(blockedReasons).map(([reason, count]) => ({
145
+ labels: { reason },
146
+ value: count,
147
+ })));
136
148
  family("sigrun_tasks_live", "Live tasks by scheduler state", Object.entries(taskStatuses).map(([status, count]) => ({
137
149
  labels: { status },
138
150
  value: count,
@@ -173,6 +185,10 @@ export function registerMetricsRoutes(app, ctx) {
173
185
  // failure even while inspection (and the whole scrape) stays green.
174
186
  const storage = await checkStorage(ctx);
175
187
  family("sigrun_ready", "1 when the readiness storage check (the /readyz root-conversation read) succeeds", [{ labels: {}, value: storage === "ok" ? 1 : 0 }]);
188
+ // D5: process-local durable failure reports. Every known type is
189
+ // emitted, including zeros, for scrape-stable alert expressions.
190
+ const reportCounts = opened.reports.counts();
191
+ family("sigrun_durable_reports_total", "Durable failure reports since process start", REPORT_TYPES.map((type) => ({ labels: { type }, value: reportCounts[type] })), "counter");
176
192
  // Provider readiness: the shared derivation (`GET /v1/auth/check`'s
177
193
  // exact chain), one 1-valued sample per (provider, current status).
178
194
  const producersByName = new Map(opened.providerHealth.map((producer) => [producer.name, producer]));
@@ -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,13 @@ 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.
9
16
 
10
17
  ## [0.2.1] - 2026-10-28
11
18
  ### 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;