@sigrun/sigrun 0.6.1 → 0.7.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 (63) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +105 -18
  3. package/dist/blocks/build-stamp.json +2 -2
  4. package/dist/blocks/schema.js +5 -0
  5. package/dist/cli.js +85 -22
  6. package/dist/curation/host.js +46 -21
  7. package/dist/curation/migrate.js +96 -0
  8. package/dist/curation/overlays.js +52 -16
  9. package/dist/curation/paths.js +22 -0
  10. package/dist/curation/runs.js +34 -17
  11. package/dist/curation/skill-tool.js +34 -9
  12. package/dist/curation/store.js +382 -254
  13. package/dist/curation/targets.js +21 -0
  14. package/dist/curation/wrap.js +1 -6
  15. package/dist/extension.d.ts +12 -3
  16. package/dist/harness/boot.js +143 -6
  17. package/dist/harness/event-bus.js +19 -0
  18. package/dist/harness/loader.js +22 -0
  19. package/dist/harness/reserved-sections.js +2 -0
  20. package/dist/harness/seam-host.js +71 -16
  21. package/dist/harness/workspace.js +34 -1
  22. package/dist/history/git.js +707 -0
  23. package/dist/history/trailers.js +145 -0
  24. package/dist/history/workspace-history.js +47 -0
  25. package/dist/jobs/index.js +5 -3
  26. package/dist/jobs/reconcile.js +2 -3
  27. package/dist/jobs/state-dir.js +9 -0
  28. package/dist/jobs/transfer.js +6 -6
  29. package/dist/sandbox/grants.js +11 -0
  30. package/dist/sandbox/launch.js +19 -9
  31. package/dist/sandbox/policy.js +6 -1
  32. package/dist/serve.js +3 -0
  33. package/dist/server/auth/proxy-only.js +5 -4
  34. package/dist/server/core-views/index.js +26 -0
  35. package/dist/server/core-views/jobs.js +661 -0
  36. package/dist/server/core-views/skills-tree.js +628 -0
  37. package/dist/server/core-views/skills.js +1090 -0
  38. package/dist/server/index.js +88 -0
  39. package/dist/server/jobs-access.js +62 -0
  40. package/dist/server/notifications.js +24 -12
  41. package/dist/server/routes/extensions.js +9 -1
  42. package/dist/server/routes/jobs.js +19 -52
  43. package/dist/server/routes/meta.js +9 -0
  44. package/dist/server/routes/onboarding.js +13 -1
  45. package/dist/server/surfaces/service.js +38 -0
  46. package/dist/skills/index.js +1 -1
  47. package/dist/skills/roots.js +301 -0
  48. package/node_modules/@sigrun/shared/CHANGELOG.md +8 -0
  49. package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
  50. package/node_modules/@sigrun/shared/dist/auth.js +26 -0
  51. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
  52. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
  53. package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
  54. package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
  55. package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
  56. package/node_modules/@sigrun/shared/package.json +9 -1
  57. package/package.json +7 -7
  58. package/webui/dist/assets/index-BI5k-Oci.js +55 -0
  59. package/webui/dist/assets/index-EGluyRXk.css +1 -0
  60. package/webui/dist/favicon.svg +7 -5
  61. package/webui/dist/index.html +3 -3
  62. package/webui/dist/assets/index-BR0di0OG.css +0 -1
  63. package/webui/dist/assets/index-BzrA1T4g.js +0 -55
package/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
5
5
  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
+ ## [0.7.0] - 2026-11-04
9
+
10
+ The polish pass: three waves of behaviour fixes, the full deferred-minors sweep, and a
11
+ fresh-eyes clean-room install verification.
12
+
13
+ ### Core
14
+
15
+ - **Host-bridge fail-fast** (breaking for embedders): bridged host members now reject
16
+ immediately with `HostBridgeUnavailableError` when no server will ever bind the bridge
17
+ (`openHarness` gains `expectHostBridge`; `createServer` marks it expected on entry).
18
+ Handlers log once per extension+member instead of stalling the hook deadline.
19
+ - Date-range presets (`PnD`/`PnW`) count calendar days; the `T…` part stays elapsed time.
20
+ - `modelBreadcrumb` in `@sigrun/shared/model-label`; the webui shows the provider as a
21
+ breadcrumb (`b/c/acme-flash`) with the full id on hover.
22
+ - `isProxyOnly` lives once in `@sigrun/shared/auth` (empty/absent methods fail safe);
23
+ the CLI onboarding gate keys on proxy identity (its claim can never carry the loopback
24
+ vouch; `sigrun run`'s own loopback companion is exempt).
25
+ - Curator: empty-text skill writes refused; `.tmp` sweep; pruned undos report-and-continue
26
+ (siblings still revert); two-targets-one-tool validated at load; scan caps gained
27
+ cursors; the 10k guard reports truncation.
28
+ - serializePolicy has one writer; smoke scripts fail loudly (the registry smoke's
29
+ false-green fixed); `-h/--help` accepted at every CLI level.
30
+ - Onboarding claim errors list the accepted body keys; token-create against fresh roots
31
+ blames the roots.
32
+ - e2e suite: zero fixed sleeps (condition waits + boot barriers); a stale-closure in the
33
+ composer's answered-refetch was found and fixed by the strictness.
34
+
35
+ ### Fresh-eyes fixes (clean-room verified)
36
+
37
+ - The compose route boots (tmpfs `/cache:uid=1000`); the container documents its token
38
+ path and the image puts `sigrun` on PATH (next build); the README documents the
39
+ submission and claim body shapes and where the setup code lands per deploy shape.
40
+
8
41
  ## [0.6.1] - 2026-11-03
9
42
 
10
43
  ### Fixed
@@ -15,6 +48,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
48
 
16
49
  - Release-pin bump to 0.6.1 (root, shared, tui, sandbox platform packages, chart appVersion, compose defaults).
17
50
 
51
+ ## [Unreleased]
52
+
53
+ ### Added
54
+ - **Views are tabs (webui).** The webui shell registers views as rail
55
+ tabs with remembered routes; the chat view keeps the conversation
56
+ list and right panel to itself. Below the tablet breakpoint a phone
57
+ bottom tab bar (with a More sheet) replaces the rail.
58
+ - **`page.selection`** — a server-declared, presentation-only
59
+ list-first drill-in for compact widths (`{selected, back}` on a
60
+ `page`; webui and TUI render it, `toText` ignores it).
61
+ - **Core views.** Core attaches its own view surfaces under the
62
+ reserved `core` slug and lists them as `coreViews` (one
63
+ `CoreViewInfo {id, title, icon, refreshMs?}` per view) on
64
+ `/v1/extensions` and `/v1/meta`; they are never `extensions[]` rows,
65
+ so the health dot is unaffected. `job.changed` now bumps every
66
+ rendered core/jobs instance.
67
+ - **The Jobs tab** (`core/jobs`): the jobs surface as a core blocks
68
+ view — sidebar of the caller-visible jobs, drill-in with summary,
69
+ paged runs and revisions (a selected revision diffs its spec JSON
70
+ against the previous one), Run now / Reset state for write access,
71
+ and per-row navigation into a run's conversation. The Jobs panel is
72
+ gone.
73
+ - **The Skills tab** (`core/skills`): every skill root the deployment
74
+ serves — Workspace / Learned / Config dir (the deployment-wide
75
+ `<config-dir>/skills/`, read-only) / one group per installed package
76
+ (`· <pkg>`, with a per-row package badge) / Deleted — with shadowing visible, owned-skill CRUD, per-skill git
77
+ history (25 entries per page, per-file diffs, restore), deleted-skill
78
+ restore, and per-user overlays as the only write to the read-only
79
+ shared roots. Some surface actions return `navigate`; the webui
80
+ applies that navigation, the TUI does not yet (a known channel gap).
81
+ - **Every-root skill discovery** (`src/skills/roots.ts`): workspace,
82
+ learned, user dir, and every installed package's skills listed in one
83
+ pass, with precedence/shadowing resolution and symlink-safe reads.
84
+ - **GitHistory** (`src/history/git.ts`) — isolated local git for
85
+ sigrun-owned history stores: execFile with explicit `--git-dir` /
86
+ `--work-tree`, a hermetic git env, per-repo locking, and fail-soft
87
+ writes (a broken repo is quarantined aside and re-initialized;
88
+ mutations never fail because history did).
89
+ - **Curation stores on git** (Task 9): each user's learned skills,
90
+ overlays, and curator state live in a per-user git history repo; the
91
+ change feed's `actor` widens to `"outside"` and its `id`/`before`/
92
+ `after` are commit SHAs. A once-per-process migration imports the
93
+ pre-git store (the legacy files are left read-only, outside the
94
+ repo's pathspec).
95
+ - **Workspace skills history** (Task 10): the workspace `skills/`
96
+ tree gets its own per-user repo (git dir outside the work tree),
97
+ caught up on settled runs and tab opens; ownerless conversations
98
+ skip it.
99
+
100
+ ### Changed
101
+ - **The curation seal**: workspaces and grants that sit inside or
102
+ contain a user's `curation/` tree are rejected; boot revalidates
103
+ stored records and names each failing one loudly. A user whose
104
+ workspace was the bare user area loses agent tools until an admin
105
+ re-points it: `PATCH /v1/users/<u> {"workspace": "<userArea>/workspace"}`.
106
+ History tamper-resistance holds under active sandbox isolation; a
107
+ single-user install without isolation runs the agent unsandboxed
108
+ (the existing trust model).
109
+
18
110
  ## [0.6.0] - 2026-11-02
19
111
 
20
112
  ### Added
package/README.md CHANGED
@@ -20,7 +20,10 @@ contracts can still evolve between minor releases. See
20
20
  [`SECURITY.md`](SECURITY.md) for the support, trust, and verification
21
21
  posture, and [`CONTRIBUTING.md`](CONTRIBUTING.md) to build and test.
22
22
 
23
- **Try it:** `npm i -g @sigrun/sigrun && sigrun run` — or jump to
23
+ **Try it:** `npm i -g @sigrun/sigrun && sigrun run` (on a workstation,
24
+ with a terminal — `sigrun run` is the interactive mode: it claims the
25
+ install via prompts and launches the TUI; without a TTY or on a server,
26
+ use `sigrun serve` below) — or jump to
24
27
  [Install](#install) and [Quickstart](#quickstart) for the model/credential
25
28
  setup every install needs.
26
29
 
@@ -114,6 +117,9 @@ Open `http://localhost:3000/` (web UI) or drive the API:
114
117
  ```bash
115
118
  curl localhost:3000/v1/meta
116
119
  curl -X POST localhost:3000/v1/conversations -H 'content-type: application/json' -d '{}'
120
+ # A submission carries its text in the `input` field:
121
+ curl -X POST localhost:3000/v1/conversations/1/submissions \
122
+ -H 'content-type: application/json' -d '{"input": "hello"}'
117
123
  ```
118
124
 
119
125
  From a source checkout the dev shape is `npm install && npm start`
@@ -121,6 +127,9 @@ From a source checkout the dev shape is `npm install && npm start`
121
127
 
122
128
  ## Launch modes
123
129
 
130
+ `sigrun --help` (or `-h`) prints usage and exits 0; the same flag works
131
+ after a subcommand (`sigrun serve --help`).
132
+
124
133
  The `sigrun` binary (`package.json` `bin`, run via tsx) has three
125
134
  entrypoints:
126
135
 
@@ -387,7 +396,7 @@ or a `/32` / `/128` entry) does vouch, as an explicit operator
387
396
  decision, and boot logs a warning for each such entry when no shared
388
397
  secret is configured.
389
398
 
390
- **Rejection diagnostics.** Each rejection logs one sanitized line
399
+ **Rejection diagnostics.** A rejection logs one sanitized line
391
400
  (subject to the throttle below) naming the socket peer, the method, a
392
401
  short reason, and the route, when known — never tokens, cookies, or
393
402
  identity-header values. The throttle emits one line per reason per 60 s,
@@ -664,17 +673,26 @@ re-pointed to a different user. The holder or an administrator must remove
664
673
  a conflicting old link first.
665
674
 
666
675
  **Onboarding (the first claim).** An unclaimed server serves
667
- `GET /v1/onboarding` + `POST /v1/onboarding/claim`. On a loopback bind
668
- or through a trusted proxy identity the claim needs no code; otherwise
669
- the one-shot setup code is required — printed once in the log at boot
670
- and written to `<cacheDir>/setup-code` (in the Helm chart: the pod log
671
- and `/cache/setup-code`; `SIGRUN_SETUP_CODE` pins it instead). The
672
- claimant becomes the first admin and owns the reserved root
673
- conversation. Later users join by invite code (`POST /v1/users/invites`,
674
- admin) or by trusted-proxy auto-provision; admins can transfer or remove
675
- users (`PATCH`/`DELETE /v1/users/{user}` — deletion transfers their
676
+ `GET /v1/onboarding` + `POST /v1/onboarding/claim`. The claim body is
677
+ `{"code": "…", "user": "<username>"}` (plus optional `name`, `email`,
678
+ `issueToken`, `tokenName`); the identity field is `user`, not
679
+ `username`. On a loopback bind or through a trusted proxy identity the
680
+ claim needs no code; otherwise the one-shot setup code is required —
681
+ printed once in the log at boot and written to `<cacheDir>/setup-code`
682
+ (in the Helm chart: the pod log and `/cache/setup-code`;
683
+ `SIGRUN_SETUP_CODE` pins it instead). The claimant becomes the first
684
+ admin and owns the reserved root conversation. Later users join by
685
+ invite code (`POST /v1/users/invites`, admin) or by trusted-proxy
686
+ auto-provision; admins can transfer or remove users
687
+ (`PATCH`/`DELETE /v1/users/{user}` — deletion transfers their
676
688
  conversations).
677
689
 
690
+ **Setup code location.** `<cacheDir>` resolves from `SIGRUN_CACHE_DIR`
691
+ (compose/chart set it to `/cache`); without it the code lands under the
692
+ `SIGRUN_HOME`-derived XDG cache (e.g. `/data/home/.cache/sigrun` on the
693
+ published image). The boot **log line** is the always-true source
694
+ regardless of deploy shape — grep it: `grep 'setup code' <the log>`.
695
+
678
696
  **Proxy-only onboarding.** On a proxy-only server bound beyond
679
697
  loopback, onboarding happens entirely **through the proxy**: the first
680
698
  claim and every later sign-in arrive as trusted proxy identities, no
@@ -1206,14 +1224,42 @@ typed — the submission's user entry carries
1206
1224
  `{kind: "sigrun.user.expanded", data: {typed, command}}` while the
1207
1225
  model receives the expanded text.
1208
1226
 
1227
+ **The Skills tab.** The core Skills view (`#/ext/core/skills`) is the
1228
+ management surface for all of this: every skill root the deployment
1229
+ serves, grouped — **Workspace**, **Learned**, **Config dir** (the
1230
+ deployment-wide `<config-dir>/skills/`, read-only), one group **per
1231
+ installed package** (`· <pkg>` — package skills, each row carrying the
1232
+ package badge), and **Deleted** — with
1233
+ shadowing made visible (a lower-precedence skill that never wins says
1234
+ so). Skills you own are editable in place; the deployment's shared
1235
+ roots (the config dir and packages) are read-only here, and a per-user
1236
+ **overlay** is their only write (your overlay, your precedence — other
1237
+ users are unaffected). Each history-backed skill keeps its full **git
1238
+ history**: page through it 25 entries at a time, read the per-file
1239
+ diffs of any change, and restore an old state.
1240
+
1241
+ The tab is per-user — a caller sees their own Workspace and Learned
1242
+ roots. On the naive single-operator loopback posture (the userless
1243
+ superuser — no `X-Sigrun-User` header), the Skills tab resolves that
1244
+ caller to the **primary admin** (the earliest-created directory user
1245
+ with the admin flag — the same fallback the execution jail uses), so
1246
+ her workspace and learned skills are the ones on screen. With an empty
1247
+ directory (no claimed user yet) there is no primary admin, so the
1248
+ tab shows only the deployment's shared roots (Config dir / Packages)
1249
+ and its own empty Workspace/Learned scope.
1250
+
1251
+ Some surface actions
1252
+ hand the channel a navigation to apply (a surface action returning
1253
+ `navigate`): the webui applies it; the TUI does not yet apply
1254
+ `ActionResult.navigate` — a known channel gap.
1255
+
1209
1256
  **The curator and learned skills.** Beyond the three roots above, every
1210
1257
  user has a private fourth root — learned skills the curator records for
1211
1258
  them under `<data-dir>/users/<user>/skills/`. For skills it ranks second:
1212
1259
  the workspace wins over it, and it wins over the user dir and packages. The curator reviews the
1213
1260
  user's conversations (owner-authored tool calls are captured as
1214
1261
  "Noted" hints, never executed on the spot) and writes through the
1215
- versioned `skill_manage` surface, so every learned skill keeps its last
1216
- ten versions, a change feed, and one-call undo. Learned skills never
1262
+ versioned `skill_manage` surface. Learned skills never
1217
1263
  leave their user: they are not shared, not transferred on ownership
1218
1264
  moves, and removed with the user on `DELETE /v1/users/{user}`. Each
1219
1265
  user can pause or disable the curator in their own per-user settings —
@@ -1223,6 +1269,36 @@ off entirely (both are per-user and take effect on the next run) — and
1223
1269
  an operator can disable it server-wide with
1224
1270
  `"builtinExtensions": {"curator": false}` (restart required).
1225
1271
 
1272
+ **Skill history is local git, per user.** Both writable roots keep
1273
+ their history in a private per-user git repository under the user's
1274
+ curation history dir — the learned skills, overlays, and curator state
1275
+ in one repo, the workspace's `skills/` tree in a second repo whose git
1276
+ dir lives outside the work tree. There is no remote and no upstream:
1277
+ the history is a local audit trail, not a bounded version count. Edits
1278
+ made outside the store are folded in as catch-up commits: when the tab
1279
+ opens they record actor `outside`; when a run settles, the workspace
1280
+ repo catches up with actor `agent` carrying the conversation id (only
1281
+ the workspace repo has a settle hook). History writes for the
1282
+ workspace root are keyed to the conversation's owner — ownerless
1283
+ conversations skip it. The history's tamper-resistance holds under
1284
+ active sandbox isolation (the agent's jail never grants the curation
1285
+ dir); a single-user install without isolation runs the agent
1286
+ unsandboxed — the existing trust model, nothing extra promised.
1287
+
1288
+ **Upgrade note (curation seal).** Workspaces and grants are now
1289
+ rejected when they sit inside or contain a user's `curation/` tree — a
1290
+ record pointing at the bare user area would hand the agent the whole
1291
+ curation state, history included. A pre-existing user whose workspace
1292
+ or grant was exactly that loses its agent's tools until an admin
1293
+ re-points it; boot logs each such user and path loudly. The repair is
1294
+ one call:
1295
+
1296
+ ```sh
1297
+ curl -X PATCH localhost:3000/v1/users/<u> \
1298
+ -H 'content-type: application/json' \
1299
+ -d '{"workspace": "<userArea>/workspace"}'
1300
+ ```
1301
+
1226
1302
  ## Jobs
1227
1303
 
1228
1304
  A job is a schedule plus an action, durable by construction — one
@@ -1281,9 +1357,17 @@ always maps to the exact definition it ran.
1281
1357
 
1282
1358
  **Run now:** `POST /v1/jobs/{id}/run` (write access) fires immediately
1283
1359
  — the same hook external triggers (CI, Alertmanager) use with a token.
1284
- In the web UI the Jobs panel (the clock icon in the header) lists jobs
1285
- with a “Run now” button; in the TUI `/jobs` prints the table (name,
1286
- next run, last outcome).
1360
+ In the web UI the Jobs tab (the core `core/jobs` view, `#/ext/core/jobs`)
1361
+ lists jobs with a “Run now” button; in the TUI `/jobs` prints the table (name,
1362
+ next run, last outcome). The tab is the block-rendered twin of the
1363
+ REST surface: the sidebar lists the caller-visible jobs, selecting one
1364
+ drills into its summary, runs, and revisions (a revision view diffs its
1365
+ spec against the previous one), and a run with a conversation links
1366
+ straight to it — that action-result navigation is applied by the webui;
1367
+ the TUI does not yet apply `ActionResult.navigate` (a known channel
1368
+ gap). Access is exactly the `/v1/jobs` rule — including the naive
1369
+ single-operator posture: the userless superuser (no `X-Sigrun-User`
1370
+ header) sees and manages every job, matching the REST routes' grant.
1287
1371
 
1288
1372
  ## Restarting
1289
1373
 
@@ -1353,11 +1437,14 @@ curl -X POST -H "Authorization: Bearer $TOKEN" \
1353
1437
  the history (newest first).
1354
1438
  - `POST /v1/conversations`, `POST /v1/conversations/{id}/submissions`,
1355
1439
  `GET /v1/conversations/{id}` — the durable conversation surface
1356
- (SSE streaming views included).
1440
+ (SSE streaming views included). A submission body is
1441
+ `{"input": "..."}` (or a `{"input": [parts]}` array of
1442
+ text/image/file content parts).
1357
1443
  - `GET /v1/providers` — per-provider readiness, statuses only (never
1358
1444
  credentials); `GET /v1/auth/check` is the deprecated alias.
1359
1445
  - `GET /v1/onboarding`, `POST /v1/onboarding/claim` — the unclaimed
1360
- state and the first-user claim.
1446
+ state and the first-user claim (body `{"code": "…", "user": "…"}` —
1447
+ the identity field is `user`).
1361
1448
  - `POST /v1/auth/login`, `POST /v1/auth/logout`, `GET /v1/auth/whoami` —
1362
1449
  token sign-in, logout, and the resolved principal.
1363
1450
  - `GET /v1/auth/providers`, `GET /v1/auth/login/{provider}`,
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "sources": {
4
- "shared/src/blocks/index.ts": "ca37dbb56a4a1a05a40a0125a06d864b9a0671c70817c83a44dd0e3962999e30",
4
+ "shared/src/blocks/index.ts": "f1a1a5c59d83ee1b062b6ee118597cf20094a40df13554d7aa572c41cb2d64a0",
5
5
  "src/blocks/builders.ts": "a0c9835aaeab871bc2548d4b8d4426849c60cdb0cb1b20142d3bbe11e8377005",
6
6
  "src/blocks/images.ts": "2e1bd54546c9c1c5bf0cfe9b488aaffd7c37979ef84bae9789168275e63e202f",
7
7
  "src/blocks/index.ts": "8878a2e2269572ebdecca2cf3b95fa67f144232d8a8fe4258b120948064d0520",
8
8
  "src/blocks/limits.ts": "1e913f29167114e26bed6f8c1fd04c64b119ee1db3f4e9e2ae161ddf70578683",
9
9
  "src/blocks/placements.ts": "fccda42669713d91dc78d025268ee127f926891cf383727c1e235a39b9f15c6f",
10
- "src/blocks/schema.ts": "27dbc5cdf1152a16a75bfb1a70383f1a456999a41d393308c05079b41f087a83",
10
+ "src/blocks/schema.ts": "13f2a225b09e41019f448b88a47b6f98ef508561955b8d847314dc88351f930a",
11
11
  "src/blocks/validate.ts": "31b9fc78f569cceff229bd1f28bfc62e2f8da437684100e051104f059a1ca19f"
12
12
  }
13
13
  }
@@ -185,6 +185,11 @@ const PageBlock = Type.Object({
185
185
  title: Type.String(),
186
186
  hint: Type.Optional(Type.String()),
187
187
  })),
188
+ /** Server-declared compact drill-in (presentation-only). */
189
+ selection: Type.Optional(Type.Object({
190
+ selected: Type.Boolean(),
191
+ back: Type.Object({}, { additionalProperties: true }),
192
+ })),
188
193
  });
189
194
  const StackBlock = Type.Object({
190
195
  ...BlockBase,
package/dist/cli.js CHANGED
@@ -40,7 +40,7 @@
40
40
  * cluster's monolith).
41
41
  */
42
42
  import { spawn as nodeSpawn } from "node:child_process";
43
- import { readFileSync, realpathSync } from "node:fs";
43
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
44
44
  import { mkdir, readFile } from "node:fs/promises";
45
45
  import { createRequire } from "node:module";
46
46
  import { dirname, join } from "node:path";
@@ -57,9 +57,11 @@ import { SettingsStore } from "./harness/settings.js";
57
57
  import { resolveHelper } from "./sandbox/helper.js";
58
58
  import { selfPackageRoot } from "./harness/packages.js";
59
59
  import { createAdmissionGuard } from "./harness/identity/users.js";
60
+ import { isProxyOnly } from "@sigrun/shared/auth";
60
61
  import { serve } from "./serve.js";
61
62
  import { ACCENT_NAMES, PALETTE_NAMES, isAccentName, isPaletteName, } from "@sigrun/shared/themes";
62
- export const USAGE_TEXT = `usage: sigrun [--user <name>]
63
+ export const USAGE_TEXT = `usage: sigrun [-h | --help]
64
+ sigrun [--user <name>]
63
65
  sigrun serve
64
66
  sigrun attach [--server <url>] [--user <name>]
65
67
  sigrun token create <name> --user <user>
@@ -92,6 +94,8 @@ function usageError(message) {
92
94
  * is a usage error.
93
95
  */
94
96
  function parseTokenArgs(argv) {
97
+ if (argv.includes("-h") || argv.includes("--help"))
98
+ return { command: "help" };
95
99
  const sub = argv[0];
96
100
  if (sub === "list") {
97
101
  if (argv.length !== 1) {
@@ -143,6 +147,8 @@ function parseTokenArgs(argv) {
143
147
  * deriving it from the external username; ordinary add still requires it.
144
148
  */
145
149
  function parseUserArgs(argv) {
150
+ if (argv.includes("-h") || argv.includes("--help"))
151
+ return { command: "help" };
146
152
  const sub = argv[0];
147
153
  if (sub === "list") {
148
154
  if (argv.length !== 1) {
@@ -215,6 +221,8 @@ function parseUserArgs(argv) {
215
221
  * note says so).
216
222
  */
217
223
  function parseProviderArgs(argv) {
224
+ if (argv.includes("-h") || argv.includes("--help"))
225
+ return { command: "help" };
218
226
  const sub = argv[0];
219
227
  if (sub !== "list" && sub !== "key" && sub !== "clear") {
220
228
  throw usageError(`unknown provider subcommand ${JSON.stringify(sub ?? "")} (list, key, clear)`);
@@ -277,6 +285,12 @@ export function parseArgs(argv) {
277
285
  return parseUserArgs(argv.slice(1));
278
286
  if (argv[0] === "provider")
279
287
  return parseProviderArgs(argv.slice(1));
288
+ // `-h`/`--help` ANYWHERE wins over every other argument: the first
289
+ // thing a stranger types must print usage with exit 0, never a usage
290
+ // error (fresh-eyes A6).
291
+ if (argv.includes("-h") || argv.includes("--help")) {
292
+ return { command: "help" };
293
+ }
280
294
  let command;
281
295
  let server;
282
296
  let user;
@@ -526,10 +540,21 @@ export async function ttyPrompt(question, opts) {
526
540
  * source is operator-typed (unchecked): derive the canonical key before
527
541
  * it reaches the wire (the server derives on receipt too). Unusable
528
542
  * input fails loud, never a silent empty identity.
543
+ *
544
+ * The flag is OPERATOR-EXPLICIT (polish task 12): an empty `--user ""`
545
+ * errors like any other unusable flag value — never a silent fallthrough
546
+ * to SIGRUN_USER/tui.json (the operator asked for a user; resolving a
547
+ * different identity would be a surprise). Ambient sources (env, file)
548
+ * keep the lenient "empty means unset" rule.
529
549
  */
530
- function resolveUser(parsed, env, file) {
531
- const raw = parsed.user ?? env.SIGRUN_USER ?? file.user;
532
- if (raw === undefined || raw.trim() === "")
550
+ export function resolveUser(parsed, env, file) {
551
+ // The ambient chain: an empty/blank value means UNSET and the next
552
+ // source takes over (the pre-existing env→file semantics — only the
553
+ // explicit flag errors on empty).
554
+ const envUser = env.SIGRUN_USER?.trim() === "" ? undefined : env.SIGRUN_USER;
555
+ const fileUser = file.user?.trim() === "" ? undefined : file.user;
556
+ const raw = parsed.user ?? envUser ?? fileUser;
557
+ if (raw === undefined)
533
558
  return undefined;
534
559
  const derived = deriveUsername(raw);
535
560
  if (derived === undefined) {
@@ -677,7 +702,8 @@ async function runProviderCommand(parsed, env, io, fetchImpl, readSecret) {
677
702
  * - `token revoke` fails loud on an unknown id.
678
703
  */
679
704
  async function runIdentityCommand(parsed, env, io, isolationProbe) {
680
- const stores = openIdentityStores(resolveStoreRoots(env));
705
+ const roots = resolveStoreRoots(env);
706
+ const stores = openIdentityStores(roots);
681
707
  if (parsed.command === "token") {
682
708
  switch (parsed.sub) {
683
709
  case "create": {
@@ -690,6 +716,17 @@ async function runIdentityCommand(parsed, env, io, isolationProbe) {
690
716
  }
691
717
  const user = derived;
692
718
  if (stores.users.get(user) === undefined) {
719
+ // Fresh-eyes B2b: an ABSENT users.json means this CLI is
720
+ // pointed at roots that have never been claimed — almost
721
+ // always the host-vs-container trap (the claim happened in
722
+ // the container's /data; the host CLI reads XDG roots), so
723
+ // "add the user" is the wrong remedy. A PRESENT directory
724
+ // without the user is a genuine unknown user.
725
+ if (!existsSync(join(roots.configDir, "users.json"))) {
726
+ throw new Error(`unknown user "${user}" — no user directory at ${roots.configDir}: this CLI's state roots hold no claimed install. ` +
727
+ `If the server runs in a container, mint the token there (docker exec <ctr> sigrun token create …), ` +
728
+ `or point this CLI at the same state roots (SIGRUN_CONFIG_DIR/SIGRUN_DATA_DIR) as the server.`);
729
+ }
693
730
  throw new Error(`unknown user "${user}" — add it first: sigrun user add ${user}`);
694
731
  }
695
732
  const { token } = await stores.tokens.create(parsed.tokenName, user);
@@ -810,15 +847,6 @@ async function runIdentityCommand(parsed, env, io, isolationProbe) {
810
847
  // parseArgs guarantees one of the cases above.
811
848
  throw new Error(`unhandled ${parsed.command} subcommand`);
812
849
  }
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
- }
822
850
  /**
823
851
  * The pre-launch onboarding flow (Task 7, spec §5.8): `attach`/`run`
824
852
  * ask the server its state; an UNCLAIMED server gets the interactive
@@ -830,7 +858,14 @@ function isProxyOnly(methods) {
830
858
  * old path and surfaces whatever the server says. A failed CLAIM is
831
859
  * loud (the server's error body, exit 1) — the operator re-runs.
832
860
  */
833
- async function onboardIfUnclaimed(server, prompt, fetchImpl) {
861
+ export async function onboardIfUnclaimed(server, prompt, fetchImpl,
862
+ /**
863
+ * `loopbackVouch`: set by the `run` command — its companion boot is
864
+ * loopback-bound, so the claim redeems on the loopback vouch even
865
+ * though the (unauthenticated) methods list is the proxy-only
866
+ * fail-safe shape. Unset (attach) keeps the strict guard.
867
+ */
868
+ opts) {
834
869
  let state;
835
870
  try {
836
871
  const response = await fetchImpl(`${server}/v1/onboarding`, {
@@ -846,6 +881,10 @@ async function onboardIfUnclaimed(server, prompt, fetchImpl) {
846
881
  const body = (await response.json().catch(() => undefined));
847
882
  if (body?.error !== "setup_required")
848
883
  return undefined;
884
+ // The 403 carries no methods: the posture is unknown, so the
885
+ // dead-end guard stays armed (`[]` is proxy-only by definition —
886
+ // a stranger-facing server that hides its methods must not
887
+ // prompt for a setup code it may not accept).
849
888
  state = { state: "unclaimed", methods: [], codeRequired: true };
850
889
  }
851
890
  else {
@@ -858,10 +897,21 @@ async function onboardIfUnclaimed(server, prompt, fetchImpl) {
858
897
  }
859
898
  if (state.state !== "unclaimed")
860
899
  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) {
900
+ // Proxy-trust Task 6 (+ polish task 12's parity ruling): a proxy-only
901
+ // server mints no redeemable setup code, so ANY CLI claim against it
902
+ // dead-ends — even a non-loopback deployment with NO proxy secret
903
+ // (the loopback peer vouches in that shape, so codeRequired is false,
904
+ // yet the claim 403s on own-host protection). The webui gates on
905
+ // codeRequired && no proxyIdentity alone; the CLI used to ALSO exempt
906
+ // that loopback case and prompt for a code the server then refuses
907
+ // with 403 proxy_identity_required. The CLI now gates exactly like
908
+ // the webui: a proxy-only server means onboarding happens in a
909
+ // browser, no exceptions. (The run-mode companion boot IS exempted:
910
+ // it binds loopback, which both drops codeRequired AND vouches the
911
+ // identity. Its methods are [] — proxy-only by the fail-safe — and
912
+ // the claim then succeeds on the loopback vouch.)
913
+ const methods = state.methods; // the ?? [] version-skew guard lives in isProxyOnly itself (fail-safe on unknown posture)
914
+ if (isProxyOnly(methods) && state.proxyIdentity === undefined && opts?.loopbackVouch !== true) {
865
915
  throw new Error("sigrun: this sigrun onboards through its sign-in proxy; open it in a browser");
866
916
  }
867
917
  let code;
@@ -965,6 +1015,12 @@ export async function cliMain(argv, env = process.env, deps, io = {
965
1015
  }
966
1016
  try {
967
1017
  switch (parsed.command) {
1018
+ case "help": {
1019
+ // Help goes to the DATA sink (stdout): the operator asked for
1020
+ // it, so exit 0 — only a usage ERROR prints to stderr + exit 1.
1021
+ io.out?.(USAGE_TEXT);
1022
+ return 0;
1023
+ }
968
1024
  case "serve": {
969
1025
  await realDeps.serve(env);
970
1026
  return 0;
@@ -1028,8 +1084,15 @@ export async function cliMain(argv, env = process.env, deps, io = {
1028
1084
  const server = `http://localhost:${booted.port}`;
1029
1085
  // Loopback onboarding (Task 7): no code (loopback is the trust);
1030
1086
  // tui.json gains ONLY the user — workstation run stays
1031
- // unauthenticated, so there is no token to keep.
1032
- const onboarded = await onboardIfUnclaimed(server, prompt, fetchImpl);
1087
+ // unauthenticated, so there is no token to keep. The
1088
+ // loopbackVouch exemption (fresh-eyes A5): this boot's methods
1089
+ // are [] (unauthenticated loopback), which the dead-end guard
1090
+ // reads as proxy-only — but the claim HERE redeems on the
1091
+ // loopback vouch, so run mode must skip the guard or a fresh
1092
+ // `sigrun run` dead-ends on the proxy message.
1093
+ const onboarded = await onboardIfUnclaimed(server, prompt, fetchImpl, {
1094
+ loopbackVouch: true,
1095
+ });
1033
1096
  if (onboarded !== undefined) {
1034
1097
  await writeTuiFile({ user: onboarded.user }, env);
1035
1098
  }
@@ -119,6 +119,7 @@ export function createCurationHost(deps) {
119
119
  let count = 0;
120
120
  let scanned = 0;
121
121
  let cursor;
122
+ let truncated = false;
122
123
  for (;;) {
123
124
  const page = await conversation.entries(query, MODEL_CALLS_PAGE, cursor, BACKGROUND_CONTEXT);
124
125
  for (const entry of page.items) {
@@ -129,13 +130,20 @@ export function createCurationHost(deps) {
129
130
  continue;
130
131
  count += 1;
131
132
  }
132
- if (page.next === undefined ||
133
- page.items.length === 0 ||
134
- scanned >= MODEL_CALLS_SCAN_LIMIT) {
133
+ if (page.next === undefined || page.items.length === 0)
134
+ break;
135
+ if (scanned >= MODEL_CALLS_SCAN_LIMIT) {
136
+ truncated = true;
135
137
  break;
136
138
  }
137
139
  cursor = page.next;
138
140
  }
141
+ if (truncated) {
142
+ // Report, never truncate silently (the A9 ruling): the count
143
+ // is a LOWER bound — a review gated on it may fire early,
144
+ // and the operator must know the scan guard tripped.
145
+ console.warn(`curation: modelCallsSince(${String(id)}) hit the ${MODEL_CALLS_SCAN_LIMIT}-entry scan guard — the reported count ${count} is a lower bound (the conversation is longer)`);
146
+ }
139
147
  return count;
140
148
  },
141
149
  async takeHints(id) {
@@ -202,7 +210,9 @@ export function createCurationHost(deps) {
202
210
  list: () => store.list(),
203
211
  view: (name, file) => store.view(name, file),
204
212
  feed: (limit, before) => store.feed(limit, before),
205
- pin: (name, pinned) => store.pin(name, pinned),
213
+ pin: async (name, pinned) => {
214
+ await store.pin(name, pinned);
215
+ },
206
216
  undo: (entryIds) => store.undo(entryIds),
207
217
  apply: (name, change, meta) => store.apply(name, change, {
208
218
  actor: "curator",
@@ -301,25 +311,40 @@ export function createCurationHost(deps) {
301
311
  const conversation = await harness.conversation(runId, BACKGROUND_CONTEXT);
302
312
  if (conversation === undefined)
303
313
  return;
304
- const page = await conversation.entries({ order: "ascending" }, 500, undefined, BACKGROUND_CONTEXT);
305
- for (const entry of page.items) {
306
- if (entry.kind !== "pi.tool-result")
307
- continue;
308
- for (const message of entry.model ?? []) {
309
- if (message.role !== "toolResult")
310
- continue;
311
- // SAFETY: the tool-result details carry the A6/A8
312
- // `details.curation` shape ({changeId, summary}); the
313
- // structural read below only touches those two fields.
314
- const details = message.details;
315
- const change = details?.curation;
316
- if (change === undefined)
317
- continue;
318
- const target = curation.byTool(message.toolName);
319
- if (target === undefined)
314
+ // Cursor-complete walk (the A9 ruling): a run with more tool
315
+ // results than one page loses NOTHING — pages until exhaustion,
316
+ // bounded so a cursor bug cannot loop forever.
317
+ let cursor;
318
+ let scanned = 0;
319
+ for (;;) {
320
+ const page = await conversation.entries({ order: "ascending" }, 500, cursor, BACKGROUND_CONTEXT);
321
+ for (const entry of page.items) {
322
+ scanned += 1;
323
+ if (entry.kind !== "pi.tool-result")
320
324
  continue;
321
- await target.undo(user, change.changeId, { abortSignal: undefined });
325
+ for (const message of entry.model ?? []) {
326
+ if (message.role !== "toolResult")
327
+ continue;
328
+ // SAFETY: the tool-result details carry the A6/A8
329
+ // `details.curation` shape ({changeId, summary}); the
330
+ // structural read below only touches those two fields.
331
+ const details = message.details;
332
+ const change = details?.curation;
333
+ if (change === undefined)
334
+ continue;
335
+ const target = curation.byTool(message.toolName);
336
+ if (target === undefined)
337
+ continue;
338
+ await target.undo(user, change.changeId, { abortSignal: undefined });
339
+ }
340
+ }
341
+ if (page.next === undefined || page.items.length === 0)
342
+ return;
343
+ if (scanned >= MODEL_CALLS_SCAN_LIMIT) {
344
+ console.warn(`curation: undoTargetChanges hit the ${MODEL_CALLS_SCAN_LIMIT}-entry scan guard on review ${reviewId} — target undos beyond the guard were NOT run`);
345
+ return;
322
346
  }
347
+ cursor = page.next;
323
348
  }
324
349
  }
325
350
  /** The run conversation carrying one reviewId (or undefined). */