@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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-29
11
+
12
+ ### Added
13
+ - Parked-task visibility and recovery: pi-durable reports now reach logs,
14
+ metrics, alerts, and job status, and administrators can list and retry
15
+ parked tasks through the admin API.
16
+ - Proxy identity links with case-insensitive matching, plus typed-username
17
+ normalization for `user add --proxy`, `link`, and `unlink`.
18
+ - An optional chart `networkPolicy`, `OnboardingState.proxyIdentity.subject`,
19
+ the ProxyUnidentified screen, and the shared `./usernames` export.
20
+
21
+ ### Changed
22
+ - Upgraded the Earendil runtime to pi-durable 1.4.0 (with chord and pi-ai
23
+ 1.4.0), including durable schedule sleeps, retry backoffs that survive
24
+ restarts, and the crash-loop parked-task guard.
25
+ - Extension shutdown now runs in parallel within a fixed budget, with the
26
+ chart's termination grace period matched to that budget.
27
+ - The loader quarantines extensions whose declared Earendil peer range
28
+ excludes the host version instead of allowing an incompatible extension
29
+ to fail later at runtime.
30
+ - `settings.compaction.modelOverrides` supports per-model compaction
31
+ settings while preserving environment-over-file precedence.
32
+ - Proxy-only non-loopback boots refuse unvouched account creation, including
33
+ login and code paths, and mint no setup code there. Login derives from the
34
+ full email; boot sweeps orphaned proxy links, gates own-host peers, and
35
+ throttles proxy diagnostics.
36
+
37
+ ### Fixed
38
+ - CLI empty-string identity values now mean "unset".
39
+
40
+ ### Breaking
41
+ - When `SIGRUN_API_TOKEN` is set, `SIGRUN_API_TOKEN_USER` must be a valid
42
+ username key (the validation error includes a derivation suggestion). The
43
+ user value is validated only when the token is set.
44
+
45
+ ### Upgrade notes
46
+ - **One-way storage migration:** the first 0.3.0 boot migrates the SQLite
47
+ durable schema to pi-durable 1.4.0's format. Before opening storage, boot
48
+ writes a crash-consistent snapshot beside the database named
49
+ `<storagePath>.pre-durable-schema-v<CURRENT>-<UTC stamp>.bak`. Snapshots
50
+ are never deleted automatically. If a needed snapshot cannot be written,
51
+ boot stops before migration. `SIGRUN_SKIP_MIGRATION_SNAPSHOT=1` is the
52
+ emergency opt-out for an operator with an independent backup.
53
+ - **Rollback to 0.2.x (SQLite):** stop sigrun, restore the `.bak` over the
54
+ database file, remove the database's `-wal` and `-shm` sidecars, then
55
+ deploy 0.2.x. Anything committed after the upgrade is lost.
56
+ - **JSONL storage:** there is no snapshot because its schema does not
57
+ change. A 0.2.x process nevertheless never resumes a task left `sleeping`
58
+ by 0.3.0, so rollback can strand that task.
59
+
10
60
  ## [0.2.1] - 2026-10-28
11
61
 
12
62
  ### Added
package/README.md CHANGED
@@ -175,6 +175,7 @@ still override it per field.
175
175
  | `SIGRUN_PORT` | positive int | `3000` | `server.port` | HTTP listen port. |
176
176
  | `SIGRUN_LISTEN_HOST` | host | `::` | — | HTTP listen host — the environment equivalent of the programmatic `serve({host})` option (the option wins when both are set). One resolved value drives the bind AND the auth defaults: a loopback bind derives the no-auth workstation posture, any other bind derives `token` auth. `GET /v1/meta` reports the effective listener (`listener.host`/`port`/`families`). |
177
177
  | `SIGRUN_STORAGE_PATH` | path | `<dataDir>/sigrun.db` | `server.storagePath` | SQLite database location. Relative values resolve against the data root (absolute values verbatim; the cwd-relative `data/sigrun.db` default is retired). |
178
+ | `SIGRUN_SKIP_MIGRATION_SNAPSHOT` | `1` | *(unset)* | — | Skip the automatic pre-migration SQLite snapshot when set to `1`. Emergency escape hatch for an operator who already has an independent backup; without it, a needed snapshot failure stops boot before the one-way durable schema migration. |
178
179
  | `SIGRUN_WORKSPACE_PATH` | path | `<dataDir>/workspace` | `server.workspacePath` | Root of the static local workspace every conversation's tools run against. The default rides the data root (chart/compose deploys set the path explicitly); it is never persisted on first boot. |
179
180
  | `SIGRUN_DATA_DIR` | path | `$XDG_DATA_HOME/sigrun` or `~/.local/share/sigrun` | `server.dataDir` | Data root (the DB default and extension data dirs). Env/file values win verbatim. |
180
181
  | `SIGRUN_CACHE_DIR` | path | `$XDG_CACHE_HOME/sigrun` or `~/.cache/sigrun` | `server.cacheDir` | Cache root (regenerable; never ensured at boot). Env/file values win verbatim. |
@@ -183,6 +184,7 @@ still override it per field.
183
184
  | — | — | *(none)* | `gitDir` is always `<configDir>/git` | Git extension checkouts clone here (pi layout). Not env-configurable. |
184
185
  | `SIGRUN_WEBUI_BUNDLE` | path | `webui/dist` | `server.webuiBundle` | Web UI bundle directory; explicit-but-missing = boot error, default-missing = API-only. |
185
186
  | `SIGRUN_CONFIG_DIR` | path | `~/.config/sigrun` | *env-only* | The settings file's own directory. |
187
+ | `SIGRUN_API_TOKEN_USER` | username key | `sigrun` | *env-only* | User for `SIGRUN_API_TOKEN`. Must already be a valid username key (`^[a-z0-9][a-z0-9._-]*$`, at most 128 characters); invalid values fail boot with a derivation suggestion. Validated only when `SIGRUN_API_TOKEN` is non-empty. |
186
188
  | `SIGRUN_COMPACTION_ENABLED` | `true`/`false` | `true` | `server.compaction.enabled` | Context compaction on/off. |
187
189
  | `SIGRUN_COMPACTION_RESERVE_TOKENS` | positive int | `16384` | `server.compaction.reserveTokens` | Headroom reserved for the model's reply. |
188
190
  | `SIGRUN_COMPACTION_KEEP_RECENT_TOKENS` | positive int | `20000` | `server.compaction.keepRecentTokens` | Recent context retained verbatim through a compaction. |
@@ -192,6 +194,32 @@ still override it per field.
192
194
  | `SIGRUN_BACKUP_S3_PREFIX` | string | *(unset)* | `server.backup.s3.prefix` | Key prefix within the bucket. |
193
195
  | `SIGRUN_BACKUP_S3_ENDPOINT` | URL | AWS default | `server.backup.s3.endpoint` | Custom S3 endpoint (S3-compatible stores). |
194
196
 
197
+ Per-model overrides: `server.compaction.modelOverrides` is a map keyed by
198
+ the exact `provider/modelId` string pi-durable composes — note that a
199
+ chain-shaped model id doubles the prefix. Use it when a small-window model
200
+ must sit alongside the large-window default policy (lower
201
+ `reserveTokens`/`keepRecentTokens`/`backgroundTokens`, or `enabled: false`
202
+ for a model that must never compact):
203
+
204
+ ```jsonc
205
+ "server": {
206
+ "compaction": {
207
+ "reserveTokens": 16384,
208
+ "modelOverrides": {
209
+ // Synthetic example: provider "bifrost", model id "bifrost/small-chat".
210
+ "bifrost/bifrost/small-chat": { "reserveTokens": 4096, "keepRecentTokens": 6000 }
211
+ }
212
+ }
213
+ }
214
+ ```
215
+
216
+ Each entry is a partial policy (the same fields and minimums as the parent
217
+ block); unset fields inherit the global policy. Boot-time lifetime like the
218
+ rest of compaction, and there is no env spelling. After the boot catalog
219
+ refresh an override key that matches no known model logs one warning
220
+ (`compaction override "<key>" matches no known model`) — never an error,
221
+ since catalogs move.
222
+
195
223
  ### settings.json schema
196
224
 
197
225
  ```jsonc
@@ -218,7 +246,10 @@ still override it per field.
218
246
  "enabled": true,
219
247
  "reserveTokens": 16384, // positive integer
220
248
  "keepRecentTokens": 20000, // positive integer
221
- "backgroundTokens": 32768 // non-negative integer (0 = off)
249
+ "backgroundTokens": 32768, // non-negative integer (0 = off)
250
+ "modelOverrides": { // per-model partial policies, keyed by exact provider/modelId
251
+ "bifrost/bifrost/small-chat": { "reserveTokens": 4096 }
252
+ }
222
253
  },
223
254
  "backup": { // target: { "path": "/backups" } XOR { "s3": { "bucket": "...", "prefix": "...", "endpoint": "..." } }
224
255
  "staleAfterHours": 36 // positive number: health.backup degrades past this marker age
@@ -307,6 +338,64 @@ configurable) when the request comes from a peer inside
307
338
  proxy and nothing else: every peer in `trustedProxies` can forge any
308
339
  identity, including an admin.
309
340
 
341
+ **Which address to list.** `trustedProxies` must list the address the
342
+ proxy **connects from**, as the pod sees it — not the proxy's own
343
+ Service or DNS name. The same list also gates trust in
344
+ `X-Forwarded-Host` (the Origin check) and `X-Forwarded-Proto` (the
345
+ Secure-cookie decision): a peer outside the list cannot steer either.
346
+ On a dual-stack cluster the Service may be reached over either family
347
+ (the Service can be IPv6-first while the proxy still connects over
348
+ IPv4), so list every address the proxy actually sources from.
349
+
350
+ **Cilium Gateway API / Ingress.** The connect-from address is the
351
+ per-node **Cilium ingress IP**, allocated from that node's pod CIDR —
352
+ enumerate them with:
353
+
354
+ ```sh
355
+ kubectl get ciliumnodes -o jsonpath='{..spec.ingress}'
356
+ ```
357
+
358
+ Listing those addresses exactly is the tightest posture. Trusting the
359
+ whole pod CIDR (e.g. `10.42.0.0/16`, `fd00:42::/64`) is acceptable
360
+ **only together with a policy admitting the `ingress` entity** — the
361
+ chart's `networkPolicy.enabled` option installs that
362
+ CiliumNetworkPolicy, whose default also admits `host` for kubelet
363
+ probes. Without it, any pod in the CIDR can forge
364
+ identity headers directly to the Service. Note that the `ingress`
365
+ entity covers **every Cilium gateway in the cluster**, so every route
366
+ to sigrun must pass through forward auth; a gateway route that skips
367
+ it is an open impersonation path.
368
+
369
+ **Forward-auth header hygiene.** Forward auth must return **every
370
+ identity header sigrun reads** on every authenticated response, and
371
+ its upstream-header mode must **override, not append** — Envoy's
372
+ `allowed_upstream_headers`, not `allowed_upstream_headers_to_append`.
373
+ With append semantics a client can supply the headers forward auth
374
+ omits and sigrun will read the client's copies.
375
+
376
+ **Own-host peers (C5).** A trusted **range** never vouches for
377
+ sigrun's own addresses — loopback or any local interface address —
378
+ without the shared secret, because every local process shares those
379
+ source addresses. An **exactly listed** local address (a bare address,
380
+ or a `/32` / `/128` entry) does vouch, as an explicit operator
381
+ decision, and boot logs a warning for each such entry when no shared
382
+ secret is configured.
383
+
384
+ **Rejection diagnostics.** Each rejection logs one sanitized line
385
+ (subject to the throttle below) naming the socket peer, the method, a
386
+ short reason, and the route, when known — never tokens, cookies, or
387
+ identity-header values. The throttle emits one line per reason per 60 s,
388
+ with `(N suppressed)` on the next line for that reason. The reasons:
389
+
390
+ | Reason | Meaning |
391
+ |---|---|
392
+ | `untrusted proxy` | The socket peer is outside `trustedProxies`. |
393
+ | `own-host peer without shared secret` | The peer is a local address trusted only through a range, and no shared secret is configured — see C5 above. |
394
+ | `missing or wrong shared secret (<header> header)` | The named secret header was absent or did not match `SIGRUN_PROXY_SECRET`. |
395
+ | `missing identity header (<header>)` | The proxy vouched but sent no user header — forward auth is not returning every identity header. |
396
+ | `unusable identity claim` | The vouched username cannot be a directory user (403 `invalid_username`). |
397
+ | `untrusted identity source` | The user does not exist and `autoProvision` is off (403 `user_not_provisioned`). |
398
+
310
399
  **Stored groups and group-derived admin.** `server.auth.adminGroups`
311
400
  lists groups (Authelia's `Remote-Groups`, for example) whose members act as
312
401
  admins. Core stores groups per authenticating source: `proxy` for validated
@@ -329,6 +418,106 @@ until then. Token and session requests only read the stored record; they
329
418
  never accept groups from the request. People-scoped conversation shares may
330
419
  also name groups, matched against the same stored groups.
331
420
 
421
+ ### Usernames
422
+
423
+ Every external identity — proxy header, OIDC subject, invite claim,
424
+ CLI argument — resolves to one **directory user key**. The key is the
425
+ stable identifier: it names the user's storage directory
426
+ (`<dataDir>/users/<user>`), their config directories, their per-user
427
+ extension stores, and it appears in tool registrations as
428
+ `<slug>@<user>`, split at the **first `@`**. The key pattern is
429
+ therefore a safety rule, not a style choice:
430
+
431
+ - lowercase letters, digits, `.`, `_`, `-` only, and no `/` against
432
+ traversal (case-insensitive filesystems would otherwise let `Alice`
433
+ and `alice` collide on one directory);
434
+ - must start with a letter or digit (no leading `.` against hidden
435
+ paths or `..` keys);
436
+ - no `@` — it is the tool-registration delimiter;
437
+ - at most 128 characters, bounding key length for storage and tool
438
+ names.
439
+
440
+ `deriveUsername(input)` maps any input onto that pattern: Unicode NFKD
441
+ and strip combining marks, lowercase, `@` → `_`, every other disallowed
442
+ character → `-` (no collapsing), strip leading non-alphanumerics,
443
+ truncate to 128. Empty result → no usable key: proxy authentication
444
+ refuses with 403 `invalid_username`, typed forms with 400, and login
445
+ derivation falls back to the literal key `user`. The normative vectors:
446
+
447
+ | Input | Key |
448
+ |---|---|
449
+ | `mikemacleod` | `mikemacleod` |
450
+ | `Mike` | `mike` |
451
+ | `mike@example.ca` | `mike_example.ca` |
452
+ | `mike@example.com` | `mike_example.com` |
453
+ | `Mike.Smith@Example.COM` | `mike.smith_example.com` |
454
+ | `mike+test@example.com` | `mike-test_example.com` |
455
+ | `José García` | `jose-garcia` |
456
+ | `DOMAIN\mike` | `domain-mike` |
457
+ | `Alice Smith` | `alice-smith` |
458
+ | `.hidden` | `hidden` |
459
+ | `mike.` | `mike.` (valid key, unchanged) |
460
+ | `` (empty) | `undefined` (login: `user`) |
461
+ | `@@@` | `undefined` (login: `user`) |
462
+
463
+ Derivation is idempotent and every valid key is a fixed point. On the
464
+ proxy path, a folded subject that is itself a valid key adopts the
465
+ existing user with that key; a derived-key collision under login
466
+ auto-provisioning takes the next free suffix instead.
467
+
468
+ **Proxy uniqueness assumption (deployment requirement).** Proxy
469
+ identity matching is case-insensitive: the proxy's usernames are
470
+ assumed unique case-insensitively, so `Alice` and `alice` are the same
471
+ person. Authelia/LDAP satisfy this. On a proxy where they are distinct
472
+ accounts, `Alice` would resolve to `alice`'s account — including admin
473
+ rights. Verify your identity source satisfies this before enabling the
474
+ proxy method.
475
+
476
+ **Derived vs typed collisions.** When a derived key is already taken
477
+ (proxy auto-provisioning, login auto-provisioning), the next free
478
+ suffix is chosen automatically: `alice`, then `alice-2`, `alice-3`,
479
+ …. Typed names — `sigrun user add`, the invite claim form, the
480
+ onboarding claim form — **refuse** with `username_taken` instead: an
481
+ admin choosing a name never gets silently suffixed.
482
+
483
+ **Login rule.** A new login user with no username claim derives from
484
+ the **full email** (`mike@example.com` → `mike_example.com`), never
485
+ just the local part. Existing linked users are unchanged — links are
486
+ matched by exact subject, not by re-deriving the key.
487
+
488
+ **Recipes.** Pre-provision a proxy user (and optionally grant admin):
489
+
490
+ ```sh
491
+ sigrun user add alice --proxy Alice --admin
492
+ ```
493
+
494
+ Link an additional proxy identity to an existing user:
495
+
496
+ ```sh
497
+ sigrun user link alice --proxy alice.smith
498
+ ```
499
+
500
+ **Crash-orphan repair.** If the process crashed between writing the
501
+ user row and writing the proxy link, the user exists but cannot
502
+ resolve. The boot sweep logs one line per orphan:
503
+
504
+ ```
505
+ identity: proxy user "alice-2" has no proxy link; repair with: sigrun user link alice-2 --proxy <username>
506
+ ```
507
+
508
+ Choose the repair that matches the intended owner:
509
+
510
+ - If the orphaned `-2` user should own the identity, run the printed
511
+ `sigrun user link <key> --proxy <username>` command.
512
+ - If the correct account already exists, delete the duplicate as an
513
+ admin with `DELETE /v1/users/alice-2?transferTo=alice`, where
514
+ `transferTo` names that existing account and receives its transferable
515
+ resources.
516
+
517
+ These are alternatives, not a sequence: deleting a user also removes
518
+ its links, so linking and then deleting the same `-2` user would undo
519
+ the repair.
520
+
332
521
  ### External sign-in (OIDC and OAuth 2)
333
522
 
334
523
  The web sign-in screen lists configured external providers above the token
@@ -480,6 +669,20 @@ admin) or by trusted-proxy auto-provision; admins can transfer or remove
480
669
  users (`PATCH`/`DELETE /v1/users/{user}` — deletion transfers their
481
670
  conversations).
482
671
 
672
+ **Proxy-only onboarding.** On a proxy-only server bound beyond
673
+ loopback, onboarding happens entirely **through the proxy**: the first
674
+ claim and every later sign-in arrive as trusted proxy identities, no
675
+ setup or invite codes apply (no setup code is minted, and an invite
676
+ code cannot be redeemed without a vouch), and an unvouched
677
+ account-creating request is refused with 403
678
+ `proxy_identity_required`. A browser reaching sigrun without a trusted
679
+ proxy identity sees a ProxyUnidentified screen instead of a token or
680
+ setup-code form. As a deliberate, bounded exception,
681
+ `/v1/auth/callback/:provider` still lets a login provider with
682
+ `policy.autoProvision` create its own non-admin account on a proxy-only
683
+ server, although the resulting session cannot authenticate because the
684
+ token method is off.
685
+
483
686
  **Sharing.** Conversations are private to their owner by default. The
484
687
  owner can share one — household-wide or with named users and groups,
485
688
  `read` or `write` — through the web UI's ⋯ menu; `write`
@@ -1075,6 +1278,34 @@ launcher) is not supervised either; there the callout says "Restart
1075
1278
  sigrun to apply" with no button. `GET /v1/meta` reports the posture
1076
1279
  (`supervised`).
1077
1280
 
1281
+ ## Parked tasks
1282
+
1283
+ A task cut off repeatedly without progress (a crash loop) is parked by
1284
+ pi-durable's restart limit: it shows `blocked` with reason
1285
+ `restart_limit` and does not run again until an administrator retries
1286
+ it. The `SigrunTaskParked` alert fires on this. Two admin-only
1287
+ endpoints inspect and recover parked tasks:
1288
+
1289
+ - `GET /v1/admin/tasks?state=blocked` — lists blocked tasks with
1290
+ `{id, kind, reason, conversationId, owner?, extension?}` per row
1291
+ (owner is the conversation's session owner when it has one; extension
1292
+ is the registered task definition's owner when known).
1293
+ - `POST /v1/admin/tasks/:id/retry` — re-runs a restart-parked task:
1294
+ 204 on success, 404 `unknown_task` for an unknown id, 409
1295
+ `not_parked` for a task that isn't parked (including one blocked for
1296
+ another reason, such as `missing_task`). A retried schedule job's
1297
+ status returns to `waiting` as the task runs again.
1298
+
1299
+ ```sh
1300
+ # List blocked tasks (admin token).
1301
+ curl -H "Authorization: Bearer $TOKEN" \
1302
+ "http://localhost:3000/v1/admin/tasks?state=blocked"
1303
+
1304
+ # Retry a parked task.
1305
+ curl -X POST -H "Authorization: Bearer $TOKEN" \
1306
+ http://localhost:3000/v1/admin/tasks/7/retry
1307
+ ```
1308
+
1078
1309
  ## API sketch
1079
1310
 
1080
1311
  - `GET /v1/meta` — version, extensions, health, auth posture
@@ -1084,6 +1315,8 @@ sigrun to apply" with no button. `GET /v1/meta` reports the posture
1084
1315
  differs from boot) and PATCH reports `{applied, pending}`.
1085
1316
  - `POST /v1/admin/restart` — graceful restart (admin, supervised
1086
1317
  deploys only; see Restarting).
1318
+ - `GET /v1/admin/tasks?state=blocked`, `POST /v1/admin/tasks/{id}/retry`
1319
+ — parked-task inspection and recovery (admin; see Parked tasks).
1087
1320
  - `GET /v1/jobs`, `POST /v1/jobs`, `PATCH`/`DELETE /v1/jobs/{id}` —
1088
1321
  the jobs surface (see [Jobs](#jobs)); `POST /v1/jobs/{id}/run` fires
1089
1322
  now; `GET /v1/jobs/{id}/runs` and `GET /v1/jobs/{id}/revisions` are
package/dist/cli.js CHANGED
@@ -47,7 +47,9 @@ import { dirname, join } from "node:path";
47
47
  import * as readline from "node:readline/promises";
48
48
  import { Writable } from "node:stream";
49
49
  import { resolveStoreRoots, writeFileAtomic } from "./harness/stores.js";
50
- import { openIdentityStores } from "./harness/identity/index.js";
50
+ import { openIdentityStores, AccountLinkConflictError } from "./harness/identity/index.js";
51
+ import { normalizeTypedUsername } from "./harness/identity/typed-username.js";
52
+ import { deriveUsername, foldSubject } from "@sigrun/shared/usernames";
51
53
  import { pathToFileURL } from "node:url";
52
54
  import { effectiveMethods, loadConfig } from "./config.js";
53
55
  import { probeIsolation, runHelperProbe, selfTestHelper } from "./sandbox/availability.js";
@@ -63,7 +65,9 @@ export const USAGE_TEXT = `usage: sigrun [--user <name>]
63
65
  sigrun token create <name> --user <user>
64
66
  sigrun token list
65
67
  sigrun token revoke <id>
66
- sigrun user add <user> [--admin] [--name <n>] [--email <e>]
68
+ sigrun user add [<user>] [--proxy <username>] [--admin] [--name <n>] [--email <e>]
69
+ sigrun user link <user> --proxy <username>
70
+ sigrun user unlink --proxy <username>
67
71
  sigrun user list
68
72
  sigrun provider list [--url <base>] [--token <token>]
69
73
  sigrun provider key <id> [--url <base>] [--token <token>]
@@ -134,8 +138,9 @@ function parseTokenArgs(argv) {
134
138
  throw usageError(`unknown token subcommand ${JSON.stringify(sub ?? "")} (create, list, revoke)`);
135
139
  }
136
140
  /**
137
- * Parse `user <sub> …` (Task 7): `add <user> [--admin] [--name <n>]
138
- * [--email <e>]`, `list`. One positional (the username), flags anywhere.
141
+ * Parse `user <sub> …`: `add [<user>] [--proxy <username>]`, explicit
142
+ * `link`/`unlink`, and `list`. A proxy add may omit the directory key,
143
+ * deriving it from the external username; ordinary add still requires it.
139
144
  */
140
145
  function parseUserArgs(argv) {
141
146
  const sub = argv[0];
@@ -145,51 +150,61 @@ function parseUserArgs(argv) {
145
150
  }
146
151
  return { command: "user", sub: "list" };
147
152
  }
148
- if (sub === "add") {
153
+ if (sub === "add" || sub === "link" || sub === "unlink") {
149
154
  let user;
155
+ let proxy;
150
156
  let admin = false;
151
157
  let name;
152
158
  let email;
153
159
  let index = 1;
154
160
  while (index < argv.length) {
155
161
  const arg = argv[index];
156
- if (arg === "--admin") {
162
+ if (sub === "add" && arg === "--admin") {
157
163
  admin = true;
158
164
  index += 1;
159
165
  continue;
160
166
  }
161
- if (arg === "--name" || arg === "--email") {
167
+ if (arg === "--proxy" || (sub === "add" && (arg === "--name" || arg === "--email"))) {
162
168
  const value = argv[index + 1];
163
169
  if (value === undefined || value.startsWith("--")) {
164
170
  throw usageError(`${arg} requires a value`);
165
171
  }
166
- if (arg === "--name")
172
+ if (arg === "--proxy")
173
+ proxy = value;
174
+ else if (arg === "--name")
167
175
  name = value;
168
176
  else
169
177
  email = value;
170
178
  index += 2;
171
179
  continue;
172
180
  }
173
- if (user === undefined && !arg.startsWith("--")) {
181
+ if (sub !== "unlink" && user === undefined && !arg.startsWith("--")) {
174
182
  user = arg;
175
183
  index += 1;
176
184
  continue;
177
185
  }
178
186
  throw usageError(`unexpected argument ${JSON.stringify(arg)}`);
179
187
  }
180
- if (user === undefined) {
181
- throw usageError("user add requires a <user>");
188
+ if (proxy === undefined) {
189
+ if (sub !== "add")
190
+ throw usageError(`user ${sub} requires --proxy <username>`);
191
+ if (user === undefined)
192
+ throw usageError("user add requires a <user>");
193
+ }
194
+ if (sub === "link" && user === undefined) {
195
+ throw usageError("user link requires a <user>");
182
196
  }
183
197
  return {
184
198
  command: "user",
185
- sub: "add",
186
- user,
199
+ sub,
200
+ ...(user === undefined ? {} : { user }),
201
+ ...(proxy === undefined ? {} : { proxy }),
187
202
  ...(admin ? { admin } : {}),
188
203
  ...(name === undefined ? {} : { name }),
189
204
  ...(email === undefined ? {} : { email }),
190
205
  };
191
206
  }
192
- throw usageError(`unknown user subcommand ${JSON.stringify(sub ?? "")} (add, list)`);
207
+ throw usageError(`unknown user subcommand ${JSON.stringify(sub ?? "")} (add, link, unlink, list)`);
193
208
  }
194
209
  /**
195
210
  * Parse `provider <sub> …` (release 0.1 Task 2b): `list`, `key <id>`,
@@ -506,9 +521,21 @@ export async function ttyPrompt(question, opts) {
506
521
  rl.close();
507
522
  }
508
523
  }
509
- /** Resolve the attach/run user: `--user` > `SIGRUN_USER` > tui.json. */
524
+ /**
525
+ * Resolve the attach/run user: `--user` > `SIGRUN_USER` > tui.json. The
526
+ * source is operator-typed (unchecked): derive the canonical key before
527
+ * it reaches the wire (the server derives on receipt too). Unusable
528
+ * input fails loud, never a silent empty identity.
529
+ */
510
530
  function resolveUser(parsed, env, file) {
511
- return parsed.user ?? env.SIGRUN_USER ?? file.user;
531
+ const raw = parsed.user ?? env.SIGRUN_USER ?? file.user;
532
+ if (raw === undefined || raw.trim() === "")
533
+ return undefined;
534
+ const derived = deriveUsername(raw);
535
+ if (derived === undefined) {
536
+ throw new Error(`invalid_username: "${raw}" derives no usable username`);
537
+ }
538
+ return derived;
512
539
  }
513
540
  /**
514
541
  * The tui.json UI refresh fields (C1) as a spreadable launch fragment —
@@ -654,7 +681,14 @@ async function runIdentityCommand(parsed, env, io, isolationProbe) {
654
681
  if (parsed.command === "token") {
655
682
  switch (parsed.sub) {
656
683
  case "create": {
657
- const user = parsed.user;
684
+ // A typed source, same rule as `user add`: the resulting key
685
+ // is what the token (and its printed secret) belongs to.
686
+ const raw = parsed.user;
687
+ const derived = deriveUsername(raw);
688
+ if (derived === undefined) {
689
+ throw new Error(`invalid_username: "${raw}" derives no usable username`);
690
+ }
691
+ const user = derived;
658
692
  if (stores.users.get(user) === undefined) {
659
693
  throw new Error(`unknown user "${user}" — add it first: sigrun user add ${user}`);
660
694
  }
@@ -685,6 +719,13 @@ async function runIdentityCommand(parsed, env, io, isolationProbe) {
685
719
  }
686
720
  switch (parsed.sub) {
687
721
  case "add": {
722
+ const input = parsed.user ?? parsed.proxy;
723
+ const typed = normalizeTypedUsername(input, (key) => stores.users.get(key) !== undefined);
724
+ if (!typed.ok) {
725
+ throw new Error(typed.error === "invalid_username"
726
+ ? `invalid_username: "${input}" derives no usable username`
727
+ : `username_taken: user "${parsed.user === undefined ? input : deriveUsername(input) ?? input}" already exists`);
728
+ }
688
729
  const config = loadConfig(env, new SettingsStore({ dir: defaultConfigDir(env) }).read());
689
730
  const coreRoot = selfPackageRoot();
690
731
  const isolation = await isolationProbe({
@@ -703,13 +744,53 @@ async function runIdentityCommand(parsed, env, io, isolationProbe) {
703
744
  proxySecretHeader: config.auth.proxy.secretHeader,
704
745
  }),
705
746
  });
706
- await stores.users.add({
707
- user: parsed.user,
747
+ const record = await stores.users.add({
748
+ user: typed.key,
708
749
  admin: parsed.admin ?? false,
709
750
  ...(parsed.name === undefined ? {} : { name: parsed.name }),
710
751
  ...(parsed.email === undefined ? {} : { email: parsed.email }),
711
752
  source: "cli",
712
753
  });
754
+ if (parsed.proxy !== undefined) {
755
+ const subject = foldSubject(parsed.proxy);
756
+ try {
757
+ await stores.accounts.link({
758
+ provider: "proxy",
759
+ subject,
760
+ user: record.user,
761
+ via: "admin",
762
+ });
763
+ }
764
+ catch (error) {
765
+ if (!(error instanceof AccountLinkConflictError))
766
+ throw error;
767
+ await stores.users.remove(record.user);
768
+ const holder = stores.accounts.lookup("proxy", subject)?.user;
769
+ throw new Error(`account_link_conflict: proxy identity is linked to ${holder ?? "another user"}; removed user "${record.user}"`);
770
+ }
771
+ }
772
+ io.out?.(record.user);
773
+ return 0;
774
+ }
775
+ case "link": {
776
+ const user = deriveUsername(parsed.user);
777
+ if (user === undefined || stores.users.get(user) === undefined) {
778
+ throw new Error(`unknown user "${parsed.user}"`);
779
+ }
780
+ const link = await stores.accounts.link({
781
+ provider: "proxy",
782
+ subject: foldSubject(parsed.proxy),
783
+ user,
784
+ via: "admin",
785
+ });
786
+ io.out?.(`${link.provider}:${link.subject}\t${link.user}`);
787
+ return 0;
788
+ }
789
+ case "unlink": {
790
+ const subject = foldSubject(parsed.proxy);
791
+ if (!(await stores.accounts.unlink("proxy", subject))) {
792
+ throw new Error(`no proxy link for "${subject}"`);
793
+ }
713
794
  return 0;
714
795
  }
715
796
  case "list": {
@@ -719,6 +800,8 @@ async function runIdentityCommand(parsed, env, io, isolationProbe) {
719
800
  record.user,
720
801
  ...(record.name === undefined ? [] : [record.name]),
721
802
  ...(record.email === undefined ? [] : [record.email]),
803
+ ...stores.accounts.listForUser(record.user)
804
+ .map((link) => `${link.provider}:${link.subject}`),
722
805
  ].join("\t"));
723
806
  }
724
807
  return 0;
@@ -727,6 +810,15 @@ async function runIdentityCommand(parsed, env, io, isolationProbe) {
727
810
  // parseArgs guarantees one of the cases above.
728
811
  throw new Error(`unhandled ${parsed.command} subcommand`);
729
812
  }
813
+ /**
814
+ * The CLI-local mirror of the server's proxy-only rule (proxy-trust
815
+ * Task 6): no token callers exist, so a setup-code prompt can never
816
+ * be redeemed. Kept here rather than imported from src/server — the
817
+ * CLI does not import server auth modules.
818
+ */
819
+ function isProxyOnly(methods) {
820
+ return methods.includes("proxy") && !methods.includes("token");
821
+ }
730
822
  /**
731
823
  * The pre-launch onboarding flow (Task 7, spec §5.8): `attach`/`run`
732
824
  * ask the server its state; an UNCLAIMED server gets the interactive
@@ -766,6 +858,12 @@ async function onboardIfUnclaimed(server, prompt, fetchImpl) {
766
858
  }
767
859
  if (state.state !== "unclaimed")
768
860
  return undefined;
861
+ // Proxy-trust Task 6: a proxy-only server without a vouched identity
862
+ // refuses the claim (403 proxy_identity_required) and never mints a
863
+ // setup code — prompt for one and the operator can only lose.
864
+ if (isProxyOnly(state.methods) && state.codeRequired && state.proxyIdentity === undefined) {
865
+ throw new Error("sigrun: this sigrun onboards through its sign-in proxy; open it in a browser");
866
+ }
769
867
  let code;
770
868
  if (state.codeRequired) {
771
869
  code = (await prompt("setup code: ")).trim();
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { isIanaTimeZone } from "./jobs/spec.js";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import { resolveStoreRoots } from "./harness/stores.js";
4
4
  import { isLoopbackHost } from "./server/auth/loopback.js";
5
+ import { deriveUsername, isUsernameKey } from "@sigrun/shared/usernames";
5
6
  /**
6
7
  * Sigrun configuration.
7
8
  *
@@ -432,6 +433,15 @@ function parseAuth(env, server) {
432
433
  }
433
434
  const token = env.SIGRUN_API_TOKEN?.trim() || undefined;
434
435
  const secret = env.SIGRUN_PROXY_SECRET?.trim() || undefined;
436
+ // The service-token user is an operator-typed identity that no request
437
+ // ever re-derives: it must ALREADY be a canonical username key at load
438
+ // time (unlike the header/CLI sources, which derive). Anything else is
439
+ // a config error naming the derived suggestion.
440
+ const serviceTokenUser = env.SIGRUN_API_TOKEN_USER?.trim() || "sigrun";
441
+ if (token !== undefined && !isUsernameKey(serviceTokenUser)) {
442
+ throw new Error(`SIGRUN_API_TOKEN_USER must already be a username key, got "${serviceTokenUser}" — ` +
443
+ `did you mean "${deriveUsername(serviceTokenUser) ?? "sigrun"}"?`);
444
+ }
435
445
  return {
436
446
  sessionDays: server?.sessionDays ?? DEFAULT_AUTH.sessionDays,
437
447
  methods,
@@ -459,7 +469,7 @@ function parseAuth(env, server) {
459
469
  : {
460
470
  serviceToken: {
461
471
  token,
462
- user: env.SIGRUN_API_TOKEN_USER?.trim() || "sigrun",
472
+ user: serviceTokenUser,
463
473
  },
464
474
  }),
465
475
  };
@@ -556,8 +566,13 @@ export function parseBackupSections(raw) {
556
566
  function parseCompaction(env, server) {
557
567
  // Env parse first (a bad env value fails loud even when the file's
558
568
  // value is valid); a file field applies only where its env var is
559
- // absent; code defaults last.
569
+ // absent; code defaults last. `modelOverrides` is file-only (a map
570
+ // has no env spelling), passed through verbatim — pi-durable's
571
+ // `compactionPolicy` applies it per model at compaction time.
560
572
  return {
573
+ ...(server?.modelOverrides === undefined
574
+ ? {}
575
+ : { modelOverrides: server.modelOverrides }),
561
576
  enabled: parseCompactionEnabled(env) ??
562
577
  server?.enabled ??
563
578
  DEFAULT_COMPACTION.enabled,
@@ -831,6 +846,7 @@ export function resolvedServerSection(config) {
831
846
  * | `SIGRUN_PORT` | `port` |
832
847
  * | `SIGRUN_WORKSPACE_PATH` | `workspacePath` |
833
848
  * | `SIGRUN_STORAGE_PATH` | `storagePath` |
849
+ * | `SIGRUN_SKIP_MIGRATION_SNAPSHOT` | — (boot escape hatch; never persisted) |
834
850
  * | `SIGRUN_DATA_DIR` | `dataDir` |
835
851
  * | `SIGRUN_CACHE_DIR` | `cacheDir` |
836
852
  * | `SIGRUN_STATE_DIR` | `stateDir` |
@@ -994,6 +1010,9 @@ export function effectiveServerSection(env, fileServer, config) {
994
1010
  emit("compaction.reserveTokens", config.compaction.reserveTokens, ["compaction", "reserveTokens"]);
995
1011
  emit("compaction.keepRecentTokens", config.compaction.keepRecentTokens, ["compaction", "keepRecentTokens"]);
996
1012
  emit("compaction.backgroundTokens", config.compaction.backgroundTokens, ["compaction", "backgroundTokens"]);
1013
+ if (config.compaction.modelOverrides !== undefined) {
1014
+ emit("compaction.modelOverrides", config.compaction.modelOverrides, ["compaction", "modelOverrides"]);
1015
+ }
997
1016
  emit("memorySeam.hookDeadlineMs", config.memorySeam.hookDeadlineMs, ["memorySeam", "hookDeadlineMs"]);
998
1017
  emit("memorySeam.idleThresholdMs", config.memorySeam.idleThresholdMs, ["memorySeam", "idleThresholdMs"]);
999
1018
  emit("memorySeam.workerDeadlineMs", config.memorySeam.workerDeadlineMs, ["memorySeam", "workerDeadlineMs"]);