samagotchi 0.119.0 → 0.120.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/bin/chi +6 -1
  4. data/docs/architecture.md +35 -5
  5. data/docs/cli.md +33 -8
  6. data/docs/configuration.md +45 -10
  7. data/docs/internals/participants.md +109 -12
  8. data/docs/internals/remote-sessions.md +6 -1
  9. data/docs/internals/web-push.md +4 -2
  10. data/docs/sessions.md +4 -3
  11. data/docs/testing.md +10 -1
  12. data/lib/samagotchi/answer_command.rb +2 -0
  13. data/lib/samagotchi/bridge.rb +56 -13
  14. data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
  15. data/lib/samagotchi/bridge_client.rb +58 -19
  16. data/lib/samagotchi/bridge_secret.rb +35 -0
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +3 -0
  19. data/lib/samagotchi/participant_messages.rb +5 -0
  20. data/lib/samagotchi/participants.rb +16 -6
  21. data/lib/samagotchi/review_command.rb +3 -1
  22. data/lib/samagotchi/run_options.rb +3 -1
  23. data/lib/samagotchi/session_access/contract.rb +4 -1
  24. data/lib/samagotchi/session_access/local.rb +15 -4
  25. data/lib/samagotchi/session_access/remote.rb +43 -7
  26. data/lib/samagotchi/session_access.rb +11 -1
  27. data/lib/samagotchi/session_manager.rb +1 -1
  28. data/lib/samagotchi/sessions_command.rb +1 -2
  29. data/lib/samagotchi/share_command.rb +29 -8
  30. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
  31. data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
  32. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  33. data/lib/samagotchi/version.rb +1 -1
  34. data/lib/samagotchi/web/app.rb +117 -43
  35. data/lib/samagotchi/web/grants.rb +127 -16
  36. data/lib/samagotchi/web/link_codes.rb +24 -15
  37. data/lib/samagotchi/web/participant_access.rb +69 -13
  38. data/lib/samagotchi/web/participant_exposure.rb +44 -0
  39. data/lib/samagotchi/web/participant_review.rb +66 -8
  40. data/lib/samagotchi/web/participants_admin.rb +12 -6
  41. data/lib/samagotchi/web/proxy_gate.rb +18 -3
  42. data/lib/samagotchi/web/public/annotate_bar.css +18 -0
  43. data/lib/samagotchi/web/public/annotate_bar.js +10 -3
  44. data/lib/samagotchi/web/public/app.js +66 -155
  45. data/lib/samagotchi/web/public/composer.css +73 -0
  46. data/lib/samagotchi/web/public/composer_grip.js +21 -15
  47. data/lib/samagotchi/web/public/composer_mount.js +196 -0
  48. data/lib/samagotchi/web/public/index.html +7 -83
  49. data/lib/samagotchi/web/public/notifications.js +58 -8
  50. data/lib/samagotchi/web/public/participant.html +45 -10
  51. data/lib/samagotchi/web/public/participant.js +92 -18
  52. data/lib/samagotchi/web/public/participant_annotate.js +75 -0
  53. data/lib/samagotchi/web/public/participant_model.js +53 -1
  54. data/lib/samagotchi/web/public/participants_panel.js +34 -10
  55. data/lib/samagotchi/web/public/review_history.js +52 -0
  56. data/lib/samagotchi/web/public/scope.js +12 -8
  57. data/lib/samagotchi/web/public/stage_frame.js +9 -4
  58. data/lib/samagotchi/web/public/stage_view.js +4 -4
  59. data/lib/samagotchi/web/push/notifier.rb +17 -3
  60. data/lib/samagotchi/web/running.rb +7 -4
  61. data/lib/samagotchi/web/server.rb +34 -11
  62. data/lib/samagotchi/web/session_create.rb +7 -7
  63. data/lib/samagotchi/web/start_dir.rb +26 -0
  64. data/lib/samagotchi/worker_sidecar.rb +22 -11
  65. metadata +9 -1
@@ -38,7 +38,14 @@ participant** and **one role**, until it expires or is revoked.
38
38
  bare 401; then it goes.
39
39
  - **Store.** `$XDG_STATE_HOME/samagotchi/web-grants/grants.json`, next to `web-token`: the file 0600, in its own 0700
40
40
  folder, written whole (`AtomicFile`) under a lock file (`grants.lock`) since chi web and a local `chi share` both
41
- write it. Readers follow its mtime (a `chi share revoke` in another process takes effect at chi web's next request).
41
+ write it. Its `version` is 1, and 2 once a grant is bound (never lowered); a store above `Grants::MAX_VERSION` is a
42
+ newer chi's: read (its links work, but a grant with keys this chi doesn't know is left out: such a key may
43
+ restrict it, so it fails closed) and never written (`Grants::Newer`, one message saying to update chi; a revoke
44
+ the gate can't write still refuses the grant). A grant's keys this chi doesn't know are kept on a rewrite
45
+ (`Grant#extra`), so a field a later chi adds the omit-if-default way survives. A chi from before the version check
46
+ doesn't look at it: **mixed installs** on one state dir are a hazard, as a chi before 0.109 (no `identity`) rewriting
47
+ the store unbinds every grant, and one before this change drops unknown keys. A stored `identity` that isn't one
48
+ `#issue` would write (`false`, `""`, a list, upper case) makes the store Corrupt (fails closed). Readers follow its mtime (a `chi share revoke` in another process takes effect at chi web's next request).
42
49
  `last_used_at` is written at most once a minute per grant. **chi web refuses to start** when that folder or file can
43
50
  be read by another user, or isn't this user's (`Grants.check_permissions!`, one message naming the `chmod`). Only
44
51
  those two are checked: the state dir above is often 0755, and that's fine. Never point `XDG_STATE_HOME` at a
@@ -48,11 +55,53 @@ participant** and **one role**, until it expires or is revoked.
48
55
  an invite can't keep only its own grant and lose every other digest. The owner's invite, cap, revoke and list are
49
56
  refused with one message naming the file (`Grants::Corrupt`: `chi share` exits 1, chi web answers 500 with it);
50
57
  an archive or delete still goes ahead. Fix the file, or move it aside to end every link.
58
+ - **Exposure** (`exposure`, `Web::ParticipantExposure`; the review-composer plan's D3): what the participant learns
59
+ of the review's usage, their review data's `viewer.usage` ([below](#what-a-participant-sees)). One level today,
60
+ `own`, every grant's: their own spend and cap, their own queued turns and queue limit, their link's expiry, and the
61
+ session's model; never another participant's or the owner's usage, nor the session's. `ParticipantExposure::LEVELS`
62
+ maps a level to its fields, and `ParticipantReview#review_usage` names each field's source: a later tier adds its
63
+ row and its sources there. A level this chi doesn't know (a newer chi's, a hand edit) reads as `own`, the narrowest,
64
+ and is written back as it was. `own` is left out of the store (as an unbound `identity`). A non-default level
65
+ **doesn't bump the store's version**, unlike `identity`: identity restricts a grant, so a chi that ignored it would
66
+ widen access (the version stops it writing); a chi that ignores `exposure` shows the participant `own`, so it fails
67
+ closed, and one with `Grant#extra` keeps the key on its rewrites. A chi from before `Grant#extra` drops it on a
68
+ rewrite: the grant goes back to `own`. No `--expose` flag until a second level exists; adding one touches
69
+ `Participants.invite`, `SessionAccess` Local/Remote `#invite`, the invite route, the web participants panel, the
70
+ TUI's participant commands, `SessionAccess::Contract` and `SessionAccess::ParticipantView`'s fields. The owner's
71
+ routes (`public_h`) don't show it yet.
51
72
  - Rotating the owner token (`chi web --new-token`) never touches grants; `chi share revoke --all` ends them all.
52
73
  - **Bound grants** (`identity`, an email in lower case; nil for any, and absent from the store then): a bound grant
53
74
  is honoured only on a request through the proxy (`web.public_url`) whose identity header carries that email; on the
54
- LAN or this machine it is refused (401). The gate checks it (`participant_principal(identity:, proxied:)`);
55
- issuing one (`chi share --identity`, the Invite dialog's email) is shared-sessions P6.
75
+ LAN, this machine or for another identity it is refused **401 `token_bound`** (`ParticipantMessages::TOKEN_BOUND`,
76
+ the page says it as a sentence; neither identity is named). One check, `ParticipantAccess#bound_ok?`, for the gate
77
+ (`participant_principal(identity:, proxied:)`) and the code trade (below).
78
+ - **Issuing.** `chi share ID --name NAME --identity EMAIL`, `POST …/participants {identity}`, the Invite dialog's
79
+ email field (shown when `GET …/participants` gives `identity_header`). `Grants.clean_identity` strips and
80
+ lower-cases it as `ProxyGate#proxy_identity` does (both ASCII only: the gate refuses a non-ASCII header, so the
81
+ two lower cases always agree), and refuses anything but one address (one `@`, no space,
82
+ control character, comma, quote or angle bracket, at most 254 characters; `Grants::BadIdentity`); a dot-less
83
+ domain is fine. `Participants.invite(identity:, public_url:)` refuses it unless that chi web's `PublicUrl` has an
84
+ identity header (`Grants::Unbindable`: the link could never open). Locally `public_url` is config's
85
+ `web.public_url` + `web.proxy.identity_header`, or a running `chi web --tunnel`'s (web.json records its origin and
86
+ identity header). The `issued` audit line names the identity; `chi share` warns after an unbound invite when the
87
+ chi web could have bound it (decision B).
88
+ - **Through `--remote`.** `/api/info` lists the feature `bound_grants` (`App::BOUND_FEATURE`); `Remote#invite` with
89
+ an identity requires it first, so an older chi web (which would drop the unknown `identity` and issue an unbound
90
+ grant) is never asked (`RemoteTooOld`). A grant that still comes back unbound is revoked at once and refused, its
91
+ link never printed. The owner's `/api/info` carries `proxy: {identity_header}` (nil without a proxy):
92
+ `Remote#identity_header`, for the warning and `chi review`'s hint.
93
+ - **The code (decision C).** A bound grant's link code is checked before it is used up: `LinkCodes#redeem` asks its
94
+ block under the lock, and `App#code_identity_ok?` declines unless the trade is proxied with the grant's identity.
95
+ Declined, the code stays and the trade answers `token_bound`, so a forwarded link opened by someone else still
96
+ opens, once, for the account it was sent to. Owner codes and unbound grants' codes are used up whatever the gate
97
+ says, as before.
98
+ - **Which identity string.** The grant binds to whatever the header this run trusts carries: through Okta and
99
+ oauth2-proxy the work email (`alex@corp.com`), through Tailscale Serve the Tailscale login (`alex@github`, or a
100
+ private-relay address). The same person is two strings: bind to the one that proxy sends. A request from a
101
+ tagged Tailscale device carries no user login, so a bound grant refuses it.
102
+ - **Local header spoofing (decision D).** Any process on this machine that reaches chi web's port directly can send
103
+ the identity header itself: it is trusted only as the proxy's word. That is fine on a single-user machine or pod
104
+ (whoever runs code there is the owner already); on a host other users share, the identity header isn't a boundary.
56
105
 
57
106
  ## The gate
58
107
 
@@ -60,7 +109,9 @@ participant** and **one role**, until it expires or is revoked.
60
109
 
61
110
  0. With `web.public_url` (`Web::ProxyGate`; `chi web --tunnel tailscale` sets the same for its run, never in config:
62
111
  public_url = the machine's `ts.net` name, identity_header `Tailscale-User-Login`, owner `web.proxy.owner` else this
63
- machine's Tailscale login, `Web::Tunnel::Tailscale`), a **proxied** request (a proxy trace: `X-Forwarded-*`, `Forwarded`,
112
+ machine's Tailscale login, `Web::Tunnel::Tailscale`), a **proxied** request (from a loopback peer only,
113
+ `ProxyGate.loopback_peer?`: the proxy runs on this machine, and a LAN peer's traces are its own words, so with
114
+ `web.host: lan` and `web.public_url` a LAN device forging them is a LAN request; with a proxy trace: `X-Forwarded-*`, `Forwarded`,
64
115
  `X-Real-IP`, a known identity header, the configured one, or the public Host) is `#proxied_principal`'s: the owner
65
116
  only with the owner's token **and**, when `web.proxy.identity_header` is set, that header being one clean value
66
117
  (not repeated, no comma, not empty, under one name with no underscore: the raw header names) equal to
@@ -73,7 +124,8 @@ participant** and **one role**, until it expires or is revoked.
73
124
  grant:)`), from any peer: loopback and a chi web not on the LAN too. An ended or unknown one is refused
74
125
  (below), never the owner.
75
126
  3. No `chp_` token from a loopback peer, or on a chi web not on the LAN: the **owner** (unchanged).
76
- 4. Anything else: nil. `#token_gate` answers an ended grant's token **401 `token_revoked`** or **401
127
+ 4. Anything else: nil. `#token_gate` answers a bound grant's token without its identity **401 `token_bound`**, an
128
+ ended grant's token **401 `token_revoked`** or **401
77
129
  `token_expired`** (the page says the same words as a sentence: "This review link was revoked by the session's
78
130
  owner." / "This review link expired: ask the session's owner for a new one."), any other **401
79
131
  `unauthorized`** as before.
@@ -105,7 +157,7 @@ row answers another session's id 404, so a new route is covered without a new ex
105
157
  | `GET /api/sessions/resolve?id=` | participant | their session for its id or a prefix of it; anything else 404 as no session (never an ambiguity listing others) |
106
158
  | `POST /api/sessions/:id/wait/prepare`, `…/wait` | participant | only for a turn of theirs (`turn`: the enqueued id `POST …/turn` answered; else 403); `wait/prepare` answers only what that wait needs, `{session_id, baseline: {last_turn}, known_turn, live, last_turn_id}` (`last_turn_id` only when it is their turn), never the owner's cursor, message count, question or running turn; the answer trimmed to `{status, text, turn_id, cancel_reason}`, the reply's `text` through `ParticipantView.reply` (no thinking or tool call, redacted) and any other text (an error's exception, a failed turn's summary) `ParticipantMessages::TURN_FAILED`; a question or approval their turn raised, or their turn held by the owner's step-limit Continue offer ([Turns](#turns)), is `{status: "waiting_for_owner", detail: "waiting for the session's owner"}` with nothing of it; a revoke or the expiry ends a `wait` already running (401 `token_revoked` / `token_expired`), as it ends their stream |
107
159
  | `GET /` | participant | the participant page's shell (`participant.html`, its body's `data-session` and `data-name` the grant's; `participant.js` reads the rest through the two routes below). Never the owner's page, where every route would refuse them. |
108
- | `GET /api/sessions/:id/review` | participant | the page's data, filtered ([What a participant sees](#what-a-participant-sees)): `{viewer, session: {id, title, status}, subjects, participants, messages, current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}` |
160
+ | `GET /api/sessions/:id/review` | participant | the page's data, filtered ([What a participant sees](#what-a-participant-sees)): `{viewer, session: {id, title, status}, subjects, participants, messages, current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}`; a participant's `viewer` has their `usage` |
109
161
  | `GET /api/sessions/:id/stream` | participant | the session's stream through `ParticipantStreamFilter` (only `from_seq` and `snapshot` reach the Bridge); it ends within a poll of a revoke or the expiry |
110
162
  | `GET /api/info` | participant | `{app, version, features}` (what `SessionAccess::Remote`'s check needs); not `pid`, `cwd`, `lan`, `installed` |
111
163
  | `GET /api/sessions/:id` | participant | their session only, whatever the query (`brief`, `cards`, `tail`, `timing`): `{session: {id, title, status, participants: [{id, name, kind, role}]}}` (the active ones). `title` is the session's own title, or "an untitled session": never `display_title`'s fallback, the first prompt's opening words |
@@ -205,7 +257,8 @@ made by `chi share revoke` in another process; the page then reads a 401 and sho
205
257
 
206
258
  D5 (a): prompts and replies with their authors, one-line tool rows, turn boundaries, the queue and waiting states.
207
259
  Not tool output or arguments, thinking, approvals' and questions' content, memories (text or names), context notes,
208
- plugin steers, automatic and delegate input, cards, command output, the working set (the user decided: not), cost,
260
+ plugin steers, automatic and delegate input, cards, command output, the working set (the user decided: not), cost
261
+ (but their own usage, as their grant's exposure shows it),
209
262
  timing (durations, records: only the wall time `at` of each prompt, reply and turn start, for the page's gap lines
210
263
  and hover times, D6 of the session-time-gaps plan), host paths or the owner's prompt history. Every read a participant reaches goes through one module,
211
264
  `Web::ParticipantView` (`lib/samagotchi/web/participant_view.rb`), whose every answer is a new hash built from named
@@ -247,6 +300,20 @@ fields: a field added to an event later reaches nobody until it is named there.
247
300
  `html` is rendered from the filtered text. The turn in progress (`.current_turn`, over the Bridge snapshot's) gives
248
301
  its start's fields, the text so far, its tool rows (with `running`) and `waiting_for_owner`.
249
302
  - **The queue.** Their own queued turns (enqueued id and text, for Take back) and how many others wait.
303
+ - **Their usage** (`viewer.usage`, `ParticipantReview#review_usage`, built from named fields by the exposure policy
304
+ beside `ParticipantView`'s): the fields their grant's exposure shows
305
+ ([Grants](#grants)); at `own`, `{spent, cap, queued, queue_limit, expires_at, model}`. `spent` is dollars: the saved
306
+ analytics' count by their author (the cap check's), or the live worker's `spend_by_author` for them when it is more
307
+ (its count holds the saved turns, the running turn's finished generations and the side requests they caused, so a
308
+ running turn shows before it is saved; the two are never added), read from the Bridge snapshot the review already
309
+ reads. `queued` counts their own queued turns on the worker; `model` is the session's model as its host is sent it
310
+ (the alias applied, no host name), live else saved, redacted. Every source reads the grant's own author: another
311
+ participant's spend, the owner's and the session's totals never reach them (the red-team spec checks the body). The
312
+ owner's participant view has none. The page draws it as one line under the comment box (`usageItems`): "$0.42 of
313
+ $2.00 cap · 1/3 queued · link: 6 d left · model", each with a title in words; warn colours from 80% of the cap, a
314
+ full queue and a link's last day, "over" at the cap. On a phone it is the only line under the box (the hint is
315
+ hidden there), cut at the end (the model first); folded, it goes with the hint. It is redrawn at each re-read (a
316
+ turn's end, a queued or taken-back comment), not while a turn streams.
250
317
  - **A fork's note.** A review fork (`chi review --from`, `/coordinate review`) starts with a note holding the source's
251
318
  recap and, from the coordinator, its handoff's body: a participant's review turn reads it like the rest of the
252
319
  history and may quote it (host paths, worktree names). That is by design (it is what the review is about); the
@@ -265,6 +332,28 @@ fields: a field added to an event later reaches nobody until it is named there.
265
332
  page shows them as the Plan: beside the thread on a wide screen (≥ 1100 px), a Plan | Thread toggle on a narrower
266
333
  one (the thread first, the composer pinned in both views); several subjects get a file picker. It re-reads them
267
334
  with the rest at every turn boundary and redraws the Plan only when they changed.
335
+ - **Annotate** (`participant_annotate.js` over the owner's `annotate_bar.js`, styled by the shared
336
+ `annotate_bar.css`). A participant who can comment selects text in the Plan or the thread and gets **Annotate** and
337
+ the owner's `web.annotate_presets` (the participant shell's `data-annotate-presets`, as `index.html`'s), which append
338
+ the quote to the comment box; nothing is sent. Labels: the Plan `From docs/plan.md:` (the subject shown), a comment
339
+ `From Sam's comment:` / `From my earlier comment:` / `From the owner's message:` (the message's `data-by`,
340
+ `data-name`), a reply none. Not annotatable: the reply still streaming, tool rows, waiting lines, gap lines, the
341
+ header, the Plan's truncation note. No plugin pills (a review turn refuses `/` lines), and none of it in the owner's
342
+ participant view (no presets in its shell) or while `can_comment` is false. On a touch screen the bar docks above the
343
+ composer. A re-read keeps unchanged thread nodes and the Plan's (redrawn only on change), so a selection survives it.
344
+ - **The comment box** is the owner's composer (`composer_mount.js`, styled by the shared `composer.css`), mounted by
345
+ `participant.js` with a subset on. It has: the height that grows with the text, the grip (drag to resize,
346
+ double-click resets, click or drag down folds it; folded, the grip is the handle on the dock's edge and typing on the
347
+ page opens it; Annotate opens it for the visit), the one-row layout of the owner's session composer (Send at its
348
+ bottom right; a phone keeps it one row), and ↑/↓ over the participant's own comments: the ones the server took from
349
+ this browser, in localStorage under `chi_review_history:<grant id>` (`review_history.js`; the grant id is the review
350
+ data's `viewer.id`), at most 50, forgotten when the link ends (a 401 with chi's words), never the owner's `GET
351
+ /api/history` (the owner may open a link in the same browser). It lacks: the `/` list and plugin pills (a review turn
352
+ refuses `/` lines), the delivery line and its steer/queue choices (every comment is `POST …/turn`, a queued review
353
+ turn; Alt+Enter is Enter), the model picker, Stop turn, and images: the chips are mounted but `imagesOff` refuses a
354
+ paste, drop or pick with one line (the image button is hidden) until participant images land. The grip's floor and
355
+ the fold are the owner page's keys (`chi_composer_floor`, `chi_composer_collapsed`), per browser. The owner's
356
+ participant view mounts none: its dock stays hidden.
268
357
  - **Redaction** (`ParticipantView::Redact`, on every string that goes out): secret shapes (`sk-…`, `ghp_…`,
269
358
  `github_pat_…`, `AKIA…`, `xox…-`, `chp_…`, `AIza…`, `rk_live_…`, JWTs, Slack webhooks, private key blocks, an
270
359
  `Authorization:` header's value, any `…token` / `…api_key` / `…secret` / `…password` value, `=` or `:`, quoted or
@@ -329,16 +418,17 @@ gate and routes) answers these.
329
418
 
330
419
  | Route | Body / params | Answer | Refusals |
331
420
  |---|---|---|---|
332
- | `GET /api/sessions/:id/participants` | | 200 `{participants: [grant], warning}`, every state; `warning`: the share warning for a session that isn't a review session (`ReviewStart.share_warning`, null for one), shown before the panel's Invite with Start a review of it | |
421
+ | `GET /api/sessions/:id/participants` | | 200 `{participants: [grant], warning, identity_header}`, every state; `warning`: the share warning for a session that isn't a review session (`ReviewStart.share_warning`, null for one), shown before the panel's Invite with Start a review of it; `identity_header`: the header this chi web's proxy names the user in (null without one), the Invite's email field | |
333
422
  | `GET /api/participants` | | 200 `{participants: [grant]}`, every session's | |
334
- | `POST /api/sessions/:id/participants` | `name`, `kind` (`human`/`agent`), `ttl` (seconds) | 201 `{participant: grant, token, link, lan, qr_svg, warning}`: the token and the link once; `warning` for a session that isn't a review session (`ReviewStart.share_warning`, null for one); `link` names this chi web's LAN address (`http://IP:PORT/?token=…#s=<id>`), `lan` false when it has none | 400 `invalid_participant` (name, kind, ttl, an archived session); 404 |
423
+ | `POST /api/sessions/:id/participants` | `name`, `kind` (`human`/`agent`), `ttl` (seconds), `cap`, `identity` (an email: a bound grant) | 201 `{participant: grant, token, link, lan, qr_svg, warning}`: the token and the link once; `warning` for a session that isn't a review session (`ReviewStart.share_warning`, null for one); `link` names this chi web's LAN address (`http://IP:PORT/?token=…#s=<id>`), `lan` false when it has none | 400 `invalid_participant` (name, kind, ttl, an identity that isn't one address or with no proxy to check it, an archived session); 404 |
335
424
  | `GET /api/sessions/:id/apply[?turn=T]` | `turn`: a turn id or a prefix (none: the latest review turn with a reply) | 200 `{text, turn_id, author, author_name}`: the Apply turn for the composer, never sent | 404 `not_found` (no such turn, no review turn), 400 `not_review_turn` (an owner's turn), 409 `no_reply` |
336
425
  | `DELETE /api/sessions/:id/participants[?id=p_…]` | | 200 `{revoked: [grant]}`: one participant, or the session's | 400 `invalid_participant` |
337
426
  | `DELETE /api/participants` | | 200 `{revoked: [grant]}`: every session's | |
338
427
  | `POST /api/sessions/:id/participants/:p/cap` | `cap` (dollars, 0 to 1000) | 200 `{participant: grant}`: their cap from their next turn on | 400 `invalid_participant` (the cap, no such active participant) |
339
428
 
340
429
  A grant here is `Grant#public_h`: `id, session_id, kind, name, role, created_at, expires_at, revoked_at, revoked_by,
341
- last_used_at, state` (`active | revoked | expired`), `cap` (dollars) and `queue`. Never the token or its digest.
430
+ last_used_at, state` (`active | revoked | expired`), `cap` (dollars), `queue` and `identity` (null: unbound). Never
431
+ the token or its digest.
342
432
  `POST …/participants` also takes `cap`. A grant from before caps takes the config's.
343
433
 
344
434
  `chi share` (docs/cli.md) uses them through `--remote`, and `SessionAccess::Local`'s same-named operations here
@@ -359,8 +449,12 @@ invited to has none: its files stay byte for byte as they were.
359
449
  The worker reads it with `Participants.read` (P3a's system-prompt line, `ctx`).
360
450
  - **`participants.jsonl`** (0600, append-only, `Participants.log`): one JSON line per event, with `at`: `issued`
361
451
  (participant, name, kind, role, expires_at), `revoked` (participant, by), `expired` (the first refused use after the
362
- expiry, once per chi web), `peer` (the first use and each new peer address since chi web started: ip, user agent),
363
- `refused` (method, path, reason; at most once a minute per route; or `what: command`, a participant's `/word` or
452
+ expiry, once per chi web), `peer` (the first use and each new peer since chi web started: ip, user agent; behind a
453
+ proxy, where every ip is 127.0.0.1, the peer is the signed-in identity, else the proxy's X-Forwarded-For hop:
454
+ `identity`, `peer`), `issued` names a bound grant's `identity`,
455
+ `refused` (method, path, reason; at most once a minute per route; `token_bound` with the `identity` that presented
456
+ it, or `(none)`, once a minute per grant and identity whatever the path; at most 256 refusals a minute are
457
+ remembered and audited; or `what: command`, a participant's `/word` or
364
458
  `!line` the Bridge or the worker refused), `turn` (chars, `status: accepted` with its enqueued id, or `refused` with
365
459
  the reason: `not_permitted`, `too_many_turns`, `cap_reached`, `review_unavailable`, `worker_timeout`), `cap` (a new
366
460
  cap). It goes with the session (retention, delete).
@@ -412,6 +506,7 @@ code, for any route):
412
506
  |---|---|---|
413
507
  | 401 `token_revoked` | `TokenRevoked` (an `AuthRefused`) | `this review link was revoked by the session's owner` |
414
508
  | 401 `token_expired` | `TokenExpired` (an `AuthRefused`) | `this review link expired: ask the session's owner for a new one` |
509
+ | 401 `token_bound` | `TokenBound` (an `AuthRefused`) | `this review link is for another account: open it through the proxy, signed in as the person it was sent to` |
415
510
  | 403 `not_permitted` | `NotPermitted` | `a participant can't do that: only the session's owner can` |
416
511
  | 429 `too_many_turns` | `TooManyTurns` | `this participant has too many turns waiting: wait for one to finish` |
417
512
  | 429 `cap_reached` | `CapReached` | `this participant's spending cap is reached: ask the session's owner to raise it` |
@@ -448,6 +543,8 @@ participant TUI in v1.
448
543
  no participant token; nothing else. Through a proxy the owner is the token and, with an identity header
449
544
  configured, that identity: never the identity alone, never a loopback peer. A participant token never widens to the
450
545
  owner, from any peer.
546
+ - A bound grant works only on a proxied request whose identity header is its identity: never on the LAN, this
547
+ machine's loopback or for another identity, and its link code is used up only by that identity.
451
548
  - A participant reaches one session, through the `:participant` rows only; another session is a 404.
452
549
  - No token is written anywhere: the grants store has digests, the sidecar and the audit trail have neither.
453
550
  - Owner sessions are unchanged: no sidecar, no audit file, no key in the session JSON until a grant exists.
@@ -6,7 +6,8 @@ session's `context`, `sessions list/stop/restart/archive/unarchive/delete`) agai
6
6
  sessions, workers, memories and code stay on that machine; this one only sends requests and prints the answers.
7
7
  User-facing behaviour is in [cli.md](../cli.md#remote-sessions). This page is the code's shape, for changing it.
8
8
 
9
- **The one rule:** the remote side is that machine's **chi web**, never a worker's Bridge. Bridges stay on loopback.
9
+ **The one rule:** the remote side is that machine's **chi web**, never a worker's Bridge. Bridges stay on loopback,
10
+ and answer only a request that names their secret from `bridge.json` ([architecture.md](../architecture.md)).
10
11
  chi web answers each request by calling the same `SessionAccess::Local` operation the CLI would call there.
11
12
 
12
13
  ## The seam: `SessionAccess`
@@ -160,6 +161,10 @@ the session commands*. Keep that table in step with `SessionAccessRoutes`.
160
161
  only when its detail starts with `not found: /` (the 404 from `serve_static`, for a path no route takes). A route's
161
162
  own 404 (no such session or source) stays a refusal. **Keep that rule:** a new route's 404 must never use the
162
163
  `not found: /…` wording.
164
+ - **A field an older chi web would ignore.** A body field whose meaning is a restriction (`share --identity`'s
165
+ `identity`: an older chi web drops it and issues a link anyone could open) gets a feature of its own
166
+ (`bound_grants`, `App::BOUND_FEATURE`), and `Remote` requires it before sending the field (`require_feature!`):
167
+ never a silent downgrade.
163
168
 
164
169
  ## Which checks run where
165
170
 
@@ -96,10 +96,12 @@ injected transport) that decrypts each body with the device's key and checks the
96
96
  The service worker is the only reader of the payload (JSON, at most `Notifier::OPEN_LIMIT`, 3000 bytes, with
97
97
  `open`; one aes128gcm record leaves 3993 bytes of plaintext under Web Push's 4096: 86 header, 1 delimiter, 16 tag):
98
98
 
99
- - `{"v":1,"kind":"show","tag":KEY,"title":…,"body":…,"url":"/#/s/<id>","badge":N,"open":[KEY…]}`: a waiting
99
+ - `{"v":1,"kind":"show","tag":KEY,"title":…,"body":…,"url":URL,"badge":N,"open":[KEY…]}`: a waiting
100
100
  reason's (approval, question, continue, card) TTL 12 h (`SHOW_TTL`), `Urgency: high`; a failed or done turn's
101
101
  TTL 10 min (`EVENT_TTL`), `Urgency: normal`. `open` only while the whole list fits the limit; the test push
102
- (`chi:test`) has neither `open` nor `badge`.
102
+ (`chi:test`) has neither `open` nor `badge`. URL (`Notifier.session_url`) is the session in its own project's
103
+ view, `/?dir=<project root>#/s/<id>` (the folder URL-encoded, its slashes kept), or `/?scope=all#/s/<id>` for a
104
+ session in no repo: never the bare `/`, which is chi web's start project.
103
105
 
104
106
  `badge` and `open` are the waiting keys still open in the Notifier's summaries (`Attention.open_keys`: a pending
105
107
  question of any reason, a hook's and a continue's too, not relayed, and a card with actions; never a failed or done
data/docs/sessions.md CHANGED
@@ -90,9 +90,9 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
90
90
  **Ordering:**
91
91
 
92
92
  - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
93
- - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new sessions start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new session there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat, and the grip on the chat's top edge — the same thin handle as the composer's, its twin — puts them away and brings them back: a drag up (or a click, or Enter/Space on it) hides them, a drag down (or a click) shows them again, and the choice is remembered. A hidden strip leaves its status in the top bar (`N sessions · ● 1 live · ● 1 waiting`, from the cards), and a click on that pill opens All sessions, without showing the strip first. In a window under 900 px wide, where the strip scrolls sideways, the grip stays under it, outside the scroller; a window under 700 px tall hides the strip by itself, so a question or approval card in the stage keeps room, and the grip shows it until the window is tall again. A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new session. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`); after a `/model` switch the info bar's tooltip adds a `per model:` row per run of turns on one model above those totals (`splash:qwen3-35b · 6 turns · 96.0k in / 9.0k out · ~$0.02`, a switch's prefill under its row: `└ switch prefill 58.0k uncached prompt ≈$0.01`; the timing's `model_segments`, replaced by each `generation_completed`'s; the session cards stay as they were), and right after the switch its ctx % is the new model's window's; and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new session starts on, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new session only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
93
+ - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new sessions start in that folder; the header chip says `<project> · all`, and `all` opens the same place as `?scope=all` (every session; a new session there starts in chi web's start folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the start folder's project). A bare `/` (the LAN link, the Home Screen app) is the start folder's project too: the folder chi web runs in, or `web.start_dir` / `chi web --start-dir DIR`; outside a repo it shows every session. A pushed notification opens its session in that session's own project (`/?dir=<its project>#/s/<id>`; `?scope=all` outside a repo). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat, and the grip on the chat's top edge — the same thin handle as the composer's, its twin — puts them away and brings them back: a drag up (or a click, or Enter/Space on it) hides them, a drag down (or a click) shows them again, and the choice is remembered. A hidden strip leaves its status in the top bar (`N sessions · ● 1 live · ● 1 waiting`, from the cards), and a click on that pill opens All sessions, without showing the strip first. In a window under 900 px wide, where the strip scrolls sideways, the grip stays under it, outside the scroller; a window under 700 px tall hides the strip by itself, so a question or approval card in the stage keeps room, and the grip shows it until the window is tall again. A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new session. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`); after a `/model` switch the info bar's tooltip adds a `per model:` row per run of turns on one model above those totals (`splash:qwen3-35b · 6 turns · 96.0k in / 9.0k out · ~$0.02`, a switch's prefill under its row: `└ switch prefill 58.0k uncached prompt ≈$0.01`; the timing's `model_segments`, replaced by each `generation_completed`'s; the session cards stay as they were), and right after the switch its ctx % is the new model's window's; and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new session starts on, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new session only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
94
94
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
95
- - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker and its LLM context and thinking chips, a choice for the next new session only; a fresh chain link's picker and its `/model`), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
95
+ - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `composer_mount.js` (the composer mounted on a page's markup: the message box and its height, the image chips from a paste, a drop or the image button, the grip and fold, ↑/↓ over the prompt history and the keys; the page's own parts come in as hooks: what Enter and Send do, keys taken first, Esc, where the history comes from, the info bar's Stop turn and drop while folded, typing anywhere to open it; its styles are the shared `composer.css`, linked after `tokens.css` by `index.html` and `participant.html`), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `review_history.js` (a participant's ↑/↓ list on the participant page: their own comments, in localStorage per grant, never the owner's `/api/history`; `participant.js` mounts `composer_mount.js` with it, no `/` list, no delivery choices, images off), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker and its LLM context and thinking chips, a choice for the next new session only; a fresh chain link's picker and its `/model`), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
96
96
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
97
97
  - The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only. The fold keeps the height the grip set (`chi_composer_floor`): the box opens again at the height it had, and only a double-click on the grip forgets it. That height never takes more than half of the window the box is drawn in: a floor saved in a bigger window is held to that half in a smaller one (a rotated phone, a resized window, another device on the same origin), and the saved floor itself stays, so the height comes back when the room does. A click on the expanded grip hides the box as well: it folds 250 ms after the press, so a second click inside that window is read as a double-click and resets the height instead (a drag, Enter or Space act at once, and a drag after a click takes the wait back). The start page's grip only resizes.
98
98
 
@@ -274,7 +274,8 @@ sessions](cli.md#review-sessions)); both start idle and print the `chi share` li
274
274
  continue keeps it; a fork (`ctx.sessions.fork`) and a delegate child don't.
275
275
  - **The subjects** are checked where the session starts (`ReviewStart.check_subjects`), by the same check a review
276
276
  turn's `read` goes through (`Guardrails::ReviewPaths`): an existing file under the project root, as given and as it
277
- resolves, never a secret file or chi's own. They are what participants read beside the thread.
277
+ resolves, never a secret file or chi's own. They are what participants read beside the thread, and may quote into
278
+ a comment (Annotate: [participants.md](internals/participants.md#what-a-participant-sees)).
278
279
  - **The note:** it starts with a context note from `review` naming the subjects and what a review turn does; a fork's
279
280
  starts with `This review session starts from 3f9a1c2e (2026-10-23 09:12), a working session: it brings that
280
281
  session's recap, not its transcript.` and the recap (as a continue's carry note), so nothing of the source's
data/docs/testing.md CHANGED
@@ -44,7 +44,7 @@ exception's text changes between Ruby versions: write the string yourself).
44
44
  The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_view.js`,
45
45
  `turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
46
46
  `question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
47
- `chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `session_actions.js`, `send_turn.js`, `dock_reserve.js`,
47
+ `chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `session_actions.js`, `send_turn.js`, `composer_mount.js`, `dock_reserve.js`,
48
48
  `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
49
49
  never shipped; lit reads `document` as it loads). The shim's `markupOf(template)`,
50
50
  `htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments; the DOM writes text with only `&`, `<`, `>`
@@ -157,6 +157,15 @@ Every flake is classified into one of the four patterns, each with its own fix:
157
157
  A second flake of the same pattern at a new site means that pattern needs a seam in lib there (for example an `on_*:`
158
158
  callback the spec can wait on), not another local fix in the spec.
159
159
 
160
+ ### Talking to a real Bridge
161
+
162
+ A session's Bridge answers only a request that names its secret (`X-Chi-Bridge`, from the worker's `bridge.json`;
163
+ see [architecture.md](architecture.md)). A spec that writes its own request to a real Bridge (`Net::HTTP`, a raw
164
+ socket) names it too: `include BridgeAuth` (spec/support/bridge_auth.rb, required by the spec that needs it) gives
165
+ `bridge_auth(session_dir_or_sidecar)`, the header as a Hash, and `bridge_auth_line(...)` for a raw request; or read
166
+ `port` and `secret` from the sidecar in one go, as `BridgeClient.discover` does. A client made with `BridgeClient.new`
167
+ takes `secret:` (nil for a fake Bridge that doesn't check it).
168
+
160
169
  ## Bundle specs: the test kit
161
170
 
162
171
  A shipped bundle's plugin and hook files are tested through the bundle test kit (`samagotchi/testing`,
@@ -196,6 +196,8 @@ module Samagotchi
196
196
  end
197
197
  return failure(worker_gone(id)) unless reply
198
198
 
199
+ return failure(BridgeSecret::REFUSED) if reply.secret_refused?
200
+
199
201
  case reply.status
200
202
  when 200 then nil
201
203
  when 409
@@ -5,8 +5,10 @@ require "json"
5
5
  require "uri"
6
6
  require "fileutils"
7
7
  require "securerandom"
8
+ require "openssl"
8
9
  require "time"
9
10
  require_relative "atomic_file"
11
+ require_relative "bridge_secret"
10
12
  require_relative "delivery"
11
13
  require_relative "session_inbox"
12
14
 
@@ -47,8 +49,10 @@ module Samagotchi
47
49
  # `SessionInbox` file-IPC input path.
48
50
  # * One capture observer fills a shared ring buffer; each connection gets a
49
51
  # per-connection live queue (see SSEWriter).
50
- # * Binds 127.0.0.1 only. There is NO auth: binding 0.0.0.0 exposes remote
51
- # turn-execution (RCE). Documented, not fixed.
52
+ # * Binds 127.0.0.1 only, and answers only a request that names its secret
53
+ # (#secret_ok?): a random one per Bridge, in the worker's 0600 bridge.json,
54
+ # which only this machine's user can read. Binding 0.0.0.0 would still put
55
+ # remote turn-execution (RCE) one leaked secret away: don't.
52
56
  # * Optional at runtime: this module is never loaded unless explicitly
53
57
  # engaged as a transport.
54
58
  class Bridge
@@ -67,6 +71,12 @@ module Samagotchi
67
71
  MAX_BODY_BYTES = 1_000_000
68
72
  # A request's deadline (see #handle_post_turn) that isn't epoch seconds.
69
73
  BAD_DEADLINE = [{ "Allow" => "POST" }, 400, { error: "bad_deadline", detail: "deadline must be epoch seconds" }].freeze
74
+ # The request header that names the Bridge's secret, lowercased as #read_request keeps it.
75
+ SECRET_HEADER = BridgeSecret::HEADER.downcase
76
+ # Bytes of randomness in a Bridge's secret (hex in bridge.json: twice as many characters).
77
+ SECRET_BYTES = 32
78
+ # What a request without this Bridge's secret, or with another one, is told (403 bridge_secret).
79
+ SECRET_REFUSED = BridgeSecret::REFUSED
70
80
 
71
81
  # @param engine [Samagotchi::Engine] the owning engine (must already live
72
82
  # in this process)
@@ -130,6 +140,9 @@ module Samagotchi
130
140
  @pending_card = PendingCard.new(session_dir)
131
141
  @heartbeat_interval = heartbeat_interval
132
142
  @epoch = SecureRandom.hex(4)
143
+ # Only in bridge.json (0600) and this process: never logged, never in an
144
+ # event, a reply or the session's files.
145
+ @secret = SecureRandom.hex(SECRET_BYTES)
133
146
 
134
147
  @capture_handle = nil
135
148
  @server = nil
@@ -137,8 +150,8 @@ module Samagotchi
137
150
  @connection_threads = []
138
151
  @stopped = false
139
152
  @mutex = Monitor.new
140
- @proxied_log_mutex = Mutex.new
141
- @proxied_logged_at = nil
153
+ @refused_log_mutex = Mutex.new
154
+ @refused_logged_at = {}
142
155
  @open_streams = 0
143
156
  # Open streams by the client_id a stream named (`?client_id=`); web
144
157
  # tabs, through the `chi web` proxy, name none.
@@ -146,6 +159,10 @@ module Samagotchi
146
159
  @last_client_activity_at = monotonic_now
147
160
  end
148
161
 
162
+ # The secret never shows in an inspect (a log line, an error's message).
163
+ def inspect = "#<#{self.class.name} session_id=#{@session_id.inspect} port=#{@port}>"
164
+ alias to_s inspect
165
+
149
166
  # @return [Boolean] whether the server has been stopped.
150
167
  def stopped?
151
168
  @mutex.synchronize { @stopped }
@@ -442,7 +459,6 @@ module Samagotchi
442
459
  request = read_request(io)
443
460
  break if request.nil?
444
461
 
445
- note_client_activity
446
462
  started = monotonic_now
447
463
 
448
464
  method = request[:method].to_s.upcase
@@ -455,22 +471,37 @@ module Samagotchi
455
471
  if request[:too_large]
456
472
  write_json(io, 413, { "Connection" => "close" }, { error: "too_large", detail: "request body over #{MAX_BODY_BYTES} bytes" })
457
473
  break
474
+ # A refusal closes the connection, as its Connection: close says: a
475
+ # client that asked to keep it open (a stream's keep-alive) reads
476
+ # the refusal to its end at once rather than waiting.
458
477
  elsif browser_request?(headers)
459
478
  write_json(io, 403, nil, { error: "cross_origin", detail: "the bridge answers chi's own clients only" })
479
+ break
460
480
  elsif proxied_request?(headers)
461
- note_proxied_refused(request[:path])
481
+ note_refused(:proxied, "proxied_refused", request[:path])
462
482
  write_json(io, 403, nil, { error: "proxied", detail: "the bridge answers chi's own clients on this machine, " \
463
483
  "never a request through a proxy" })
484
+ break
485
+ elsif !secret_ok?(headers)
486
+ note_refused(:secret, "secret_refused", request[:path])
487
+ write_json(io, 403, nil, { error: BridgeSecret::ERROR, detail: SECRET_REFUSED })
488
+ break
489
+ # From here on a client of this worker (it named the secret): only its
490
+ # requests keep the worker from its idle exit, never a refused one's
491
+ # (an older chi web's polls, a scan through a forward).
464
492
  elsif route.nil?
493
+ note_client_activity
465
494
  write_json(io, 404, { "Allow" => "GET, POST" },
466
495
  { error: "not_found", path: request[:path] })
467
496
  elsif route.first == :stream
497
+ note_client_activity
468
498
  cursor = reconnect_cursor(headers, request[:query])
469
499
  Log.debug(:bridge, "stream", method: method, path: request[:path], client_id: stream_client_id(request[:query]))
470
500
  serve_sse(io, route.last, last_event_id: cursor, snapshot: snapshot_requested?(request[:query]),
471
501
  client_id: stream_client_id(request[:query]), raw: raw_requested?(request[:query]))
472
502
  break # SSE owns the connection until the client disconnects.
473
503
  else
504
+ note_client_activity
474
505
  payload, status, body = dispatch(route.first, route.last, request[:body], request[:query])
475
506
  write_json(io, status, payload, body)
476
507
  end
@@ -554,15 +585,26 @@ module Samagotchi
554
585
  # web's stream proxy forwards Last-Event-ID only (ProxyStreamBody).
555
586
  def proxied_request?(headers) = headers.keys.any? { |name| ProxyTrace.header?(name) }
556
587
 
557
- PROXIED_LOG_INTERVAL = 60.0
588
+ REFUSED_LOG_INTERVAL = 60.0
589
+
590
+ # Whether the request names this Bridge's secret (SECRET_HEADER), compared
591
+ # in constant time. Every route asks for it, the stream and the read-only
592
+ # ones too: what they show is the session itself.
593
+ def secret_ok?(headers)
594
+ given = headers[SECRET_HEADER]
595
+ given.is_a?(String) && OpenSSL.secure_compare(given, @secret)
596
+ end
558
597
 
559
- # At most once a minute: a scan through a forward would fill the log.
560
- def note_proxied_refused(path)
598
+ # A refusal's log line, at most once a minute per +kind+: a scan through
599
+ # a forward, or an old chi web's every poll, would fill the log. The path
600
+ # only, never a header.
601
+ def note_refused(kind, event, path)
561
602
  now = monotonic_now
562
- due = @proxied_log_mutex.synchronize do
563
- (@proxied_logged_at.nil? || now - @proxied_logged_at >= PROXIED_LOG_INTERVAL).tap { |fresh| @proxied_logged_at = now if fresh }
603
+ due = @refused_log_mutex.synchronize do
604
+ last = @refused_logged_at[kind]
605
+ (last.nil? || now - last >= REFUSED_LOG_INTERVAL).tap { |fresh| @refused_logged_at[kind] = now if fresh }
564
606
  end
565
- Log.warn(:bridge, "proxied_refused", path: path) if due
607
+ Log.warn(:bridge, event, path: path) if due
566
608
  end
567
609
 
568
610
  def browser_request?(headers)
@@ -1455,9 +1497,10 @@ module Samagotchi
1455
1497
  # version: the chi this worker runs (chi update and the web's badge
1456
1498
  # report older ones); features: what a client may ask of it; epoch:
1457
1499
  # this worker's, as its event ids carry it.
1500
+ # secret: what every request must name (#secret_ok?).
1458
1501
  WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
1459
1502
  version: Samagotchi::VERSION, input_format: @input_format, features: FEATURES,
1460
- epoch: @epoch).write(session_dir)
1503
+ epoch: @epoch, secret: @secret).write(session_dir)
1461
1504
  rescue StandardError => e
1462
1505
  Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{WorkerSidecar::FILE}: #{e.class}: #{e.message}", error: e.class.name)
1463
1506
  end