samagotchi 0.131.0 → 0.133.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/bin/chi +19 -1
  4. data/docs/architecture.md +59 -1
  5. data/docs/cli.md +29 -8
  6. data/docs/configuration.md +115 -16
  7. data/docs/desktop.md +16 -6
  8. data/docs/guardrails.md +10 -4
  9. data/docs/internals/anthropic.md +3 -2
  10. data/docs/internals/engine.md +4 -4
  11. data/docs/internals/prompt-caching.md +26 -6
  12. data/docs/memory.md +16 -2
  13. data/docs/plugins.md +68 -1
  14. data/docs/sessions.md +60 -6
  15. data/docs/testing.md +1 -1
  16. data/lib/samagotchi/bootstrap_command.rb +35 -6
  17. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -3
  19. data/lib/samagotchi/bundles/model-notes/manifest.yml +4 -1
  20. data/lib/samagotchi/bundles/model-notes/model_notes_tone.md +3 -0
  21. data/lib/samagotchi/bundles/model-notes/notes/README.md +14 -0
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/cache_confirm.rb +18 -0
  24. data/lib/samagotchi/chi_home.rb +311 -0
  25. data/lib/samagotchi/chi_shim.rb +77 -0
  26. data/lib/samagotchi/child_env.rb +5 -3
  27. data/lib/samagotchi/config.rb +2 -0
  28. data/lib/samagotchi/context_fetch.rb +4 -2
  29. data/lib/samagotchi/desktop/macos.rb +100 -18
  30. data/lib/samagotchi/engine.rb +35 -4
  31. data/lib/samagotchi/engine_snapshots.rb +5 -0
  32. data/lib/samagotchi/gem_update.rb +19 -10
  33. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  34. data/lib/samagotchi/guardrails/pair_boundary.rb +21 -2
  35. data/lib/samagotchi/guardrails/pair_command.rb +57 -1
  36. data/lib/samagotchi/guardrails/protected_paths.rb +6 -3
  37. data/lib/samagotchi/guardrails/review_paths.rb +7 -4
  38. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  39. data/lib/samagotchi/home_command.rb +145 -0
  40. data/lib/samagotchi/home_doctor.rb +177 -0
  41. data/lib/samagotchi/home_notice.rb +80 -0
  42. data/lib/samagotchi/installed_versions.rb +5 -3
  43. data/lib/samagotchi/llm_context_command.rb +53 -7
  44. data/lib/samagotchi/llm_context_override.rb +3 -2
  45. data/lib/samagotchi/plugin/api.rb +45 -1
  46. data/lib/samagotchi/plugin/context.rb +34 -1
  47. data/lib/samagotchi/plugin/loader.rb +5 -2
  48. data/lib/samagotchi/plugin/prompt_sections.rb +124 -0
  49. data/lib/samagotchi/project_env.rb +360 -0
  50. data/lib/samagotchi/run_options.rb +1 -0
  51. data/lib/samagotchi/self_report.rb +33 -0
  52. data/lib/samagotchi/session_access/contract.rb +1 -1
  53. data/lib/samagotchi/session_commands.rb +68 -19
  54. data/lib/samagotchi/session_manager.rb +54 -1
  55. data/lib/samagotchi/session_model_settings.rb +5 -2
  56. data/lib/samagotchi/system_prompt.rb +34 -3
  57. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  59. data/lib/samagotchi/testing/boundary.rb +3 -1
  60. data/lib/samagotchi/testing/bundle.rb +22 -3
  61. data/lib/samagotchi/testing/host.rb +22 -1
  62. data/lib/samagotchi/testing/recorder.rb +6 -1
  63. data/lib/samagotchi/testing/recording_api.rb +1 -1
  64. data/lib/samagotchi/tools/builtins.rb +13 -2
  65. data/lib/samagotchi/update_command.rb +65 -7
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +61 -1
  68. data/lib/samagotchi/web/public/app.js +4 -1
  69. data/lib/samagotchi/web/public/chip_popover.js +5 -2
  70. data/lib/samagotchi/web/public/composer.css +11 -1
  71. data/lib/samagotchi/web/public/data.js +8 -4
  72. data/lib/samagotchi/web/public/index.html +52 -10
  73. data/lib/samagotchi/web/public/level_chip.js +22 -38
  74. data/lib/samagotchi/web/public/llm_context_chip.js +30 -73
  75. data/lib/samagotchi/web/public/model_pick.js +26 -0
  76. data/lib/samagotchi/web/public/model_picker.js +3 -1
  77. data/lib/samagotchi/web/public/review_start.js +13 -57
  78. data/lib/samagotchi/web/public/send_turn.js +3 -2
  79. data/lib/samagotchi/web/public/start_page.js +131 -99
  80. data/lib/samagotchi/web/public/start_settings.js +298 -0
  81. data/lib/samagotchi/web/public/start_toolkit.js +157 -0
  82. data/lib/samagotchi/web/public/thinking_chip.js +9 -22
  83. data/lib/samagotchi/web/server.rb +27 -8
  84. data/lib/samagotchi/web/update_check.rb +3 -2
  85. metadata +11 -1
@@ -9,6 +9,7 @@ require "rbconfig"
9
9
  require_relative "../atomic_file"
10
10
  require_relative "../version"
11
11
  require_relative "../self_report"
12
+ require_relative "../chi_home"
12
13
  require_relative "../installed_gem"
13
14
  require_relative "../config"
14
15
  require_relative "../host_keys"
@@ -41,9 +42,16 @@ module Samagotchi
41
42
  BUILD_NAME = ".chi-helper-build"
42
43
  OLD_NAME = ".chi-helper-old"
43
44
  # 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, state and tools.
45
- # Only these, and only when set: no tokens, no runtime knobs.
46
- ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME GEM_HOME GEM_PATH RUBYLIB].freeze
45
+ # launch file carries what chi needs to find its state and tools.
46
+ # Only these, and only when set: no tokens, no runtime knobs. No
47
+ # RUBYLIB, and GEM_HOME/GEM_PATH (GEM_ENV) only for a [ruby, chi]
48
+ # argv, whose wrapper may find its gem only through them (a chruby
49
+ # GEM_HOME): the chi home's boot sets its gem path itself, and they
50
+ # would reach every tool its sessions run (a project's `bundle exec`).
51
+ ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME].freeze
52
+ GEM_ENV = %w[GEM_HOME GEM_PATH].freeze
53
+ # What older launch files carried and a refresh drops (#refresh_launch_file).
54
+ DROPPED_ENV = %w[GEM_HOME GEM_PATH RUBYLIB].freeze
47
55
  # PATH is separately filtered to absolute dirs and frozen when written,
48
56
  # so the helper's chi, workers and execute can find the shell's tools.
49
57
  # Without it Ruby reads stdin as US-ASCII and non-ASCII notes break.
@@ -131,7 +139,7 @@ module Samagotchi
131
139
  def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
132
140
  source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
133
141
  arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, terminals: {},
134
- broadcast_timeout: nil)
142
+ broadcast_timeout: nil, chi_home: ChiHome.current(gem_spec: InstalledGem.spec(source_dir)))
135
143
  home = env["HOME"] || Dir.home
136
144
  @app_dir = app_dir || File.join(home, "Applications")
137
145
  @support_dir = support_dir || File.join(home, "Library", "Application Support", APP_NAME)
@@ -146,6 +154,7 @@ module Samagotchi
146
154
  @register = register
147
155
  @terminals = terminals
148
156
  @broadcast_timeout = broadcast_timeout
157
+ @chi_home = chi_home
149
158
  end
150
159
 
151
160
  def app_path = File.join(@app_dir, "#{APP_NAME}.app")
@@ -155,10 +164,12 @@ module Samagotchi
155
164
  def executable_path = File.join(app_path, "Contents", "MacOS", EXECUTABLE)
156
165
  def installed? = File.directory?(app_path)
157
166
 
158
- # What the helper runs: absolute ruby + chi (no shims, which need the
159
- # shell's PATH), and the allowlisted env. chi is an installed gem's
160
- # RubyGems wrapper (it activates the gem and outlives upgrades and
161
- # `gem cleanup`), else this checkout's bin/chi. A section per terminal
167
+ # What the helper runs: the chi home's boot (ChiHome: its ruby, its gem
168
+ # path in the -e string, then "--" and the helper's args) when there
169
+ # is one; else absolute ruby + chi (no shims, which need the shell's
170
+ # PATH). chi is an installed gem's RubyGems wrapper (it activates the
171
+ # gem and outlives upgrades and `gem cleanup`), else this checkout's
172
+ # bin/chi. And the allowlisted env. A section per terminal
162
173
  # switched on (TERMINALS order); the broadcast timeout when given.
163
174
  # The XDG dirs go in absolute (Paths): launchd starts the app in /,
164
175
  # where a relative one would mean /<dir>. PATH is filtered to absolute,
@@ -166,16 +177,22 @@ module Samagotchi
166
177
  # it is frozen at bake time.
167
178
  def launch_config
168
179
  env = { "LANG" => LANG }
169
- ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
180
+ argv = launch_argv
181
+ names = home_boot?(argv) ? ENV_ALLOWLIST : ENV_ALLOWLIST + GEM_ENV
182
+ names.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
170
183
  path = self.class.baked_path(@env)
171
184
  env["PATH"] = path if path
172
185
  env.merge!(Paths.absolute_xdg(env: env, set: :key))
173
- config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
186
+ config = { "version" => @version, "argv" => argv, "env" => env, "sources_sha" => sources_sha }
174
187
  TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
175
188
  config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
176
189
  config
177
190
  end
178
191
 
192
+ def launch_argv
193
+ @chi_home ? @chi_home.boot_argv(run: ChiHome::CHI_BOOT) + ["--"] : [@ruby, @chi_path]
194
+ end
195
+
179
196
  # A digest of what a build compiles: the Swift sources and the
180
197
  # Info.plist template. The launch file records the one it was built
181
198
  # from, so a new chi rebuilds only when these changed.
@@ -209,13 +226,37 @@ module Samagotchi
209
226
 
210
227
  # Why chi update can't keep the launch file's ruby or chi: one of them
211
228
  # is gone. nil when it runs, or has no usable argv (a refresh writes
212
- # this chi's then).
229
+ # this chi's then). Only the absolute paths in it count (a boot
230
+ # string, "-e" and "--" are not paths), as ChiRunner.swift checks.
213
231
  def launch_problem
214
232
  argv = usable_argv(read_launch)
215
- missing = Array(argv).reject { |path| File.exist?(path) }
216
- return nil if missing.empty?
233
+ argv && argv_problem(argv)
234
+ end
235
+
236
+ # Why the launch file doesn't boot +home+ (chi home doctor), or nil:
237
+ # it runs [ruby, chi] (installed before the home), boots another Ruby
238
+ # or gem path (the home was set since), or bakes GEM_HOME/GEM_PATH
239
+ # under the home's boot. Each fixed by chi desktop upgrade. nil without
240
+ # a launch file.
241
+ # @param home [ChiHome::Record]
242
+ # @return [String, nil]
243
+ def home_problem(home)
244
+ launch = read_launch
245
+ argv = usable_argv(launch)
246
+ return nil unless argv
247
+
248
+ unless home_boot?(argv)
249
+ return "the desktop helper runs #{argv.first(2).join(" ")}, not the chi home's boot: chi desktop upgrade"
250
+ end
251
+ unless ChiHome.real(argv[0]) == ChiHome.real(home.ruby) && argv[2].to_s.start_with?("#{home.prelude};")
252
+ return "the desktop helper boots the chi home as it was (#{argv[0]}), not as it is now: chi desktop upgrade"
253
+ end
254
+
255
+ env = launch["env"]
256
+ baked = env.is_a?(Hash) ? GEM_ENV.select { |key| env.key?(key) } : []
257
+ return nil if baked.empty?
217
258
 
218
- "chi not found at #{missing.join(", ")}: run chi desktop install --force from the shell whose chi it should run"
259
+ "the desktop helper's launch file sets #{baked.join(", ")} for its sessions' tools: chi desktop upgrade"
219
260
  end
220
261
 
221
262
  # The chi version the launch file names, nil without one.
@@ -232,7 +273,10 @@ module Samagotchi
232
273
  # Rewrite the launch file for this chi and its settings, keeping
233
274
  # its argv and the env it was installed with; a PATH only when it has
234
275
  # none (changing them takes chi desktop install --force or upgrade). A
235
- # newer version stays. The app reads it at each send.
276
+ # newer version stays. The app reads it at each send. The kept env
277
+ # loses what ENV_ALLOWLIST no longer takes (DROPPED_ENV): RUBYLIB
278
+ # always; GEM_HOME/GEM_PATH once the argv boots the chi home (an old
279
+ # [ruby, wrapper] argv may find its gem only through them).
236
280
  def refresh_launch_file
237
281
  config = launch_config
238
282
  old = read_launch
@@ -240,7 +284,8 @@ module Samagotchi
240
284
  config["version"] = old["version"] unless older_version?(old["version"])
241
285
  old_env = old["env"]
242
286
  if old_env.is_a?(Hash)
243
- config["env"] = old_env
287
+ dropped = home_boot?(config["argv"]) ? DROPPED_ENV : DROPPED_ENV - GEM_ENV
288
+ config["env"] = old_env.except(*dropped)
244
289
  path = self.class.baked_path(@env)
245
290
  config["env"]["PATH"] = path if config["env"]["PATH"].to_s.empty? && path
246
291
  end
@@ -466,11 +511,41 @@ module Samagotchi
466
511
  !env.is_a?(Hash) || env["PATH"].to_s.empty?
467
512
  end
468
513
 
514
+ # A launch file whose argv runs (#argv_problem finds nothing).
469
515
  def launch_ok?(launch)
470
- argv = Array(launch["argv"])
471
- !argv.empty? && argv.all? { |path| File.exist?(path) }
516
+ argv = usable_argv(launch)
517
+ !argv.nil? && argv_problem(argv).nil?
472
518
  end
473
519
 
520
+ # Why a launch argv (strings) can't run chi, or nil: its ruby isn't an
521
+ # absolute path, an absolute path in it is gone, or it boots the chi
522
+ # home (home.json's ruby) and the home can't boot chi
523
+ # (ChiHome.problem).
524
+ def argv_problem(argv)
525
+ return "the launch file's ruby #{argv[0]} isn't an absolute path: run chi desktop install --force" unless argv[0].start_with?("/")
526
+
527
+ missing = launch_paths(argv).reject { |path| File.exist?(path) }
528
+ unless missing.empty?
529
+ return "chi not found at #{missing.join(", ")}: run chi desktop install --force from the shell whose chi it should run"
530
+ end
531
+ return nil unless home_boot?(argv)
532
+
533
+ home = begin
534
+ ChiHome.read
535
+ rescue ChiHome::Invalid
536
+ nil
537
+ end
538
+ why = home && ChiHome.real(home.ruby) == ChiHome.real(argv[0]) && ChiHome.problem(home)
539
+ why ? "the chi home it boots can't run chi (#{why}): chi home doctor" : nil
540
+ end
541
+
542
+ # The argv entries that are paths (absolute): a boot's "-e", its
543
+ # string and "--" are not.
544
+ def launch_paths(argv) = Array(argv).select { |arg| arg.is_a?(String) && arg.start_with?("/") }
545
+
546
+ # An argv in the chi home's boot form ([ruby, "-e", boot, "--"]).
547
+ def home_boot?(argv) = argv.is_a?(Array) && argv[1] == "-e"
548
+
474
549
  def write_launch_file(config = launch_config, keep_launch: false)
475
550
  if keep_launch
476
551
  old = read_launch
@@ -478,6 +553,13 @@ module Samagotchi
478
553
  old_env = old["env"]
479
554
  old_path = old_env["PATH"] if old_env.is_a?(Hash)
480
555
  config["env"]["PATH"] = old_path unless old_path.to_s.empty?
556
+ # A kept [ruby, wrapper] argv keeps the GEM_* its gem was found
557
+ # through; a kept home boot takes none.
558
+ if home_boot?(config["argv"])
559
+ config["env"] = config["env"].except(*GEM_ENV)
560
+ elsif old_env.is_a?(Hash)
561
+ config["env"].merge!(old_env.slice(*GEM_ENV))
562
+ end
481
563
  end
482
564
  FileUtils.mkdir_p(@support_dir)
483
565
  AtomicFile.write(launch_path, JSON.pretty_generate(config) + "\n")
@@ -319,6 +319,9 @@ module Samagotchi
319
319
  # The context sources the peer bundles declare as a peer agent's
320
320
  # answers (chi.peer_source); the worker's ContextAbsorber asks it.
321
321
  @peer_sources = PeerSources.new
322
+ # The system-prompt text plugins add (chi.prompt_section), run when
323
+ # the prompt is built (#build_system_prompt's sections:).
324
+ @prompt_sections = Plugin::PromptSections.new
322
325
  load_plugins if load
323
326
  # Registered from what the sources published while they loaded, and
324
327
  # with no plugins too: a toolkit's left-out built-ins are reachable
@@ -527,7 +530,8 @@ module Samagotchi
527
530
  thinking: -> { turn_thinking }, model: -> { model_identity },
528
531
  model_notes: -> { model_notes }, memories: memories,
529
532
  muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files,
530
- participants: -> { @participants_line })
533
+ participants: -> { @participants_line },
534
+ sections: -> { @prompt_sections ? @prompt_sections.render(@session&.id) : [] })
531
535
  end
532
536
  private :build_system_prompt
533
537
 
@@ -832,6 +836,13 @@ module Samagotchi
832
836
  @host_registry.model_ref(name).ref
833
837
  end
834
838
 
839
+ # The ref #switch_model! would switch +model_name+ to, checked the way it
840
+ # checks one (UnknownHost, MissingModel), without switching.
841
+ # @return [String]
842
+ def model_switch_ref(model_name)
843
+ model_ref_for(ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries))
844
+ end
845
+
835
846
  # Writes the effective model to +session+: the resolved ref, and the
836
847
  # name as typed when that differs (model_typed: an alias, for the
837
848
  # models: lookup after a resume).
@@ -1304,7 +1315,7 @@ module Samagotchi
1304
1315
  # the saved context's status: SessionModelSettings' (resolved on every
1305
1316
  # read); these forward.
1306
1317
  def saved_context_status(context, model: :current) = @model_settings.saved_context_status(context, model: model)
1307
- def llm_context_explained = @model_settings.llm_context_explained
1318
+ def llm_context_explained(own: :session) = @model_settings.llm_context_explained(own: own)
1308
1319
  def llm_context_summary = @model_settings.llm_context_summary
1309
1320
  def llm_context_override = @model_settings.llm_context_override
1310
1321
  def chat_model? = @model_settings.chat_model?
@@ -2827,7 +2838,9 @@ module Samagotchi
2827
2838
  },
2828
2839
  toolkits: @toolkit_binding.plugin_kits,
2829
2840
  tool_index: @tool_index,
2830
- peer_sources: @peer_sources
2841
+ peer_sources: @peer_sources,
2842
+ prompt_sections: @prompt_sections,
2843
+ prompt_changed: -> { prompt_changed! }
2831
2844
  )
2832
2845
  # What plugins show while they load (an MCP server that didn't
2833
2846
  # start) waits for the first turn, beside the load warnings: no UI
@@ -3144,8 +3157,18 @@ module Samagotchi
3144
3157
  # The tools changed (a plugin's chi.tools_changed!): the system prompts,
3145
3158
  # which declare them, are built again on the next turn.
3146
3159
  def tools_changed!
3160
+ prompt_changed!
3161
+ end
3162
+
3163
+ # A plugin's prompt section would give other text now
3164
+ # (chi.prompt_changed!): the system prompts are built again at their
3165
+ # next build (the next turn's start; a running turn keeps the prompt it
3166
+ # started with). One cache restart: the plugin's command asked first
3167
+ # (Plugin::Context#cache_confirm).
3168
+ def prompt_changed!
3147
3169
  @prompt_builder&.reset!
3148
3170
  end
3171
+ public :prompt_changed!
3149
3172
 
3150
3173
  # What a Plugin::Context reads and calls: the session now, and the
3151
3174
  # hook runtime's notify and ask_user.
@@ -3176,6 +3199,12 @@ module Samagotchi
3176
3199
  },
3177
3200
  model_name: -> { effective_model_ref },
3178
3201
  model_key: -> { @model_binding.model_key },
3202
+ # The names a models: map looks the model up under; [] when it doesn't resolve.
3203
+ model_names: lambda do
3204
+ effective_model.names
3205
+ rescue StandardError
3206
+ []
3207
+ end,
3179
3208
  state_dir: -> { session_state_dir },
3180
3209
  scratch: -> { @scratch },
3181
3210
  setup: -> { SessionSetup.of(@session) },
@@ -3186,7 +3215,9 @@ module Samagotchi
3186
3215
  frontend: -> { interface },
3187
3216
  cost: -> { @metrics.cost },
3188
3217
  usage: -> { @metrics.usage },
3189
- turn_author: -> { turn_author }
3218
+ turn_author: -> { turn_author },
3219
+ # A plugin's prompt section is in the stable part: a full re-read.
3220
+ cache_confirm: ->(line, changed) { CacheConfirm.ask(line: line, cost: :full, changed: changed, engine: self) }
3190
3221
  )
3191
3222
  end
3192
3223
 
@@ -62,6 +62,8 @@ module Samagotchi
62
62
  # price_host: the effective model's host, whose hosts.<name>.models
63
63
  # prices the cost estimate names. kit_prompt: the next turn's kit
64
64
  # prompt text ({kit:, tokens:}), only when its toolkit has one.
65
+ # prompt_sections: the plugins' sections the built prompt holds, only
66
+ # when it holds some.
65
67
  # settings: the session's own settings ({"recap.enabled" => false}),
66
68
  # only when it has some.
67
69
  # @return [Hash] @metrics.snapshot, filled in
@@ -78,6 +80,9 @@ module Samagotchi
78
80
  price_host: model.host_name)
79
81
  kit_prompt = kit_prompt_stats
80
82
  snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
83
+ # The plugins' prompt sections the built prompt holds ({bundle:, name:}).
84
+ sections = prompt_builder.prompt_sections.map(&:to_stats)
85
+ snapshot = snapshot.merge(prompt_sections: sections) unless sections.empty?
81
86
  # Each participant's spend under their name (participants.json's).
82
87
  if (spend = snapshot[:spend_by_author])
83
88
  named = spend.to_h { |author, row| [author, row.merge(name: participants.name_for(author, session: session))] }
@@ -6,13 +6,15 @@ require "open3"
6
6
  require "rbconfig"
7
7
  require "rubygems/package"
8
8
  require "time"
9
+ require_relative "chi_home"
9
10
  require_relative "version"
10
11
  require_relative "redacted_inspect"
11
12
 
12
13
  module Samagotchi
13
14
  # The gem part of `chi update`: the newest samagotchi on rubygems, and a
14
- # `gem install` of it through this Ruby's own gem command (the real
15
- # interpreter's bindir, not a shim: the same under mise, rbenv and asdf).
15
+ # `gem install` of it through the chi home's gem command, else this
16
+ # Ruby's own (the real interpreter's bindir, not a shim: the same under
17
+ # mise, rbenv and asdf).
16
18
  #
17
19
  # SAMAGOTCHI_UPDATE_GEM_FILE=<path to a .gem> (hidden, for smokes) makes that
18
20
  # file the newest version and installs it instead.
@@ -32,26 +34,31 @@ module Samagotchi
32
34
  Latest = Struct.new(:version, :source, keyword_init: true)
33
35
  Release = Data.define(:version, :created_at, :prerelease)
34
36
 
35
- # Runs a command without a shell; [output, success].
37
+ # Runs a command without a shell; [output, success]. +env+ is merged
38
+ # into the inherited one (nil unsets a key).
36
39
  class Runner
37
- def run(argv)
38
- output, status = Open3.capture2e(*argv)
40
+ def run(argv, env: {})
41
+ output, status = Open3.capture2e(env, *argv)
39
42
  [output, status.success?]
40
43
  rescue SystemCallError => e
41
44
  [e.message, false]
42
45
  end
43
46
  end
44
47
 
45
- # This Ruby's own gem command (chi bootstrap installs commonmarker with
46
- # it too).
47
- def self.gem_bin = File.join(RbConfig::CONFIG["bindir"], "gem")
48
+ # The gem command an install runs: the chi home's (ChiHome.gem_bin),
49
+ # else this Ruby's own (chi bootstrap installs commonmarker with it too).
50
+ def self.gem_bin = ChiHome.gem_bin
48
51
 
49
52
  # @param fetcher [#call, nil] → the latest.json body (specs)
50
- def initialize(env: ENV, fetcher: nil, runner: Runner.new, gem_bin: GemUpdate.gem_bin)
53
+ # @param install_env [Hash] the install child's env on top of this
54
+ # process's (ChiHome.install_env: into the home's gems)
55
+ def initialize(env: ENV, fetcher: nil, runner: Runner.new, gem_bin: GemUpdate.gem_bin,
56
+ install_env: ChiHome.install_env)
51
57
  @env = env
52
58
  @fetcher = fetcher || method(:fetch)
53
59
  @runner = runner
54
60
  @gem_bin = gem_bin
61
+ @install_env = install_env
55
62
  end
56
63
 
57
64
  # @return [Latest]
@@ -104,7 +111,9 @@ module Samagotchi
104
111
 
105
112
  # @return [Array(String, Boolean)] gem's output, success
106
113
  def install(latest)
107
- @runner.run(install_argv(latest))
114
+ return @runner.run(install_argv(latest)) if @install_env.empty?
115
+
116
+ @runner.run(install_argv(latest), env: @install_env)
108
117
  end
109
118
 
110
119
  private
@@ -265,12 +265,14 @@ module Samagotchi
265
265
  @protected ||= begin
266
266
  require_relative "memory_bundle/provenance"
267
267
  require_relative "context_sources"
268
+ require_relative "chi_home"
268
269
  Guardrails::ProtectedPaths.new(
269
270
  store_dir: File.dirname(@approvals.path),
270
271
  bundles_dir: MemoryBundle::Provenance.bundles_dir,
271
272
  config_path: Samagotchi::ConfigFile.global_path,
272
273
  hooks_dir: Hooks::Loader.hooks_dir,
273
- context_dir: ContextSources.root
274
+ context_dir: ContextSources.root,
275
+ home_path: ChiHome.path
274
276
  )
275
277
  end
276
278
  end
@@ -79,6 +79,10 @@ module Samagotchi
79
79
  "approves it"
80
80
  GIT_CONFIG = "a participant's turn can't change git's global config or set what makes git run a command " \
81
81
  "(core.hooksPath, core.fsmonitor, core.sshCommand, core.pager, core.editor, ! aliases)"
82
+ ENV_LOADER = "a participant's turn runs mise or direnv: they load the project's env files (a mise.local.toml, " \
83
+ "an .envrc), which may hold the owner's secrets, so the owner approves it"
84
+ MISE_CONFIG = "a participant's turn changes mise's config: the owner's next worker start runs it (its [env] " \
85
+ "can run commands), so the owner approves it"
82
86
  GIT_HOOKS = "a participant's turn can't write git's hooks (.git/hooks): git runs them in the owner's name"
83
87
  # chi web on loopback asks the owner's token too (Web::App#token_gate,
84
88
  # LAN mode); this text check stays as defense in depth: a loopback-only
@@ -127,7 +131,8 @@ module Samagotchi
127
131
  ParentApprovals::CHI_TEXT,
128
132
  %r{\.local/state/samagotchi|web-grants|grants\.json|web-token},
129
133
  %r{(?:\A|[\s/'"=])\.(?:ssh|gnupg|aws|kube|docker)(?![\w.-])},
130
- %r{(?:\A|[\s/'"=])(?:\.env(?:\.[\w.-]*)?|[._]netrc|\.npmrc|\.pypirc|\.git-credentials)(?![\w-])},
134
+ %r{(?:\A|[\s/'"=])(?:\.env(?:\.[\w.-]*)?|\.envrc|\.?mise(?:\.[\w-]+)?\.local\.toml|(?:\.config/mise|\.?mise)/config(?:\.[\w-]+)?\.local\.toml|[._]netrc|\.npmrc|\.pypirc|
135
+ \.git-credentials)(?![\w-])}x,
131
136
  /\bid_(?:rsa|dsa|ecdsa|ed25519)\b|credentials|\.(?:pem|p12|pfx|tfstate)\b/
132
137
  )
133
138
 
@@ -187,16 +192,30 @@ module Samagotchi
187
192
  end
188
193
 
189
194
  # Why the owner is asked (fresh, once): a command that makes a link
190
- # (LINK), a write that may land outside the project (OUTSIDE_WRITE, or
195
+ # (LINK), one that runs mise or direnv (ENV_LOADER), a write to mise's
196
+ # config (MISE_CONFIG), a write that may land outside the project (OUTSIDE_WRITE, or
191
197
  # NO_PROJECT without one); nil when nothing asks.
192
198
  def ask_reason(verdict)
193
199
  targets = verdict.targets
194
200
  return LINK if targets&.command && command(targets, nil).links?
201
+ return ENV_LOADER if targets&.command && command(targets, nil).loads_env?
202
+ return MISE_CONFIG if mise_config_write?(verdict)
195
203
  return nil unless outside_write?(verdict)
196
204
 
197
205
  project_root ? OUTSIDE_WRITE : NO_PROJECT
198
206
  end
199
207
 
208
+ # A write or edit of a mise config file (PairCommand.mise_config?), or a
209
+ # command that writes or names one (PairCommand#mise_config_write?).
210
+ def mise_config_write?(verdict)
211
+ targets = verdict.targets
212
+ return false unless targets
213
+ return command(targets, nil).mise_config_write? if targets.command
214
+ return false unless WRITE_TOOLS.include?(verdict.call&.dig(:name).to_s)
215
+
216
+ targets.paths.to_a.compact.any? { |path| PairCommand.mise_config?(File.expand_path(path, targets.cwd || Dir.pwd)) }
217
+ end
218
+
200
219
  # A write or edit whose path may land outside the project root, or a
201
220
  # command that may write there (PairCommand#outside_writes?); a tmp
202
221
  # dir the project isn't in aside (PairCommand.writable?).
@@ -52,6 +52,9 @@ module Samagotchi
52
52
  ENV_READERS = %w[printenv security op].freeze
53
53
  # Commands that print a stored credential (a token, a password, a
54
54
  # key), by the program and the words that say so in its arguments.
55
+ # Programs that load a project's env files (PairBoundary::ENV_LOADER).
56
+ ENV_LOADERS = %w[mise direnv].freeze
57
+ ENV_LOADER_TEXT = %r{(?<![\w./-])(?:mise|direnv)(?![\w./-])}
55
58
  CREDENTIAL_PRINTERS = {
56
59
  "gh" => [%w[auth token]],
57
60
  "git" => [%w[credential], %w[credential-osxkeychain], %w[credential-store], %w[credential-cache],
@@ -139,6 +142,20 @@ module Samagotchi
139
142
  Outside.inside?(path, root: root) || Outside.tmp_write?(path, root: root)
140
143
  end
141
144
 
145
+ # mise's config files (mise.toml, .mise.toml, mise.<env>.toml, .config/mise.toml,
146
+ # anything under .config/mise/, mise/ or .mise/ config*.toml): mise runs
147
+ # their [env] at the next worker start (ProjectEnv).
148
+ MISE_CONFIG_PATH = %r{(?:\A|/)(?:\.?mise(?:\.[^/]+)?\.toml|\.config/mise\.toml|\.config/mise/.+|
149
+ \.?mise/config[^/]*\.toml)\z}x
150
+ MISE_CONFIG_TEXT = %r{(?<![\w-])(?:\.?mise(?:\.[\w.-]+)?\.toml|\.config/mise|\.?mise/config[\w.-]*\.toml)}
151
+
152
+ # Whether +path+ is one of mise's config files (MISE_CONFIG_PATH), as
153
+ # given or as it resolves.
154
+ def self.mise_config?(path)
155
+ path.to_s.match?(MISE_CONFIG_PATH) ||
156
+ (path.to_s.start_with?("/") && PathResolve.reals(path).any? { |real| real.match?(MISE_CONFIG_PATH) })
157
+ end
158
+
142
159
  # Whether +path+ is in a .git/hooks folder, as given or as it resolves
143
160
  # (ShellPaths::HOOKS_PART): git runs what is there.
144
161
  def self.git_hook?(path)
@@ -193,6 +210,7 @@ module Samagotchi
193
210
 
194
211
  @refusal = read
195
212
  rescue StandardError
213
+ @mise_config = true
196
214
  @refusal = PairBoundary::UNREADABLE
197
215
  end
198
216
 
@@ -210,6 +228,28 @@ module Samagotchi
210
228
  @links = true
211
229
  end
212
230
 
231
+ # Whether it runs mise or direnv (ENV_LOADERS): they load the
232
+ # project's env files (a mise.local.toml, an .envrc), which hold the
233
+ # owner's secrets, into what the command prints; the owner approves
234
+ # it. True for one it can't read.
235
+ def loads_env?
236
+ return @loads_env if defined?(@loads_env)
237
+
238
+ @loads_env = @depth > MAX_DEPTH || ShellLex.simple_commands(ShellLex.lex(@text)).any? do |words|
239
+ words.is_a?(Array) && env_loader_command?(words)
240
+ end
241
+ rescue StandardError
242
+ @loads_env = true
243
+ end
244
+
245
+ # Whether it writes (or, not only reading, names) a mise config file
246
+ # (.mise_config?), a script it hands on included: the owner approves
247
+ # it. True for one it can't read.
248
+ def mise_config_write?
249
+ refusal
250
+ @mise_config ||= false
251
+ end
252
+
213
253
  # Whether it may write outside the project (asked about, never
214
254
  # denied); true for one it can't read.
215
255
  def outside_writes?
@@ -313,8 +353,10 @@ module Samagotchi
313
353
  program = File.basename(words[at])
314
354
  scripts(words, at).each do |script|
315
355
  reason = if SHELLS.include?(program) || program == "eval"
316
- nested(script).refusal
356
+ inner = nested(script)
357
+ inner.refusal.tap { @mise_config = true if inner.mise_config_write? }
317
358
  elsif INTERPRETERS.include?(program)
359
+ @mise_config = true if script.match?(MISE_CONFIG_TEXT)
318
360
  script_text_refusal(script)
319
361
  end
320
362
  return reason if reason
@@ -448,6 +490,8 @@ module Samagotchi
448
490
  resolved.each do |word, path|
449
491
  return PairBoundary::GIT_HOOKS if !@read_only && PairCommand.git_hook?(path || word)
450
492
 
493
+ @mise_config = true if !@read_only && PairCommand.mise_config?(path || word)
494
+
451
495
  if path
452
496
  return PairBoundary::SECRET if @paths.secret_refusal([path])
453
497
  return PairBoundary::HOME_SWEEP if SWEEPERS.include?(program_word) && above_denied?(path)
@@ -512,6 +556,18 @@ module Samagotchi
512
556
  end
513
557
  end
514
558
 
559
+ def env_loader_command?(words)
560
+ at = program_at(words)
561
+ return true if at && ENV_LOADERS.include?(File.basename(words[at]))
562
+ return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && ENV_LOADERS.include?(File.basename(word)) }
563
+
564
+ runner = (0..(at || (words.size - 1))).find { |i| runner?(words[i]) } or return false
565
+ name = File.basename(words[runner])
566
+ scripts(words, runner).any? do |script|
567
+ SHELLS.include?(name) || name == "eval" ? nested(script).loads_env? : script.match?(ENV_LOADER_TEXT)
568
+ end
569
+ end
570
+
515
571
  def nested(script) = PairCommand.new(script, cwd: @cwd, paths: @paths, home: @home, root: @root,
516
572
  depth: @depth + 1, web: @web)
517
573
 
@@ -14,7 +14,8 @@ module Samagotchi
14
14
  # hooks, rules, manifests) and attached context (a source's
15
15
  # command runs later, outside the gate; chi context writes
16
16
  # it); nothing legitimate writes there through the file tools.
17
- # ask (once / session): config.yml and the plain hooks dir. The
17
+ # ask (once / session): config.yml, home.json (the chi home: the
18
+ # Ruby every chi launch boots) and the plain hooks dir. The
18
19
  # system bundle's config protocol edits config.yml with
19
20
  # write/edit, so this keeps the user in the loop without
20
21
  # breaking it.
@@ -29,7 +30,8 @@ module Samagotchi
29
30
  # @param config_path [String] config.yml
30
31
  # @param hooks_dir [String] the plain hooks dir
31
32
  # @param context_dir [String, nil] <state dir>/context (ContextSources.root)
32
- def initialize(store_dir:, bundles_dir:, config_path:, hooks_dir:, context_dir: nil)
33
+ # @param home_path [String, nil] home.json (ChiHome.path)
34
+ def initialize(store_dir:, bundles_dir:, config_path:, hooks_dir:, context_dir: nil, home_path: nil)
33
35
  @deny = [
34
36
  ["guardrail-store", store_dir, "the approval store belongs to the user"],
35
37
  ["installed-bundles", bundles_dir, "installed bundles (hooks, rules, manifests) are changed by chi bundle install"],
@@ -37,8 +39,9 @@ module Samagotchi
37
39
  ]
38
40
  @ask = [
39
41
  ["chi-config", config_path, "changes chi's config.yml"],
42
+ ["chi-home", home_path, "changes the Ruby every chi launch boots"],
40
43
  ["chi-hooks", hooks_dir, "changes chi's hooks"]
41
- ]
44
+ ].select { |_, root, _| root }
42
45
  end
43
46
 
44
47
  # Vote on +verdict+ (its targets).
@@ -30,13 +30,16 @@ module Samagotchi
30
30
  .netrc .npmrc .pypirc .claude.json .zsh_history .bash_history .history .python_history
31
31
  .node_repl_history .psql_history .mysql_history .irb_history .lesshst
32
32
  .local/share/fish/fish_history].freeze
33
- # Project files that hold secrets, by a path part's name: .env*,
34
- # netrc, keys and certificates, anything named credentials, package
33
+ # Project files that hold secrets, by a path part's name: .env* (.envrc
34
+ # too), mise's local files (mise.local.toml, .mise.dev.local.toml), netrc, keys and certificates, anything named credentials, package
35
35
  # registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
36
36
  # secrets file; and a git config (.git/config, a submodule's
37
37
  # .git/modules/x/config), checked apart.
38
- SECRET_FILE = /\A(?:\.env.*|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
38
+ SECRET_FILE = /\A(?:\.env.*|\.?mise(?:\.[\w-]+)?\.local\.toml|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
39
39
  \.npmrc|\.pypirc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|secrets?\.(?:ya?ml|json))\z/xi
40
+ # mise's local config in its config dirs (.config/mise/config.local.toml,
41
+ # config.<env>.local.toml; mise/ and .mise/ too): by the path's end.
42
+ SECRET_PATH = %r{(?:\A|/)(?:\.config/mise|\.?mise)/config(?:\.[\w-]+)?\.local\.toml\z}i
40
43
  # The .docker/config.json kind: a dot dir's config holding tokens.
41
44
  SECRET_DIR_FILES = { ".docker" => "config.json", ".kube" => "config" }.freeze
42
45
  GIT_DIR = ".git"
@@ -124,7 +127,7 @@ module Samagotchi
124
127
  return true if denied_roots.any? { |root| PathResolve.under?(path, root) }
125
128
 
126
129
  parts = path.split(File::SEPARATOR)
127
- return true if parts.any? { |part| SECRET_FILE.match?(part) }
130
+ return true if parts.any? { |part| SECRET_FILE.match?(part) } || path.match?(SECRET_PATH)
128
131
  return true if parts.each_cons(2).any? { |dir, file| SECRET_DIR_FILES[dir.downcase] == file.downcase }
129
132
 
130
133
  git_config?(parts)
@@ -23,7 +23,7 @@ module Samagotchi
23
23
  # ParentPolicy's user_only); System 1 may, only if the user lists the
24
24
  # rule by name in guardrails.system_one.rules, and never for the
25
25
  # chi-answer/chi-broadcast ones (SystemOne::SelfProtect::NEVER_AUTO).
26
- PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
26
+ PROTECTED_RULES = %w[chi-config chi-home chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
27
27
  model-overlay-write chi-answer-piped chi-answer-http].freeze
28
28
  SCOPES = %w[once session repo rule].freeze
29
29
  DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."