samagotchi 0.128.0 → 0.130.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +126 -1
- data/docs/architecture.md +13 -6
- data/docs/cli.md +20 -10
- data/docs/configuration.md +7 -2
- data/docs/guardrails.md +202 -33
- data/docs/internals/background-tasks.md +11 -0
- data/docs/internals/engine.md +4 -3
- data/docs/internals/participants.md +29 -6
- data/docs/internals/remote-sessions.md +5 -4
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +20 -4
- data/lib/samagotchi/engine_peers.rb +3 -1
- data/lib/samagotchi/guardrail_wiring.rb +6 -5
- data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +26 -16
- data/lib/samagotchi/guardrails/outside.rb +68 -17
- data/lib/samagotchi/guardrails/pair_boundary.rb +89 -26
- data/lib/samagotchi/guardrails/pair_command.rb +327 -72
- data/lib/samagotchi/guardrails/parent_approvals.rb +8 -10
- data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +18 -24
- data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
- data/lib/samagotchi/guardrails/review_paths.rb +30 -14
- data/lib/samagotchi/guardrails/rm_targets.rb +7 -6
- data/lib/samagotchi/guardrails/rules.rb +23 -5
- data/lib/samagotchi/guardrails/scratch_writes.rb +4 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +75 -38
- data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
- data/lib/samagotchi/guardrails/shell_paths.rb +85 -48
- data/lib/samagotchi/guardrails/targets.rb +131 -29
- data/lib/samagotchi/remote_command.rb +13 -3
- data/lib/samagotchi/remotes.rb +3 -2
- data/lib/samagotchi/review_start.rb +3 -2
- data/lib/samagotchi/session_inbox.rb +17 -0
- data/lib/samagotchi/session_manager.rb +9 -15
- data/lib/samagotchi/system_one/self_protect.rb +127 -20
- data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/builtins.rb +10 -1
- data/lib/samagotchi/tools/peers.rb +4 -0
- data/lib/samagotchi/tools/task_wait.rb +26 -1
- data/lib/samagotchi/turn_state.rb +23 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +32 -25
- data/lib/samagotchi/web/proxy_gate.rb +8 -6
- data/lib/samagotchi/web/public/app.js +8 -1
- data/lib/samagotchi/web/public/participant.js +2 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
- data/lib/samagotchi/web/server.rb +29 -4
- data/lib/samagotchi/worker.rb +91 -33
- data/lib/samagotchi/worker_idle_exit.rb +17 -0
- data/lib/samagotchi/worker_link.rb +13 -5
- metadata +3 -1
data/docs/guardrails.md
CHANGED
|
@@ -274,7 +274,8 @@ sub-agent](sub-agent.md)) tell them to use `chi answer` only.
|
|
|
274
274
|
|
|
275
275
|
## Protected paths
|
|
276
276
|
|
|
277
|
-
Built in, for `write`, `edit` and `memory_write` (symlinks resolved
|
|
277
|
+
Built in, for `write`, `edit` and `memory_write` (symlinks resolved; see
|
|
278
|
+
[How paths resolve](#how-paths-resolve)):
|
|
278
279
|
|
|
279
280
|
- deny: the approval store's dir, installed bundles (`memories/.bundles/`),
|
|
280
281
|
and [attached context](context.md) sources (`<state dir>/context/`: a
|
|
@@ -305,7 +306,21 @@ else the parent's checkout). Rule `child-boundary`, source `core`; it asks when
|
|
|
305
306
|
overwriting a branch other than its own.
|
|
306
307
|
|
|
307
308
|
Read-only commands (`git -C ../app log`, `cat ../app/x`) and paths elsewhere
|
|
308
|
-
(`/usr/bin`, `~/.gem`) never ask
|
|
309
|
+
(`/usr/bin`, `~/.gem`) never ask, nor does a read-only command that writes only
|
|
310
|
+
into a tmp dir (`/tmp`, `/var/tmp`, `$TMPDIR`): its `>` / `>>` targets, `cp`'s
|
|
311
|
+
destination (the last argument, or `-t` / `--target-directory`) and `tee`'s
|
|
312
|
+
files, resolved against its `cwd:` and any `cd` or `pushd` before them, as in
|
|
313
|
+
`sqlite3 -readonly -safe ../app/x.db "select 1" > /tmp/rows` or
|
|
314
|
+
`cp ../app/x.db /tmp/x.db`. A target that links back out of the tmp dir (a
|
|
315
|
+
symlink, a dangling one, a file with another hard link), `cp -l`/`-s`, `mv`,
|
|
316
|
+
`&>`, `>|` and `>&file` still ask, as does a target with a `$` or `~` (the
|
|
317
|
+
call's `env:` may set it), a `popd`, and a `cd`/`pushd` that isn't one existing
|
|
318
|
+
dir spelt `/…`, `.` or `..` joined by `&&` only (it may fail or be skipped and
|
|
319
|
+
leave the shell elsewhere); so does every tmp write when the
|
|
320
|
+
child's folder is itself in that tmp dir. A `task_create` call's paths resolve under chi's env and
|
|
321
|
+
under its own `env:` (its `$TMPDIR`, `$HOME`, `GIT_DIR`, …): either one naming another checkout asks. An `env:` that
|
|
322
|
+
redefines the shell (`BASH_ENV`, `BASH_FUNC_cat%%`, `CDPATH`, …, as under `skip_read_only` below) always asks, even
|
|
323
|
+
for `cat a.txt` in the child's own folder: what it runs is in no word of the command. The answer is once or for the child's
|
|
309
324
|
session only: a repo or rule answer would hold in every worktree, for every
|
|
310
325
|
child, so a stored repo or rule approval doesn't let it through either. Its
|
|
311
326
|
ask goes before the rules, so with the guardrails bundle in strict mode the
|
|
@@ -331,13 +346,16 @@ participant's turn may call only the `review` kit's tools:
|
|
|
331
346
|
|
|
332
347
|
- `read`, under the session's project root (the work tree top of its folder)
|
|
333
348
|
and never a secret: chi's state and config dirs (sessions, the grants, the
|
|
334
|
-
web token, memories and their `.bundles`, `config.yml`, hooks
|
|
349
|
+
web token, memories and their `.bundles`, `config.yml`, hooks, and a
|
|
350
|
+
`hooks.hooks_dir` configured outside the config dir), `~/.ssh`,
|
|
335
351
|
`~/.gnupg`, `~/.aws`, and the project's `.env*`, `.netrc`, `*.pem`, `*.key`,
|
|
336
352
|
`*.p12`, anything named `credentials` (`.git-credentials` too), `.npmrc`,
|
|
337
353
|
`.pypirc`, ssh keys (`id_rsa`, `id_ed25519`, …), `*.tfstate`,
|
|
338
354
|
`secrets.yml`/`.json`, `.docker/config.json`, `.kube/config` and any config
|
|
339
355
|
under `.git` (a submodule's too). The path is checked as given and as it
|
|
340
|
-
resolves, so a symlink in the project to `~/.ssh` is `~/.ssh
|
|
356
|
+
resolves, so a symlink in the project to `~/.ssh` is `~/.ssh`, and
|
|
357
|
+
`node_modules/foo/../../.config/gh/hosts.yml` with `foo` a link to `~/src/foo`
|
|
358
|
+
is `~/.config/gh/hosts.yml` ([How paths resolve](#how-paths-resolve)). A session
|
|
341
359
|
whose folder is the home folder or above it reads nothing.
|
|
342
360
|
- `memory_read` with `scope: project` (a read without a scope would fall back
|
|
343
361
|
to the system memories). The prompt's preloaded memories and the memory
|
|
@@ -391,31 +409,67 @@ message:
|
|
|
391
409
|
each word resolved as `touches: chi_dirs` resolves it; a glob that matches a
|
|
392
410
|
secret or whose fixed part leads into a denied folder (`~/.ss?/id_*`,
|
|
393
411
|
`~/.config/*/config.yml`, `.en?`), a `$var` word whose fixed part does
|
|
394
|
-
(`~/.ss${d}h`),
|
|
412
|
+
(`~/.ss${d}h`), with a leading `~`, `$HOME`, XDG dir (its default when
|
|
413
|
+
unset) or `$TMPDIR` expanded (`ls $XDG_CONFIG_HOME/samago*`); after `$TMPDIR`
|
|
414
|
+
the fixed part counts only inside a denied folder, not as the start of one's
|
|
415
|
+
name (an isolated run's state dir is in tmp: `ls $TMPDIR/*` isn't denied);
|
|
416
|
+
and a text match for the rest. A prefix that comes through a
|
|
417
|
+
variable (`x=~/.s; cat ${x}sh/config`) isn't followed: such a command isn't
|
|
418
|
+
denied, it asks (its `$var` word and the assignment's path place it outside
|
|
419
|
+
the project);
|
|
395
420
|
- sweeping a folder that holds secrets: `find`, `tar`, `cp`, `rsync`, `scp`,
|
|
396
421
|
`zip`, `ditto`, `grep`/`rg`/`ag` and the like given home or any folder above
|
|
397
|
-
a denied one;
|
|
398
|
-
- the environment and
|
|
399
|
-
`export`, `declare` or `typeset` that list, `launchctl
|
|
400
|
-
`security`, `/proc/*/environ
|
|
422
|
+
a denied one, and linking to one (`ln -s ~/.config g`, `ln -s ../.. up`);
|
|
423
|
+
- the environment, the keychain and stored credentials: `env` or `printenv`
|
|
424
|
+
alone, `set`, `export`, `declare` or `typeset` that list, `launchctl
|
|
425
|
+
getenv`, `ps e`/`ps -E`, `security`, `/proc/*/environ`, and the commands
|
|
426
|
+
that print a credential (`gh auth token`, `git credential fill` and the
|
|
427
|
+
`git credential-*`/`docker-credential-*` helpers, `gcloud … print-access-token`,
|
|
428
|
+
`op`, `aws configure get`/`export-credentials`, `az account
|
|
429
|
+
get-access-token`, `heroku auth:token`, `kubectl config view --raw`;
|
|
430
|
+
`PairCommand::CREDENTIAL_PRINTERS`). On top of it, a pair turn's `execute` runs
|
|
401
431
|
without every variable whose name matches `KEY|TOKEN|SECRET|PASS|CREDENTIAL|AUTH`
|
|
402
432
|
(`PairBoundary::SECRET_ENV_NAME`, case-insensitive), and a command still
|
|
403
433
|
running at the limit is killed, never handed over as a background task;
|
|
404
434
|
- git settings that make git run a command (`core.hooksPath`,
|
|
405
435
|
`core.fsmonitor`, `core.sshCommand`, `core.pager`, `core.editor`, a `!`
|
|
406
436
|
alias) by `git config` or `git -c`, and `git config --global`/`--system`;
|
|
437
|
+
and writing git's hooks: a `write` or `edit` of a path in a `.git/hooks`
|
|
438
|
+
folder (as given or as it resolves), or a command that isn't read-only and
|
|
439
|
+
names one (`printf … > .git/hooks/pre-commit`, `cp x .git/hooks/`);
|
|
407
440
|
- running chi, by any wrapper (`bin/chi`, `bundle exec chi`, `ruby … bin/chi`,
|
|
408
441
|
`env … chi`, `watch`, `npx`, `sh -c 'chi …'`, `$(chi …)`), in an assignment's
|
|
409
442
|
value or an alias, a function, after `-exec`/`-c`/`-e`, but `chi --version` and
|
|
410
443
|
`chi --help`: its commands act in the owner's name (shares, caps, sends,
|
|
411
444
|
answers, sessions);
|
|
445
|
+
- calling chi web on this machine: a command whose text names a loopback name
|
|
446
|
+
(`localhost` and its subdomains, `127.x`, `0.0.0.0`, `[::1]`, `::ffff:127.x`,
|
|
447
|
+
`0:PORT`) or this machine's host name together with chi web's port (the one
|
|
448
|
+
the running chi web recorded in `web.json`, config's `web.port`, 4567), by
|
|
449
|
+
`curl`, `wget`, `nc`, `/dev/tcp`, a script's URL or anything else
|
|
450
|
+
(`PairBoundary::CHI_WEB`): defense in depth, as a LAN chi web asks loopback
|
|
451
|
+
for the token too, and a loopback-only one still takes it as the owner. A
|
|
452
|
+
Bridge answers only with its secret, which sits in chi's state dir;
|
|
453
|
+
- opening a URL or a file in the owner's apps (`open`, `xdg-open`, `gio open`,
|
|
454
|
+
a browser, `osascript`'s `open location`, a script's `webbrowser`) and
|
|
455
|
+
listening on a port (`nc -l`, `socat …LISTEN`, `python -m http.server`,
|
|
456
|
+
`ruby -run -e httpd`, `php -S`, a static server, a script's `listen(`/
|
|
457
|
+
`HTTPServer`/`createServer`): the owner's browser sends its cookies (chi
|
|
458
|
+
web's on 127.0.0.1 or the LAN address, kept for every port) to the page
|
|
459
|
+
it opens (`PairBoundary::OPENER`, `PairBoundary::LISTENER`);
|
|
460
|
+
- a program whose name the shell expands: a `$var`, a `$(…)` or a glob in a
|
|
461
|
+
command's name (`bin/ch? …`, `e=ch; ${e}i …`, `$CMD x`), whatever it would
|
|
462
|
+
turn out to be (`PairBoundary::VERB`);
|
|
412
463
|
- leaving something running: `&` at a command's end, `nohup`, `setsid`,
|
|
413
|
-
`disown`, `screen -d…`, `tmux new -d
|
|
464
|
+
`disown`, `screen -d…`, `tmux new -d`, and scheduling it: `crontab`, `at`,
|
|
465
|
+
`batch`;
|
|
414
466
|
- owner-voiced tools, which act in the owner's name beyond the turn:
|
|
415
467
|
`delegate` and any `delegate_*` tool, `register_reminder`,
|
|
416
468
|
`cancel_reminder`, `send_note`, `forget_outputs`, `task_create`,
|
|
417
469
|
`task_stop`, `find_tools` (it loads tools into the session), and
|
|
418
|
-
`memory_write` (a memory reaches the owner's later sessions);
|
|
470
|
+
`memory_write` (a memory reaches the owner's later sessions); and the tools
|
|
471
|
+
that read the owner's background tasks and their output, `task_wait`,
|
|
472
|
+
`task_get`, `task_list` (a pair turn starts none);
|
|
419
473
|
- `ask_user_question`, with the advice to ask in the reply: nobody on the
|
|
420
474
|
participant's side answers a card.
|
|
421
475
|
|
|
@@ -426,13 +480,41 @@ own, one another interpreter runs (`ruby -e`, `python - <<EOF`) by its text
|
|
|
426
480
|
`tee`, `git commit -F -`) is not looked into: where it goes is checked. A
|
|
427
481
|
command the checks can't read is refused, never let through on a text match.
|
|
428
482
|
|
|
429
|
-
And it **asks the owner** before a `write` or `edit` outside the
|
|
430
|
-
project root, and before a command that
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
483
|
+
And it **asks the owner** before a `write` or `edit` that may land outside the
|
|
484
|
+
session's project root, and before a command that may write there, every time:
|
|
485
|
+
the ask is fresh (`Verdict#fresh_ask`: no stored approval answers it, and no
|
|
486
|
+
rule lowers it), so it comes even in auto mode. A command fails closed
|
|
487
|
+
(`PairCommand#outside_writes?`): it runs unasked only when it
|
|
488
|
+
|
|
489
|
+
- only reads (`ReadOnlyShell.read_only?`, wherever it reads), or
|
|
490
|
+
- only reads but for writes it can name (a `>`/`>>` target, `cp`'s
|
|
491
|
+
destination, `tee`'s files) into the project or a tmp dir
|
|
492
|
+
(`ReadOnlyShell.read_only_but_writes?`), or
|
|
493
|
+
- stays inside the project: every part of it runs in the project, and every
|
|
494
|
+
path it names lands in the project or a tmp dir, an option's or an
|
|
495
|
+
assignment's value (`of=`, `--output=`, `GIT_DIR=`), a glob's fixed part
|
|
496
|
+
(`~/oth*`), `git -C`'s folder and the paths in a script it hands on (`sh -c`
|
|
497
|
+
read as a command, `python -c`/`ruby -e`/a heredoc by the paths in its text)
|
|
498
|
+
among them. The program's own word doesn't count (`/usr/bin/sed`), nor does
|
|
499
|
+
`PATH=…`, nor a message's heredoc (`git commit -m "$(cat <<'EOF' … EOF)"`).
|
|
500
|
+
|
|
501
|
+
Anything else asks: `rm -rf ~/x`, `sed -i … ~/x`, `curl -o ~/x`, `dd of=~/x`,
|
|
502
|
+
`touch`/`mkdir` outside, `perl -pi … ~/x`, `cd .. && make`, `cd && make`, and
|
|
503
|
+
whatever the checks can't place: a `$var` (`rm -rf $DIR`, `> "$OUT"`), a `$(…)`,
|
|
504
|
+
`xargs`, a `cd $X`. A path "lands" in the project or a tmp dir every way it
|
|
505
|
+
resolves ([How paths resolve](#how-paths-resolve)) and never through a dangling
|
|
506
|
+
symlink or onto a file with another hard link (`Outside.inside?`,
|
|
507
|
+
`Outside.tmp_write?`, the child boundary's tmp rule): the tmp dirs are
|
|
508
|
+
`Dir.tmpdir`, `/tmp` and `/var/tmp`, except the one the project itself is in.
|
|
509
|
+
`/dev/null` and the like neither ask nor deny. A script file's body (`ruby
|
|
510
|
+
x.rb`, `make`, `sh ./run.sh`) isn't read: it runs as in-project work.
|
|
511
|
+
A command that makes a link asks too, wherever it points (`ln`, `link`, `cp
|
|
512
|
+
-s`/`-l`, `sh -c 'ln …'`, an interpreter's `symlink`/`link(`): a path through
|
|
513
|
+
a link the same command makes is judged before the link exists, so `ln -s ..
|
|
514
|
+
up && cat up/…` can't be read for what it reaches.
|
|
515
|
+
A session whose folder is the home folder or above it has no project to
|
|
516
|
+
keep the turn in (`ReviewPaths.home_root?`, the review boundary's test): every
|
|
517
|
+
write there asks.
|
|
436
518
|
|
|
437
519
|
A participant's ask names them on the owner's card ("Sam's turn asks: approve
|
|
438
520
|
tool call?", `approval.author`/`author_name`, `approval.asked_by`, the same
|
|
@@ -448,7 +530,10 @@ The trust model is a team's (everyone invited is trusted): the boundary is an
|
|
|
448
530
|
hostile participant; a review session is. A pair turn can still reach, through
|
|
449
531
|
the owner's rules: `web_fetch` (an open egress path: what the turn can read it
|
|
450
532
|
can send out in a URL), plugin and MCP tools, `memory_read` and `context_read`,
|
|
451
|
-
`list_sessions` (the owner's other sessions' titles),
|
|
533
|
+
`list_sessions` (the owner's other sessions' titles), chi web by a name the
|
|
534
|
+
check doesn't know (a DNS name or a decimal address for 127.0.0.1, a port
|
|
535
|
+
forward, a port spelt in a variable), where a LAN chi web still takes a
|
|
536
|
+
token-less loopback request as the owner's, and anything a script
|
|
452
537
|
does that the command checks don't read (a script file's body, an interpreter's
|
|
453
538
|
`-e` code). Share a working session only with people you would let type in it.
|
|
454
539
|
|
|
@@ -564,7 +649,10 @@ only `run list|view`, `pr list|view|diff|checks`, `issue list|view` and
|
|
|
564
649
|
`repo view`; `--web`/`-w`, `gh api` and `pr checks --watch` are not read-only.
|
|
565
650
|
`$(…)`, backticks, `$VAR` (other than `$HOME`, `$XDG_*`, `$TMPDIR` and `$PWD`
|
|
566
651
|
at a word's start), an env prefix, `sh -c`, `xargs`, `eval`, `sudo` and the
|
|
567
|
-
like make a command not read-only
|
|
652
|
+
like make a command not read-only, as does a `task_create` `env:` that sets `BASH_ENV`, `ENV`, `CDPATH`, `IFS`,
|
|
653
|
+
`SHELLOPTS`, `BASHOPTS`, `BASH_FUNC_…`, `LD_PRELOAD`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `GIT_EXTERNAL_DIFF`,
|
|
654
|
+
`GIT_CONFIG_PARAMETERS` or `GIT_CONFIG_COUNT` (they change what its words run; `PATH` doesn't count). It errs
|
|
655
|
+
towards asking.
|
|
568
656
|
|
|
569
657
|
A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
|
|
570
658
|
say): `tool: "mcp_*"` or `tool: ["mcp_{git,gh}_*", web_fetch]` (`*`, `?`, `[…]` and
|
|
@@ -629,8 +717,14 @@ the toolkit removes stays refused before the gate.
|
|
|
629
717
|
```
|
|
630
718
|
|
|
631
719
|
All the fields a rule gives must match. Absolute and `**/` globs match the
|
|
632
|
-
|
|
633
|
-
|
|
720
|
+
absolute path; other globs match the path relative to the repo root. A glob
|
|
721
|
+
matches the path as the call gives it (against the cwd, `~` expanded) and as it
|
|
722
|
+
resolves (symlinks, `..` the kernel's way: see How paths resolve), a relative
|
|
723
|
+
glob then from the repo root as it resolves (`/tmp` is `/private/tmp` on
|
|
724
|
+
macOS): `write hl/pre-commit` with `hl` a link to `.git/hooks` matches
|
|
725
|
+
`**/.git/hooks/**`. Rules only ask or deny, so the resolved path only ever adds
|
|
726
|
+
a vote; were a verdict ever to let a call through, a symlink into its glob
|
|
727
|
+
would not count.
|
|
634
728
|
|
|
635
729
|
`outside_repo` is measured from the session's repo: the git work tree its
|
|
636
730
|
folder is in (a linked worktree is its own), or the folder itself outside a
|
|
@@ -647,6 +741,63 @@ one folder; a link in the repo that points elsewhere is outside). Never outside:
|
|
|
647
741
|
- a tmp folder (`$TMPDIR`, `/tmp`, `/var/tmp`), unless the session's repo is
|
|
648
742
|
itself in that tmp folder: a sandbox there still gets asked about its siblings.
|
|
649
743
|
|
|
744
|
+
A path that is or goes through a dangling symlink (a write would create the
|
|
745
|
+
link's target) or a file with another hard link (a write changes the other
|
|
746
|
+
name too) is always outside, in the repo or a tmp folder alike
|
|
747
|
+
(`Outside.resolve`, which the child boundary's tmp writes read as well).
|
|
748
|
+
|
|
749
|
+
#### How paths resolve
|
|
750
|
+
|
|
751
|
+
Every path check (protected paths, `outside_repo`, `touches`, `rm`, `memory:
|
|
752
|
+
prompt`, the child, review and pair boundaries, System 1's own-files test)
|
|
753
|
+
resolves paths with `Guardrails::PathResolve` (the map below):
|
|
754
|
+
|
|
755
|
+
- `..` is taken the kernel's way (`.expand`): after a symlinked folder it
|
|
756
|
+
climbs out of the link's target, so `repo/link/../x` with `link` pointing
|
|
757
|
+
to `/elsewhere/deep` is `/elsewhere/x`, not `repo/x`;
|
|
758
|
+
- symlinks are resolved on the longest part that exists, and a dangling
|
|
759
|
+
symlink is followed to the file a write through it would create (`.real`);
|
|
760
|
+
- a check reads the path both ways, the lexical answer (`File.expand_path`,
|
|
761
|
+
stopping at a dangling link) and where the kernel lands (`.reals`): it
|
|
762
|
+
refuses, asks or votes when either is protected, outside or a secret, so
|
|
763
|
+
the kernel's answer only ever adds to the lexical one. A file tool call's
|
|
764
|
+
paths (`Targets#paths`, what approvals show) list both when they differ.
|
|
765
|
+
|
|
766
|
+
In a shell command a file argument resolves the same way, against the folder
|
|
767
|
+
the command is in by then. A `cd` or `pushd` to such a path may land either
|
|
768
|
+
way: `cd link/..` is back where it started only while that logical folder
|
|
769
|
+
exists (zsh, which chi's shell is on macOS, and bash chdir to the physical one
|
|
770
|
+
when it doesn't), and `cd -P`, `set -P` and zsh's `chase_links`/`chase_dots`
|
|
771
|
+
always take the physical one. So after it the command counts as in both
|
|
772
|
+
folders: each later word is resolved against each (a word that can't be
|
|
773
|
+
resolved from one isn't resolved), and judged both ways as above; past eight
|
|
774
|
+
folders, or after `cd -`, the folder is unknown. After `cd link` a `../x`
|
|
775
|
+
climbs out of the link's target as well. git's `-C`, `--git-dir` and
|
|
776
|
+
`--work-tree` (and `GIT_DIR`/`GIT_WORK_TREE`) are counted both ways, from each
|
|
777
|
+
folder the command may be in, with a leading `~`, `$HOME`, XDG dir or `$TMPDIR`
|
|
778
|
+
expanded as a `cd` expands it (`PathResolve.expand_word`).
|
|
779
|
+
|
|
780
|
+
On a disk that folds case (macOS), a path is in a protected folder however its
|
|
781
|
+
letters are cased, also where the rest doesn't exist yet (`PathResolve.under?`):
|
|
782
|
+
a write to `<state dir>/CONTEXT/x` is a write to the attached context dir once it
|
|
783
|
+
exists.
|
|
784
|
+
|
|
785
|
+
For whoever changes these checks, one shared helper per primitive, each surface
|
|
786
|
+
keeping its own predicate (`spec/guardrails/path_consistency_spec.rb` pins what
|
|
787
|
+
every surface answers for the same paths):
|
|
788
|
+
|
|
789
|
+
| Primitive | Where | Used by |
|
|
790
|
+
|---|---|---|
|
|
791
|
+
| expand (`..` the kernel's way), real, lexical_real, reals | `PathResolve` (`ProtectedPaths` keeps the old names) | every surface |
|
|
792
|
+
| in a dir: `under?` (case-folded on a folding disk; `fold: false` exact), `strictly_under?`, `within?` (root resolved too) | `PathResolve` | `under?`: ShellPaths, ReviewPaths, PairCommand, ChildBoundary, SelfProtect's words; `Outside.within?` and RmTargets' `strictly_under?` fold only when the dir exists (a missing session root keeps the case it was typed in); `within?`: ProtectedPaths, ScratchWrites, ParentApprovals, SelfProtect's paths |
|
|
793
|
+
| tmp dirs and "not one the session is in" | `PathResolve.tmp_roots(session:)` (exact case) | Outside (`Outside.tmp_roots` is the list it and Targets read), RmTargets; `touches_chi?` takes all of them |
|
|
794
|
+
| a shell word's `~`, `$HOME`, XDG dirs, `$TMPDIR` | `PathResolve.expand_word` | ShellPaths::Walk, ShellGitDirs; PairCommand has its own `~`/`$HOME` (`expand_tilde`) |
|
|
795
|
+
| chi's dirs | `ChiDirs.protected` / `.touches` / `.self_protect` / `.review` | ParentApprovals, Targets, SelfProtect, ReviewPaths (`ProtectedPaths` and `ScratchWrites` get theirs from the wiring) |
|
|
796
|
+
|
|
797
|
+
Left apart on purpose: `Targets.prompt_memory_path?` compares in any case on
|
|
798
|
+
every disk, `PairCommand#leads_into_denied?` matches a partial name (`~/.ss`),
|
|
799
|
+
and each surface resolves its root its own way.
|
|
800
|
+
|
|
650
801
|
`git: outside_repo` (shell tools only) reads the command for git subcommands
|
|
651
802
|
that change a checkout: `commit`, `add`, `reset`, `checkout`, `switch`,
|
|
652
803
|
`rebase`, `merge`, `push`, `stash` (not `stash list`/`show`), `rm`, `mv`,
|
|
@@ -655,7 +806,8 @@ that change a checkout: `commit`, `add`, `reset`, `checkout`, `switch`,
|
|
|
655
806
|
and the call's `cwd:`, and matches when one of them runs outside the session's
|
|
656
807
|
repo (the same `outside_repo` as above). `cd /other && git status`, `git -C
|
|
657
808
|
/other log` and `cd /other && bundle exec rspec` don't match. A folder the text
|
|
658
|
-
doesn't tell (`cd $REPO`, `cd "$(…)"`, `cd
|
|
809
|
+
doesn't tell (`cd $REPO`, `cd "$(…)"`, `cd -`; a `$XDG_CONFIG_HOME` or
|
|
810
|
+
`$TMPDIR` is told) doesn't either; see Limits.
|
|
659
811
|
|
|
660
812
|
To switch off single rules (a bundle's, say) without editing its files, list
|
|
661
813
|
them under `disable:`. A plain id switches off every rule with that id; `bundle:id`
|
|
@@ -878,17 +1030,34 @@ gate below passes, in this order:
|
|
|
878
1030
|
are never auto-allowed, even when listed
|
|
879
1031
|
(`SystemOne::SelfProtect::NEVER_AUTO`);
|
|
880
1032
|
4. the call does not change chi itself (`SystemOne::SelfProtect.refuse?`), by
|
|
881
|
-
|
|
882
|
-
|
|
1033
|
+
its own set of chi's dirs (`Guardrails::ChiDirs.self_protect`: the dirs whose
|
|
1034
|
+
asks only the user may answer, `ParentApprovals.chi_files?`, plus the whole
|
|
1035
|
+
state dir), wider than that test: no edit or write whose paths are in chi's
|
|
883
1036
|
config dir (config.yml), the hooks dir, the installed bundles dir, the
|
|
884
|
-
guardrails store (where `approvals.json` and `judgments.jsonl` live)
|
|
885
|
-
attached context dir
|
|
886
|
-
of them
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1037
|
+
guardrails store (where `approvals.json` and `judgments.jsonl` live), the
|
|
1038
|
+
attached context dir or anywhere else in chi's state dir (the sessions), and
|
|
1039
|
+
no `execute` or `task_create` command naming any of them: a write's paths and
|
|
1040
|
+
a `cat` of them answer alike (`SelfProtect.chi_dirs`). `chi_files?` itself
|
|
1041
|
+
still applies on top (its text match, the command spelling a dir). A command is checked twice: by its text
|
|
1042
|
+
(`ParentApprovals::CHI_TEXT`: `$XDG_CONFIG_HOME/samagotchi/config.yml`,
|
|
1043
|
+
`~/.config/samagotchi/…`, a `cd ~/.config` then `samagotchi/config.yml`),
|
|
1044
|
+
and by its words resolved as `shell-touches-chi` resolves them
|
|
1045
|
+
(`Guardrails::ShellPaths::Walk`: cd chains, `~`, `$HOME` and the XDG dirs,
|
|
1046
|
+
symlinks, `..`; under the call's `env:` too), so `cd ~/.config; cd
|
|
1047
|
+
samagotchi; cat config.yml` or a symlink into the config dir is refused.
|
|
1048
|
+
A call whose `env:` redefines the shell (`Targets#redefines_shell?`:
|
|
1049
|
+
`BASH_ENV`, `BASH_FUNC_…`, …) is refused whatever it names. The memories
|
|
1050
|
+
dir is deliberately not one of these: reading or appending a memory file
|
|
1051
|
+
can be auto-allowed, by a command none of whose words lands in chi's dirs
|
|
1052
|
+
outside the memories, whose text shows no climb (`SelfProtect::CLIMB`: a
|
|
1053
|
+
`..` segment, any `$` or `$(…)`, backticks, a `~` other than the home one,
|
|
1054
|
+
`cd -`), and whose words the walk can't resolve are only data read while
|
|
1055
|
+
it knew the dir: a script or heredoc body (`python3 -c '…'`, `cat >> x.md
|
|
1056
|
+
<<'EOF'`), a phrase (`echo "- a note"`) or a glob with no `/`. So
|
|
1057
|
+
`cd memories; cd ..`, `cd "${PWD%/*}"`, `cd $(dirname $PWD)` or `cd -`
|
|
1058
|
+
loses the exemption, and the memories text then refuses it. A script body
|
|
1059
|
+
is not looked into beyond its text: an accident guard, not containment.
|
|
1060
|
+
A protected rule
|
|
892
1061
|
(`Verdict::PROTECTED_RULES`) may be listed by name and System 1 may answer
|
|
893
1062
|
it, but a call that really writes config.yml or the hooks is refused here;
|
|
894
1063
|
5. the judgment's `p` is at least `guardrails.system_one.threshold` (0.8);
|
|
@@ -30,6 +30,17 @@ Behavior:
|
|
|
30
30
|
- Which tasks are a session's own (a cancelled turn's note, a worker's restart hold, the finish wake below, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
|
|
31
31
|
- `task_get` returns metadata and `output_path`; use `read` for output contents.
|
|
32
32
|
- `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
|
|
33
|
+
- `task_wait`'s results: a finished task's status and tail (no `wait_result`), or one still running with
|
|
34
|
+
`wait_result:` `pattern_matched`, `timeout`, `canceled` (the user's Stop of the turn) or `message_waiting`. One poll
|
|
35
|
+
(0.5 s) checks in that order: finished, pattern matched, Stop, a message waiting, the deadline. `message_waiting`
|
|
36
|
+
ends the wait when a person's message waits for the turn's next step (`Engine#input_waiting?`, run_turn's
|
|
37
|
+
`input_waiting:` probe through the kernel's `PeerView`): in a worker an input file that the next boundary merges
|
|
38
|
+
from the user, `chi send` or a delegating parent (`SessionInbox.steer_waiting?`; never a queued, image or
|
|
39
|
+
participant's one, never a plugin's steer or a child's report, never during a participant's turn), in the REPL
|
|
40
|
+
(`--no-shared`) a line in its steering queue. The probe only peeks: the drain still merges the message at the
|
|
41
|
+
next boundary. The result says `note: a message from the user is waiting; the task is STILL RUNNING.`, how to wait
|
|
42
|
+
again or stop it (`TaskWait::MESSAGE_WAITING_NOTE`), and `waited: Ns`; the task keeps running and stays watched
|
|
43
|
+
(below). A task that finished meanwhile gives its finished result, as after a Stop. A `chi -p` turn has no probe.
|
|
33
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.
|
|
34
45
|
- `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.
|
|
35
46
|
- 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.
|
data/docs/internals/engine.md
CHANGED
|
@@ -16,13 +16,14 @@ Engine#run_turn → backend.complete(...) → KernelLoop#run (native) or ChatLoo
|
|
|
16
16
|
|
|
17
17
|
```
|
|
18
18
|
Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:, max_tool_output_chars:,
|
|
19
|
-
pending_input:, continue:, origin:, images:, id:, resumed:, resume_from:)
|
|
19
|
+
pending_input:, continue:, origin:, images:, id:, resumed:, resume_from:, input_waiting:)
|
|
20
20
|
│
|
|
21
|
-
├── begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id:)
|
|
21
|
+
├── begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id:, input_waiting:)
|
|
22
22
|
│ # (then, on a continue, turn.resumed = resumed (a step-limit Continue) and
|
|
23
23
|
│ # turn.resume_from = resume_from (its run's start; nil: it starts cold))
|
|
24
24
|
│ # @session = session, session.status = RUNNING, used memories absorbed,
|
|
25
|
-
│ # TurnState#begin!(controller:, sink:) (a new CancellationController when none
|
|
25
|
+
│ # TurnState#begin!(controller:, sink:, input_waiting:) (a new CancellationController when none
|
|
26
|
+
│ # is given; input_waiting: the caller's peek, Engine#input_waiting?, which task_wait polls),
|
|
26
27
|
│ # a recap in flight invalidated, GuardrailWiring#begin_turn(origin)
|
|
27
28
|
│ # Returns a Turn {session, prompt (nil on a continue), continue, on_event, controller,
|
|
28
29
|
│ # origin, started_at, id (the caller's id:, else a new uuid), resumed, resume_from,
|
|
@@ -126,7 +126,15 @@ participant** and **one role**, until it expires or is revoked.
|
|
|
126
126
|
2. A `chp_` token (`Grants.token?`) whose grant is active: a **participant** (`Principal.new(kind: :participant,
|
|
127
127
|
grant:)`), from any peer: loopback and a chi web not on the LAN too. An ended or unknown one is refused
|
|
128
128
|
(below), never the owner.
|
|
129
|
-
3. No `chp_` token
|
|
129
|
+
3. No `chp_` token on a chi web on loopback only (no `web.host: lan`, no `web.public_url`): the **owner**. On the LAN
|
|
130
|
+
this machine's loopback needs the owner's token too (`App#token_required?`; a pair participant's shell runs here,
|
|
131
|
+
and a token-less `curl 127.0.0.1` was the owner, finding A1); only `GATE_FREE_PATHS` and chi's token-less
|
|
132
|
+
`GET /api/info` probe (`ProxyGate#probe_info?`: no Authorization, no Cookie, its checked fields and `start_dir`)
|
|
133
|
+
are answered without it. `chi web` prints and opens a `Local:` link for 127.0.0.1 with an owner's one-time code
|
|
134
|
+
(`Server.local_owner_link`), the fragment trade sets the cookie there. `PairCommand` still denies a pair command
|
|
135
|
+
naming chi web on a loopback name (defense in depth), and URL openers and listeners: a page opened in the owner's
|
|
136
|
+
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).
|
|
130
138
|
4. Anything else: nil. `#token_gate` answers a bound grant's token without its identity **401 `token_bound`**, an
|
|
131
139
|
ended grant's token **401 `token_revoked`** or **401
|
|
132
140
|
`token_expired`** (the page says the same words as a sentence: "This review link was revoked by the session's
|
|
@@ -138,8 +146,9 @@ The `?token=` trade (and `POST /api/token`, with a link's one-time `{code}` or t
|
|
|
138
146
|
presented, the owner's or the participant's
|
|
139
147
|
(one at a time per browser: a participant link opened in the owner's browser replaces the owner's cookie there, and
|
|
140
148
|
makes that browser the participant, on loopback too). A grant's token is traded on any peer; the owner's only where
|
|
141
|
-
the gate asks for it. On a
|
|
142
|
-
cookie, so that browser is the owner's again at its next
|
|
149
|
+
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
|
|
150
|
+
401 for a grant's cookie that no longer works clears the cookie, so that browser is the owner's again at its next
|
|
151
|
+
request; on the LAN that browser gets the ended page and opens the owner's link again.
|
|
143
152
|
|
|
144
153
|
Then `#route`, for each matching `ROUTES` row:
|
|
145
154
|
|
|
@@ -176,8 +185,11 @@ Everything else is `:owner`: `/answer`, `/command`, `/images` (an image a prompt
|
|
|
176
185
|
to act: the turn only reads (`Guardrails::ReviewBoundary`), and proposes what the owner applies ([Apply](#apply)).
|
|
177
186
|
- **`:pair`**, a working session's participant invited to act: the turn acts with the session's tools under the
|
|
178
187
|
owner's guardrails, its asks go to the owner and offer once only, and `Guardrails::PairBoundary` denies secrets,
|
|
179
|
-
chi itself
|
|
180
|
-
|
|
188
|
+
chi itself and chi web on loopback, background and scheduled jobs, the environment and credential printers, git's
|
|
189
|
+
hooks, owner-voiced tools and the owner's task outputs, and the model's own question, and asks before a command
|
|
190
|
+
that makes a link and before a write that may land outside the project, failing closed (a session at or above
|
|
191
|
+
home has none: every write asks) ([guardrails](../guardrails.md#a-participants-pair-boundary)). No Apply: it
|
|
192
|
+
acted itself.
|
|
181
193
|
|
|
182
194
|
`ParticipantMode.of(session, features:)` is the session's: what a participant invited now gets. No session is
|
|
183
195
|
`:review`. A worker names `pair_turns` (`Delivery::PAIR_FEATURE`) when it runs pair turns; one without it runs
|
|
@@ -230,6 +242,15 @@ worker checks their link (`participants.json`: revoked, expired) and their cap a
|
|
|
230
242
|
dequeued (`:turn_dequeued`) and audited (`revoked`, `cap_reached`) instead of run, so a revoke also stops what they
|
|
231
243
|
had queued; its settled record (`TurnCheckpoint.record_refused`: canceled, `cancel_reason` the reason) ends their
|
|
232
244
|
wait on it at once, which `ParentReport` reads as status `refused`, exit 1.
|
|
245
|
+
|
|
246
|
+
**The Verify gate.** A worker names `verify` (`VerifyAnchor::FEATURE`, in the Bridge's `FEATURES`) when it understands a
|
|
247
|
+
Verify turn's anchor (`VerifyAnchor`, `{turn_id, quote}`: a UUID and a quote of 1 to 200 characters). A turn carrying an
|
|
248
|
+
anchor, or a Verify fresh check, sent to a connected worker without it is refused 409 `verify_unavailable`
|
|
249
|
+
(`VerifyAnchor::UNAVAILABLE`: "restart this session to use Verify"; `SessionManager` checks `worker_has?` before it
|
|
250
|
+
delivers, on the features of the sidecar its client was made from), since an older worker would run it as a plain prompt. A session with no connected worker is not gated: the
|
|
251
|
+
worker that starts for it is current. The participant page's session summary carries `verify_unavailable`
|
|
252
|
+
(`VerifyAnchor.worker_unavailable?`, from the worker's features; `participant_review.rb`), and the page disables
|
|
253
|
+
Verify and Verify fresh with a "restart this session" hint.
|
|
233
254
|
**The owner's input during a review turn queues.** While a participant's turn runs, the owner's input never merges
|
|
234
255
|
into it, from any client (the web, an attached TUI, `chi send` without `--queue`): the Bridge's `POST /turn` makes it
|
|
235
256
|
`delivery: queue` whatever it asked, so it never steers or cuts (`TurnAccumulator#participant_turn?`, under the event
|
|
@@ -264,7 +285,9 @@ queued, as `--turn T` does.
|
|
|
264
285
|
**The tool calls.** In a review session `Guardrails::ReviewBoundary` denies a participant's turn everything but
|
|
265
286
|
reads of the project ([guardrails](../guardrails.md#a-participants-review-boundary)). In a working session
|
|
266
287
|
`Guardrails::PairBoundary` takes its seat: the owner's checks and rules decide the rest, as for the owner's turns
|
|
267
|
-
([guardrails](../guardrails.md#a-participants-pair-boundary)).
|
|
288
|
+
([guardrails](../guardrails.md#a-participants-pair-boundary)). Both judge a path every way it resolves
|
|
289
|
+
(`PathResolve.reals`: a `..` after a symlinked folder climbs out of its target, as the read opens it;
|
|
290
|
+
[guardrails](../guardrails.md#how-paths-resolve)). Approvals and questions are owner-only: either
|
|
268
291
|
boundary denies the model's own question, and `/answer` is an owner route. A pair turn's ask reaches the owner's card
|
|
269
292
|
naming who asks (`approval.author`, `author_name`; `Guardrails::Approval::PARTICIPANT_HEADER`) with Allow once as its
|
|
270
293
|
only scope, and System 1 never judges it (`GuardrailWiring#system_one_judge`). A grant without `--identity` (a LAN
|
|
@@ -195,10 +195,11 @@ with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendComma
|
|
|
195
195
|
|
|
196
196
|
## Trust model
|
|
197
197
|
|
|
198
|
-
- **The token is the owner.** In LAN mode (`web.host: lan`) every
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
(`Remotes::Entry#token` is nil, and no header is
|
|
198
|
+
- **The token is the owner.** In LAN mode (`web.host: lan`) every request needs the `Web::Token` access token,
|
|
199
|
+
loopback too (but chi's token-less `/api/info` probe, `ProxyGate#probe_info?`), as the `chi_token` cookie or
|
|
200
|
+
`Authorization: Bearer` (`App#token_gate`). This runs over plain http, which is fine on a home LAN, the same as the
|
|
201
|
+
phone page. A loopback chi web without LAN mode needs no token (`Remotes::Entry#token` is nil, and no header is
|
|
202
|
+
sent); `chi remote add` with no token refuses one that answers only the probe (no `cwd`: `RemoteCommand#check`). Behind an auth proxy (`web.public_url`, experimental) every
|
|
202
203
|
request needs it, loopback too, and a proxied owner also the configured identity header
|
|
203
204
|
([configuration.md: Behind an auth proxy](../configuration.md#behind-an-auth-proxy), `Web::ProxyGate`); `chi
|
|
204
205
|
--remote` through such a proxy isn't supported yet (the CLI can't pass its sign-in, and streams need TLS).
|
data/docs/internals/web-push.md
CHANGED
|
@@ -202,7 +202,10 @@ yet). Results: `:ok` stamps the device's
|
|
|
202
202
|
`last_ok_at`; `:gone` removes it; `:failed` keeps its code in `last_error` and logs `push_failed`; `:bad_jwt` logs
|
|
203
203
|
one `push_bad_jwt` warning (on chi web's terminal too) and stops all pushes until chi web restarts. Log lines carry
|
|
204
204
|
`device:` (the short id), `code:`, `ms:`, `kind:`: never the endpoint, keys or text. `#stop` drops pending holds and
|
|
205
|
-
joins with a timeout; `Server.start`'s ensure calls it.
|
|
205
|
+
joins with a timeout; `Server.start`'s ensure calls it. chi web's own restart (`restart_web`, `Server.reexec`) execs
|
|
206
|
+
without either: the shows still in their hold and the retries waiting in `@pending` / `@retries` go with the process,
|
|
207
|
+
unsent and not in `sent.json`. The exec'd chi web's seed brings back only what restart catch-up covers (a question
|
|
208
|
+
still open); a closed-meanwhile or non-question show held then is not pushed. Specs drive `#pump` with a moved clock
|
|
206
209
|
(`start(hub, sender: false)`), and one example runs the real thread with a client that blocks.
|
|
207
210
|
|
|
208
211
|
## The routes (`web/push_routes.rb`)
|
data/docs/sessions.md
CHANGED
|
@@ -6,7 +6,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
|
|
|
6
6
|
|
|
7
7
|
**Verify verdicts:** a web Verify turn stamps its opening user message with `verify: {turn_id, quote}` (the selected source turn and quote; metadata only, never added to the prompt). When that turn completes and the final assistant reply's last non-empty line is a valid `Verdict: confirmed|refuted|unclear — <reason>`, the session file appends `{anchor: {turn_id, quote}, verdict, reason, by, check_turn_id, at}` to its optional `verdicts` array. `by` is the turn's author (nil for the owner), `check_turn_id` names the Verify turn, and `at` is its timestamp. The array is append-only; older files without it have no saved verdicts. An `unclear` verdict can be checked again by **Verify fresh** (a delegate child that gets only the claim; see *Delegating*): its verdict is appended to the parent's `verdicts` with `fresh: true`, `of` (the verdict it checked again) and `child_id`, from the parent's `fresh_verdicts/` dir, which its worker takes between turns. Verdicts are display state: none enters a prompt.
|
|
8
8
|
|
|
9
|
-
**Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
|
|
9
|
+
**Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). Such a file, with a prompt, from the user, `chi send` or a delegating parent, also ends a running `task_wait` (`SessionInbox.steer_waiting?`, a peek the worker's turn polls through `Engine#input_waiting?`; never in a participant's turn): the file stays for the drain. A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
|
|
10
10
|
|
|
11
11
|
**Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
|
|
12
12
|
|
|
@@ -21,7 +21,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
|
|
|
21
21
|
|
|
22
22
|
A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
|
|
23
23
|
|
|
24
|
-
**Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`SessionManager.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken, a later exit
|
|
24
|
+
**Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`SessionManager.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken (claimed before a stop-all stops anything), the first one decides how the worker leaves: a later request of the same kind is answered as that exit, one of another kind (a stop-all from another terminal after a restart, chi web's "Restart workers" after a `/exit`) gets 409 `held` with `reason` `leaving_exit`, `leaving_delete` or `leaving_restart`, and a restart says it was refused rather than "restarted". A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
|
|
25
25
|
|
|
26
26
|
**Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
|
|
27
27
|
|
|
@@ -88,7 +88,8 @@ module Samagotchi
|
|
|
88
88
|
def self.for_sidecar(session_id, sidecar, host: HOST, read_timeout: READ_TIMEOUT)
|
|
89
89
|
return nil unless sidecar
|
|
90
90
|
|
|
91
|
-
new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout
|
|
91
|
+
new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout,
|
|
92
|
+
features: sidecar.features)
|
|
92
93
|
end
|
|
93
94
|
|
|
94
95
|
# How often a wait for a new worker's Bridge looks: a missing sidecar is
|
|
@@ -111,6 +112,9 @@ module Samagotchi
|
|
|
111
112
|
end
|
|
112
113
|
|
|
113
114
|
attr_reader :session_id, :port, :host
|
|
115
|
+
# @return [Array<String>] what the worker's sidecar named (WorkerSidecar#features)
|
|
116
|
+
# as this client was made from it; empty for one made without a sidecar
|
|
117
|
+
attr_reader :features
|
|
114
118
|
|
|
115
119
|
# Which worker this client talks to, for EventStream: a rediscovered
|
|
116
120
|
# client with another key is a new worker. A Bridge's port, and a
|
|
@@ -122,7 +126,9 @@ module Samagotchi
|
|
|
122
126
|
# with every request (nil: a worker from before secrets, which asks for
|
|
123
127
|
# none); from the worker's sidecar (.for_sidecar), never typed
|
|
124
128
|
# @param read_timeout [Numeric] see READ_TIMEOUT
|
|
125
|
-
|
|
129
|
+
# @param features [Array<String>] the sidecar's (#features)
|
|
130
|
+
def initialize(session_id:, port:, secret:, host: HOST, read_timeout: READ_TIMEOUT, features: [])
|
|
131
|
+
@features = Array(features).freeze
|
|
126
132
|
@session_id = session_id
|
|
127
133
|
@port = port
|
|
128
134
|
@secret = secret
|