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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/cli.md +11 -0
  4. data/docs/configuration.md +8 -4
  5. data/docs/guardrails.md +102 -13
  6. data/docs/internals/background-tasks.md +1 -0
  7. data/docs/internals/participants.md +6 -5
  8. data/docs/internals/tool-guardrails.md +2 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +3 -2
  11. data/docs/testing.md +7 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/chi_home.rb +12 -2
  14. data/lib/samagotchi/desktop/macos.rb +19 -4
  15. data/lib/samagotchi/engine.rb +13 -0
  16. data/lib/samagotchi/guardrails/implicit_writers.rb +143 -0
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +6 -4
  18. data/lib/samagotchi/guardrails/pair_command.rb +493 -60
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +1 -1
  20. data/lib/samagotchi/guardrails/read_only_shell.rb +34 -2
  21. data/lib/samagotchi/guardrails/shell_git_dirs.rb +6 -1
  22. data/lib/samagotchi/guardrails/shell_lex.rb +23 -0
  23. data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
  24. data/lib/samagotchi/guardrails/targets.rb +8 -3
  25. data/lib/samagotchi/home_command.rb +22 -6
  26. data/lib/samagotchi/home_doctor.rb +36 -3
  27. data/lib/samagotchi/home_notice.rb +8 -1
  28. data/lib/samagotchi/hooks/loader.rb +5 -0
  29. data/lib/samagotchi/log_line.rb +12 -0
  30. data/lib/samagotchi/project_env.rb +3 -11
  31. data/lib/samagotchi/self_report.rb +3 -1
  32. data/lib/samagotchi/system_one/self_protect.rb +58 -2
  33. data/lib/samagotchi/testing/waiting.rb +13 -0
  34. data/lib/samagotchi/toolkit_binding.rb +16 -0
  35. data/lib/samagotchi/update_command.rb +7 -3
  36. data/lib/samagotchi/update_hint.rb +6 -1
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/public/answer_fold.js +262 -0
  39. data/lib/samagotchi/web/public/answer_refs.js +2 -0
  40. data/lib/samagotchi/web/public/app.js +18 -1
  41. data/lib/samagotchi/web/public/cards_place.js +5 -2
  42. data/lib/samagotchi/web/public/chip_popover.js +73 -11
  43. data/lib/samagotchi/web/public/claim_link.js +4 -2
  44. data/lib/samagotchi/web/public/composer.css +3 -1
  45. data/lib/samagotchi/web/public/history_reveal.js +23 -0
  46. data/lib/samagotchi/web/public/index.html +44 -28
  47. data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
  48. data/lib/samagotchi/web/public/refs.js +6 -7
  49. data/lib/samagotchi/web/public/review_start.js +1 -1
  50. data/lib/samagotchi/web/public/start_page.js +4 -4
  51. data/lib/samagotchi/web/public/start_settings.js +29 -18
  52. data/lib/samagotchi/web/public/start_toolkit.js +5 -4
  53. data/lib/samagotchi/web/public/timing.js +5 -1
  54. data/lib/samagotchi/web/public/turn_html.js +12 -6
  55. data/lib/samagotchi/web/public/turn_range.js +32 -0
  56. data/lib/samagotchi/web/public/turn_view.js +21 -11
  57. data/lib/samagotchi/web/public/verdict_card.js +5 -13
  58. data/lib/samagotchi/web/public/verify_target.js +7 -10
  59. 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
- commands = split(ShellLex.lex(text)) or return false
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 counts too, outside the tmp dirs.
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. A call whose
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? { |key| SHELL_REDEFINING_ENV.include?(key) || key.start_with?(SHELL_REDEFINING_PREFIX) }
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 desktop helper, sessions, the tools' chi script,
29
- this project's Gemfile.lock, mise and the last worker's project env
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.running_here? && home.newest_version.to_s == VERSION
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]) : ChiHome.from_running(env: @env)
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 ChiHome.from_running(env: @env) if ChiHome.real(path) == ChiHome.real(RbConfig.ruby)
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) ? helper : nil
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? || (home.running_here?(ruby: ruby, gem_dir: gem_dir) && home.newest_version.to_s == version)
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.
@@ -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 for #last_warning.
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.running_here?
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? ? (defined?(::Bundler) || !ENV["BUNDLE_GEMFILE"].to_s.empty?) : bundler
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([{ IN_HOME_ENV => "1" }, *@run_home.boot_argv(run: ChiHome::CHI_BOOT), "--", "update", *@argv])
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? ? Desktop::MacOS.new(env: env) : nil
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.133.0"
4
+ VERSION = "0.134.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end