samagotchi 0.133.0 → 0.134.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 +68 -1
- data/docs/cli.md +11 -0
- data/docs/configuration.md +8 -4
- data/docs/guardrails.md +102 -13
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/participants.md +6 -5
- data/docs/internals/tool-guardrails.md +2 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -2
- data/docs/testing.md +7 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_home.rb +12 -2
- data/lib/samagotchi/desktop/macos.rb +19 -4
- data/lib/samagotchi/engine.rb +13 -0
- data/lib/samagotchi/guardrails/implicit_writers.rb +143 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +6 -4
- data/lib/samagotchi/guardrails/pair_command.rb +493 -60
- data/lib/samagotchi/guardrails/parent_approvals.rb +1 -1
- data/lib/samagotchi/guardrails/read_only_shell.rb +34 -2
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +6 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +23 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +8 -3
- data/lib/samagotchi/home_command.rb +22 -6
- data/lib/samagotchi/home_doctor.rb +36 -3
- data/lib/samagotchi/home_notice.rb +8 -1
- data/lib/samagotchi/hooks/loader.rb +5 -0
- data/lib/samagotchi/log_line.rb +12 -0
- data/lib/samagotchi/project_env.rb +3 -11
- data/lib/samagotchi/self_report.rb +3 -1
- data/lib/samagotchi/system_one/self_protect.rb +58 -2
- data/lib/samagotchi/testing/waiting.rb +13 -0
- data/lib/samagotchi/toolkit_binding.rb +16 -0
- data/lib/samagotchi/update_command.rb +7 -3
- data/lib/samagotchi/update_hint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +262 -0
- data/lib/samagotchi/web/public/answer_refs.js +2 -0
- data/lib/samagotchi/web/public/app.js +18 -1
- data/lib/samagotchi/web/public/cards_place.js +5 -2
- data/lib/samagotchi/web/public/chip_popover.js +73 -11
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/composer.css +3 -1
- data/lib/samagotchi/web/public/history_reveal.js +23 -0
- data/lib/samagotchi/web/public/index.html +44 -28
- data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
- data/lib/samagotchi/web/public/refs.js +6 -7
- data/lib/samagotchi/web/public/review_start.js +1 -1
- data/lib/samagotchi/web/public/start_page.js +4 -4
- data/lib/samagotchi/web/public/start_settings.js +29 -18
- data/lib/samagotchi/web/public/start_toolkit.js +5 -4
- data/lib/samagotchi/web/public/timing.js +5 -1
- data/lib/samagotchi/web/public/turn_html.js +12 -6
- data/lib/samagotchi/web/public/turn_range.js +32 -0
- data/lib/samagotchi/web/public/turn_view.js +21 -11
- data/lib/samagotchi/web/public/verdict_card.js +5 -13
- data/lib/samagotchi/web/public/verify_target.js +7 -10
- metadata +5 -1
|
@@ -42,7 +42,7 @@ module Samagotchi
|
|
|
42
42
|
MEMORY_REMOVE_RULE = "memory-remove"
|
|
43
43
|
# The guardrails bundle's shell-touches-chi, for a word it can't
|
|
44
44
|
# resolve to a path (Guardrails::ShellPaths): CHI_TEXT and .git/hooks.
|
|
45
|
-
CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/hooks})
|
|
45
|
+
CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/(?:modules/\S+/)?hooks})
|
|
46
46
|
|
|
47
47
|
module_function
|
|
48
48
|
|
|
@@ -98,12 +98,44 @@ module Samagotchi
|
|
|
98
98
|
text = command.to_s
|
|
99
99
|
return false if text.strip.empty?
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
!commands.empty? && commands.all? { |words| simple_read_only?(words) }
|
|
101
|
+
tokens_read_only?(ShellLex.lex(text))
|
|
103
102
|
rescue ArgumentError, EncodingError
|
|
104
103
|
false
|
|
105
104
|
end
|
|
106
105
|
|
|
106
|
+
def tokens_read_only?(tokens)
|
|
107
|
+
commands = split(tokens) or return false
|
|
108
|
+
!commands.empty? && commands.all? { |words| simple_read_only?(words) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Operators that end a part of a list (.read_only_parts) outside ( … ).
|
|
112
|
+
PART_ENDS = ["&&", "||", ";", "\n"].freeze
|
|
113
|
+
|
|
114
|
+
# Per simple command of +tokens+ (ShellLex.lex's; the word lists of
|
|
115
|
+
# ShellLex.simple_commands, in order), whether the part of the list it
|
|
116
|
+
# is in only reads: the list split at ;, &&, || and newlines outside
|
|
117
|
+
# ( … ). A pipe or a group keeps its commands in one part, so
|
|
118
|
+
# `find . -name x | xargs rm` reads as one part that writes.
|
|
119
|
+
# @return [Array<Boolean>]
|
|
120
|
+
def read_only_parts(tokens)
|
|
121
|
+
parts = [[]]
|
|
122
|
+
depth = 0
|
|
123
|
+
tokens.each do |token|
|
|
124
|
+
kind, value = token
|
|
125
|
+
depth += 1 if kind == :op && value == "("
|
|
126
|
+
depth -= 1 if kind == :op && value == ")" && depth.positive?
|
|
127
|
+
next parts << [] if kind == :op && depth.zero? && PART_ENDS.include?(value)
|
|
128
|
+
|
|
129
|
+
parts.last << token
|
|
130
|
+
end
|
|
131
|
+
parts.flat_map do |part|
|
|
132
|
+
read_only = tokens_read_only?(part)
|
|
133
|
+
ShellLex.simple_commands(part).grep(Array).map { read_only }
|
|
134
|
+
end
|
|
135
|
+
rescue ArgumentError, EncodingError
|
|
136
|
+
ShellLex.simple_commands(tokens).grep(Array).map { false }
|
|
137
|
+
end
|
|
138
|
+
|
|
107
139
|
# Whether the command only reads but for writes whose resolved paths
|
|
108
140
|
# the block accepts: > and >> targets, cp's destination (and the
|
|
109
141
|
# files it makes in it) and tee's files. Paths resolve against +cwd+
|
|
@@ -28,7 +28,12 @@ module Samagotchi
|
|
|
28
28
|
STASH_READS = %w[list show].freeze
|
|
29
29
|
PREFIXES = %w[command builtin exec time nohup env ! { if then else elif do while until].freeze
|
|
30
30
|
# git global options that take the next word as their value.
|
|
31
|
-
VALUE_OPTIONS = %w[-C -c --git-dir --work-tree --namespace].freeze
|
|
31
|
+
VALUE_OPTIONS = %w[-C -c --git-dir --work-tree --namespace --attr-source --shallow-file].freeze
|
|
32
|
+
# The variables that hand git config of their own (another global or
|
|
33
|
+
# system file, settings as -c gives them) or a template (an init's
|
|
34
|
+
# hooks): what they set may run a command (Targets::SHELL_REDEFINING_ENV,
|
|
35
|
+
# PairCommand's refusal). GIT_CONFIG_NOSYSTEM only turns one off.
|
|
36
|
+
CONFIG_ENV = /\A(?:GIT_CONFIG(?:_GLOBAL|_SYSTEM|_PARAMETERS|_COUNT|_KEY_\d+|_VALUE_\d+)?|GIT_TEMPLATE_DIR)\z/
|
|
32
37
|
|
|
33
38
|
# @param command [String] the shell command
|
|
34
39
|
# @param cwd [String] where it starts (the call's cwd:, else the session's)
|
|
@@ -63,6 +63,29 @@ module Samagotchi
|
|
|
63
63
|
commands.reject { |c| c.is_a?(Array) && c.empty? }
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# For each word list .simple_commands gives (its :open / :close
|
|
67
|
+
# markers left out), whether it reads a pipe: a | or |& comes before
|
|
68
|
+
# it, a ( between them included (`x | (sh)`).
|
|
69
|
+
# @return [Array<Boolean>]
|
|
70
|
+
def self.piped_flags(tokens)
|
|
71
|
+
flags = []
|
|
72
|
+
words = 0
|
|
73
|
+
pending = false
|
|
74
|
+
piped = false
|
|
75
|
+
tokens.each do |kind, value|
|
|
76
|
+
if kind == :op
|
|
77
|
+
flags << piped if words.positive?
|
|
78
|
+
words = 0
|
|
79
|
+
pending = %w[| |&].include?(value) || (value == "(" && pending)
|
|
80
|
+
else
|
|
81
|
+
piped = pending if words.zero?
|
|
82
|
+
words += 1
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
flags << piped if words.positive?
|
|
86
|
+
flags
|
|
87
|
+
end
|
|
88
|
+
|
|
66
89
|
# Whether an ( or { group is still open at token +at+: a pipe after
|
|
67
90
|
# the group's ) or } takes what the group prints. The counters never go
|
|
68
91
|
# below 0 (a case pattern's "x)" closes nothing); ((…)) arithmetic and
|
|
@@ -11,7 +11,8 @@ module Samagotchi
|
|
|
11
11
|
# command is in by then (cd, pushd/popd and ( … ) followed as in
|
|
12
12
|
# ShellGitDirs), with ~, $HOME and the XDG dirs expanded and symlinks
|
|
13
13
|
# resolved. A word names a dir when it is in it. Any path with a
|
|
14
|
-
# .git/hooks part
|
|
14
|
+
# .git/hooks part (a submodule's .git/modules/<name>/hooks too) counts
|
|
15
|
+
# too, outside the tmp dirs.
|
|
15
16
|
#
|
|
16
17
|
# A word it can't resolve (another $VAR, $(…), a glob, a relative path
|
|
17
18
|
# after cd $X) or one that reads as a script (sh -c '…', ruby -e '…':
|
|
@@ -23,7 +24,7 @@ module Samagotchi
|
|
|
23
24
|
# A word that is a script rather than a path.
|
|
24
25
|
SCRIPT = /[\s;&|<>()'"`]/
|
|
25
26
|
GLOB = /[*?\[\]{}]/
|
|
26
|
-
HOOKS_PART = %r{(?:\A|/)\.git/hooks(?:/|\z)}
|
|
27
|
+
HOOKS_PART = %r{(?:\A|/)\.git/(?:modules/.+/)?hooks(?:/|\z)}
|
|
27
28
|
# $HOME, ${HOME} and the XDG dirs at a word's start (PathResolve's).
|
|
28
29
|
EXPANSION = PathResolve::EXPANSION
|
|
29
30
|
# Where XDG dirs default when unset.
|
|
@@ -46,7 +46,8 @@ module Samagotchi
|
|
|
46
46
|
# goes, under every shell chi may use or in the programs it runs: a
|
|
47
47
|
# startup file (BASH_ENV, ENV), an exported function (BASH_FUNC_…), a
|
|
48
48
|
# cd search path, word splitting, shell options, a library loaded into
|
|
49
|
-
# every program, git config or a diff program that runs
|
|
49
|
+
# every program, git config or a diff program that runs (and the rest
|
|
50
|
+
# of git's config variables: ShellGitDirs::CONFIG_ENV). A call whose
|
|
50
51
|
# env: sets one is never read-only (.redefines_shell?). PATH is not
|
|
51
52
|
# one (the tool's own description suggests it); nor are
|
|
52
53
|
# PROMPT_COMMAND (interactive shells only) and ZDOTDIR (zsh in sh
|
|
@@ -250,9 +251,13 @@ module Samagotchi
|
|
|
250
251
|
{}
|
|
251
252
|
end
|
|
252
253
|
|
|
253
|
-
# Whether +env+ sets a SHELL_REDEFINING_ENV variable
|
|
254
|
+
# Whether +env+ sets a SHELL_REDEFINING_ENV variable (or a git config
|
|
255
|
+
# one: ShellGitDirs::CONFIG_ENV).
|
|
254
256
|
def self.redefines_shell?(env)
|
|
255
|
-
env.each_key.any?
|
|
257
|
+
env.each_key.any? do |key|
|
|
258
|
+
SHELL_REDEFINING_ENV.include?(key) || key.start_with?(SHELL_REDEFINING_PREFIX) ||
|
|
259
|
+
key.match?(ShellGitDirs::CONFIG_ENV)
|
|
260
|
+
end
|
|
256
261
|
end
|
|
257
262
|
|
|
258
263
|
# +path+ (a file tool's, as Tools::ToolPath has it) absolute against
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "rbconfig"
|
|
4
4
|
require_relative "chi_home"
|
|
5
5
|
require_relative "home_doctor"
|
|
6
|
+
require_relative "home_notice"
|
|
6
7
|
require_relative "cli/command"
|
|
7
8
|
require_relative "cli/exit"
|
|
8
9
|
require_relative "cli/flags"
|
|
@@ -25,8 +26,9 @@ module Samagotchi
|
|
|
25
26
|
set --ruby PATH record that Ruby (its own gem dirs) as the home; it must have samagotchi
|
|
26
27
|
(gem install samagotchi with it first)
|
|
27
28
|
doctor check the home (its Ruby is there and has samagotchi) and what should
|
|
28
|
-
follow it: this chi, the
|
|
29
|
-
this project's Gemfile.lock, mise and the last
|
|
29
|
+
follow it: this chi, the last worker boot, the desktop helper, sessions,
|
|
30
|
+
the tools' chi script, this project's Gemfile.lock, mise and the last
|
|
31
|
+
worker's project env
|
|
30
32
|
Every chi launch (workers, chi web's restart and Update, the macOS helper's next
|
|
31
33
|
chi desktop install or upgrade) boots the home's Ruby with the home's gems. Saved in
|
|
32
34
|
#{Paths.tilde(ChiHome.path)}.
|
|
@@ -42,14 +44,16 @@ module Samagotchi
|
|
|
42
44
|
# @param path [String] home.json
|
|
43
45
|
# @param probe [#call] (ruby) → ChiHome::Record or nil (ChiHome.probe)
|
|
44
46
|
# @param doctor [#call] (home) → HomeDoctor
|
|
47
|
+
# @param bundler [Boolean, nil] running under Bundler (nil: found out)
|
|
45
48
|
def initialize(argv, stdout: $stdout, stderr: $stderr, path: ChiHome.path, gem_spec: InstalledGem.spec,
|
|
46
|
-
env: ENV, probe: nil, doctor: nil)
|
|
49
|
+
env: ENV, probe: nil, doctor: nil, bundler: nil)
|
|
47
50
|
@argv = argv.dup
|
|
48
51
|
@stdout = stdout
|
|
49
52
|
@stderr = stderr
|
|
50
53
|
@path = path
|
|
51
54
|
@gem_spec = gem_spec
|
|
52
55
|
@env = env
|
|
56
|
+
@bundler = bundler.nil? ? ChiHome.under_bundler?(env) : bundler
|
|
53
57
|
@probe = probe || ->(ruby) { ChiHome.probe(ruby, env: env) }
|
|
54
58
|
@doctor = doctor || lambda { |home|
|
|
55
59
|
HomeDoctor.new(home: home, env: env, gem_spec: gem_spec, helper: HomeDoctor.default_helper(env: env))
|
|
@@ -70,6 +74,9 @@ module Samagotchi
|
|
|
70
74
|
rescue ChiHome::Invalid => e
|
|
71
75
|
error_line("#{command_name}: #{e.message} (chi home set rewrites it)")
|
|
72
76
|
CLI::Exit::FAILED
|
|
77
|
+
rescue ChiHome::UnderBundler => e
|
|
78
|
+
error_line("#{command_name}: #{e.message}")
|
|
79
|
+
CLI::Exit::FAILED
|
|
73
80
|
end
|
|
74
81
|
|
|
75
82
|
private
|
|
@@ -98,12 +105,12 @@ module Samagotchi
|
|
|
98
105
|
list
|
|
99
106
|
end
|
|
100
107
|
|
|
101
|
-
def running_home?(home) = @gem_spec && home
|
|
108
|
+
def running_home?(home) = @gem_spec && HomeNotice.home?(home)
|
|
102
109
|
|
|
103
110
|
def running_ruby = "#{RbConfig.ruby}#{" (a checkout)" unless @gem_spec}"
|
|
104
111
|
|
|
105
112
|
def run_set(options)
|
|
106
|
-
home = options[:ruby] ? probed(options[:ruby]) :
|
|
113
|
+
home = options[:ruby] ? probed(options[:ruby]) : running_record
|
|
107
114
|
return CLI::Exit::FAILED unless home
|
|
108
115
|
|
|
109
116
|
if (problem = ChiHome.problem(home))
|
|
@@ -127,13 +134,22 @@ module Samagotchi
|
|
|
127
134
|
error_line("#{command_name}: no Ruby at #{ruby}")
|
|
128
135
|
return nil
|
|
129
136
|
end
|
|
130
|
-
return
|
|
137
|
+
return running_record if ChiHome.real(path) == ChiHome.real(RbConfig.ruby)
|
|
131
138
|
|
|
132
139
|
home = @probe.call(path)
|
|
133
140
|
error_line("#{command_name}: #{ruby} didn't answer as a Ruby") unless home
|
|
134
141
|
home
|
|
135
142
|
end
|
|
136
143
|
|
|
144
|
+
# This process's Ruby and gem path, never under Bundler (the bundle's
|
|
145
|
+
# gems). Another Ruby is probed with Bundler's variables unset.
|
|
146
|
+
# @raise [ChiHome::UnderBundler]
|
|
147
|
+
def running_record
|
|
148
|
+
raise ChiHome::UnderBundler if @bundler
|
|
149
|
+
|
|
150
|
+
ChiHome.from_running(env: @env)
|
|
151
|
+
end
|
|
152
|
+
|
|
137
153
|
# The home's rows and what should follow it (HomeDoctor); exit 1 when
|
|
138
154
|
# one fails.
|
|
139
155
|
def run_doctor
|
|
@@ -7,6 +7,7 @@ require_relative "config"
|
|
|
7
7
|
require_relative "desktop"
|
|
8
8
|
require_relative "home_notice"
|
|
9
9
|
require_relative "installed_gem"
|
|
10
|
+
require_relative "log_line"
|
|
10
11
|
require_relative "log_path"
|
|
11
12
|
require_relative "paths"
|
|
12
13
|
require_relative "project_env"
|
|
@@ -51,18 +52,21 @@ module Samagotchi
|
|
|
51
52
|
def rows
|
|
52
53
|
list = [home_row]
|
|
53
54
|
if @home && !ChiHome.problem(@home)
|
|
54
|
-
list.concat([running_row, helper_row, sessions_row, shim_row].compact)
|
|
55
|
+
list.concat([running_row, boot_row, helper_row, sessions_row, shim_row].compact)
|
|
55
56
|
end
|
|
56
57
|
list.concat([gemfile_row, mise_row, project_env_row].compact)
|
|
57
58
|
end
|
|
58
59
|
|
|
59
60
|
# The desktop helper chi home doctor checks: this machine's, when it
|
|
60
|
-
# is installed.
|
|
61
|
+
# is installed and its chi reads this chi's config dir (home.json's,
|
|
62
|
+
# Desktop::MacOS#reads_this_config?).
|
|
61
63
|
def self.default_helper(env: ENV)
|
|
62
64
|
return nil unless Desktop.supported?
|
|
63
65
|
|
|
64
66
|
helper = Desktop::MacOS.new(env: env, register: false)
|
|
65
|
-
File.file?(helper.launch_path)
|
|
67
|
+
return nil unless File.file?(helper.launch_path)
|
|
68
|
+
|
|
69
|
+
helper.reads_this_config? ? helper : nil
|
|
66
70
|
end
|
|
67
71
|
|
|
68
72
|
private
|
|
@@ -89,6 +93,35 @@ module Samagotchi
|
|
|
89
93
|
text && warn_row("#{text}: launches boot the home; chi home set makes this chi the home")
|
|
90
94
|
end
|
|
91
95
|
|
|
96
|
+
# The newest worker boot in the debug log since the home was set, when
|
|
97
|
+
# it is SessionManager::HOME_BOOT_FAILED's line (the home's Ruby
|
|
98
|
+
# couldn't load chi) rather than a worker's `start`. Without a log
|
|
99
|
+
# file (log.file off) there is no line to read.
|
|
100
|
+
def boot_row
|
|
101
|
+
failed = last_boot_failure
|
|
102
|
+
return nil unless failed
|
|
103
|
+
|
|
104
|
+
ruby = failed.fields["ruby"] || @home.ruby
|
|
105
|
+
fail_row("the last worker (#{failed.ts}, session #{failed.sid}) failed to boot the home's chi on #{ruby}: " \
|
|
106
|
+
"#{failed.fields["error"]}: gem install samagotchi with #{ruby} (or chi home set another Ruby), " \
|
|
107
|
+
"then chi sessions restart #{failed.sid}")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def last_boot_failure
|
|
111
|
+
return nil unless @log_path && File.file?(@log_path)
|
|
112
|
+
|
|
113
|
+
since = Time.iso8601(@home.set_at.to_s)
|
|
114
|
+
newest = nil
|
|
115
|
+
LogLine.each_tail_record(@log_path, bytes: ProjectEnv::LOG_TAIL_BYTES) do |record|
|
|
116
|
+
next unless record.tag == "worker" && %w[start boot_failed].include?(record.event)
|
|
117
|
+
|
|
118
|
+
newest = record if Time.iso8601(record.ts) >= since
|
|
119
|
+
end
|
|
120
|
+
newest if newest&.event == "boot_failed"
|
|
121
|
+
rescue SystemCallError, ArgumentError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
92
125
|
def helper_row
|
|
93
126
|
return nil unless @helper
|
|
94
127
|
|
|
@@ -48,11 +48,18 @@ module Samagotchi
|
|
|
48
48
|
nil
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Whether this chi is +home+'s: it runs on the home's Ruby and gems and
|
|
52
|
+
# is its newest chi. The one rule for "not the home's chi" (this line,
|
|
53
|
+
# chi home doctor, chi home's "running" row, chi self's home row).
|
|
54
|
+
def home?(home, ruby: RbConfig.ruby, gem_dir: Gem.dir, version: VERSION)
|
|
55
|
+
home.running_here?(ruby: ruby, gem_dir: gem_dir) && home.newest_version.to_s == version
|
|
56
|
+
end
|
|
57
|
+
|
|
51
58
|
# This chi isn't the home's: the text, also chi home doctor's row; nil
|
|
52
59
|
# when this process runs on +home+ (or there is none).
|
|
53
60
|
# @return [String, nil]
|
|
54
61
|
def mismatch(home, ruby: RbConfig.ruby, gem_dir: Gem.dir, version: VERSION)
|
|
55
|
-
return nil if home.nil? ||
|
|
62
|
+
return nil if home.nil? || home?(home, ruby: ruby, gem_dir: gem_dir, version: version)
|
|
56
63
|
|
|
57
64
|
where = ruby
|
|
58
65
|
# The same Ruby on other gems (a chruby GEM_HOME): name them both.
|
|
@@ -165,6 +165,11 @@ module Samagotchi
|
|
|
165
165
|
end
|
|
166
166
|
end
|
|
167
167
|
|
|
168
|
+
# Forgets the cached plugin instances (load_plugin builds new ones;
|
|
169
|
+
# Ruby's require cache keeps their classes). For tests: the cache is
|
|
170
|
+
# process-wide.
|
|
171
|
+
def self.reset_cache! = @plugin_cache = nil
|
|
172
|
+
|
|
168
173
|
# The plugin's #call under the entry's error policy: a required
|
|
169
174
|
# before_tool_call hook that raises denies the call; otherwise
|
|
170
175
|
# on_error: log warns and skip is silent.
|
data/lib/samagotchi/log_line.rb
CHANGED
|
@@ -104,6 +104,18 @@ module Samagotchi
|
|
|
104
104
|
line.start_with?(INDENT)
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# Yields each Record in the last +bytes+ of the log at +path+ (a torn
|
|
108
|
+
# first line skipped): chi home doctor's readers.
|
|
109
|
+
# @raise [SystemCallError]
|
|
110
|
+
def each_tail_record(path, bytes:, &)
|
|
111
|
+
File.open(path, "rb") do |io|
|
|
112
|
+
size = io.size
|
|
113
|
+
io.seek([size - bytes, 0].max)
|
|
114
|
+
io.gets if size > bytes
|
|
115
|
+
each_record(io, &)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
107
119
|
# Yields each Record of a log (payload lines joined onto theirs). Lines
|
|
108
120
|
# that are neither (an older format, a torn write) go to on_invalid.
|
|
109
121
|
def each_record(io, on_invalid: nil)
|
|
@@ -80,7 +80,8 @@ module Samagotchi
|
|
|
80
80
|
# chi home doctor's row when session.project_env is mise and no mise is found.
|
|
81
81
|
NO_MISE = "project env: no mise found (home.json's, PATH, /opt/homebrew/bin, /usr/local/bin, ~/.local/bin): " \
|
|
82
82
|
"tools run in the env of whoever starts the worker; install mise, or set session.project_env: off"
|
|
83
|
-
# How much of the debug log's end chi home doctor reads
|
|
83
|
+
# How much of the debug log's end chi home doctor reads (#last_warning,
|
|
84
|
+
# HomeDoctor's boot row).
|
|
84
85
|
LOG_TAIL_BYTES = 4 * 1024 * 1024
|
|
85
86
|
|
|
86
87
|
# A worker's `project_env` warning from the debug log (#last_warning).
|
|
@@ -327,7 +328,7 @@ module Samagotchi
|
|
|
327
328
|
|
|
328
329
|
want = Array(dirs).map { |dir| ChiHome.real(dir) }
|
|
329
330
|
newest = {}
|
|
330
|
-
each_tail_record(log_path) do |record|
|
|
331
|
+
LogLine.each_tail_record(log_path, bytes: LOG_TAIL_BYTES) do |record|
|
|
331
332
|
next unless record.tag == "worker" && %w[project_env project_env_applied].include?(record.event)
|
|
332
333
|
|
|
333
334
|
dir = record.fields["dir"]
|
|
@@ -342,15 +343,6 @@ module Samagotchi
|
|
|
342
343
|
nil
|
|
343
344
|
end
|
|
344
345
|
|
|
345
|
-
def each_tail_record(log_path, &)
|
|
346
|
-
File.open(log_path, "rb") do |io|
|
|
347
|
-
size = io.size
|
|
348
|
-
io.seek([size - LOG_TAIL_BYTES, 0].max)
|
|
349
|
-
io.gets if size > LOG_TAIL_BYTES # a torn first line
|
|
350
|
-
LogLine.each_record(io, &)
|
|
351
|
-
end
|
|
352
|
-
end
|
|
353
|
-
|
|
354
346
|
def configured_mode
|
|
355
347
|
Config.get("session.project_env").to_s
|
|
356
348
|
rescue StandardError
|
|
@@ -25,6 +25,7 @@ require_relative "desktop"
|
|
|
25
25
|
require_relative "live_versions"
|
|
26
26
|
require_relative "web/lan"
|
|
27
27
|
require_relative "chi_home"
|
|
28
|
+
require_relative "home_notice"
|
|
28
29
|
require_relative "project_env"
|
|
29
30
|
|
|
30
31
|
module Samagotchi
|
|
@@ -429,7 +430,8 @@ module Samagotchi
|
|
|
429
430
|
|
|
430
431
|
label = "#{home.ruby} (chi #{home.newest_version})"
|
|
431
432
|
return "#{label}; this chi is a source checkout" unless InstalledGem.spec
|
|
432
|
-
return label if home
|
|
433
|
+
return label if HomeNotice.home?(home)
|
|
434
|
+
return "#{label}; this chi is #{VERSION}" if home.running_here?
|
|
433
435
|
|
|
434
436
|
"#{label}; this chi runs on #{RbConfig.ruby}"
|
|
435
437
|
rescue ChiHome::Invalid => e
|
|
@@ -5,6 +5,7 @@ require_relative "../memory_paths"
|
|
|
5
5
|
require_relative "../paths"
|
|
6
6
|
require_relative "../guardrails/chi_dirs"
|
|
7
7
|
require_relative "../guardrails/path_resolve"
|
|
8
|
+
require_relative "../guardrails/pair_command"
|
|
8
9
|
require_relative "../guardrails/parent_approvals"
|
|
9
10
|
require_relative "../guardrails/shell_lex"
|
|
10
11
|
require_relative "../guardrails/shell_paths"
|
|
@@ -31,7 +32,12 @@ module Samagotchi
|
|
|
31
32
|
# `/`) read while the dir was known. A word the walk can't follow
|
|
32
33
|
# otherwise (`${PWD%/*}`, `$(dirname $PWD)`, `cd -`) may climb out of the
|
|
33
34
|
# memories dir without saying so, so its memories text stays in and the
|
|
34
|
-
# text check refuses it.
|
|
35
|
+
# text check refuses it. So does a script an interpreter or shell runs
|
|
36
|
+
# that climbs by its own calls (SCRIPT_CLIMB: os.pardir, dirname,
|
|
37
|
+
# chdir, Dir.pwd, Path.parent, rindex, a slice from the end), and one
|
|
38
|
+
# chi can't see: a script file it names, or stdin
|
|
39
|
+
# (Guardrails::PairCommand.script_source). A heredoc or phrase that is
|
|
40
|
+
# only written (cat >> note <<EOF) is data whatever it says.
|
|
35
41
|
#
|
|
36
42
|
# Pure: it only reads the paths as this process's environment names them,
|
|
37
43
|
# and answers on the approval's own facts (Guardrails::Approval.payload's
|
|
@@ -58,6 +64,21 @@ module Samagotchi
|
|
|
58
64
|
/(?:\A|[\s;&|(])(?:cd|pushd)\s+-(?=[\s;&|)]|\z)/
|
|
59
65
|
)
|
|
60
66
|
|
|
67
|
+
# An interpreter's own calls that climb out of a dir or cut a path
|
|
68
|
+
# from its end, as a script body may spell them without `..` or `$`
|
|
69
|
+
# (an accident guard, not a parser: a script can always build a path
|
|
70
|
+
# some other way).
|
|
71
|
+
SCRIPT_CLIMB = Regexp.union(
|
|
72
|
+
/\b(?:pardir|dirname|parents?|chdir|getcwd|cwd|pwd|abspath|realpath|normpath|expand_path|absolute_path|
|
|
73
|
+
resolve|rsplit|rpartition|rindex|rfind|lastIndexOf)\b/x,
|
|
74
|
+
/\b(?:os\.path|File)\.split\b/, /\[[^\]\[]*:\s*-/
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Programs that run a script file (theirs or the dir's) by the words
|
|
78
|
+
# that make them do it ([]: any call): unseen, like a shell's file.
|
|
79
|
+
FILE_RUNNERS = { /\Ag?make\z/ => [], /\Arake\z/ => [], /\A[gmn]?awk\z/ => %w[-f], /\Adeno\z/ => %w[run task],
|
|
80
|
+
/\A(?:npm|yarn|pnpm|bun)\z/ => %w[run run-script test start exec x] }.freeze
|
|
81
|
+
|
|
61
82
|
# What resolving a command's words found: whether one lands in chi's
|
|
62
83
|
# dirs (outside the memories dir, or in its installed bundles), and
|
|
63
84
|
# whether every word resolved or is data (#plain).
|
|
@@ -133,14 +154,18 @@ module Samagotchi
|
|
|
133
154
|
tokens.none? { |_, value| value.include?(Guardrails::ShellLex::SUBST) }
|
|
134
155
|
chi = false
|
|
135
156
|
commands = Guardrails::ShellLex.simple_commands(tokens)
|
|
157
|
+
pipes = Guardrails::ShellLex.piped_flags(tokens)
|
|
136
158
|
envs = targets ? targets.shell_envs : [[Dir.home, ENV]]
|
|
137
159
|
envs.each do |home, env|
|
|
160
|
+
piped = pipes.dup
|
|
138
161
|
walk = Guardrails::ShellPaths::Walk.new(cwd.to_s.empty? ? Dir.pwd : cwd.to_s, home, env)
|
|
139
162
|
commands.each do |simple|
|
|
140
163
|
# A cd the walk couldn't follow: no later word is data.
|
|
141
164
|
known = !simple.is_a?(Array) || !walk.paths(".").empty?
|
|
165
|
+
runs = simple.is_a?(Array) && runs_script?(simple)
|
|
166
|
+
plain &&= !unseen_script?(simple, piped: simple.is_a?(Array) && piped.shift)
|
|
142
167
|
walk.step(simple).each do |word, path|
|
|
143
|
-
next plain &&= known && data?(word) unless path
|
|
168
|
+
next plain &&= known && data?(word) && !(runs && climbs?(word)) unless path
|
|
144
169
|
|
|
145
170
|
chi ||= Guardrails::PathResolve.reals(path).any? do |real|
|
|
146
171
|
dirs.any? { |dir| Guardrails::PathResolve.under?(real, dir) } && !memory?(real, memories, bundles)
|
|
@@ -165,6 +190,37 @@ module Samagotchi
|
|
|
165
190
|
word.match?(Guardrails::ShellPaths::GLOB) && !word.include?("/")
|
|
166
191
|
end
|
|
167
192
|
|
|
193
|
+
# Whether a simple command hands a script to a shell or interpreter
|
|
194
|
+
# (one anywhere in it, find -execdir's too) or has a code option.
|
|
195
|
+
def runs_script?(simple)
|
|
196
|
+
simple.any? do |word|
|
|
197
|
+
Guardrails::PairCommand.runner?(word) || Guardrails::PairCommand::CODE_OPTIONS.include?(word)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Whether a simple command runs a script chi can't see: its shell or
|
|
202
|
+
# interpreter (any version's name: python3.12, pypy3, Rscript, tclsh)
|
|
203
|
+
# runs a file or stdin, a pipe into it included whatever its options
|
|
204
|
+
# (Guardrails::PairCommand.script_source), or another program runs a
|
|
205
|
+
# file of the dir it is in (FILE_RUNNERS: make, awk -f, deno run,
|
|
206
|
+
# npm run).
|
|
207
|
+
def unseen_script?(simple, piped: false)
|
|
208
|
+
return false unless simple.is_a?(Array)
|
|
209
|
+
return true if %i[file stdin].include?(Guardrails::PairCommand.script_source(simple, piped: piped))
|
|
210
|
+
|
|
211
|
+
simple.each_with_index.any? do |word, i|
|
|
212
|
+
next false if word.include?("\0")
|
|
213
|
+
|
|
214
|
+
verbs = FILE_RUNNERS.find { |pattern, _| File.basename(word).match?(pattern) }&.last
|
|
215
|
+
verbs && (verbs.empty? || simple[(i + 1)..].intersect?(verbs))
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Whether a script word, its memories text aside, climbs (SCRIPT_CLIMB).
|
|
220
|
+
def climbs?(word)
|
|
221
|
+
word.match?(Guardrails::ShellPaths::SCRIPT) && word.gsub(memories_text, "").match?(SCRIPT_CLIMB)
|
|
222
|
+
end
|
|
223
|
+
|
|
168
224
|
# chi's own dirs (ParentApprovals.chi_dirs) and its whole state dir
|
|
169
225
|
# (DEFAULT_STATE_TEXT's), resolved (Guardrails::ChiDirs.self_protect).
|
|
170
226
|
def chi_dirs = Guardrails::ChiDirs.self_protect
|
|
@@ -108,6 +108,19 @@ module Samagotchi
|
|
|
108
108
|
value
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
# A wall-clock upper bound for "this didn't take the full wait"
|
|
112
|
+
# (expect(elapsed).to be < time_bound(3, below: 5)): +seconds+ scaled,
|
|
113
|
+
# so a slow runner gets the headroom a helper's deadline gets, and kept
|
|
114
|
+
# under +below+, the unscaled deadline (a lib timeout, a hung child's
|
|
115
|
+
# sleep) the bound tells apart from, so the check still fails on the
|
|
116
|
+
# full wait whatever SPEC_TIME_SCALE is. A bound with nothing to tell
|
|
117
|
+
# apart from (a cost: "no quadratic blow-up") takes no +below+.
|
|
118
|
+
# @return [Float]
|
|
119
|
+
def time_bound(seconds, below: nil)
|
|
120
|
+
bound = Waiting.scaled(seconds)
|
|
121
|
+
below ? [bound, below].min : bound
|
|
122
|
+
end
|
|
123
|
+
|
|
111
124
|
# Whether no process +pid+ is left (a killed grandchild is reaped a
|
|
112
125
|
# moment later: wait_for("pid gone") { process_gone?(pid) }). One that
|
|
113
126
|
# isn't ours to signal is still there (ProcessGroup.alive?).
|
|
@@ -155,6 +155,22 @@ module Samagotchi
|
|
|
155
155
|
true
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
+
# On mode's ask gave the turn no suggestion to judge (Engine
|
|
159
|
+
# #settle_toolkit_suggestion: a timeout, no answer): say, as a warn
|
|
160
|
+
# notice, that +session+ runs its default (not once a toolkit was
|
|
161
|
+
# chosen meanwhile). +why+ is fallback_notice's.
|
|
162
|
+
def fell_back(session, why)
|
|
163
|
+
return if session.nil? || session.toolkit_origin != "default"
|
|
164
|
+
|
|
165
|
+
@notify.call(self.class.fallback_notice(Toolkit.of(session.toolkit).name, why), :warn, "toolkit", extra: {})
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# "toolkit: full (no suggestion: System 1 took over 5000 ms) · /toolkit
|
|
169
|
+
# to change"; +why+ follows "no suggestion: ".
|
|
170
|
+
def self.fallback_notice(name, why)
|
|
171
|
+
"toolkit: #{name} (no suggestion: #{why}) · /toolkit to change"
|
|
172
|
+
end
|
|
173
|
+
|
|
158
174
|
# "toolkit: smoke (suggested, p=0.71) · /toolkit to change"; a
|
|
159
175
|
# deferred switch says "from this turn".
|
|
160
176
|
def self.suggestion_notice(name, confidence, deferred:)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "cli/command"
|
|
4
4
|
require_relative "cli/flags"
|
|
5
5
|
require_relative "chi_home"
|
|
6
|
+
require_relative "child_env"
|
|
6
7
|
require_relative "config"
|
|
7
8
|
require_relative "desktop"
|
|
8
9
|
require_relative "gem_update"
|
|
@@ -89,7 +90,7 @@ module Samagotchi
|
|
|
89
90
|
# Built after the home is known: its install env goes into the home.
|
|
90
91
|
@gem_update_given = gem_update
|
|
91
92
|
@exec = exec || ->(argv) { Kernel.exec(*argv) }
|
|
92
|
-
@bundler = bundler.nil? ?
|
|
93
|
+
@bundler = bundler.nil? ? ChiHome.under_bundler? : bundler
|
|
93
94
|
@argv = argv.dup
|
|
94
95
|
@stdout = stdout
|
|
95
96
|
@stderr = stderr
|
|
@@ -168,11 +169,14 @@ module Samagotchi
|
|
|
168
169
|
|
|
169
170
|
# A chi on another Ruby than the home's updates the home: exec its chi
|
|
170
171
|
# (newer?, --to and the bundle rows are then the home's), once
|
|
171
|
-
# (IN_HOME_ENV).
|
|
172
|
+
# (IN_HOME_ENV), outside a `bundle exec`'s bundle (ChildEnv.unbundled,
|
|
173
|
+
# as worker spawns and chi web's restart boot it). hand_over never runs
|
|
174
|
+
# under Bundler: the gem row skips there.
|
|
172
175
|
def to_home(options)
|
|
173
176
|
notice(options, "updating the chi home (#{@run_home.ruby})")
|
|
174
177
|
@stdout.flush
|
|
175
|
-
@exec.call([
|
|
178
|
+
@exec.call([ChildEnv.unbundled.merge(IN_HOME_ENV => "1"), *@run_home.boot_argv(run: ChiHome::CHI_BOOT), "--",
|
|
179
|
+
"update", *@argv])
|
|
176
180
|
0 # only when exec is a spec's stand-in
|
|
177
181
|
end
|
|
178
182
|
|
|
@@ -52,8 +52,13 @@ module Samagotchi
|
|
|
52
52
|
"chi #{version}: #{parts.join(" and ")} can be updated: chi update"
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# The helper whose chi reads this chi's config dir: an isolated run
|
|
56
|
+
# (XDG_CONFIG_HOME elsewhere) never hints about the real one.
|
|
55
57
|
def default_helper(env)
|
|
56
|
-
Desktop.supported?
|
|
58
|
+
return nil unless Desktop.supported?
|
|
59
|
+
|
|
60
|
+
helper = Desktop::MacOS.new(env: env, register: false)
|
|
61
|
+
helper.reads_this_config? ? helper : nil
|
|
57
62
|
end
|
|
58
63
|
end
|
|
59
64
|
end
|