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 +4 -4
- data/CHANGELOG.md +33 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +12 -3
- data/docs/desktop.md +28 -9
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/participants.md +13 -3
- data/docs/sessions.md +4 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +99 -19
- data/lib/samagotchi/desktop_command.rb +7 -2
- data/lib/samagotchi/guardrails/rules.rb +2 -2
- data/lib/samagotchi/guardrails/targets.rb +13 -0
- data/lib/samagotchi/session.rb +49 -4
- data/lib/samagotchi/tools/execute.rb +5 -2
- data/lib/samagotchi/tools/task_runtime.rb +4 -2
- data/lib/samagotchi/update_command.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +47 -8
- data/lib/samagotchi/web/participant_review.rb +9 -2
- data/lib/samagotchi/web/proxy_gate.rb +6 -0
- data/lib/samagotchi/web/token.rb +20 -1
- data/lib/samagotchi/worker.rb +7 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dcca1391e17e266cf83e1c56dec7ab52d21a1928a4c5190d2bf594884cca936f
|
|
4
|
+
data.tar.gz: fc59a582d279b19352c3bda61bb5d2fc3796ae0e079e52b3dcb26dc85988ea33
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
294
|
-
|
|
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.
|
|
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
|
|
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)
|
|
261
|
-
rewrites `launch.json
|
|
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
|
|
291
|
-
using it. `
|
|
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
|
|
335
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
182
|
-
# other terminal settings or
|
|
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"]
|
|
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
|
|
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
|
-
|
|
196
|
-
config["
|
|
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
|
-
|
|
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
|
|
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 ||=
|
|
116
|
-
forms +=
|
|
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
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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
|
|
724
|
-
#
|
|
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)" }
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
|
477
|
-
#
|
|
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] ||
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
data/lib/samagotchi/web/token.rb
CHANGED
|
@@ -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.
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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)
|