samagotchi 0.108.0 → 0.109.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f63eb88d80157a6b77f733031284c1a4e376528356f8d099ffe356b4cd7e6c1
4
- data.tar.gz: eab92f98b12468b5c882e5ee80524fdf151a1b479fd232ea54f8318bf952c2e3
3
+ metadata.gz: 296c5f9af0f1605fb4f2d92a82c7bcd0e435adcebded5e7c18638aeb1c651595
4
+ data.tar.gz: 57b18910dabdcc0b0239f9986d49ee369f6231dd5c182bc1b885812e6be4ecbb
5
5
  SHA512:
6
- metadata.gz: c5df88fe266c32153072be45890e6c12f54bcab0492d367cabc0f0066e6dec41bfc8d54e800f08c610979c3ec66131f7082dbc8691ea3854498ce603f553ffec
7
- data.tar.gz: e324e458bc5801eec9bc0f170ab5b839fbc4633ca33ce026566cac121deed90b53149a1052a9ec4607f67a7e3d284b4201c08309b5e8eeb1181bd1ff3ec65e31
6
+ metadata.gz: 969c44f06a17ccceb15b7bcfa7253c7543da71142c3d535c697844622832b69e4a9002f85e1ec6f1226edfda9001b347a2c0df186b683d5b75a4249ab555ebb3
7
+ data.tar.gz: a1724dec2c5e7a76794a74b9ffca5d3f85d60f9529a85114cfba656a5958f6401aab48ec1264b4e49db4384db2ceee830dca992b5645caef43f988c9b13d9ca9
data/CHANGELOG.md CHANGED
@@ -8,6 +8,37 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.109.0] - 2026-10-24
12
+
13
+ ### Added
14
+
15
+ - Experimental: `chi web` behind an auth proxy (oauth2-proxy, nginx `auth_request`, Caddy `forward_auth`, Cloudflare
16
+ Access, Tailscale Serve, or a plain TLS proxy). `web.public_url` (an https origin) makes every request need the
17
+ access token, this machine's too; `web.proxy.identity_header` + `web.proxy.owner` also check the signed-in email
18
+ the proxy sends (owner = that identity AND the token; a repeated, comma, empty or underscore-twin header is
19
+ refused). Through the proxy the cookie is `__Host-chi_token` (Secure), a `?token=` page isn't traded (the paste box's
20
+ redirect is absolute from `web.public_url`), links (the start line, the web's and `chi share`'s invites) are `https://…/#code=…` with a
21
+ one-time code, never a token, and on this machine a request takes the token as `Authorization: Bearer` only. See
22
+ docs/configuration.md (Behind an auth proxy).
23
+ - Experimental, with `web.public_url`: a link's `#code=` is traded for the cookie by the page (`POST /api/token`;
24
+ a page that already has a session asks first), and the 401 page's paste box posts the token, never puts it in the
25
+ URL; the page notices a lost sign-in (the proxy's session or chi's token ended:
26
+ a 401, a redirect to the sign-in page, the session list's stream giving up) and reloads once, then says "Signed out"
27
+ with Reload instead of going quietly stale. The page's API calls ask for JSON (`Accept: application/json`).
28
+
29
+ ### Changed
30
+
31
+ - chi web's HTML answers can't be framed by another page (`X-Frame-Options: DENY`, `frame-ancestors 'none'`), and a
32
+ frame/embed/object load from another origin is refused (403 `framed`).
33
+ - A session's Bridge refuses any request that came through a proxy (`X-Forwarded-*`, `Forwarded`, `X-Real-IP`,
34
+ `X-Auth-Request-*` and the like, underscore twins too: 403 `proxied`): an auth proxy that forwards every local port
35
+ and adds such a header no longer reaches a worker's unauthenticated Bridge (a forward that adds none still does).
36
+ chi's own clients never send these headers. A worker's `bridge.json` is now 0600.
37
+ - `chi share`'s LAN link names the LAN address the running chi web reports only if it is one of this machine's own
38
+ addresses; else the link names 127.0.0.1 (whatever answers on chi web's port may be another user's listener).
39
+
40
+ Update with `chi update` (the system bundle 0.109.0), then restart `chi web`.
41
+
11
42
  ## [0.108.0] - 2026-10-24
12
43
 
13
44
  ### Added
@@ -4165,7 +4196,8 @@ and long-lived sessions.
4165
4196
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4166
4197
  hotkey panel that send selected text or the clipboard to your sessions.
4167
4198
 
4168
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.108.0...HEAD
4199
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.109.0...HEAD
4200
+ [0.109.0]: https://github.com/dm1try/samagotchi/compare/v0.108.0...v0.109.0
4169
4201
  [0.108.0]: https://github.com/dm1try/samagotchi/compare/v0.107.0...v0.108.0
4170
4202
  [0.107.0]: https://github.com/dm1try/samagotchi/compare/v0.106.0...v0.107.0
4171
4203
  [0.106.0]: https://github.com/dm1try/samagotchi/compare/v0.105.0...v0.106.0
data/docs/architecture.md CHANGED
@@ -153,7 +153,7 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
153
153
  | UI | `Samagotchi::TerminalUI` | Interactive REPL (Reline), rendering (ANSI, spinner, status line), REPL commands. Delegates all core work to an `Engine`. |
154
154
  | Model loops and adapters | `KernelLoop`, `LLM::ChatLoop`, `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::AnthropicMessages`, `LLM::HTTP` | The model↔tool loops and the HTTP adapters they talk through (see "Model loops and adapters"). |
155
155
  | Bridge (SSE/HTTP) | `Samagotchi::Bridge`, `SessionManager` | The **single live client transport**: an SSE read stream + HTTP POST turn/cancel/answer surface that attaches to a worker's existing `Engine` via `Engine#subscribe`. Every session worker starts it (bound `127.0.0.1`, no auth, localhost-only). |
156
- | Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request but this machine's needs the access token (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; [CLI](cli.md#chi-web-on-your-phone)). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. Markdown (`web.markdown`) is rendered here only (`Web::MarkdownRenderer`): the answers it serves, card bodies, and `POST /api/render` for a text the page already shows. `bin/chi web` entrypoint. |
156
+ | Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request but this machine's needs the access token (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; [CLI](cli.md#chi-web-on-your-phone)). Experimental: behind an auth proxy (`web.public_url`, `Web::ProxyGate`) every request needs the token, this machine's too, and one that came through the proxy also the configured identity header for the owner ([configuration.md](configuration.md#behind-an-auth-proxy)). A request goes through, in order: the Host check (`local_host_header?`: loopback names, the LAN address, the public host), the cross-site check (`cross_site?`: Origin this server's or the public origin, no `Sec-Fetch-Site` cross-site/same-site on a POST or `/api`), the frame check (`framed?`: a frame/embed/object load from another origin, 403 `framed`; every HTML answer carries `X-Frame-Options: DENY` and `frame-ancestors 'none'`), the token gate (`token_gate` → `principal_for`, `proxied_principal` for a proxied request; the `?token=` / `POST /api/token` trade sets the cookie, `__Host-chi_token` through the proxy; with `web.public_url` a direct request takes Bearer only, and chi's token-less `/api/info` probe gets `probe_info`), then the route's permission (`Principal#permits?`). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. Markdown (`web.markdown`) is rendered here only (`Web::MarkdownRenderer`): the answers it serves, card bodies, and `POST /api/render` for a text the page already shows. `bin/chi web` entrypoint. |
157
157
  | Sessions | `Samagotchi::Session`, `SessionManager` | File-based `~/.local/state/samagotchi/sessions/<uuid>.json` + sidecar `input/`/`output/`; retention (14d/500) + ordering (`updated_at desc`). |
158
158
 
159
159
  - `bin/chi` in REPL mode (see `LaunchMode` above) builds `TerminalUI`. `TerminalUI#run` is the single
@@ -387,7 +387,11 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
387
387
  - No browser: only chi's own clients talk to a Bridge. A request with an `Origin` header, a `Sec-Fetch-Site`
388
388
  other than `none`, or a `Host` that isn't loopback (a DNS-rebound page) is `403 cross_origin`
389
389
  (`Bridge#browser_request?`, before any route); there is no CORS and no `OPTIONS` route. The browser reaches a
390
- worker only through `chi web`.
390
+ worker only through `chi web`. A request carrying any trace a proxy adds (`ProxyTrace`: `X-Forwarded-*`,
391
+ `Forwarded`, `X-Real-IP`, `X-Auth-Request-*`, `Cf-*`…, underscore twins too) is `403 proxied`
392
+ (`Bridge#proxied_request?`): an auth proxy that forwards every local port and adds such a header doesn't reach a
393
+ worker. A heuristic: a forward that adds none still does, until Bridges have a secret (shared-sessions P6).
394
+ `bridge.json` is 0600.
391
395
 
392
396
  The metrics in `/state`, `/snapshot`, `/stats` and the SSE `snapshot`/`reset` frames
393
397
  (`SessionMetrics#snapshot`) carry the session's totals and only the recent timing records: the
@@ -696,6 +700,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
696
700
  | LLM context strategies | `llm_context_strategy.rb`, `llm_context_view.rb`, `llm_context_stale.rb`, `llm_context_forget.rb` | What a request sends of the history (none, stale, forget) ([internals/llm-context-forget.md](internals/llm-context-forget.md)). |
697
701
  | Session chains | `session_continue.rb` (`SessionContinue`), `session_chain.rb` (`SessionChain`) | A session that continues an earlier one. `SessionChain.opener_for` gives the text web `continue →` prefills in the new link: the previous link's own first typed message when that link continues one itself, else nil (the 201's `opener`; not saved) ([sessions.md](sessions.md)). |
698
702
  | Review sessions | `review_start.rb` (`ReviewStart`), `session_review.rb` (`SessionReview`, the session's `review` field), `review_command.rb` (`ReviewCommand`, `chi review`), `review_apply.rb` (`ReviewApply`) | A session made to be shared: idle, its subjects project files checked by `Guardrails::ReviewPaths`, a fork of a working session from its recap (`--from`); Apply quotes a participant's review turn into the owner's composer ([sessions.md](sessions.md#review-sessions), [internals/participants.md](internals/participants.md)). |
703
+ | Behind an auth proxy | `web/public_url.rb` (`Web::PublicUrl`: `web.public_url`, `web.proxy.identity_header`, `web.proxy.owner`), `web/proxy_gate.rb` (`Web::ProxyGate`, mixed into App; `RawHeaderNames`, prepended to WEBrick's request by `Web::Server`), `web/link_codes.rb` (`Web::LinkCodes`: the links' one-time codes), `proxy_trace.rb` (`ProxyTrace`), `web/public/token_fragment.js`, `web/public/auth_lost.js` | Experimental: chi web behind an auth proxy. Every request needs the token; a proxied one's owner also needs the identity header (one clean value under one name); the `__Host-` cookie, an absolute `Location` from the public URL, https links with a one-time code in the fragment; the page's lost-sign-in banner. Bridges refuse proxy traces ([configuration.md](configuration.md#behind-an-auth-proxy)). |
699
704
  | Participants | `participants.rb` (`Participants`, the `participants.json` sidecar), `share_command.rb` (`ShareCommand`, `chi share`), `session_participants.rb`, `participants_watch.rb`, `web/grants.rb` (`Web::Grants`), `web/principal.rb` (`Web::Principal`), `web/participant_access.rb` (`Web::ParticipantAccess`: the participant's gate and routes), `web/participants_admin.rb` (`Web::ParticipantsAdmin`: the owner's invite, list, revoke, cap and Apply routes), `web/participant_review.rb` + `web/participant_view.rb` (`Web::ParticipantReview`, `Web::ParticipantView`), `web/participant_stream_filter.rb`, `guardrails/review_boundary.rb` + `guardrails/review_paths.rb` | The people or agents an owner invites to review one session through chi web: their grants and gate, what they read, their read-only review turns ([internals/participants.md](internals/participants.md), [guardrails.md](guardrails.md)). |
700
705
  | Analytics | `analytics_file.rb` (`AnalyticsFile`), `session_metrics/totals.rb` (`SessionMetrics::Totals`), `session_metrics/model_segment.rb` (`SessionMetrics::ModelSegment`) | A session's `analytics.json` (`SessionMetrics#persist`); the sums over its turn records (`Totals`: tokens, cost, speeds, kept as running counters) and their split per model after a `/model` switch (`ModelSegment`: a run of turns on one model, the `model_segments` rows); `session_metrics/cost.rb` (`SessionMetrics::Cost`): the spend `ctx.cost` reads; `session_metrics/usage.rb` (`SessionMetrics::Usage`): the work `ctx.usage` reads. `session_metrics/side_totals.rb` (`SessionMetrics::SideTotals`) and `side_usage.rb` (`SideUsage`): the side requests' sums, apart from the turns'. |
701
706
  | Images / vision | `image_store.rb` (`ImageStore`), `vision_support.rb`, `vision_context.rb` | Stored prompt images and whether a model takes them. |
@@ -1596,6 +1596,84 @@ web:
1596
1596
  - `GET /api/sessions/:id` (and its `?tail=1`) carries `capabilities.editor` (true for such a viewer) and
1597
1597
  `editor_url`, the template (null otherwise).
1598
1598
 
1599
+ ## Behind an auth proxy
1600
+
1601
+ Experimental. `chi web` binds 127.0.0.1 only; to use it from elsewhere over https, put it behind a proxy that does TLS
1602
+ and, usually, a sign-in (oauth2-proxy, nginx `auth_request`, Caddy `forward_auth`, Cloudflare Access, Tailscale
1603
+ Serve; a work pod forwarding `https://<port>.<pod>.<domain>` through oauth2-proxy) and tell chi web its public URL:
1604
+
1605
+ ```yaml
1606
+ web:
1607
+ public_url: https://4567.pod.example.com # the https origin people open: no path
1608
+ proxy:
1609
+ identity_header: X-Forwarded-Email # optional: the header the proxy puts the signed-in email in
1610
+ owner: me@example.com # optional, needs identity_header: you, as that header says it
1611
+ ```
1612
+
1613
+ What changes once `web.public_url` is set:
1614
+
1615
+ - **Every request needs the access token**, this machine's too (`$XDG_STATE_HOME/samagotchi/web-token`, made at
1616
+ start; `chi web --new-token` replaces it). A proxy that adds no header and rewrites Host (nginx's `proxy_pass` with
1617
+ its defaults) would otherwise look like this machine. On this machine the page opens through the proxy too: a
1618
+ request that didn't come through it takes the token only as `Authorization: Bearer` (no cookie: a cookie for
1619
+ 127.0.0.1 would go to every server on any local port; a phone on the LAN, with `web.host: lan`, keeps its LAN link
1620
+ and cookie). chi's own probe of the port (a second `chi web`, `chi share`,
1621
+ `chi update`) sends no token; chi web answers it the few fields it checks. Links and the start line come from this
1622
+ machine's config, never from whatever answers on the port.
1623
+ - **A request through the proxy** (it carries `X-Forwarded-For`, `Forwarded`, `X-Real-IP`, a known identity header,
1624
+ or the public Host) is the owner only with the token **and**, when `web.proxy.identity_header` is set, that header
1625
+ being exactly `web.proxy.owner` (any case). A repeated, comma-separated or empty value, or the header sent under a
1626
+ second spelling (`X_Forwarded_Email` next to `X-Forwarded-Email`), is refused (401, the reason in the answer's
1627
+ detail). With `identity_header` unset the token alone decides: that works behind any proxy. `owner` without
1628
+ `identity_header` stops `chi web` from starting.
1629
+ - Through the proxy the cookie is `__Host-chi_token` (Secure, Path=/, no Domain: other host names under the domain,
1630
+ such as a pod's other ports `https://<port>.<pod>…`, don't get it; the LAN's `chi_token` is separate). Cookies aren't
1631
+ scoped by port: another server on the **same host name** and a different port does get it, so give chi web a host
1632
+ name of its own, or port 443. Through the proxy a `?token=` page isn't traded (it would ride the sign-in redirect,
1633
+ and a crafted link could swap your session): a link's one-time code and the 401 page's paste box do it, the paste
1634
+ box's redirect going to the public URL (never to a client-sent `X-Forwarded-Host`). The page accepts `POST`s from the public `https://` origin only (a sibling port on the same
1635
+ domain, or `http://`, is refused). No page of chi web's may be framed by another (`X-Frame-Options: DENY`,
1636
+ `frame-ancestors 'none'`, in every mode).
1637
+ - Links carry a **one-time code** in the fragment, never a token: `chi web` prints `Proxied: https://…/#code=chc_…`
1638
+ (the owner's: once, within 10 minutes; `chi web` again prints a new one), and a participant's link (`chi share`,
1639
+ the Invite dialog) is `https://<public url>/#code=chc_…&s=ID` (once, until the grant expires). A fragment isn't sent
1640
+ to the proxy, but it outlives the sign-in's redirects: the sign-in page sits at that URL (its scripts can read it)
1641
+ and it stays in the browser's history, which is why it holds a code that works once. The page trades the code for
1642
+ the cookie (`POST /api/token`) and drops it from the URL; a page that already has a session asks first, since the
1643
+ trade replaces it. A code presented with the wrong identity is used up and gives nothing, so whoever already holds
1644
+ a link can spend it (`chi web` again, or a new invite, makes another). The 401 page's paste box posts the token in
1645
+ the request's body, never in the URL.
1646
+ - When the proxy's sign-in or chi's token ends, the page says so: it reloads once (the proxy's sign-in brings you
1647
+ back), and within a minute of that shows "Signed out … Reload" instead of a list that quietly stopped updating. Its
1648
+ API calls ask for JSON, so oauth2-proxy answers an ended sign-in with 401 instead of a redirect.
1649
+ - A session's Bridge (its worker's own port, unauthenticated) refuses a request that carries a proxy header such as
1650
+ `X-Forwarded-For` or `Forwarded` (403 `proxied`). That is a heuristic, not a lock: a forward that adds no such header
1651
+ (nginx's `proxy_pass` with its defaults, a plain TCP forward) still reaches a worker, as the owner.
1652
+ - `chi --remote` to a chi web behind a sign-in proxy isn't supported yet.
1653
+
1654
+ > **Warning.** Until Bridges get a secret of their own (planned), don't run chi web behind a proxy or tunnel that can
1655
+ > reach any local port, such as a pod forwarding every `https://<port>.<pod>…`, unless it adds `X-Forwarded-For` or
1656
+ > `Forwarded` to every request; and never one that also lets other people through its sign-in.
1657
+
1658
+ Which header to trust: only one the proxy **sets** on every signed-in request **and replaces** when a client sends
1659
+ it. Check before setting `identity_header`: run an echo on a free port behind the same proxy (`python3 -c` with
1660
+ `http.server`, or any header-echo server), open it signed in, and send your own copies (`curl -H 'X-Forwarded-Email:
1661
+ evil@x' -H 'X_Forwarded_Email: evil@x' …` with your sign-in cookie): the header you trust must come back with your
1662
+ email only. chi warns once in its log when a proxied request carries a known identity header but not the configured
1663
+ one.
1664
+
1665
+ | Setup | `identity_header` | Notes |
1666
+ |---|---|---|
1667
+ | oauth2-proxy (`--pass-user-headers`) | `X-Forwarded-Email` | Replaced, spelling variants dropped; `X-Forwarded-For` always added. **Not** `X-Auth-Request-Email`: oauth2-proxy puts that on its *response*, a client's copy reaches chi untouched. Checked against oauth2-proxy 7.15.5. |
1668
+ | nginx `auth_request` + oauth2-proxy (`--set-xauthrequest`) | `X-Auth-Request-Email` | Only as nginx sets it from the auth reply (`auth_request_set $email $upstream_http_x_auth_request_email; proxy_set_header X-Auth-Request-Email $email;`), which replaces a client's copy; nginx drops underscore spellings by default. Checked with nginx 1.27. |
1669
+ | Caddy `forward_auth` (`copy_headers X-Auth-Request-Email`) | the copied header | Replaced from the auth reply; Caddy passes an underscore spelling through, which chi refuses. Checked with Caddy 2.8. |
1670
+ | Cloudflare Access | `Cf-Access-Authenticated-User-Email` | Not checked yet: verify with an echo. chi trusts the header only; it doesn't validate the `Cf-Access-Jwt-Assertion` token. |
1671
+ | Tailscale Serve | `Tailscale-User-Login` | Not checked yet: verify with an echo. Funnel (public) requests carry no identity: with the header set they are refused, leave it unset to let the token decide. |
1672
+ | Authelia, Authentik (behind nginx, Caddy, Traefik) | `Remote-Email` (Authelia), `X-Authentik-Email` (Authentik) | Not checked yet; only as the front proxy copies it from the auth reply (the nginx and Caddy rows). |
1673
+ | A TLS proxy with no sign-in (nginx, Caddy, an SSH tunnel) | leave unset | The token alone decides. |
1674
+
1675
+ Keep the state dir (`web-token`, the grants) out of any folder others can read, such as a team's shared folder.
1676
+
1599
1677
  ## All settings
1600
1678
 
1601
1679
  Every setting below takes the three forms described in
@@ -1723,6 +1801,9 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
1723
1801
  | `web.participants.expires` | `7d` | | How long a participant's link lasts unless `chi share --expires` says otherwise: `Nh` or `Nd`, 1h to 30d. See [participants](internals/participants.md). |
1724
1802
  | `web.participants.cap` | `2.0` | | What a participant's turns, and the side calls they cause, may cost you in dollars unless `chi share --cap` says otherwise ($0 to $1000); at the cap their next comment is refused (429 `cap_reached`) and you get a notice. `chi share cap ID P DOLLARS` changes one grant's. |
1725
1803
  | `web.participants.queue` | `3` | | How many of a participant's turns may wait behind their running one (0 to 20); one more is refused (429 `too_many_turns`). |
1804
+ | `web.public_url` | | | Experimental. The https origin people open `chi web` at through an auth proxy (`https://host` or `https://host:port`, no path); set, every request needs the access token, this machine's too, and links use it. A value that isn't an https origin stops `chi web` from starting. See [Behind an auth proxy](#behind-an-auth-proxy). |
1805
+ | `web.proxy.identity_header` | | | Experimental. The request header the proxy puts the signed-in user's email in (`X-Forwarded-Email` for oauth2-proxy), checked next to the token: only one the proxy sets *and* replaces. Unset or `none`: the token alone decides. See [Behind an auth proxy](#behind-an-auth-proxy). |
1806
+ | `web.proxy.owner` | | | Experimental. Your email as the identity header carries it (any case): a proxied request is the owner only with this identity and the token. Unset: nobody is the owner by identity. Needs `web.proxy.identity_header`. |
1726
1807
  | `history.file` | state dir | | Prompt history path, shared by the TUI and the web composer. |
1727
1808
  | `no_interrupt` | `false` | `--no-interrupt` | Raise the step limit of a turn to 1000 (or `turn.max_iterations`, when larger); a top-level key. |
1728
1809
  | `no_default_input` | `false` | `--no-default-input` | Don't pre-fill `default.input`; a top-level key. |
@@ -7,9 +7,11 @@ limited and capped; and what they **see** (P4a): their page and its reads, all t
7
7
  experimental. An agent participant waits on its own turns' replies through the API. The code and docs say
8
8
  "participants" and "review", never "shared": `session.shared` already means "attached by default".
9
9
 
10
- Participants are a **LAN** feature for now (`web.host: lan`; a public URL, `web.public_url`, is planned, not a
11
- config key yet): a loopback peer with no token is
12
- the owner, and a reverse proxy on the same machine would make every visitor loopback. A participant's token is the
10
+ Participants reach chi web on the **LAN** (`web.host: lan`), or **behind an auth proxy** with `web.public_url`
11
+ (experimental; docs/configuration.md, "Behind an auth proxy"): there every request needs a token, this machine's too,
12
+ and invite links are `https://<public url>/#code=chc_…&s=ID`: a one-time code (`Web::LinkCodes`, until the grant
13
+ expires; the grant's token sealed under the code, so the store keeps no token) that the page trades for the cookie. Without either, a loopback peer with no token is the
14
+ owner, and a reverse proxy on the same machine would make every visitor loopback. A participant's token is the
13
15
  participant's wherever it comes from: their link opened on this machine (or on a chi web not on the LAN, whose link
14
16
  works on this machine only) is the participant, never the owner.
15
17
 
@@ -47,11 +49,22 @@ participant** and **one role**, until it expires or is revoked.
47
49
  refused with one message naming the file (`Grants::Corrupt`: `chi share` exits 1, chi web answers 500 with it);
48
50
  an archive or delete still goes ahead. Fix the file, or move it aside to end every link.
49
51
  - Rotating the owner token (`chi web --new-token`) never touches grants; `chi share revoke --all` ends them all.
52
+ - **Bound grants** (`identity`, an email in lower case; nil for any, and absent from the store then): a bound grant
53
+ 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.
50
56
 
51
57
  ## The gate
52
58
 
53
59
  `App#principal_for` resolves every request to a `Web::Principal`:
54
60
 
61
+ 0. With `web.public_url` (`Web::ProxyGate`), a **proxied** request (a proxy trace: `X-Forwarded-*`, `Forwarded`,
62
+ `X-Real-IP`, a known identity header, the configured one, or the public Host) is `#proxied_principal`'s: the owner
63
+ only with the owner's token **and**, when `web.proxy.identity_header` is set, that header being one clean value
64
+ (not repeated, no comma, not empty, under one name with no underscore: the raw header names) equal to
65
+ `web.proxy.owner`; a participant with a live grant (a bound one only for its identity); else 401, its reason in
66
+ the detail (only the requester's own identity). Unproxied, every request needs a token too (no "loopback is the
67
+ owner"): rule 3 doesn't apply.
55
68
  1. The owner's token (Bearer, then the cookie; `?token=` on the page's trade): the **owner**. Checked first, so the
56
69
  owner's requests never touch the grants store (measured: no change to an owner route's time).
57
70
  2. A `chp_` token (`Grants.token?`) whose grant is active: a **participant** (`Principal.new(kind: :participant,
@@ -63,7 +76,9 @@ participant** and **one role**, until it expires or is revoked.
63
76
  owner." / "This review link expired: ask the session's owner for a new one."), any other **401
64
77
  `unauthorized`** as before.
65
78
 
66
- The `?token=` trade sets the `chi_token` cookie to the token that was presented, the owner's or the participant's
79
+ The `?token=` trade (and `POST /api/token`, with a link's one-time `{code}` or the 401 page's pasted `{token}`; with
80
+ `web.public_url` only through the proxy, as a direct request takes Bearer only) sets the `chi_token` cookie (`__Host-chi_token`, Secure, on a proxied request) to the token that was
81
+ presented, the owner's or the participant's
67
82
  (one at a time per browser: a participant link opened in the owner's browser replaces the owner's cookie there, and
68
83
  makes that browser the participant, on loopback too). A grant's token is traded on any peer; the owner's only where
69
84
  the gate asks for it. On a peer the gate doesn't ask, the 401 for a grant's cookie that no longer works clears the
@@ -426,8 +441,10 @@ participant TUI in v1.
426
441
 
427
442
  ## Invariants
428
443
 
429
- - The owner is the owner's token, or a loopback peer (or a chi web not on the LAN) with no participant token; nothing
430
- else. A participant token never widens to the owner, from any peer.
444
+ - The owner is the owner's token, or a loopback peer (or a chi web not on the LAN, and without `web.public_url`) with
445
+ no participant token; nothing else. Through a proxy the owner is the token and, with an identity header
446
+ configured, that identity: never the identity alone, never a loopback peer. A participant token never widens to the
447
+ owner, from any peer.
431
448
  - A participant reaches one session, through the `:participant` rows only; another session is a 404.
432
449
  - No token is written anywhere: the grants store has digests, the sidecar and the audit trail have neither.
433
450
  - Owner sessions are unchanged: no sidecar, no audit file, no key in the session JSON until a grant exists.
@@ -189,7 +189,10 @@ with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendComma
189
189
  - **The token is the owner.** In LAN mode (`web.host: lan`) every non-loopback request needs the `Web::Token` access
190
190
  token, as the `chi_token` cookie or `Authorization: Bearer` (`App#token_gate`). This runs over plain http, which is
191
191
  fine on a home LAN, the same as the phone page. A loopback chi web without LAN mode needs no token
192
- (`Remotes::Entry#token` is nil, and no header is sent).
192
+ (`Remotes::Entry#token` is nil, and no header is sent). Behind an auth proxy (`web.public_url`, experimental) every
193
+ request needs it, loopback too, and a proxied owner also the configured identity header
194
+ ([configuration.md: Behind an auth proxy](../configuration.md#behind-an-auth-proxy), `Web::ProxyGate`); `chi
195
+ --remote` through such a proxy isn't supported yet (the CLI can't pass its sign-in, and streams need TLS).
193
196
  - **Every request has a principal.** `App#principal_for` resolves who a request is, as a `Web::Principal`
194
197
  (`web/principal.rb`): `Principal::OWNER` for a peer the gate doesn't ask (no LAN mode, or a loopback peer) and for
195
198
  the access token; a participant for a live grant's `chp_` token; nil (a 401) for anyone else (`token_revoked` /
@@ -205,7 +208,8 @@ with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendComma
205
208
  - **The token at rest.** `remotes.yml` is written through `AtomicFile.write(perm: 0o600)`, its read-modify-write (`Remotes.put` / `remove`) under `remotes.yml.lock`. `Entry#inspect` / `#to_s`
206
209
  redact the token. `chi remote ls` never prints it (JSON shows `token: true|false`). `HTTPTransport#inspect` and
207
210
  `Remote#inspect` show only the label.
208
- - **The Host check.** `local_host_header?` accepts only the loopback names and the exact LAN IP. `http://studio.local`
211
+ - **The Host check.** `local_host_header?` accepts only the loopback names and the exact LAN IP (and, with
212
+ `web.public_url`, its host exactly, port included). `http://studio.local`
209
213
  gets a 403 `forbidden`, which the client words as "use the LAN IP". The check guards against DNS rebinding.
210
214
  `cross_site?` lets CLI clients through because they send no `Origin` or `Sec-Fetch-Site`.
211
215
  - **`client_id` only tightens.** `handle_question_answer` forwards `client_id` only when it is `ClientId::CLI_ANSWER`
@@ -26,6 +26,7 @@ require_relative "turn_origin"
26
26
  require_relative "participants"
27
27
  require_relative "image_store"
28
28
  require_relative "log"
29
+ require_relative "proxy_trace"
29
30
  require_relative "version"
30
31
  require_relative "worker_sidecar"
31
32
  require_relative "relay_verifier"
@@ -120,6 +121,8 @@ module Samagotchi
120
121
  @connection_threads = []
121
122
  @stopped = false
122
123
  @mutex = Monitor.new
124
+ @proxied_log_mutex = Mutex.new
125
+ @proxied_logged_at = nil
123
126
  @open_streams = 0
124
127
  # Open streams by the client_id a stream named (`?client_id=`); web
125
128
  # tabs, through the `chi web` proxy, name none.
@@ -438,6 +441,10 @@ module Samagotchi
438
441
  break
439
442
  elsif browser_request?(headers)
440
443
  write_json(io, 403, nil, { error: "cross_origin", detail: "the bridge answers chi's own clients only" })
444
+ elsif proxied_request?(headers)
445
+ note_proxied_refused(request[:path])
446
+ write_json(io, 403, nil, { error: "proxied", detail: "the bridge answers chi's own clients on this machine, " \
447
+ "never a request through a proxy" })
441
448
  elsif route.nil?
442
449
  write_json(io, 404, { "Allow" => "GET, POST" },
443
450
  { error: "not_found", path: request[:path] })
@@ -524,6 +531,24 @@ module Samagotchi
524
531
  # no Sec-Fetch-Site. A browser page always sends one of them (another
525
532
  # website's text/plain POST needs no preflight), and a DNS-rebound page
526
533
  # also has a foreign Host. Headers are the raw ones, lowercased.
534
+ # A request through a proxy (an auth proxy forwarding every port, as a
535
+ # work pod's does) carries a trace chi's own clients never send
536
+ # (ProxyTrace: X-Forwarded-*, Forwarded, X-Real-IP, X-Auth-Request-*…,
537
+ # underscore twins too): refused, whoever signed in to the proxy. chi
538
+ # web's stream proxy forwards Last-Event-ID only (ProxyStreamBody).
539
+ def proxied_request?(headers) = headers.keys.any? { |name| ProxyTrace.header?(name) }
540
+
541
+ PROXIED_LOG_INTERVAL = 60.0
542
+
543
+ # At most once a minute: a scan through a forward would fill the log.
544
+ def note_proxied_refused(path)
545
+ now = monotonic_now
546
+ due = @proxied_log_mutex.synchronize do
547
+ (@proxied_logged_at.nil? || now - @proxied_logged_at >= PROXIED_LOG_INTERVAL).tap { |fresh| @proxied_logged_at = now if fresh }
548
+ end
549
+ Log.warn(:bridge, "proxied_refused", path: path) if due
550
+ end
551
+
527
552
  def browser_request?(headers)
528
553
  return true if headers.key?("origin")
529
554
  return true if headers.key?("sec-fetch-site") && headers["sec-fetch-site"].downcase != "none"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.108.0
3
+ version: 0.109.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -316,6 +316,10 @@ module Samagotchi
316
316
  Entry.new(key: "web.participants.expires", yaml_path: %w[web participants expires], type: :string, default: "7d", expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
317
317
  Entry.new(key: "web.participants.cap", yaml_path: %w[web participants cap], type: :float, default: 2.0, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
318
318
  Entry.new(key: "web.participants.queue", yaml_path: %w[web participants queue], type: :integer, default: 3, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
319
+ # chi web behind an auth proxy (Web::PublicUrl, docs/configuration.md "Behind an auth proxy").
320
+ Entry.new(key: "web.public_url", yaml_path: %w[web public_url], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
321
+ Entry.new(key: "web.proxy.identity_header", yaml_path: %w[web proxy identity_header], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
322
+ Entry.new(key: "web.proxy.owner", yaml_path: %w[web proxy owner], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
319
323
 
320
324
  Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
321
325
  Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The request headers a proxy in front of chi adds and chi's own clients
5
+ # never send: chi web takes a request with one as proxied
6
+ # (Web::ProxyGate), a Bridge refuses it (Bridge#browser_request?). Names
7
+ # are compared lower-cased with "_" read as "-", so an underscore twin
8
+ # (X_Forwarded_For) counts too. See docs/configuration.md "Behind an auth
9
+ # proxy".
10
+ module ProxyTrace
11
+ # Any name starting with one of these: X-Forwarded-For/-Host/-Proto/
12
+ # -Email/-User…, oauth2-proxy's X-Auth-Request-*, Cloudflare's Cf-*,
13
+ # Tailscale Serve's Tailscale-User-*, Authentik's X-Authentik-*, Google
14
+ # IAP's X-Goog-*, Azure App Service's X-MS-Client-Principal*, AWS ALB's
15
+ # X-Amzn-Oidc-*, Envoy's X-Envoy-*.
16
+ PREFIXES = %w[x-forwarded- x-auth-request- cf- tailscale- x-authentik- remote- x-goog- x-ms-client-principal
17
+ x-amzn-oidc- x-envoy- x-original-forwarded-].freeze
18
+ # And these whole names.
19
+ NAMES = %w[forwarded x-real-ip via true-client-ip x-client-ip x-original-url x-original-uri].freeze
20
+
21
+ # The identity headers known setups send (docs/configuration.md's
22
+ # table): chi web's hint when the proxy sends one of them but not the
23
+ # one configured.
24
+ IDENTITY_HEADERS = %w[X-Forwarded-Email X-Auth-Request-Email Cf-Access-Authenticated-User-Email Tailscale-User-Login
25
+ Remote-Email X-Authentik-Email X-Goog-Authenticated-User-Email X-MS-Client-Principal-Name
26
+ X-Amzn-Oidc-Identity X-Forwarded-User X-Auth-Request-User Remote-User].freeze
27
+
28
+ module_function
29
+
30
+ # @param name [String] a header's name as sent (any case, - or _)
31
+ def header?(name)
32
+ key = normalize(name)
33
+ NAMES.include?(key) || PREFIXES.any? { |prefix| key.start_with?(prefix) }
34
+ end
35
+
36
+ def normalize(name) = name.to_s.downcase.tr("_", "-")
37
+
38
+ # The header name of a Rack env key (HTTP_X_FORWARDED_FOR →
39
+ # x-forwarded-for), nil for a key that isn't a header's.
40
+ def env_name(key)
41
+ key = key.to_s
42
+ key.start_with?("HTTP_") ? key.delete_prefix("HTTP_").downcase.tr("_", "-") : nil
43
+ end
44
+ end
45
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "socket"
4
+
3
5
  require_relative "../session"
4
6
  require_relative "../session_access"
5
7
  require_relative "../reply_wait"
@@ -18,6 +20,8 @@ require_relative "../participants"
18
20
  require_relative "../config"
19
21
  require_relative "../web/grants"
20
22
  require_relative "../web/info_probe"
23
+ require_relative "../web/public_url"
24
+ require_relative "../web/link_codes"
21
25
  require_relative "../web/running"
22
26
 
23
27
  module Samagotchi
@@ -359,17 +363,24 @@ module Samagotchi
359
363
  grants.list(session_id: id).map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
360
364
  end
361
365
 
362
- # The link names this machine's chi web: its LAN address and port
363
- # when one runs on the LAN (the port it recorded, else web.port),
364
- # else 127.0.0.1 (+lan+ false).
366
+ # The link names this machine's chi web: web.public_url from this
367
+ # machine's config (never what the port answers) when it is set (the
368
+ # token in the fragment, as chi web's own invite), else its LAN address
369
+ # and port when one runs on the LAN (the port it recorded, else
370
+ # web.port), else 127.0.0.1 (+lan+ false: the link opens on this
371
+ # machine only).
365
372
  # @return [Invitation]
366
373
  # @raise [ArgumentError] no such session
367
374
  # @raise [BadRequest] a bad name, kind or ttl, an archived session
368
375
  def invite(id, name:, kind:, ttl: nil, cap: nil)
369
376
  issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, cap: cap, state_dir: state_dir!)
370
- ip, port = local_web
377
+ public_url = configured_public_url
378
+ web = public_url ? nil : local_web
379
+ link = if public_url then public_url.link(link_code(issued), "s=#{id}")
380
+ else "http://#{web.ip || "127.0.0.1"}:#{web.port}/?token=#{issued.token}#s=#{id}"
381
+ end
371
382
  Invitation.new(participant: ParticipantView.from_h(issued.grant.public_h(grants.now)), token: issued.token,
372
- link: "http://#{ip || "127.0.0.1"}:#{port}/?token=#{issued.token}#s=#{id}", lan: !ip.nil?,
383
+ link: link, lan: !public_url.nil? || !web.ip.nil?,
373
384
  warning: ReviewStart.share_warning(Session.load(id, **dir)))
374
385
  rescue Web::Grants::Invalid => e
375
386
  raise BadRequest.new(e.message, code: "invalid_participant")
@@ -400,18 +411,43 @@ module Samagotchi
400
411
  WEB_PORT = 4567
401
412
  LOCAL_PROBE_TIMEOUT = 1.0
402
413
 
403
- # [LAN ip or nil, port] of this machine's chi web (GET /api/info):
404
- # the port the running one recorded (Web::Running) when a chi web
405
- # answers there, else config's web.port.
414
+ # Where this machine's chi web answers (GET /api/info): its LAN ip
415
+ # (nil without one) and the port the running one recorded
416
+ # (Web::Running) when a chi web answers there, else config's web.port.
417
+ LocalWeb = Data.define(:ip, :port)
418
+
419
+ # The LAN address a probe answered, only if it is one of this
420
+ # machine's: whatever listens on the port may be another user's
421
+ # listener naming its own (nil then: the link names 127.0.0.1).
422
+ def own_address(ip)
423
+ return nil unless ip.is_a?(String)
424
+
425
+ Socket.ip_address_list.any? { |addr| addr.ip_address == ip } ? ip : nil
426
+ end
427
+
428
+ # A one-time code for the grant's link (Web::LinkCodes), until it expires.
429
+ def link_code(issued)
430
+ ttl = [Time.iso8601(issued.grant.expires_at) - grants.now, 1].max
431
+ Web::LinkCodes.for_grants(grants.path).issue(kind: :grant, token: issued.token, ttl: ttl)
432
+ end
433
+
434
+ # web.public_url as this machine's config has it; nil when unset or
435
+ # not a valid one (chi web refuses to start on that).
436
+ def configured_public_url
437
+ Web::PublicUrl.from_config(url: Config.get("web.public_url"))
438
+ rescue Web::PublicUrl::Invalid
439
+ nil
440
+ end
441
+
406
442
  def local_web
407
443
  configured = Config.get("web.port").to_s.to_i
408
444
  configured = WEB_PORT unless configured.positive?
409
445
  recorded = Web::Running.port(path: Web::Running.path(state_dir: state_dir!))
410
446
  [recorded, configured].compact.uniq.each do |port|
411
447
  info = Web::InfoProbe.call("127.0.0.1", port, timeout: LOCAL_PROBE_TIMEOUT)
412
- return [info["lan"], port] if info.is_a?(Hash)
448
+ return LocalWeb.new(ip: own_address(info["lan"]), port: port) if info.is_a?(Hash)
413
449
  end
414
- [nil, configured]
450
+ LocalWeb.new(ip: nil, port: configured)
415
451
  end
416
452
 
417
453
  def dir = @state_dir ? { state_dir: @state_dir } : {}
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.108.0"
4
+ VERSION = "0.109.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end