samagotchi 0.130.0 → 0.130.1

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: 5830c4028607020136e2f0493d0d365f5d10889fa8ff2a493b979de7b7e164b9
4
- data.tar.gz: 28f0fda8ba4b824af19381b12274262c0858f879565b58ab4a6a12e18b82f64e
3
+ metadata.gz: dcca1391e17e266cf83e1c56dec7ab52d21a1928a4c5190d2bf594884cca936f
4
+ data.tar.gz: fc59a582d279b19352c3bda61bb5d2fc3796ae0e079e52b3dcb26dc85988ea33
5
5
  SHA512:
6
- metadata.gz: 38f04438ffc54f5569abbbfb5fdfdd7470ca805926f3593969924a5a248b29ff266f2eec6972aff25d29395982b255b6518989f174a1579b227a711ae1aeec68
7
- data.tar.gz: 3dc45f2617ca67e11f8cf563c83ea1c979142d798d900b000b5f1ae0c12ffb559b692cdec8f8f614da09eeb8cd176a1e7e5af59d47958ddffc838b3faa8f8d9d
6
+ metadata.gz: b5cc1d80ca1b0d64f809360e111bdae2778772c5ca3c8cae7d6f339f650a55fb65b796ccc5ca3038b25c637223568a7facbc90a351ac5df77bee25c24fac3e59
7
+ data.tar.gz: 4e149d518537a529c69494f14b0d07435c6e3327d27ae34a42c0e30504c74fee543282c4023fbd44587a301f189c24bd7d3aebdff40235784bd45f95cfd63217
data/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.130.1] - 2026-10-29
11
+
12
+ ### Fixed
13
+
14
+ - Sessions started from the desktop helper find the tools your terminal finds (mise, Homebrew, `codex`):
15
+ `chi desktop install` writes your shell's PATH into the helper's launch file, `chi update` adds it to a
16
+ launch file that has none (it never changes a baked one; `chi desktop install --force` does), and
17
+ `chi desktop status` shows it. Run `chi update` once from your terminal, then restart the sessions the
18
+ panel started.
19
+ - `chi update` no longer repoints the desktop helper at the Ruby or chi it runs from, nor refreshes the launch file
20
+ from an older chi: only `chi desktop install --force` and `chi desktop upgrade` pick the current shell's Ruby,
21
+ chi and PATH. When the helper's Ruby or chi is gone, `chi update` says so and names that command.
22
+ - An older chi that saves a session a newer chi wrote (waking it from a project whose Ruby has an older chi, say)
23
+ keeps the session's fields it doesn't know instead of dropping them, and the session file names the chi that saved
24
+ it last (`written_by`; a worker woken on a file a newer chi saved logs `newer_writer`). This works from this release
25
+ on: chi 0.130.0 and older still drop them, so update the chi in every Ruby you run it from.
26
+ - `execute` (and a user's `!cmd`) no longer hands a project's commands chi's own Bundler setup: run under
27
+ `bundle exec`, chi passed on `RUBYOPT=-rbundler/setup` and its `BUNDLE_GEMFILE`, so `ruby -e` or `bundle exec`
28
+ in another project loaded chi's Gemfile. It now scrubs them the way `task_create` always has.
29
+
30
+ ### Security
31
+
32
+ - On the LAN (`web.host: lan`), the cookie chi web sets for this machine's 127.0.0.1 no longer holds the access
33
+ token: a server on another local port gets every 127.0.0.1 cookie, so a dev server that logs headers caught the
34
+ token. It now holds a value that works only as that cookie on this machine and ends with `chi web --new-token`.
35
+ An open 127.0.0.1 tab lands on the 401 page once: open the `Local:` link. If you ran a participant's pair turns
36
+ with dev servers on 0.130.0, run `chi web --new-token`. The LAN address's cookie (your phone's) still holds the
37
+ token.
38
+
39
+ Update with `chi update`, then restart `chi web` and the session workers (`chi sessions restart`).
40
+
10
41
  ## [0.130.0] - 2026-10-29
11
42
 
12
43
  ### Changed
@@ -4933,7 +4964,8 @@ and long-lived sessions.
4933
4964
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4934
4965
  hotkey panel that send selected text or the clipboard to your sessions.
4935
4966
 
4936
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.130.0...HEAD
4967
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.130.1...HEAD
4968
+ [0.130.1]: https://github.com/dm1try/samagotchi/compare/v0.130.0...v0.130.1
4937
4969
  [0.130.0]: https://github.com/dm1try/samagotchi/compare/v0.129.0...v0.130.0
4938
4970
  [0.129.0]: https://github.com/dm1try/samagotchi/compare/v0.128.0...v0.129.0
4939
4971
  [0.128.0]: https://github.com/dm1try/samagotchi/compare/v0.127.0...v0.128.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`, localhost-only), and it answers only a request that names its secret (`X-Chi-Bridge`, from the worker's 0600 `bridge.json`). |
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 needs the access token, this machine's on loopback too (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; chi web prints a `Local:` link with a one-time code; [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 with it or LAN mode 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. `App` keeps the gate, `ROUTES` and the helpers the handlers share; its handlers live in mixins it includes, each a `Web::` module with private methods: `SessionAccessRoutes` (`web/session_access_routes.rb`), `ParticipantAccess` (`web/participant_access.rb`), `ParticipantsAdmin` (`web/participants_admin.rb`), `ParticipantReview` (`web/participant_review.rb`), `MessageDisplay` (`web/message_display.rb`), `SessionShow` (`web/session_show.rb`), `SessionCreate` (`web/session_create.rb`), `SessionControl` (`web/session_control.rb`), `PushRoutes` (`web/push_routes.rb`) and `ProxyGate` (`web/proxy_gate.rb`). Exact paths in `GATE_FREE_PATHS` are served before the token gate (the fragment trade's script, and the Home Screen app's manifest, service worker and icons). `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 needs the access token, this machine's on loopback too (`App#token_gate`: the `chi_token` cookie, on loopback a value derived from the token (`Token.loopback_cookie`), or `Authorization: Bearer`; chi web prints a `Local:` link with a one-time code; [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 with it or LAN mode 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. `App` keeps the gate, `ROUTES` and the helpers the handlers share; its handlers live in mixins it includes, each a `Web::` module with private methods: `SessionAccessRoutes` (`web/session_access_routes.rb`), `ParticipantAccess` (`web/participant_access.rb`), `ParticipantsAdmin` (`web/participants_admin.rb`), `ParticipantReview` (`web/participant_review.rb`), `MessageDisplay` (`web/message_display.rb`), `SessionShow` (`web/session_show.rb`), `SessionCreate` (`web/session_create.rb`), `SessionControl` (`web/session_control.rb`), `PushRoutes` (`web/push_routes.rb`) and `ProxyGate` (`web/proxy_gate.rb`). Exact paths in `GATE_FREE_PATHS` are served before the token gate (the fragment trade's script, and the Home Screen app's manifest, service worker and icons). `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
data/docs/cli.md CHANGED
@@ -290,8 +290,12 @@ done
290
290
  one that failed before; one you uninstalled stays out. See [Bundle
291
291
  profiles](memory.md#bundle-profiles-core-and-dev).
292
292
  - **The desktop helper** (macOS) is rebuilt and restarted only when its Swift
293
- sources changed (or the Ruby it runs moved); otherwise only its launch file
294
- is refreshed. See [Desktop helper](desktop.md).
293
+ sources changed; otherwise only its launch file is refreshed (a newer chi or
294
+ other terminal settings). It keeps the Ruby, chi and PATH the helper runs (a
295
+ PATH is filled in only when the file has none): `chi desktop install --force`
296
+ and `chi desktop upgrade` bake this shell's. A helper whose Ruby or chi is gone
297
+ is reported as failed, naming `chi desktop install --force`. See
298
+ [Desktop helper](desktop.md).
295
299
  - **Running processes** are reported, never stopped. A live worker keeps its
296
300
  chi until it is restarted: `chi sessions restart ID` (one from before
297
301
  restarts: `chi sessions stop ID`, and the next prompt starts it on the new
@@ -1271,7 +1275,12 @@ access token", paste the token there (the part of the link after
1271
1275
  paste the token) and the API answers 401. On this Mac open the `Local:`
1272
1276
  link (`chi web --open` opens it): its one-time code (10 minutes, once) sets
1273
1277
  the cookie for 127.0.0.1 without the token in the address; the LAN link
1274
- works here too. chi's own probe (`GET /api/info` with no token or cookie)
1278
+ works here too. The cookie for 127.0.0.1 holds a value derived from the
1279
+ token, not the token: the browser sends it to a server on any other local
1280
+ port too (a dev server that logs headers), and that server can't use it
1281
+ from the network (unless something forwards it to this machine's
1282
+ loopback), as Bearer, or as the token; `chi web --new-token` ends it. The LAN address's cookie (your phone's, or this Mac's when it opens
1283
+ the LAN link) is the token itself. chi's own probe (`GET /api/info` with no token or cookie)
1275
1284
  gets a few fields (version, pid, features, the LAN address, the start
1276
1285
  folder), nothing of the sessions.
1277
1286
  - The token lives in `$XDG_STATE_HOME/samagotchi/web-token` (0600).
data/docs/desktop.md CHANGED
@@ -194,7 +194,8 @@ grant:
194
194
 
195
195
  `tmux.agents` picks the programs listed (kitty.agents' default; `"*"` lists every pane), `tmux.binary` the tmux
196
196
  to run (default: the `tmux` on your shell's PATH when the launch file is written, e.g. `/opt/homebrew/bin/tmux`;
197
- the helper itself has no PATH). See [Configuration](configuration.md#desktop-helper-agents-in-kitty-and-tmux).
197
+ the helper itself keeps macOS's default PATH, and tmux is resolved when the file is written). See
198
+ [Configuration](configuration.md#desktop-helper-agents-in-kitty-and-tmux).
198
199
  2. Run `chi update` (or `chi desktop upgrade`), and again after every edit of `tmux:`. With no tmux found they say
199
200
  "tmux.enabled but no tmux found on PATH: set tmux.binary", and the panel "tmux not found…".
200
201
 
@@ -256,9 +257,16 @@ don't break it. From a checkout it runs **that** checkout's `bin/chi`; installin
256
257
  warning, because the helper stops working once that worktree is removed. After switching between a checkout and a
257
258
  gem install, run `chi desktop upgrade` from the one you now use.
258
259
 
260
+ The helper runs the Ruby and chi of **the shell you installed from**, and only `chi desktop install --force` and
261
+ `chi desktop upgrade` change that: they pick the Ruby and chi of the shell you run them in. `chi update` never
262
+ repoints it, so running it from a project with another Ruby (or an older chi) leaves the helper where it was. If
263
+ the Ruby or chi the helper runs is gone (a Ruby upgrade, a removed worktree), `chi update` fails the helper's row
264
+ with "chi not found at …: run chi desktop install --force from the shell whose chi it should run".
265
+
259
266
  `chi update` keeps it current: it rebuilds and restarts the helper only when its Swift sources changed since the
260
- build (`launch.json` records their digest) or the Ruby it runs moved. A new chi that left the sources alone only
261
- rewrites `launch.json`, which the app reads at each send, so the app keeps its older version number and that's fine.
267
+ build (`launch.json` records their digest), keeping the Ruby, chi and PATH it runs. A newer chi that left the
268
+ sources alone only rewrites `launch.json` (its version and settings), which the app reads at each send, so the app
269
+ keeps its older version number and that's fine. An older chi than the one that wrote the file changes nothing.
262
270
 
263
271
  ## Commands
264
272
 
@@ -267,7 +275,7 @@ rewrites `launch.json`, which the app reads at each send, so the app keeps its o
267
275
  | `chi desktop install [--force] [--login]` | builds, installs and starts it; `--force` replaces an existing copy |
268
276
  | `chi desktop upgrade` | rebuilds it for this chi and restarts it, keeping the login setting (always; `chi update` does it only when needed) |
269
277
  | `chi desktop uninstall` | quits it and removes the app, its login item, launch file and settings |
270
- | `chi desktop status` | version against chi's, how it runs chi, state dirs, Service, hotkey, process, login item, host keys |
278
+ | `chi desktop status` | version against chi's, how it runs chi, state dirs, PATH, Service, hotkey, process, login item, host keys |
271
279
 
272
280
  `chi self` has a `desktop` line: `0.1.x (matches)`, `0.1.w (up to date for chi 0.1.x)` (an older build whose sources
273
281
  haven't changed), `0.1.w (chi is 0.1.x: chi update)` (a rebuild is due) or `not installed`. `chi desktop status`
@@ -278,17 +286,24 @@ says the same.
278
286
  Apps started by macOS get a bare environment: no shell rc files, so no rbenv/chruby/mise/asdf and none of your
279
287
  exports. So `install` writes `~/Library/Application Support/Chi Helper/launch.json` with:
280
288
 
281
- - the absolute path of the Ruby running chi and of chi itself (the gem's wrapper, or a checkout's `bin/chi`);
289
+ - the absolute path of the Ruby running chi and of chi itself (the gem's wrapper, or a checkout's `bin/chi`), as of
290
+ the last `chi desktop install` or `upgrade` (`chi update` keeps them);
282
291
  - `LANG=en_US.UTF-8`;
283
292
  - only these variables, and only when they are set: `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `GEM_HOME`, `GEM_PATH`,
284
293
  `RUBYLIB` (a relative `XDG_*` dir made absolute from the install's current folder: the helper starts in `/`).
285
294
  No tokens and no `SAMAGOTCHI_*` settings go in;
295
+ - `PATH`: the shell's PATH when the file is written, with only absolute folders, in order, each once. The helper's
296
+ chi, the sessions it starts and their `execute` commands can find the tools your terminal finds (mise, Homebrew,
297
+ `codex`, `node`);
286
298
  - the settings the panel needs from config.yml, as chi read them then: the `kitty:` section (when
287
299
  `kitty.listen_on` is set), the `tmux:` section (when `tmux.enabled`: the tmux binary and the socket dir resolved
288
300
  then, and the agents) and `broadcast_timeout` (`broadcast.triage_deadline` + 10 s).
289
301
 
290
- The file freezes the install shell's values. If you install with a temporary `XDG_STATE_HOME`, the helper keeps
291
- using it. `status` prints the baked dirs.
302
+ The file freezes the install shell's values, including PATH. If you install with a temporary `XDG_STATE_HOME`, the
303
+ helper keeps using it. `chi update` fills in a PATH only when the file has none (as in a launch file written before
304
+ this change); it never changes an existing baked PATH. To change it (for example, after a mise upgrade changes the
305
+ versioned folders on PATH), run `chi desktop install --force` or `chi desktop upgrade` from the shell whose PATH
306
+ you want. `status` prints the baked state dirs and PATH.
292
307
 
293
308
  **Host API keys** don't go in the launch file either: with the variable a host's `api_key_env:` names unset, chi
294
309
  reads the key from the login Keychain (service `chi`, account the variable's name; see
@@ -331,8 +346,12 @@ broadcast` says the note may be partly delivered.
331
346
 
332
347
  ## Troubleshooting
333
348
 
334
- - **"chi not found at …, run `chi desktop upgrade`"**: the Ruby or checkout in `launch.json` moved (a Ruby upgrade,
335
- a removed worktree). Run `chi desktop upgrade` (or `chi update`) from the chi you use now.
349
+ - **"chi not found at …"**: the Ruby or checkout in `launch.json` moved (a Ruby upgrade, a removed worktree).
350
+ `chi update` won't repoint it: run `chi desktop install --force` (or `chi desktop upgrade`) from the shell whose
351
+ chi the helper should run.
352
+ - **A session started from the panel can't find a tool (`codex`, `node`):** `chi desktop status` shows the PATH it
353
+ gets. With none, run `chi update`; to change it, run `chi desktop install --force` from your terminal. Then
354
+ restart that session (`chi sessions restart ID`).
336
355
  - **No "Send to chi" in the Services menu:** check `chi desktop status` (service). Try
337
356
  `/System/Library/CoreServices/pbs -update`, start the app again, or log out and back in. It must be ticked in
338
357
  System Settings → Keyboard → Keyboard Shortcuts… → Services → Text.
@@ -42,6 +42,7 @@ Behavior:
42
42
  again or stop it (`TaskWait::MESSAGE_WAITING_NOTE`), and `waited: Ns`; the task keeps running and stays watched
43
43
  (below). A task that finished meanwhile gives its finished result, as after a Stop. A `chi -p` turn has no probe.
44
44
  - `task_create` accepts `env` as a JSON object string for deterministic overrides such as `PATH`; use an absolute interpreter path when that is simpler. Ruby/Bundler isolation variables remain protected.
45
+ - `execute` and `task_create` both spawn with chi's own Ruby/Bundler setup scrubbed (`TaskRuntime.sanitized_spawn_env`): `RUBYOPT` loses its `-r…bundler/setup` flags (others stay) and `RUBYLIB`, `BUNDLE_GEMFILE`, `BUNDLE_BIN_PATH`, `BUNDLER_VERSION` are unset, so a project's `ruby -e` or `bundle exec` uses its own Gemfile when chi runs under `bundle exec`. `task_create` refuses those keys in the model's `env`; `execute`'s `env` comes from chi's callers (`Builtins`, `!cmd`), not the model, and is set over the scrub. An `execute` handed over at its limit keeps the process it spawned, so the task runs with the same env.
45
46
  - `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `ChildEnv::SESSION_MARKERS` (`TaskRuntime::MARKER_ENV_KEYS`), which a spawned worker unsets (`ChildEnv.unset`, merged into its spawn env). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
46
47
  - They also pass on a set `XDG_STATE_HOME` / `XDG_CONFIG_HOME` made absolute (`Builtins.xdg_absolute`, via `Paths`), so a chi the command runs in another `cwd` (`chi send`) reads this chi's state and config. These aren't reserved: an XDG dir the model sets in `task_create`'s `env` stays its own.
47
48
 
@@ -122,7 +122,13 @@ participant** and **one role**, until it expires or is revoked.
122
122
  the detail (only the requester's own identity). Unproxied, every request needs a token too (no "loopback is the
123
123
  owner"): rule 3 doesn't apply.
124
124
  1. The owner's token (Bearer, then the cookie; `?token=` on the page's trade): the **owner**. Checked first, so the
125
- owner's requests never touch the grants store (measured: no change to an owner route's time).
125
+ owner's requests never touch the grants store (measured: no change to an owner route's time). On a LAN chi web
126
+ (no `web.public_url`) a loopback peer's owner cookie is `Token.loopback_cookie` instead (`chl_`, an HMAC of a
127
+ label keyed by the token, `App#loopback_cookie_peer?` / `#loopback_cookie`): it stands for the token only as that
128
+ cookie from a loopback peer, never as Bearer, a trade or from the LAN address, and `chi web --new-token` ends it.
129
+ The token itself as a loopback cookie (set by 0.130.0) is refused, and its 401 clears it (`#refused_token`). The
130
+ token as Bearer on loopback is the owner as before (SSH tunnels, `chi --remote` over `ssh -L`, curl, the e2e
131
+ harness).
126
132
  2. A `chp_` token (`Grants.token?`) whose grant is active: a **participant** (`Principal.new(kind: :participant,
127
133
  grant:)`), from any peer: loopback and a chi web not on the LAN too. An ended or unknown one is refused
128
134
  (below), never the owner.
@@ -134,7 +140,10 @@ participant** and **one role**, until it expires or is revoked.
134
140
  (`Server.local_owner_link`), the fragment trade sets the cookie there. `PairCommand` still denies a pair command
135
141
  naming chi web on a loopback name (defense in depth), and URL openers and listeners: a page opened in the owner's
136
142
  browser at a port the shell listens on would catch the owner's cookie (one on 127.0.0.1 or the LAN address goes
137
- to every port).
143
+ to every port: on 127.0.0.1 it holds the loopback value of rule 1, usable only as that cookie from a loopback peer with no proxy trace (anything that can reach loopback, such as an `ssh -L` user or code running as the owner, can still replay it); on
144
+ the LAN address it is the token, a known gap, as a cookie there is replayable from any LAN device anyway). A
145
+ server on another local port can also answer `Set-Cookie: chi_token=…` and overwrite the owner's cookie (cookie
146
+ tossing: it logs the owner out, or swaps in a grant's token; visible, nothing leaks; not fixed).
138
147
  4. Anything else: nil. `#token_gate` answers a bound grant's token without its identity **401 `token_bound`**, an
139
148
  ended grant's token **401 `token_revoked`** or **401
140
149
  `token_expired`** (the page says the same words as a sentence: "This review link was revoked by the session's
@@ -143,7 +152,8 @@ participant** and **one role**, until it expires or is revoked.
143
152
 
144
153
  The `?token=` trade (and `POST /api/token`, with a link's one-time `{code}` or the 401 page's pasted `{token}`; with
145
154
  `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
146
- presented, the owner's or the participant's
155
+ presented, the owner's or the participant's (the owner's loopback value instead, on a LAN chi web's loopback peer,
156
+ `App#owner_cookie_value`)
147
157
  (one at a time per browser: a participant link opened in the owner's browser replaces the owner's cookie there, and
148
158
  makes that browser the participant, on loopback too). A grant's token is traded on any peer; the owner's only where
149
159
  the gate asks for it (on the LAN, this machine's loopback too). On a chi web the gate doesn't ask (loopback only), the
data/docs/sessions.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sessions
2
2
 
3
- Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-guardrails.md#the-runners-cut-max_tool_output_chars)). Besides the messages and turn state, the JSON keeps `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, `prompt_notes`, the model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)), `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise), and `continues`, the session this one continues (the previous link of a chain, see *Session chains*; `null` otherwise); files from before those keys read as empty lists, no parent and no previous link. `model_name` is the resolved model ref (an alias applied, `host:id` when it names a host), so a resumed session keeps its model when an alias is retargeted; `model_typed` is the name it was given as when that differs (an alias), for the `models:` lookup after a resume (`null` otherwise).
3
+ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-guardrails.md#the-runners-cut-max_tool_output_chars)). Besides the messages and turn state, the JSON keeps `written_by` (the chi version that saved it last; see *Which chi a worker runs*), `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, `prompt_notes`, the model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)), `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise), and `continues`, the session this one continues (the previous link of a chain, see *Session chains*; `null` otherwise); files from before those keys read as empty lists, no parent and no previous link. `model_name` is the resolved model ref (an alias applied, `host:id` when it names a host), so a resumed session keeps its model when an alias is retargeted; `model_typed` is the name it was given as when that differs (an alias), for the `models:` lookup after a resume (`null` otherwise).
4
4
 
5
5
  **Message times:** an entry of `messages` may carry `at`, when it happened: a local-offset ISO time with milliseconds (`2026-10-24T14:32:05.120+02:00`). User prompts, lines merged into a turn and steers, reminders, context and task notes, and the model's replies have one (a note's is its `created_at`); tool results, `!cmd` messages and chi's own turn notes don't, nor does anything saved before chi stamped messages (no backfill). It is for the UIs only (the gap dividers, a bubble's time): it never goes to the model, so it changes nothing in the requests or their prompt cache. The one time the model reads is the reminders': due reminders go into a turn as a system message headed `[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]` (the turn's local time; `ReminderQueue#inject!`), written into the saved content then, so a later request sends the same text.
6
6
 
@@ -29,7 +29,9 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
29
29
 
30
30
  **Attached by default:** plain `chi` and `chi --resume ID` run their session in a background worker and attach to it (`session.shared`, default `true`; env `SAMAGOTCHI_SESSION_SHARED`). The worker runs in the session's `working_directory`, so `!commands` and tools see the directory the session was started in. `session.shared: false` keeps the in-process REPL, `--no-shared` does for one run; a session the REPL has open can't be attached ("close it there first"). See [CLI: Sharing a session](cli.md#sharing-a-session).
31
31
 
32
- **Which chi a worker runs:** a new or woken worker runs the newest chi installed for this Ruby (`gem 'samagotchi', '>= 0.a'`, prereleases count), whoever starts it: a `chi web` or terminal still on an older chi starts it on the newest one, so a stale process doesn't spread old code. A worker already running keeps its version until it is restarted. Should the newest install fail to load, the worker runs the starter's own version and logs `boot_fallback` (its stdio is `/dev/null`). Under `bundle exec` the worker drops Bundler's env first, so it activates the newest gem too. Two consequences: a wrapper pin (`chi _0.17.0_ web`) pins only that process, not the workers it starts; and a source checkout (`bin/chi` in a worktree, `bundle exec`) starts its workers on its own `lib/`, but a session it made that is later woken by an installed chi (`chi web`, `chi send`) runs the installed code. The names an older starter hands a newer worker (the boot line, its env keys, `input/` files, the session JSON fields and the Bridge routes sessions use on each other) are pinned in `spec/worker_contract_spec.rb`.
32
+ **Which chi a worker runs:** a new or woken worker runs the newest chi installed for this Ruby (`gem 'samagotchi', '>= 0.a'`, prereleases count), whoever starts it: a `chi web` or terminal still on an older chi starts it on the newest one, so a stale process doesn't spread old code. A worker already running keeps its version until it is restarted. Should the newest install fail to load, the worker runs the starter's own version and logs `boot_fallback` (its stdio is `/dev/null`). Under `bundle exec` the worker drops Bundler's env first, so it activates the newest gem too. Two consequences: a wrapper pin (`chi _0.17.0_ web`) pins only that process, not the workers it starts; and a source checkout (`bin/chi` in a worktree, `bundle exec`) starts its workers on its own `lib/`, but a session it made that is later woken by an installed chi (`chi web`, `chi send`) runs the installed code. The names an older starter hands a newer worker (the boot line, its env keys, `input/` files, the session JSON fields and the Bridge routes sessions use on each other) are pinned in `spec/worker_contract_spec.rb`. A chi newer than 0.130.0 keeps the top-level keys it doesn't know (a newer chi's fields) when it saves a session, after its own, and doesn't act on them; `written_by` names the chi that saved the file last, and a worker woken on a file a newer chi saved logs `newer_writer`. chi 0.130.0 and older drop those keys: a session a newer chi made loses its newer fields when an older chi wakes or saves it (for example `chi --attach` from a project whose Ruby has an older chi). Keep the chi in every Ruby you run it from newer than 0.130.0.
33
+
34
+ A new session field (the rule in `Session::FIELDS`' comment): only top-level keys are kept (a new sub-key of `review`, `toolkit` or another value object is still dropped by an older chi), and an older chi keeps a key without honouring it, so a new field must be safe to come back unchanged after an older chi ran turns on the session.
33
35
 
34
36
  **A worker outlives whoever started it:** a spawned worker leaves its spawner's process tree (it is forked again, in a session of its own, and reparented to init), so closing the terminal of a `chi web`, or a tool tearing down a background `chi send --wait` and its children, no longer takes the worker and its turn with it.
35
37
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.130.0
3
+ version: 0.130.1
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:
@@ -41,9 +41,11 @@ module Samagotchi
41
41
  BUILD_NAME = ".chi-helper-build"
42
42
  OLD_NAME = ".chi-helper-old"
43
43
  # Apps started by launchd get a bare env (no shell rc files), so the
44
- # launch file carries what chi needs to find its gems and state. Only
45
- # these, and only when set: no tokens, no runtime knobs.
44
+ # launch file carries what chi needs to find its gems, state and tools.
45
+ # Only these, and only when set: no tokens, no runtime knobs.
46
46
  ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME GEM_HOME GEM_PATH RUBYLIB].freeze
47
+ # PATH is separately filtered to absolute dirs and frozen when written,
48
+ # so the helper's chi, workers and execute can find the shell's tools.
47
49
  # Without it Ruby reads stdin as US-ASCII and non-ASCII notes break.
48
50
  LANG = "en_US.UTF-8"
49
51
  # chi broadcast's default triage deadline, and the helper's seconds on
@@ -54,6 +56,17 @@ module Samagotchi
54
56
  # one launch-file section each, in this order.
55
57
  TERMINALS = %w[kitty tmux].freeze
56
58
 
59
+ # PATH dirs the helper can use from launchd's / working directory:
60
+ # keep absolute entries only, in order, once each.
61
+ def self.path_dirs(env)
62
+ env["PATH"].to_s.split(File::PATH_SEPARATOR).select { _1.start_with?("/") }.uniq
63
+ end
64
+
65
+ def self.baked_path(env)
66
+ dirs = path_dirs(env)
67
+ dirs.empty? ? nil : dirs.join(File::PATH_SEPARATOR)
68
+ end
69
+
57
70
  # Runs a command without a shell; [output, success].
58
71
  class Runner
59
72
  def run(argv)
@@ -76,7 +89,8 @@ module Samagotchi
76
89
 
77
90
  # The tmux: settings, or nil while tmux.enabled is off. The binary is
78
91
  # tmux.binary, else the tmux on +env+'s PATH ("" when there is none:
79
- # see #warnings); the socket dir is where tmux puts `-L` sockets
92
+ # only absolute dirs are usable from launchd; see #warnings); the socket
93
+ # dir is where tmux puts `-L` sockets
80
94
  # ($TMUX_TMPDIR when it is a directory, else /tmp, then tmux-<uid>).
81
95
  # Both are frozen at bake time, like the rest of the launch file.
82
96
  def self.tmux_settings(get = Config.method(:get), env: ENV)
@@ -91,8 +105,7 @@ module Samagotchi
91
105
  end
92
106
 
93
107
  def self.path_tmux(env)
94
- env["PATH"].to_s.split(File::PATH_SEPARATOR).reject(&:empty?).map { File.join(_1, "tmux") }
95
- .find { File.file?(_1) && File.executable?(_1) }.to_s
108
+ path_dirs(env).map { File.join(_1, "tmux") }.find { File.file?(_1) && File.executable?(_1) }.to_s
96
109
  end
97
110
 
98
111
  # Each switched-on terminal's settings by name ("kitty" => …).
@@ -148,10 +161,14 @@ module Samagotchi
148
161
  # `gem cleanup`), else this checkout's bin/chi. A section per terminal
149
162
  # switched on (TERMINALS order); the broadcast timeout when given.
150
163
  # The XDG dirs go in absolute (Paths): launchd starts the app in /,
151
- # where a relative one would mean /<dir>.
164
+ # where a relative one would mean /<dir>. PATH is filtered to absolute,
165
+ # deduplicated dirs so chi, its workers and execute find shell tools;
166
+ # it is frozen at bake time.
152
167
  def launch_config
153
168
  env = { "LANG" => LANG }
154
169
  ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
170
+ path = self.class.baked_path(@env)
171
+ env["PATH"] = path if path
155
172
  env.merge!(Paths.absolute_xdg(env: env, set: :key))
156
173
  config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
157
174
  TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
@@ -178,22 +195,55 @@ module Samagotchi
178
195
  launch["sources_sha"] != sources_sha || !launch_ok?(launch)
179
196
  end
180
197
 
181
- # The launch file names another chi version or path than this one, or
182
- # other terminal settings or broadcast timeout: refresh_launch_file
183
- # fixes that without a rebuild.
198
+ # The launch file names an older chi version than this one (or none), or
199
+ # other terminal settings, broadcast timeout or a missing bakeable PATH:
200
+ # refresh_launch_file fixes that without a rebuild. A different argv or
201
+ # baked PATH, or a newer version, is intentional: the ruby, chi and PATH
202
+ # the helper runs change only with chi desktop install --force or upgrade.
184
203
  def launch_outdated?
185
204
  launch = read_launch
186
- launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] ||
205
+ older_version?(launch["version"]) ||
187
206
  TERMINALS.map { launch[_1] } != TERMINALS.map { @terminals[_1] } ||
188
- launch["broadcast_timeout"] != @broadcast_timeout
207
+ launch["broadcast_timeout"] != @broadcast_timeout || path_missing?(launch)
208
+ end
209
+
210
+ # Why chi update can't keep the launch file's ruby or chi: one of them
211
+ # is gone. nil when it runs, or has no usable argv (a refresh writes
212
+ # this chi's then).
213
+ def launch_problem
214
+ argv = usable_argv(read_launch)
215
+ missing = Array(argv).reject { |path| File.exist?(path) }
216
+ return nil if missing.empty?
217
+
218
+ "chi not found at #{missing.join(", ")}: run chi desktop install --force from the shell whose chi it should run"
219
+ end
220
+
221
+ # The chi version the launch file names, nil without one.
222
+ def launch_version = read_launch["version"]
223
+
224
+ # The launch file names a newer chi than this one: an older chi's
225
+ # chi update keeps the helper as it is (no rebuild from older sources).
226
+ def launch_newer?
227
+ Gem::Version.new(launch_version.to_s) > Gem::Version.new(@version)
228
+ rescue ArgumentError
229
+ false
189
230
  end
190
231
 
191
232
  # Rewrite the launch file for this chi and its settings, keeping
192
- # the env it was installed with. The app reads it at each send: no restart.
233
+ # its argv and the env it was installed with; a PATH only when it has
234
+ # none (changing them takes chi desktop install --force or upgrade). A
235
+ # newer version stays. The app reads it at each send.
193
236
  def refresh_launch_file
194
237
  config = launch_config
195
- env = read_launch["env"]
196
- config["env"] = env if env.is_a?(Hash)
238
+ old = read_launch
239
+ config["argv"] = usable_argv(old) || config["argv"]
240
+ config["version"] = old["version"] unless older_version?(old["version"])
241
+ old_env = old["env"]
242
+ if old_env.is_a?(Hash)
243
+ config["env"] = old_env
244
+ path = self.class.baked_path(@env)
245
+ config["env"]["PATH"] = path if config["env"]["PATH"].to_s.empty? && path
246
+ end
197
247
  write_launch_file(config)
198
248
  end
199
249
 
@@ -242,7 +292,9 @@ module Samagotchi
242
292
 
243
293
  # @yield [String] progress lines
244
294
  # @param login [Boolean] also start the app at login
245
- def install(force: false, login: false, &progress)
295
+ # @param keep_launch [Boolean] keep the launch file's ruby, chi and PATH
296
+ # (chi update's rebuild) instead of baking this shell's
297
+ def install(force: false, login: false, keep_launch: false, &progress)
246
298
  raise Error, "already installed (#{app_version}); use chi desktop upgrade" if installed? && !force
247
299
 
248
300
  toolchain!
@@ -250,7 +302,7 @@ module Samagotchi
250
302
  progress&.call("building #{APP_NAME} #{@version} (can take a few minutes)…")
251
303
  built = build_app
252
304
  swap_in(built)
253
- write_launch_file
305
+ write_launch_file(keep_launch: keep_launch)
254
306
  register_app
255
307
  self.login = true if login
256
308
  progress&.call("installed #{app_path}")
@@ -258,9 +310,9 @@ module Samagotchi
258
310
  end
259
311
 
260
312
  # Keeps the login item as it was.
261
- def upgrade(&)
313
+ def upgrade(keep_launch: false, &)
262
314
  was_login = installed? && login == "enabled"
263
- install(force: true, login: was_login, &)
315
+ install(force: true, login: was_login, keep_launch: keep_launch, &)
264
316
  end
265
317
 
266
318
  # SMAppService status as the app reports it ("enabled",
@@ -313,6 +365,7 @@ module Samagotchi
313
365
  launch_ok: launch_ok?(launch),
314
366
  stale: stale?,
315
367
  baked_dirs: env.select { |name, _| name.start_with?("XDG_") },
368
+ baked_path: env["PATH"],
316
369
  service: dump.include?(BUNDLE_ID),
317
370
  running: running,
318
371
  host_keys: host_keys
@@ -393,12 +446,39 @@ module Samagotchi
393
446
  {}
394
447
  end
395
448
 
449
+ # This chi is newer than the version the launch file names; a missing or
450
+ # unreadable version counts as older.
451
+ def older_version?(version)
452
+ Gem::Version.new(@version) > Gem::Version.new(version.to_s)
453
+ rescue ArgumentError
454
+ true
455
+ end
456
+
457
+ def usable_argv(launch)
458
+ argv = launch["argv"]
459
+ argv if argv.is_a?(Array) && !argv.empty? && argv.all? { |path| path.is_a?(String) && !path.empty? }
460
+ end
461
+
462
+ def path_missing?(launch)
463
+ return false unless self.class.baked_path(@env)
464
+
465
+ env = launch["env"]
466
+ !env.is_a?(Hash) || env["PATH"].to_s.empty?
467
+ end
468
+
396
469
  def launch_ok?(launch)
397
470
  argv = Array(launch["argv"])
398
471
  !argv.empty? && argv.all? { |path| File.exist?(path) }
399
472
  end
400
473
 
401
- def write_launch_file(config = launch_config)
474
+ def write_launch_file(config = launch_config, keep_launch: false)
475
+ if keep_launch
476
+ old = read_launch
477
+ config["argv"] = usable_argv(old) || config["argv"]
478
+ old_env = old["env"]
479
+ old_path = old_env["PATH"] if old_env.is_a?(Hash)
480
+ config["env"]["PATH"] = old_path unless old_path.to_s.empty?
481
+ end
402
482
  FileUtils.mkdir_p(@support_dir)
403
483
  AtomicFile.write(launch_path, JSON.pretty_generate(config) + "\n")
404
484
  end
@@ -118,11 +118,12 @@ module Samagotchi
118
118
  "#{s[:app_version] || "?"} (up to date: chi #{s[:chi_version]} changed nothing in it)"
119
119
  end
120
120
  launch = s[:launch_argv].empty? ? "(none)" : s[:launch_argv].join(" ")
121
- launch += s[:launch_ok] ? " (ok)" : " (missing: chi update)"
121
+ launch += s[:launch_ok] ? " (ok)" : " (missing: chi desktop install --force)"
122
122
  dirs = s[:baked_dirs].empty? ? "(defaults)" : s[:baked_dirs].map { |name, value| "#{name}=#{value}" }.join(" ")
123
+ path = path_row(s[:baked_path])
123
124
  service = s[:service] ? "registered" : "not registered (try chi desktop upgrade, or log out and back in)"
124
125
  running = s[:running] ? "running" : "not running (open -g \"#{s[:app_path]}\")"
125
- rows = [["Chi Helper", version], ["app", s[:app_path]], ["runs chi", launch], ["state dirs", dirs],
126
+ rows = [["Chi Helper", version], ["app", s[:app_path]], ["runs chi", launch], ["state dirs", dirs], ["PATH", path],
126
127
  ["service", service], ["hotkey", hotkey(s[:hotkey])], ["process", running], ["at login", login(s[:login])]]
127
128
  rows << ["host keys", host_keys(s[:host_keys])] unless Array(s[:host_keys]).empty?
128
129
  @stdout.puts(format_rows(rows))
@@ -153,6 +154,10 @@ module Samagotchi
153
154
  end
154
155
  end
155
156
 
157
+ def path_row(path)
158
+ path.to_s.empty? ? "(none: macOS's default; chi update fills it, chi desktop install --force changes it)" : path
159
+ end
160
+
156
161
  def format_rows(rows)
157
162
  width = rows.map { |label, _| label.length }.max
158
163
  rows.map { |label, value| "#{label.ljust(width)} #{value}" }.join("\n")
@@ -112,8 +112,8 @@ module Samagotchi
112
112
  # A resolved match only adds an ask or a deny; were a verdict
113
113
  # ever to let a call through, a symlink into its glob must not.
114
114
  if resolved_match?
115
- real_base ||= PathResolve.real(base)
116
- forms += PathResolve.reals(p).map { |real| [real, real_base] }
115
+ real_base ||= targets.real_rule_base
116
+ forms += targets.real_forms(p).map { |real| [real, real_base] }
117
117
  end
118
118
  forms.any? do |form, from|
119
119
  candidate = absolute ? form : relative(form, from)
@@ -379,6 +379,19 @@ module Samagotchi
379
379
  @paths.any? { |p| Outside.outside?(p, root: @session_root) }
380
380
  end
381
381
 
382
+ # The resolved forms used by rules' path: globs, once per target path.
383
+ def real_forms(path)
384
+ @real_forms ||= {}
385
+ @real_forms[path] ||= PathResolve.reals(path)
386
+ end
387
+
388
+ # The resolved base used by rules' path: globs.
389
+ def real_rule_base
390
+ return @real_rule_base if defined?(@real_rule_base)
391
+
392
+ @real_rule_base = PathResolve.real(@repo_root || @cwd)
393
+ end
394
+
382
395
  # Where a shell call runs mutating git (ShellGitDirs: dirs, and
383
396
  # :unknown for one the text doesn't tell); [] for other tools.
384
397
  def git_dirs
@@ -14,6 +14,7 @@ require_relative "session_setup"
14
14
  require_relative "prompt_note"
15
15
  require_relative "working_set_pin"
16
16
  require_relative "session_review"
17
+ require_relative "version"
17
18
 
18
19
  require_relative "paths"
19
20
  require_relative "project_scope"
@@ -351,17 +352,27 @@ module Samagotchi
351
352
  # saved.
352
353
  attr_accessor :opener
353
354
 
355
+ # The top-level keys of the session file this chi doesn't know (a newer
356
+ # chi's fields), frozen, string keys: #to_h writes them back after the
357
+ # FIELDS as they were. Never read by this chi.
358
+ attr_reader :extra_fields
359
+
360
+ # The chi version that last saved the loaded file (#to_h writes this
361
+ # chi's VERSION); nil for a file from 0.130.0 or older.
362
+ attr_reader :written_by
363
+
354
364
  def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
355
- metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
365
+ metadata_version: METADATA_VERSION, written_by: nil, status: STATUS_IDLE, last_prompt: "",
356
366
  first_preview: "", first_turn_id: nil, test_run: false, pending_question: nil,
357
367
  used_memory_names: [], project_root: nil,
358
368
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
359
369
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
360
370
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
361
371
  toolkit: nil, toolkit_origin: "chosen", settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
362
- working_set_pins: [], review: nil, verdicts: nil, verify_fresh: nil)
372
+ working_set_pins: [], review: nil, verdicts: nil, verify_fresh: nil, extra_fields: {})
363
373
  @id = id
364
374
  @metadata_version = metadata_version
375
+ @written_by = written_by
365
376
  @mode = mode
366
377
  @model_name = model_name
367
378
  @model_typed = model_typed
@@ -399,12 +410,25 @@ module Samagotchi
399
410
  self.verdicts = verdicts
400
411
  self.verify_fresh = verify_fresh
401
412
  self.interrupted_turn = interrupted_turn
413
+ @extra_fields = extra_fields.to_h { |key, value| [key.to_s, value] }.freeze
402
414
  @turn_checkpoint = nil
403
415
  @archived = false
404
416
  # #save's lock: one snapshot-and-write at a time (see #save).
405
417
  @save_lock = Mutex.new
406
418
  end
407
419
 
420
+ # Whether a chi newer than this one saved the loaded file: its
421
+ # written_by is a later version, or it has keys this chi doesn't know
422
+ # (a hand-edited or future file). The worker logs it (newer_writer).
423
+ def newer_writer?
424
+ return true unless @extra_fields.empty?
425
+
426
+ # correct?(nil) is true (a 0 version): a file without the field is no newer.
427
+ return false unless @written_by.is_a?(String) && Gem::Version.correct?(@written_by)
428
+
429
+ Gem::Version.new(@written_by) > Gem::Version.new(VERSION)
430
+ end
431
+
408
432
  # A `delegate` child: its turns report to its parent (ChildReports). A
409
433
  # child saved before the field is known by the memory every delegate
410
434
  # starts with; a fork is neither.
@@ -543,8 +567,24 @@ module Samagotchi
543
567
  # the way (symbols in memory, strings on disk); last_turn, prompt_notes
544
568
  # and pending_question are value objects in memory (LastTurn,
545
569
  # PromptNote, PendingQuestion).
570
+ #
571
+ # A new field: a chi older than it (from the release after 0.130.0)
572
+ # keeps the key as it was (#extra_fields) but doesn't honour it, and the
573
+ # next newer chi reads it again. So it must be safe to come back
574
+ # unchanged after an older chi ran turns on the session: static data, or
575
+ # append-only data the older chi never appends to (review, verify_fresh,
576
+ # first_turn_id, verdicts); a cursor into messages, which an older chi
577
+ # would have moved, needs a check against what it points at. Only
578
+ # top-level keys are kept: the value objects (SessionReview,
579
+ # VerifyFreshOrigin, Toolkit, LastTurn, PendingQuestion, InterruptedTurn,
580
+ # PromptNote, WorkingSetPin, LLMContextOverride) write their own members
581
+ # only, so an older chi still drops a new sub-key of one of them; add a
582
+ # top-level field when an older chi must keep it. A removed key would
583
+ # stay an extra forever: leave it out of the extras in .from_h then (no
584
+ # key was ever removed).
546
585
  FIELDS = {
547
586
  "metadata_version" => 1,
587
+ "written_by" => nil,
548
588
  "id" => REQUIRED,
549
589
  "mode" => REQUIRED,
550
590
  "model_name" => REQUIRED,
@@ -599,6 +639,7 @@ module Samagotchi
599
639
  end
600
640
  attrs[:settings] = SessionConfig::Settings.new(core: attrs.delete(:config_settings), bundles: attrs.delete(:bundle_settings))
601
641
  attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
642
+ attrs[:extra_fields] = data.reject { |key, _| FIELDS.key?(key) }
602
643
  new(**attrs)
603
644
  end
604
645
 
@@ -720,8 +761,10 @@ module Samagotchi
720
761
  self
721
762
  end
722
763
 
723
- # The session as #save writes it (FIELDS, in their order); a running
724
- # turn's checkpoint (#turn_checkpoint) in place of its messages.
764
+ # The session as #save writes it: FIELDS, in their order, then the keys
765
+ # this chi doesn't know (#extra_fields, in the file's order; a FIELDS key
766
+ # wins); a running turn's checkpoint (#turn_checkpoint) in place of its
767
+ # messages.
725
768
  def to_h
726
769
  checkpoint = checkpointed
727
770
  FIELDS.keys.to_h do |key|
@@ -730,6 +773,7 @@ module Samagotchi
730
773
  (checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
731
774
  when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
732
775
  when "pending_question" then @pending_question && scrub_utf8(@pending_question.to_file)
776
+ when "written_by" then VERSION
733
777
  when "test_run" then !!@test_run
734
778
  when "delegate" then @delegate
735
779
  when "llm_context" then llm_context_file
@@ -755,6 +799,7 @@ module Samagotchi
755
799
  end
756
800
  [key, value]
757
801
  end.reject { |key, value| value.nil? && OPTIONAL_FIELDS.include?(key) }
802
+ .merge(@extra_fields.reject { |key, _| FIELDS.key?(key) })
758
803
  end
759
804
 
760
805
  # The running turn's checkpoint, while the turn runs (#turn_checkpoint).
@@ -85,7 +85,10 @@ module Samagotchi
85
85
  # @param stop_reason [#call] the stop's reason (:signal: a signal to
86
86
  # chi, :session_stopped: a session stop, which the result then names
87
87
  # instead of the user)
88
- # @param env [Hash, nil] added to the command's environment
88
+ # @param env [Hash, nil] added to the command's environment, over
89
+ # chi's Ruby/Bundler variables scrubbed as task_create scrubs them
90
+ # (TaskRuntime.sanitized_spawn_env); chi's callers build it, so it
91
+ # may set those keys (task_create refuses them from the model)
89
92
  # @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
90
93
  # nil reads execute.on_timeout
91
94
  def self.call(command, cwd: nil, cancelled: -> { false }, stop_reason: -> {}, env: nil, on_timeout: nil)
@@ -267,7 +270,7 @@ module Samagotchi
267
270
  # file, and each write lands at the end.
268
271
  ios = logs.map { |path| File.open(path, "a") }
269
272
  ios.each { |io| io.flock(File::LOCK_SH) }
270
- ProcessGroup.spawn(env || {}, *Shell.argv(TaskRuntime.wrapped_shell_command(command, exit_code_path)),
273
+ ProcessGroup.spawn(TaskRuntime.sanitized_spawn_env.merge(env || {}), *Shell.argv(TaskRuntime.wrapped_shell_command(command, exit_code_path)),
271
274
  chdir: cwd, in: File::NULL, out: ios[0], err: ios[1])
272
275
  ensure
273
276
  ios&.each(&:close)
@@ -445,9 +445,11 @@ module Samagotchi
445
445
  "trap #{Shellwords.escape(write)} EXIT\n#{command}\n"
446
446
  end
447
447
 
448
+ # chi's own Ruby/Bundler setup, kept out of a command's environment
449
+ # (execute and task_create both spawn with it): run under `bundle
450
+ # exec`, chi would otherwise hand a project's `ruby -e` or
451
+ # `bundle exec` its Gemfile (RUBYOPT=-rbundler/setup, BUNDLE_GEMFILE).
448
452
  def sanitized_spawn_env
449
- # Running from temporary directories can inherit Bundler-specific env
450
- # (for example RUBYOPT=-rbundler/setup) that breaks plain `ruby -e`.
451
453
  {
452
454
  "RUBYOPT" => sanitized_rubyopt,
453
455
  "RUBYLIB" => nil,
@@ -250,9 +250,17 @@ module Samagotchi
250
250
  return row.tap { |r| r.status = "not installed" } unless helper.installed?
251
251
 
252
252
  row.from = helper.app_version
253
+ if (problem = helper.launch_problem)
254
+ return failed(row, problem)
255
+ end
256
+
257
+ if helper.launch_newer?
258
+ return up_to_date(row).tap { |r| r.status += " (kept: chi #{helper.launch_version} set it up; this chi is older)" }
259
+ end
260
+
253
261
  if helper.stale?
254
262
  row.to = VERSION
255
- done(row, :update, @dry_run ? "rebuilds and restarts it" : "restarted") { helper.upgrade { nil } }
263
+ done(row, :update, @dry_run ? "rebuilds and restarts it" : "restarted") { helper.upgrade(keep_launch: true) { nil } }
256
264
  elsif helper.launch_outdated?
257
265
  helper.refresh_launch_file unless @dry_run
258
266
  up_to_date(row).tap { |r| r.status += @dry_run ? " (would refresh the launch file)" : " (launch file refreshed)" }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.130.0"
4
+ VERSION = "0.130.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -55,6 +55,7 @@ require_relative "../review_start"
55
55
  require_relative "../parent_answers_command"
56
56
  require_relative "../guardrails/parent_policy"
57
57
  require_relative "markdown_renderer"
58
+ require_relative "token"
58
59
  require_relative "capabilities"
59
60
  require_relative "editor"
60
61
  require_relative "../message_parts"
@@ -431,6 +432,10 @@ module Samagotchi
431
432
  end
432
433
 
433
434
  TOKEN_COOKIE = "chi_token"
435
+ # The owner's cookie holds the token, but on the LAN a loopback
436
+ # peer's holds Token.loopback_cookie (#owner_cookie_value): a cookie
437
+ # for 127.0.0.1 goes to every server on any of its ports (a dev
438
+ # server that logs headers), and that value works nowhere else.
434
439
  # 400 days, the longest a browser keeps a cookie (Chrome's cap). No
435
440
  # Secure: the LAN page is plain http. Lax, not Strict: a token link
436
441
  # opened from another site (a web messenger) makes its 303 chain
@@ -473,10 +478,10 @@ module Samagotchi
473
478
  principal = principal_for(req)
474
479
  return [nil, refused_token(req)] if principal.nil?
475
480
 
476
- # The cookie gets the token that was presented: the owner's, or the
477
- # participant's grant's.
481
+ # The cookie gets the participant's grant's token, or the owner's
482
+ # cookie value (the loopback one on the LAN's 127.0.0.1).
478
483
  if token_trade?(req)
479
- token = req.env[GRANT_TOKEN_ENV] || current_token
484
+ token = req.env[GRANT_TOKEN_ENV] || owner_cookie_value(req)
480
485
  return [nil, token_redirect(req, token)] unless fragment_trade?(req)
481
486
  return [nil, form_trade_redirect(req, token)] if form_post?(req)
482
487
 
@@ -492,7 +497,9 @@ module Samagotchi
492
497
  # one, looked up only when no owner token was given), wherever the
493
498
  # request comes from, this machine's loopback too; with neither, the
494
499
  # owner only on a chi web that asks no token (loopback only: no LAN
495
- # mode, no web.public_url), else nil. An ended or unknown grant's
500
+ # mode, no web.public_url), else nil. The owner's cookie on the LAN's
501
+ # loopback is the loopback value (#loopback_cookie: it stands for the
502
+ # token in +given+). An ended or unknown grant's
496
503
  # token is nil anywhere: it never falls back to the owner.
497
504
  # @return [Principal, nil]
498
505
  # With web.public_url (ProxyGate) every request needs a token, and a
@@ -512,12 +519,39 @@ module Samagotchi
512
519
  # through the proxy (curl, chi's own callers) presents the Bearer
513
520
  # header only: no cookie, and no trade that would set one. A cookie for
514
521
  # 127.0.0.1 goes to every server on any of its ports. A LAN peer
515
- # (web.host lan) keeps the LAN's token link and cookie.
522
+ # (web.host lan) keeps the LAN's token link and cookie; a loopback
523
+ # peer of a LAN chi web reads its cookie through #loopback_cookie.
516
524
  def presented_tokens(req)
517
525
  return [traded_token(req)] if token_trade?(req)
518
526
  return [bearer_token(req)] if direct_public?(req)
519
527
 
520
- [bearer_token(req), req.cookies[token_cookie_name(req)]]
528
+ cookie = req.cookies[token_cookie_name(req)]
529
+ [bearer_token(req), loopback_cookie_peer?(req) ? loopback_cookie(cookie) : cookie]
530
+ end
531
+
532
+ # A loopback peer of a LAN chi web, whose owner cookie holds the
533
+ # loopback value. With web.public_url, #direct_public? takes the
534
+ # Bearer only. A request with a proxy's trace (one the user forwards
535
+ # to loopback by hand) is the LAN's: a leaked loopback value must not
536
+ # work from off this machine through it.
537
+ def loopback_cookie_peer?(req)
538
+ !@lan.nil? && @public_url.nil? && LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s) &&
539
+ !proxy_trace?(req.env)
540
+ end
541
+
542
+ # What a trade sets as the owner's cookie.
543
+ def owner_cookie_value(req)
544
+ loopback_cookie_peer?(req) ? Token.loopback_cookie(current_token) : current_token
545
+ end
546
+
547
+ # A loopback peer's cookie: the loopback value stands for the owner's
548
+ # token, a grant's chp_ token is itself, and anything else, the token
549
+ # itself included (a 0.130.0 cookie, #refused_token clears it), is
550
+ # nothing.
551
+ def loopback_cookie(value)
552
+ return current_token if token_match?(value, Token.loopback_cookie(current_token))
553
+
554
+ Grants.token?(value) ? value : nil
521
555
  end
522
556
 
523
557
  def direct_public?(req)
@@ -622,10 +656,15 @@ module Samagotchi
622
656
  # The 401 for a request #principal_for refused: an ended grant's, else
623
657
  # unauthorized. On a peer the gate doesn't ask, a grant's cookie that
624
658
  # no longer works is cleared too, so the browser it was left in (the
625
- # owner's, who opened a participant link) is the owner's again.
659
+ # owner's, who opened a participant link) is the owner's again. A
660
+ # LAN chi web's loopback peer whose cookie is the token itself (set
661
+ # before the loopback value) has it cleared, so it stops riding to
662
+ # the other local ports.
626
663
  def refused_token(req)
627
664
  response = grant_bound_response(req) || grant_ended_response(req) || unauthorized(req)
628
- return response if token_required?(req) || !Grants.token?(req.cookies[token_cookie_name(req)])
665
+ cookie = req.cookies[token_cookie_name(req)]
666
+ stale = loopback_cookie_peer?(req) && token_match?(cookie, current_token)
667
+ return response unless stale || (!token_required?(req) && Grants.token?(cookie))
629
668
 
630
669
  status, headers, body = response
631
670
  [status, headers.merge("Set-Cookie" => cleared_token_cookie(req)), body]
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "participant_view"
4
+ require_relative "token"
4
5
  require_relative "participant_stream_filter"
5
6
  require_relative "participant_exposure"
6
7
  require_relative "../session_metrics"
@@ -39,10 +40,16 @@ module Samagotchi
39
40
 
40
41
  # Who reads: the participant (their author), or the owner through the
41
42
  # participant view (nobody's own). The session's folder places tool
42
- # paths; the owner's token is never shown.
43
+ # paths; the owner's token is never shown, nor (LAN) its loopback
44
+ # cookie value.
43
45
  def participant_viewer(req, session)
44
46
  author = principal(req).owner? ? nil : principal(req).grant.author
45
- ParticipantView::Viewer.new(author: author, cwd: session.working_directory, secrets: [(@lan || @public_url) && current_token].compact)
47
+ ParticipantView::Viewer.new(author: author, cwd: session.working_directory, secrets: owner_secrets)
48
+ end
49
+
50
+ def owner_secrets
51
+ token = (@lan || @public_url) && current_token
52
+ [token, @lan && Token.loopback_cookie(token)].compact
46
53
  end
47
54
 
48
55
  # GET /api/sessions/:id/review: the participant page's data, always
@@ -74,6 +74,12 @@ module Samagotchi
74
74
  return true if @public_url.host?(env["HTTP_HOST"])
75
75
  return true if @public_url.identity_env && env.key?(@public_url.identity_env)
76
76
 
77
+ proxy_trace?(env)
78
+ end
79
+
80
+ # Whether a request carries a proxy's trace (ProxyTrace: X-Forwarded-*,
81
+ # Forwarded, X-Real-IP, a known identity header).
82
+ def proxy_trace?(env)
77
83
  names = env[RAW_NAMES_ENV] || env.keys.filter_map { |key| ProxyTrace.env_name(key) }
78
84
  names.any? { |name| ProxyTrace.header?(name) }
79
85
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "openssl"
4
5
  require "securerandom"
5
6
  require_relative "../atomic_file"
6
7
 
@@ -14,11 +15,15 @@ module Samagotchi
14
15
  # can read, $XDG_STATE_HOME/samagotchi/web-token (0600). It is made on
15
16
  # the first LAN start and kept, so a phone's bookmark survives
16
17
  # restarts; `chi web --new-token` replaces it. A loopback-only chi web
17
- # never reads it.
18
+ # never reads it. The owner's cookie for this machine's 127.0.0.1
19
+ # holds #loopback_cookie, never the token: a server on any other local
20
+ # port is sent that cookie too.
18
21
  module Token
19
22
  FILE = "web-token"
20
23
  # 32 random bytes: 43 URL-safe characters.
21
24
  BYTES = 32
25
+ LOOPBACK_PREFIX = "chl_"
26
+ LOOPBACK_LABEL = "chi web loopback cookie v1"
22
27
 
23
28
  module_function
24
29
 
@@ -49,6 +54,20 @@ module Samagotchi
49
54
  SecureRandom.urlsafe_base64(BYTES)
50
55
  end
51
56
 
57
+ # The owner's 127.0.0.1 cookie on the LAN: an HMAC of a label keyed
58
+ # by +token+, so a leaked one gives nothing about the token, and
59
+ # rotating the token ends it. The gate takes it only as that cookie,
60
+ # from an unproxied loopback peer (App#loopback_cookie_peer?).
61
+ # @return [String, nil] "chl_" and 43 URL-safe characters; nil
62
+ # without a token
63
+ def loopback_cookie(token)
64
+ return nil if token.nil? || token.empty?
65
+
66
+ # URL-safe base64 without padding (pack: base64 isn't a default gem).
67
+ digest = OpenSSL::HMAC.digest("SHA256", token, LOOPBACK_LABEL)
68
+ LOOPBACK_PREFIX + [digest].pack("m0").tr("+/", "-_").delete("=")
69
+ end
70
+
52
71
  # Written whole under a temporary name, then renamed over +path+: a
53
72
  # reader never sees half a token. The file is 0600 from the start,
54
73
  # its folder 0700 when this makes it.
@@ -251,6 +251,13 @@ module Samagotchi
251
251
  ChildMove.clear_starting(@session_id, state_dir: @state_dir)
252
252
  ChildMove.adopt(@session_id, state_dir: @state_dir)
253
253
  @session = Session.load(@session_id, state_dir: @state_dir)
254
+ # A newer chi saved it: this worker keeps the fields it doesn't know
255
+ # but runs the session without them (docs/sessions.md).
256
+ if @session.newer_writer?
257
+ unknown = @session.extra_fields.keys.join(",")
258
+ Log.warn(:worker, "newer_writer", sid: @session_id, version: VERSION, written_by: @session.written_by,
259
+ unknown_fields: (unknown unless unknown.empty?))
260
+ end
254
261
  drop_dead_question
255
262
  # The running turn, on disk at its iteration boundaries.
256
263
  @checkpoint = TurnCheckpoint.new(session: @session, state_dir: @state_dir)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.130.0
4
+ version: 0.130.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov