samagotchi 0.129.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 +77 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +29 -11
- data/docs/configuration.md +7 -2
- data/docs/desktop.md +28 -9
- data/docs/guardrails.md +60 -16
- data/docs/internals/background-tasks.md +12 -0
- data/docs/internals/engine.md +4 -3
- data/docs/internals/participants.md +25 -6
- data/docs/internals/remote-sessions.md +5 -4
- data/docs/sessions.md +5 -3
- 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/engine.rb +20 -4
- data/lib/samagotchi/engine_peers.rb +3 -1
- data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +5 -5
- data/lib/samagotchi/guardrails/outside.rb +22 -20
- data/lib/samagotchi/guardrails/pair_boundary.rb +14 -3
- data/lib/samagotchi/guardrails/pair_command.rb +70 -10
- data/lib/samagotchi/guardrails/parent_approvals.rb +8 -11
- data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +17 -116
- data/lib/samagotchi/guardrails/review_paths.rb +13 -11
- data/lib/samagotchi/guardrails/rm_targets.rb +5 -5
- data/lib/samagotchi/guardrails/rules.rb +23 -5
- data/lib/samagotchi/guardrails/scratch_writes.rb +3 -2
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +17 -12
- data/lib/samagotchi/guardrails/shell_paths.rb +18 -29
- data/lib/samagotchi/guardrails/targets.rb +24 -13
- 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.rb +49 -4
- data/lib/samagotchi/session_inbox.rb +17 -0
- data/lib/samagotchi/system_one/self_protect.rb +34 -19
- 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/execute.rb +5 -2
- data/lib/samagotchi/tools/peers.rb +4 -0
- data/lib/samagotchi/tools/task_runtime.rb +4 -2
- data/lib/samagotchi/tools/task_wait.rb +26 -1
- data/lib/samagotchi/turn_state.rb +23 -4
- data/lib/samagotchi/update_command.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +69 -25
- data/lib/samagotchi/web/participant_review.rb +9 -2
- data/lib/samagotchi/web/proxy_gate.rb +14 -6
- data/lib/samagotchi/web/server.rb +29 -4
- data/lib/samagotchi/web/token.rb +20 -1
- data/lib/samagotchi/worker.rb +16 -1
- metadata +3 -1
data/docs/sessions.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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
|
|
|
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
|
|
|
@@ -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")
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -664,6 +664,19 @@ module Samagotchi
|
|
|
664
664
|
@turn_state.controller
|
|
665
665
|
end
|
|
666
666
|
|
|
667
|
+
# Whether a message a person sent waits for the running turn's next
|
|
668
|
+
# boundary (run_turn's input_waiting: probe): a task_wait returns on it
|
|
669
|
+
# (PeerView#input_waiting?). False outside a turn, for a turn given no
|
|
670
|
+
# probe, and when the probe raises (warned once a turn; the tool never
|
|
671
|
+
# fails for it).
|
|
672
|
+
def input_waiting?
|
|
673
|
+
probe = @turn_state.input_waiting
|
|
674
|
+
!!probe&.call
|
|
675
|
+
rescue StandardError => e
|
|
676
|
+
Log.warn(:turn, "input_waiting_failed", error: e.class.name, msg: e.message) if @turn_state.input_waiting_failed!
|
|
677
|
+
false
|
|
678
|
+
end
|
|
679
|
+
|
|
667
680
|
# Cancel the currently running turn, if any.
|
|
668
681
|
# @param reason [Symbol] cancellation reason
|
|
669
682
|
# @return [Boolean] whether a cancellation was triggered
|
|
@@ -2132,6 +2145,9 @@ module Samagotchi
|
|
|
2132
2145
|
# @param pending_input [#call, nil] optional drain proc returning
|
|
2133
2146
|
# Array<String> of steering messages queued while the turn runs; drained
|
|
2134
2147
|
# by the agentic loop at iteration boundaries (see KernelLoop#run).
|
|
2148
|
+
# @param input_waiting [#call, nil] optional probe: true while a message
|
|
2149
|
+
# a person sent waits for the next boundary (it peeks, never drains);
|
|
2150
|
+
# a running task_wait returns on it (#input_waiting?). nil: never.
|
|
2135
2151
|
# @param continue [Boolean] resume the conversation without appending a
|
|
2136
2152
|
# user prompt (continue after the iteration limit, reminder turns);
|
|
2137
2153
|
# +prompt+ is ignored and :turn_started carries `continue: true`
|
|
@@ -2170,9 +2186,9 @@ module Samagotchi
|
|
|
2170
2186
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
2171
2187
|
# one-line summary:.
|
|
2172
2188
|
def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
|
|
2173
|
-
origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil)
|
|
2189
|
+
origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil, input_waiting: nil)
|
|
2174
2190
|
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
2175
|
-
continue: continue, id: id, verify: verify)
|
|
2191
|
+
continue: continue, id: id, verify: verify, input_waiting: input_waiting)
|
|
2176
2192
|
if continue
|
|
2177
2193
|
turn.resumed = resumed
|
|
2178
2194
|
turn.resume_from = resume_from
|
|
@@ -2220,7 +2236,7 @@ module Samagotchi
|
|
|
2220
2236
|
# The turn's state before anything can fail it: the session running,
|
|
2221
2237
|
# the turn flag, its cancel controller and sink. The cross-thread state
|
|
2222
2238
|
# is set here and cleared in #release_turn only.
|
|
2223
|
-
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil)
|
|
2239
|
+
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil, input_waiting: nil)
|
|
2224
2240
|
origin = TurnOrigin.of(origin)
|
|
2225
2241
|
# A participant's Verify fresh child: every turn is theirs (their
|
|
2226
2242
|
# author, boundary and cap), whoever started it.
|
|
@@ -2240,7 +2256,7 @@ module Samagotchi
|
|
|
2240
2256
|
# invalidated recap) while the model is working. A hook's notice goes
|
|
2241
2257
|
# where the turn's events go (the REPL renders only its sink; a
|
|
2242
2258
|
# worker's observers carry it to the bridge).
|
|
2243
|
-
@turn_state.begin!(controller: effective_controller, sink: on_event)
|
|
2259
|
+
@turn_state.begin!(controller: effective_controller, sink: on_event, input_waiting: input_waiting)
|
|
2244
2260
|
# Drop a recap already in flight: the turn makes it stale.
|
|
2245
2261
|
@recap&.invalidate!
|
|
2246
2262
|
# The gate's context: who queued this turn, and git asked afresh.
|
|
@@ -8,7 +8,8 @@ module Samagotchi
|
|
|
8
8
|
# The kernel's Tools::Peers, following the current session. cancelled?
|
|
9
9
|
# is the running turn's cancel, for a tool that waits (delegate_result):
|
|
10
10
|
# the controller is set from another thread and a tool gets no other
|
|
11
|
-
# way to see it.
|
|
11
|
+
# way to see it. input_waiting? is whether a person's message waits for
|
|
12
|
+
# the running turn's next boundary (task_wait returns on it).
|
|
12
13
|
PeerView = Struct.new(:engine) do
|
|
13
14
|
def session_id = engine.session&.id
|
|
14
15
|
def cwd = engine.session&.working_directory
|
|
@@ -16,6 +17,7 @@ module Samagotchi
|
|
|
16
17
|
def state_dir = engine.peer_state_dir
|
|
17
18
|
def cancelled? = !!engine.active_cancel_controller&.cancelled?
|
|
18
19
|
def cancel_reason = engine.active_cancel_controller&.reason
|
|
20
|
+
def input_waiting? = engine.input_waiting?
|
|
19
21
|
def relay = engine.relay_peer
|
|
20
22
|
def model_ref = engine.effective_model_ref
|
|
21
23
|
def thinking = engine.thinking_override
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../paths"
|
|
5
|
+
require_relative "../memory_paths"
|
|
6
|
+
require_relative "../hooks/loader"
|
|
7
|
+
require_relative "path_resolve"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Guardrails
|
|
11
|
+
# chi's own dirs, the sets the guardrails judge a path or a command's
|
|
12
|
+
# words against, as this process's environment and config.yml name them
|
|
13
|
+
# (read each call). Each surface keeps its set:
|
|
14
|
+
# .protected the config dir, the hooks dir, the approval store's
|
|
15
|
+
# dir, the installed bundles' dir, attached context: an
|
|
16
|
+
# ask only the user may answer (ParentApprovals.chi_files?)
|
|
17
|
+
# .touches .protected and the session repo's git hooks dir: a
|
|
18
|
+
# rule's touches: chi_dirs (Targets#touches_chi?)
|
|
19
|
+
# .self_protect .protected and the whole state dir, resolved: what
|
|
20
|
+
# System 1 never answers for (SelfProtect)
|
|
21
|
+
# .review the state dir, the config dir and a hooks dir
|
|
22
|
+
# configured outside it: what a participant's turn never
|
|
23
|
+
# reads (ReviewPaths)
|
|
24
|
+
# ProtectedPaths' deny/ask lists and ScratchWrites' memories dir are
|
|
25
|
+
# built by the wiring from the same places.
|
|
26
|
+
module ChiDirs
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# chi's config dir, the hooks dir, the approval store's dir, the
|
|
30
|
+
# installed bundles' dir and attached context.
|
|
31
|
+
def protected
|
|
32
|
+
hooks = begin Hooks::Loader.hooks_dir rescue nil end
|
|
33
|
+
bundles = begin MemoryPaths.bundles_dir rescue nil end
|
|
34
|
+
[ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles, File.join(Paths.state_dir, "context")]
|
|
35
|
+
.compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# .protected (as ParentApprovals.chi_dirs, the name its callers and
|
|
39
|
+
# specs know) and the git hooks dir of +base+'s repo (+context+).
|
|
40
|
+
def touches(context, base)
|
|
41
|
+
require_relative "parent_approvals"
|
|
42
|
+
ParentApprovals.chi_dirs + [context.hooks_dir(base)].compact
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# .protected (as ParentApprovals.chi_dirs) and the whole state dir,
|
|
46
|
+
# resolved.
|
|
47
|
+
def self_protect
|
|
48
|
+
require_relative "parent_approvals"
|
|
49
|
+
[*ParentApprovals.chi_dirs, Paths.state_dir]
|
|
50
|
+
.map { |dir| PathResolve.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# chi's state dir and config dir, as Paths has them now, and the hooks
|
|
54
|
+
# dir when config.yml puts it outside the config dir (hooks run as the
|
|
55
|
+
# owner: a participant reads none of them).
|
|
56
|
+
def review
|
|
57
|
+
config = File.join(Paths.config_home, Paths::APP_DIR)
|
|
58
|
+
hooks = begin Hooks::Loader.hooks_dir rescue nil end
|
|
59
|
+
hooks = nil if hooks.to_s.empty? || PathResolve.reals(hooks).any? { |real| PathResolve.within?(real, config) }
|
|
60
|
+
[Paths.state_dir, config, hooks].compact
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "outside"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "path_resolve"
|
|
5
5
|
require_relative "shell_lex"
|
|
6
6
|
require_relative "shell_paths"
|
|
7
7
|
require_relative "shared_git"
|
|
@@ -88,8 +88,8 @@ module Samagotchi
|
|
|
88
88
|
# repository (and not into root, which may sit inside the main one),
|
|
89
89
|
# under chi's env or the call's env: (Targets#shell_envs).
|
|
90
90
|
def names_other_checkout?(targets, root)
|
|
91
|
-
real_root =
|
|
92
|
-
others = [*@worktrees.call, @common_dir.call].compact.map { |dir|
|
|
91
|
+
real_root = PathResolve.real(root)
|
|
92
|
+
others = [*@worktrees.call, @common_dir.call].compact.map { |dir| PathResolve.real(dir) } - [real_root]
|
|
93
93
|
return false if others.empty?
|
|
94
94
|
|
|
95
95
|
commands = ShellLex.simple_commands(ShellLex.lex(targets.command.to_s))
|
|
@@ -99,8 +99,8 @@ module Samagotchi
|
|
|
99
99
|
walk.step(words).any? do |_word, path|
|
|
100
100
|
next false unless path
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
!
|
|
102
|
+
PathResolve.reals(path).any? do |real|
|
|
103
|
+
!PathResolve.under?(real, real_root) && others.any? { |dir| PathResolve.under?(real, dir) }
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
end
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
|
-
|
|
5
|
-
require_relative "protected_paths"
|
|
4
|
+
require_relative "path_resolve"
|
|
6
5
|
require_relative "../memory_bundle/index_sync"
|
|
7
6
|
|
|
8
7
|
module Samagotchi
|
|
@@ -20,17 +19,18 @@ module Samagotchi
|
|
|
20
19
|
# A path whose landing place resolving can't tell (.resolve: a dangling
|
|
21
20
|
# symlink, a file with another hard link) is always outside.
|
|
22
21
|
module Outside
|
|
23
|
-
TMP_DIRS =
|
|
22
|
+
TMP_DIRS = PathResolve::TMP_DIRS
|
|
24
23
|
|
|
25
24
|
# @param path [String] absolute
|
|
26
25
|
# @param root [String] the session's repo root (or cwd)
|
|
27
26
|
def self.outside?(path, root:)
|
|
28
27
|
reals = resolve(path) or return true
|
|
29
|
-
session =
|
|
28
|
+
session = PathResolve.real(root)
|
|
29
|
+
tmp = nil
|
|
30
30
|
reals.any? do |real|
|
|
31
31
|
next false if within?(real, session)
|
|
32
32
|
next false if MemoryBundle::IndexSync.memory_scope(path)
|
|
33
|
-
next false if
|
|
33
|
+
next false if (tmp ||= session_tmp_roots(session)).any? { |dir| within?(real, dir) }
|
|
34
34
|
|
|
35
35
|
true
|
|
36
36
|
end
|
|
@@ -44,7 +44,7 @@ module Samagotchi
|
|
|
44
44
|
# @param root [String] the session's project root
|
|
45
45
|
def self.inside?(path, root:)
|
|
46
46
|
reals = resolve(path) or return false
|
|
47
|
-
session =
|
|
47
|
+
session = PathResolve.real(root)
|
|
48
48
|
reals.all? { |real| within?(real, session) }
|
|
49
49
|
end
|
|
50
50
|
|
|
@@ -55,15 +55,16 @@ module Samagotchi
|
|
|
55
55
|
# @param root [String] the session's repo root (or cwd)
|
|
56
56
|
def self.tmp_write?(path, root:)
|
|
57
57
|
reals = resolve(path) or return false
|
|
58
|
-
session =
|
|
59
|
-
|
|
58
|
+
session = PathResolve.real(root)
|
|
59
|
+
tmp = session_tmp_roots(session)
|
|
60
|
+
reals.all? { |real| tmp.any? { |dir| within?(real, dir) } }
|
|
60
61
|
end
|
|
61
62
|
|
|
62
|
-
# +path+ resolved (
|
|
63
|
+
# +path+ resolved (PathResolve.reals: the lexical answer and where
|
|
63
64
|
# the kernel lands, a `..` after a symlinked dir climbing out of its
|
|
64
65
|
# target), or nil when that doesn't say where a write lands: the path
|
|
65
66
|
# is or goes through a dangling symlink (a write would create its
|
|
66
|
-
# target;
|
|
67
|
+
# target; PathResolve.real follows it, the lexical answer stops at
|
|
67
68
|
# it, and either way the link's own place doesn't count as where the
|
|
68
69
|
# file is), or is a file with another hard link (a write would change
|
|
69
70
|
# that file too). Both outside? and tmp_write? read it, so neither
|
|
@@ -71,7 +72,7 @@ module Samagotchi
|
|
|
71
72
|
# @param path [String] absolute
|
|
72
73
|
# @return [Array<String>, nil]
|
|
73
74
|
def self.resolve(path)
|
|
74
|
-
reals =
|
|
75
|
+
reals = PathResolve.reals(path)
|
|
75
76
|
return nil if reals.any? { |real| Pathname.new(real).ascend.any?(&:symlink?) }
|
|
76
77
|
return nil if reals.any? { |real| File.file?(real) && File.stat(real).nlink > 1 }
|
|
77
78
|
|
|
@@ -80,16 +81,17 @@ module Samagotchi
|
|
|
80
81
|
nil
|
|
81
82
|
end
|
|
82
83
|
|
|
83
|
-
# The tmp dirs that exist, resolved.
|
|
84
|
-
def self.tmp_roots
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
nil
|
|
89
|
-
end.uniq
|
|
90
|
-
end
|
|
84
|
+
# The tmp dirs that exist, resolved (PathResolve.tmp_roots).
|
|
85
|
+
def self.tmp_roots = PathResolve.tmp_roots
|
|
86
|
+
|
|
87
|
+
# The tmp dirs +session+ (resolved) isn't in (PathResolve.tmp_roots).
|
|
88
|
+
def self.session_tmp_roots(session) = PathResolve.tmp_roots(session: session, roots: tmp_roots)
|
|
91
89
|
|
|
92
|
-
|
|
90
|
+
# Whether +path+ is +root+ or in it (PathResolve.under?): cased alike
|
|
91
|
+
# on a disk that folds case when +root+ exists (resolved, it has its
|
|
92
|
+
# on-disk case), by their exact text when it doesn't (a missing root
|
|
93
|
+
# keeps the case it was typed in).
|
|
94
|
+
def self.within?(path, root) = PathResolve.under?(path, root, fold: File.exist?(root))
|
|
93
95
|
end
|
|
94
96
|
end
|
|
95
97
|
end
|
|
@@ -25,7 +25,9 @@ module Samagotchi
|
|
|
25
25
|
# read or written (read, write, edit, a plugin's targets: paths), and
|
|
26
26
|
# on what a command does (execute, and any call whose targets carry
|
|
27
27
|
# a command: PairCommand, which also refuses chi and chi web on a
|
|
28
|
-
# loopback name,
|
|
28
|
+
# loopback name, URL openers (open, xdg-open, a browser) and
|
|
29
|
+
# listeners (nc -l, socat …LISTEN, a static HTTP server), background
|
|
30
|
+
# and scheduled jobs, the environment and
|
|
29
31
|
# credential printers, git settings that run commands, home sweeps
|
|
30
32
|
# and links to them, and a program named by a $var or a glob); a
|
|
31
33
|
# write or edit of git's hooks (GIT_HOOKS);
|
|
@@ -78,8 +80,17 @@ module Samagotchi
|
|
|
78
80
|
GIT_CONFIG = "a participant's turn can't change git's global config or set what makes git run a command " \
|
|
79
81
|
"(core.hooksPath, core.fsmonitor, core.sshCommand, core.pager, core.editor, ! aliases)"
|
|
80
82
|
GIT_HOOKS = "a participant's turn can't write git's hooks (.git/hooks): git runs them in the owner's name"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
# chi web on loopback asks the owner's token too (Web::App#token_gate,
|
|
84
|
+
# LAN mode); this text check stays as defense in depth: a loopback-only
|
|
85
|
+
# chi web still answers loopback as the owner.
|
|
86
|
+
CHI_WEB = "a participant's turn can't call chi web on this machine: it is the owner's (sessions, shares, answers)"
|
|
87
|
+
# A page opened in the owner's browser carries the owner's cookies
|
|
88
|
+
# (chi web's on 127.0.0.1 or the LAN address among them) to whatever
|
|
89
|
+
# it points at; a port listening here could catch them.
|
|
90
|
+
OPENER = "a participant's turn can't open a URL or a file in the owner's apps: a browser sends that page the " \
|
|
91
|
+
"owner's cookies"
|
|
92
|
+
LISTENER = "a participant's turn can't listen on a port: what it serves on this machine could catch the " \
|
|
93
|
+
"owner's browser cookies"
|
|
83
94
|
VERB = "a participant's turn runs only a program named plainly: no $, $(…) or glob in a command's name"
|
|
84
95
|
UNREADABLE = "a participant's turn runs only commands chi can read: split this command, or write the file " \
|
|
85
96
|
"with the write tool"
|