@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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,125 @@ 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
+
60
+ ## [0.2.1] - 2026-10-28
61
+
62
+ ### Added
63
+ - `GET /v1/meta` now reports the effective listener: `listener.host`,
64
+ `listener.port`, and `listener.families` — the address families the
65
+ live listener accepts, measured once at listen (the `::` wildcard
66
+ probes both loopbacks and reports exactly the families that connect;
67
+ a hostname bind reports the bound socket's family, never a concrete
68
+ peer address). Additive wire field: pre-upgrade servers omit
69
+ `listener`; clients consuming it must tolerate its absence.
70
+ - `SIGRUN_LISTEN_HOST` is now honored as the environment equivalent of
71
+ the programmatic `serve({host})` option. One host value is resolved
72
+ at the serving boundary (`options.host` → `SIGRUN_LISTEN_HOST` → `::`)
73
+ and drives the bind AND both auth derivations — the previously split
74
+ defaults (the boot auth derivation's independent env read, and the
75
+ harness-side `0.0.0.0` fallback) are gone.
76
+ - Proxy-auth rejections (401) now log one sanitized `warn` per rejected
77
+ request: the socket peer, the method, a short reason (`untrusted
78
+ proxy`, `missing or wrong shared secret`, `missing identity header`,
79
+ `untrusted identity source`), and the resolved route template — never
80
+ tokens, cookies, identity-header values, query strings, or the full
81
+ URL. Token-auth failures and successful proxy authentications stay
82
+ silent.
83
+ - The normal core build now emits real `dist/blocks/**/*.d.ts`
84
+ declarations, including the `dist/blocks/index.d.ts` named by the
85
+ `@sigrun/sigrun/blocks` export. Pack/type-resolution coverage compiles
86
+ an external consumer against those declarations; extension consumers
87
+ no longer need an ambient block-module shim.
88
+
89
+ ### Fixed
90
+ - Successful `PUT /v1/me/extensions/:slug/settings` writes now publish
91
+ through the same post-commit settings-change seam as section saves:
92
+ one per-user failure-fence clear, one observing-extension reconciliation,
93
+ and the extension slug plus changed body keys. Invalid, unknown, and
94
+ otherwise rejected writes publish nothing; repeated successful writes
95
+ retain the existing notification semantics.
96
+ - `host.peekInteraction(conversationId, callId, context)`: a
97
+ non-consuming interaction inspection read beside
98
+ `snapshotInteraction`. It returns the same stored record projection
99
+ (secret values stay masked) but NEVER touches the one-shot raw-secret
100
+ delivery — safe to repeat for notifiers, outcome edits, and answer
101
+ correlation. `snapshotInteraction` remains the ONLY read that merges
102
+ and consumes an unread delivery, reserved for the interaction's
103
+ owning extension; its first-read/second-read semantics are unchanged.
104
+ - `NotificationEvent` is now a discriminated union (shared base plus
105
+ `interaction.pending` / `job.failed` / `run.settled` variants), and
106
+ the `run.settled` variant carries a structured
107
+ `outcome: { status: "answered" } | { status: "unanswered"; reason: string }`
108
+ translated directly from the host event's existing `outcome`/`reason`
109
+ (no second answer read, no inferred status). The other variants do
110
+ not gain the field; exhaustive narrowing on `type` now typechecks.
111
+
112
+ ### Fixed (continued)
113
+ - Restored per-user worker startup by placing the selected device paths
114
+ under read-only Landlock rules. Their ordinary I/O works, while
115
+ `/dev/shm` and writes outside workspace/home/tmp remain denied.
116
+ - Preserved composer text and attachments when a slow conversation-meta
117
+ restore resolves after a local edit or clear. The interaction visual
118
+ drain now covers required secrets and multi-step forms, and the visual
119
+ e2e gate passes all 163 cases.
120
+
121
+ ### Changed
122
+ - Settled-run notifications are truthful: an answered run keeps the
123
+ `"Reply ready"` title, while an unanswered run (model error, abort,
124
+ or a missing answer) is titled `"Run ended without a reply"` with the
125
+ settle reason appended to the body — a failed run no longer
126
+ masquerades as a ready reply. Recipient, dedup, and
127
+ `notifications.runSettled` gating are unchanged.
128
+
10
129
  ## [0.2.0] - 2026-10-27
11
130
  ### Added
12
131
  - Per-user extension surface (multi-user M2): lifecycle events carry the
@@ -58,11 +177,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
58
177
  literal-content ruling.
59
178
  - Images (U7): an HMAC-signed proxy, a rebinding-safe fetcher, an LRU cache
60
179
  with `sharp` conversion, and CSP headers.
61
- - Core settings sections rebuilt on blocks (U4): one settings renderer for
62
- webui and TUI, extension-declared sections, a `/v1/settings` sections
63
- route, `pendingRestart` with an admin restart route, live/restart
64
- metadata for every core key, label search, and
65
- `settings.changed`/`onSettingsChanged`.
66
180
 
67
181
  ### Changed
68
182
  - Family versions move together to 0.2.0: `@sigrun/shared` 0.2.0
@@ -91,6 +205,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
91
205
  distinguish Landlock denials from Yama or missing-capability conditions.
92
206
 
93
207
  ## [0.1.7] - 2026-10-24
208
+
209
+ *Correction (2026-10-28): the original entry omitted substantial scope
210
+ that shipped in this release — the U4 settings rebuild, the M3a
211
+ account-link store and routes, and the accompanying `@sigrun/shared` /
212
+ `@sigrun/tui` settings-surface work. Those entries are restored below;
213
+ the Service-migration claim is also corrected (adding a secondary
214
+ family while retaining the primary is an in-place patch, not a
215
+ recreate).*
216
+
217
+ ### Added
218
+ - Core settings sections rebuilt on blocks (U4): one settings renderer
219
+ for webui and TUI, extension-declared sections, a `/v1/settings`
220
+ sections route, `pendingRestart` with an admin restart route,
221
+ live/restart metadata for every core key, label search, and
222
+ `settings.changed`/`onSettingsChanged`. The shared and TUI packages
223
+ carry the matching settings-surface and settings-form work (live
224
+ surface revs, default-on toggle handling, roster-driven tool
225
+ defaults, walk-based search).
226
+ - Account links (multi-user M3a): the core account-link store plus
227
+ routes to list and remove an account's linked external identities.
228
+
94
229
  ### Fixed
95
230
  - The deploy entry binds the dual-stack wildcard (`::`) instead of the
96
231
  IPv4-only `0.0.0.0`. On a dual-stack pod whose Service is IPv6-only
@@ -107,8 +242,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
107
242
  ### Changed
108
243
  - The chart's main Service is `PreferDualStack` over `[IPv6, IPv4]`,
109
244
  so it advertises both pod addresses regardless of the cluster's
110
- default family. Converting an existing `SingleStack` Service is a
111
- recreate (`spec.clusterIPs` is immutable).
245
+ default family. Adding a secondary family while retaining the
246
+ primary is an in-place patch (`spec.ipFamilies` gains the new
247
+ family); only changing the PRIMARY family requires recreation
248
+ (`spec.clusterIPs` is immutable). *Corrected 2026-10-28: the
249
+ original entry claimed every conversion is a recreate.*
112
250
  - `sigrun run` (workstation mode) still binds `127.0.0.1` only.
113
251
 
114
252
  ## [0.1.6] - 2026-10-24
package/README.md CHANGED
@@ -1,11 +1,28 @@
1
1
  # Sigrun
2
2
 
3
- Sigrun is a durable personal-assistant harness built on
4
- [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable):
5
- long-lived conversations over a SQLite store (surviving restarts, with
6
- resumable generation), an HTTP API plus web and terminal UIs, and an
7
- extension system that installs extra tools, providers, prompt sections,
8
- and capabilities at boot.
3
+ Sigrun is a **durable, multi-user personal-assistant server** built on
4
+ [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable).
5
+ One agent loop serves every surface — a web UI, a terminal UI, an HTTP
6
+ API, and (via the gateway extension) chat channels — over long-lived
7
+ conversations stored in SQLite.
8
+ An extension model installs extra tools, providers, prompt sections,
9
+ memory systems, and login providers at boot.
10
+
11
+ **Why use it?** Conversations survive restarts and resume where they
12
+ stopped; memory and integrations are extension-owned and individually
13
+ replaceable; multi-user boundaries are explicit (real authentication plus
14
+ a per-user execution jail); and it deploys as an npm package, a Docker
15
+ compose reference, or a Helm chart. [`docs/why.md`](docs/why.md) is the
16
+ longer rationale.
17
+
18
+ **Status:** 0.x — useful and exercised, but extension and settings
19
+ contracts can still evolve between minor releases. See
20
+ [`SECURITY.md`](SECURITY.md) for the support, trust, and verification
21
+ posture, and [`CONTRIBUTING.md`](CONTRIBUTING.md) to build and test.
22
+
23
+ **Try it:** `npm i -g @sigrun/sigrun && sigrun run` — or jump to
24
+ [Install](#install) and [Quickstart](#quickstart) for the model/credential
25
+ setup every install needs.
9
26
 
10
27
  - **Durable** — conversations, submissions, and schedules live in one
11
28
  SQLite database (`SIGRUN_STORAGE_PATH`); a restart resumes where it
@@ -156,7 +173,9 @@ still override it per field.
156
173
  | `OPENAI_API_KEY` | string | — | `auth.json` (see below) | Credential for the env-driven OpenAI-compatible provider; the `auth.json` fallback after file entries. |
157
174
  | `OPENAI_BASE_URL` | URL | OpenAI | `defaultProvider.baseUrl` | Endpoint for the env-driven provider (any compatible host). |
158
175
  | `SIGRUN_PORT` | positive int | `3000` | `server.port` | HTTP listen port. |
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`). |
159
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. |
160
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. |
161
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. |
162
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. |
@@ -165,6 +184,7 @@ still override it per field.
165
184
  | — | — | *(none)* | `gitDir` is always `<configDir>/git` | Git extension checkouts clone here (pi layout). Not env-configurable. |
166
185
  | `SIGRUN_WEBUI_BUNDLE` | path | `webui/dist` | `server.webuiBundle` | Web UI bundle directory; explicit-but-missing = boot error, default-missing = API-only. |
167
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. |
168
188
  | `SIGRUN_COMPACTION_ENABLED` | `true`/`false` | `true` | `server.compaction.enabled` | Context compaction on/off. |
169
189
  | `SIGRUN_COMPACTION_RESERVE_TOKENS` | positive int | `16384` | `server.compaction.reserveTokens` | Headroom reserved for the model's reply. |
170
190
  | `SIGRUN_COMPACTION_KEEP_RECENT_TOKENS` | positive int | `20000` | `server.compaction.keepRecentTokens` | Recent context retained verbatim through a compaction. |
@@ -174,6 +194,32 @@ still override it per field.
174
194
  | `SIGRUN_BACKUP_S3_PREFIX` | string | *(unset)* | `server.backup.s3.prefix` | Key prefix within the bucket. |
175
195
  | `SIGRUN_BACKUP_S3_ENDPOINT` | URL | AWS default | `server.backup.s3.endpoint` | Custom S3 endpoint (S3-compatible stores). |
176
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
+
177
223
  ### settings.json schema
178
224
 
179
225
  ```jsonc
@@ -200,7 +246,10 @@ still override it per field.
200
246
  "enabled": true,
201
247
  "reserveTokens": 16384, // positive integer
202
248
  "keepRecentTokens": 20000, // positive integer
203
- "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
+ }
204
253
  },
205
254
  "backup": { // target: { "path": "/backups" } XOR { "s3": { "bucket": "...", "prefix": "...", "endpoint": "..." } }
206
255
  "staleAfterHours": 36 // positive number: health.backup degrades past this marker age
@@ -289,6 +338,64 @@ configurable) when the request comes from a peer inside
289
338
  proxy and nothing else: every peer in `trustedProxies` can forge any
290
339
  identity, including an admin.
291
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
+
292
399
  **Stored groups and group-derived admin.** `server.auth.adminGroups`
293
400
  lists groups (Authelia's `Remote-Groups`, for example) whose members act as
294
401
  admins. Core stores groups per authenticating source: `proxy` for validated
@@ -311,6 +418,106 @@ until then. Token and session requests only read the stored record; they
311
418
  never accept groups from the request. People-scoped conversation shares may
312
419
  also name groups, matched against the same stored groups.
313
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
+
314
521
  ### External sign-in (OIDC and OAuth 2)
315
522
 
316
523
  The web sign-in screen lists configured external providers above the token
@@ -324,9 +531,9 @@ needed. Set `"builtinExtensions": {"oauth": false}` to disable it; like the
324
531
  other built-in toggles, this is restart-required. A configured extension
325
532
  whose package name is also `@sigrun/oauth` replaces the built-in. If the
326
533
  built-in cannot be resolved or loaded, it is quarantined without aborting
327
- boot. It remains separately installable through the normal `extensions`
328
- list (see [Extensions](#extensions)) for a deployment that wants it without
329
- the built-in. It is inert when its settings contain no valid providers.
534
+ boot. Standalone installation outside the built-in is not a supported use
535
+ case in 0.2; the package's type-only core dependency posture is accepted
536
+ for this release. It is inert when its settings contain no valid providers.
330
537
 
331
538
  Create an OAuth/OIDC client at the identity service, with the redirect URI
332
539
  shown below. Configure providers in the extension's
@@ -462,6 +669,20 @@ admin) or by trusted-proxy auto-provision; admins can transfer or remove
462
669
  users (`PATCH`/`DELETE /v1/users/{user}` — deletion transfers their
463
670
  conversations).
464
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
+
465
686
  **Sharing.** Conversations are private to their owner by default. The
466
687
  owner can share one — household-wide or with named users and groups,
467
688
  `read` or `write` — through the web UI's ⋯ menu; `write`
@@ -556,8 +777,72 @@ identity-switch capabilities once (replace `<helper>` with its path):
556
777
  sudo setcap cap_setuid,cap_setgid+ep <helper>
557
778
  ```
558
779
 
559
- The helper's boot self-test verifies the switch. This mapping is unavailable
560
- in containers, whose entrypoint drops every capability.
780
+ The helper's boot self-test verifies the switch. `SIGRUN_SANDBOX_HELPER`
781
+ points sigrun at a helper binary at a non-default absolute path (it falls back
782
+ to the installed location with one warning if the path does not exist), which is
783
+ how a staged copy gets used.
784
+
785
+ File capabilities only take effect when the capabilities they grant are
786
+ already in the **caller's** bounding set: executing the capped helper needs
787
+ `CAP_SETUID` and `CAP_SETGID` present in the process that launches it —
788
+ `CAP_SETFCAP` alone only permits writing the xattr and is not enough. A root
789
+ shell, or a host that runs sigrun as root and maps users to real OS accounts,
790
+ normally satisfies this.
791
+
792
+ This mapping is bare-metal only. The container entrypoint starts as the image's
793
+ non-root `USER` and execs straight through (one uid for the whole container), so
794
+ it holds no identity-switch capabilities to inherit — containers use the
795
+ Landlock jail alone, which is where container multi-user isolation comes from.
796
+ A root container start (`SIGRUN_UID=0`) does exist, but it is a mount-point
797
+ ownership trampoline, not an `osUsers` enabler: it needs
798
+ `CAP_CHOWN`/`CAP_SETUID`/`CAP_SETGID` for the chown-and-drop and still leaves
799
+ you with a single container uid.
800
+
801
+ #### If you insist on `osUsers` in a container — and why you shouldn't
802
+
803
+ It is possible, and two of the three pieces need no image change. A capped
804
+ helper can be staged at runtime on any writable path (a named volume, a
805
+ `tmpfs`, a bind mount), and the caps come from the container's own flags:
806
+
807
+ ```yaml
808
+ # the runtime half — no derived image required
809
+ services:
810
+ sigrun:
811
+ user: "1000:1000"
812
+ cap_add: [SETUID, SETGID] # bounding set for the helper's file caps
813
+ volumes:
814
+ - sigrun-data:/data
815
+ - helper:/opt/helper # a writable path for the capped copy
816
+ # then, once per volume: copy the helper in, chmod 755, setcap
817
+ # cap_setuid,cap_setgid+ep under --cap-add SETFCAP, and point
818
+ # SIGRUN_SANDBOX_HELPER at it
819
+ security_opt:
820
+ - no-new-privileges:false # REQUIRED — see below
821
+ ```
822
+
823
+ The blocker is the third piece: **`osUser` maps to a login name**, resolved from
824
+ `/etc/passwd`, so every user you want needs an account. The image ships exactly
825
+ two non-root accounts (`node` and `nobody`). That is what forces either a
826
+ derived image (`RUN useradd …` per user) or mounting a `passwd` file over
827
+ `/etc/passwd` through a `subPath`, which is fragile and surprises tooling.
828
+
829
+ And the default posture actively prevents it, three ways: `read_only: true`
830
+ makes `setcap` on the image layer fail with "Read-only file system",
831
+ `cap_drop: ALL` grants nothing, and `no-new-privileges: true` suppresses the
832
+ file-capability gain on `execve` even when `SETUID`/`SETGID` are in the bounding
833
+ set (the helper dies at its first privileged call). Enabling this therefore
834
+ means adding capabilities **and** permitting privilege escalation — a
835
+ deliberate threat-model relaxation, not a configuration tweak.
836
+
837
+ The reason not to is structural rather than configurational: inside one
838
+ container every `osUser` shares **one filesystem and one mount namespace**. You
839
+ would gain per-process uids and Landlock's per-process confinement, but not the
840
+ kernel-level user separation a host provides — because there is only one
841
+ container boundary to separate. You would be paying a real security cost (an
842
+ identity-switch capability held by the main process, privilege escalation
843
+ permitted) for isolation the container boundary already substantially provides.
844
+ Use the Landlock jail alone in containers; use `osUsers` on bare metal, where
845
+ the accounts and the separation are real.
561
846
 
562
847
  ### Kernel integration runner controls
563
848
 
@@ -790,7 +1075,7 @@ a dashboard) are in
790
1075
 
791
1076
  ## Built-in extensions
792
1077
 
793
- Four extensions are built into core — **codemode**, **MCP**, **tool
1078
+ Five extensions are built into core — **codemode**, **MCP**, **tool
794
1079
  search**, **gateway**, and **OAuth login**. The first three mirror
795
1080
  what pi 1.3.0 ships built-in; the gateway is sigrun's own channel-adapter
796
1081
  engine; OAuth provides OIDC, GitHub, and Discord login providers. They
@@ -833,6 +1118,11 @@ default. They join the offered set when an enabled MCP server's
833
1118
  The MCP servers themselves live in the MCP config file — the
834
1119
  `@sigrun/ext-mcp` package's README documents its format, including each
835
1120
  server's `exposure` and the `SIGRUN_MCP_CONFIG` override.
1121
+ **User-supplied MCP servers are remote-only in 0.2** (URL transport);
1122
+ a user-supplied stdio server fails loudly with a config error. Dynamic
1123
+ jailed per-user stdio services are a post-release feature — per-user
1124
+ services are declared statically by extensions (see
1125
+ docs/extending.md).
836
1126
 
837
1127
  ```jsonc
838
1128
  {
@@ -988,6 +1278,34 @@ launcher) is not supervised either; there the callout says "Restart
988
1278
  sigrun to apply" with no button. `GET /v1/meta` reports the posture
989
1279
  (`supervised`).
990
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
+
991
1309
  ## API sketch
992
1310
 
993
1311
  - `GET /v1/meta` — version, extensions, health, auth posture
@@ -997,6 +1315,8 @@ sigrun to apply" with no button. `GET /v1/meta` reports the posture
997
1315
  differs from boot) and PATCH reports `{applied, pending}`.
998
1316
  - `POST /v1/admin/restart` — graceful restart (admin, supervised
999
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).
1000
1320
  - `GET /v1/jobs`, `POST /v1/jobs`, `PATCH`/`DELETE /v1/jobs/{id}` —
1001
1321
  the jobs surface (see [Jobs](#jobs)); `POST /v1/jobs/{id}/run` fires
1002
1322
  now; `GET /v1/jobs/{id}/runs` and `GET /v1/jobs/{id}/revisions` are
@@ -0,0 +1,13 @@
1
+ {
2
+ "algorithm": "sha256",
3
+ "sources": {
4
+ "shared/src/blocks/index.ts": "ca37dbb56a4a1a05a40a0125a06d864b9a0671c70817c83a44dd0e3962999e30",
5
+ "src/blocks/builders.ts": "a0c9835aaeab871bc2548d4b8d4426849c60cdb0cb1b20142d3bbe11e8377005",
6
+ "src/blocks/images.ts": "2e1bd54546c9c1c5bf0cfe9b488aaffd7c37979ef84bae9789168275e63e202f",
7
+ "src/blocks/index.ts": "8878a2e2269572ebdecca2cf3b95fa67f144232d8a8fe4258b120948064d0520",
8
+ "src/blocks/limits.ts": "1e913f29167114e26bed6f8c1fd04c64b119ee1db3f4e9e2ae161ddf70578683",
9
+ "src/blocks/placements.ts": "fccda42669713d91dc78d025268ee127f926891cf383727c1e235a39b9f15c6f",
10
+ "src/blocks/schema.ts": "27dbc5cdf1152a16a75bfb1a70383f1a456999a41d393308c05079b41f087a83",
11
+ "src/blocks/validate.ts": "31b9fc78f569cceff229bd1f28bfc62e2f8da437684100e051104f059a1ca19f"
12
+ }
13
+ }