samagotchi 0.132.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -1
- data/bin/chi +16 -0
- data/docs/architecture.md +28 -1
- data/docs/cli.md +1 -1
- data/docs/configuration.md +55 -12
- data/docs/desktop.md +2 -1
- data/docs/guardrails.md +7 -1
- data/docs/sessions.md +60 -6
- data/docs/testing.md +1 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -3
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_shim.rb +77 -0
- data/lib/samagotchi/child_env.rb +5 -3
- data/lib/samagotchi/config.rb +2 -0
- data/lib/samagotchi/context_fetch.rb +4 -2
- data/lib/samagotchi/desktop/macos.rb +26 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +21 -2
- data/lib/samagotchi/guardrails/pair_command.rb +57 -1
- data/lib/samagotchi/guardrails/review_paths.rb +7 -4
- data/lib/samagotchi/home_command.rb +15 -19
- data/lib/samagotchi/home_doctor.rb +177 -0
- data/lib/samagotchi/home_notice.rb +80 -0
- data/lib/samagotchi/project_env.rb +360 -0
- data/lib/samagotchi/self_report.rb +33 -0
- data/lib/samagotchi/session_commands.rb +4 -2
- data/lib/samagotchi/session_manager.rb +18 -0
- data/lib/samagotchi/testing/boundary.rb +3 -1
- data/lib/samagotchi/tools/builtins.rb +13 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +50 -0
- data/lib/samagotchi/web/public/app.js +4 -1
- data/lib/samagotchi/web/public/chip_popover.js +5 -2
- data/lib/samagotchi/web/public/composer.css +11 -1
- data/lib/samagotchi/web/public/data.js +8 -4
- data/lib/samagotchi/web/public/index.html +51 -13
- data/lib/samagotchi/web/public/level_chip.js +22 -38
- data/lib/samagotchi/web/public/llm_context_chip.js +26 -70
- data/lib/samagotchi/web/public/review_start.js +13 -57
- data/lib/samagotchi/web/public/send_turn.js +3 -2
- data/lib/samagotchi/web/public/start_page.js +117 -97
- data/lib/samagotchi/web/public/start_settings.js +298 -0
- data/lib/samagotchi/web/public/start_toolkit.js +157 -0
- data/lib/samagotchi/web/public/thinking_chip.js +9 -22
- metadata +7 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require_relative "atomic_file"
|
|
6
|
+
require_relative "chi_home"
|
|
7
|
+
require_relative "log"
|
|
8
|
+
require_relative "paths"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# The `chi` a worker's tools run: $XDG_STATE_HOME/samagotchi/bin/chi, a
|
|
12
|
+
# shell script that execs the chi home's Ruby with its boot
|
|
13
|
+
# (ChiHome::Record#boot_argv, CHI_BOOT). ProjectEnv puts its dir first on
|
|
14
|
+
# the worker's PATH, so after the project env (whose PATH would find the
|
|
15
|
+
# project Ruby's chi, an old one or none) an agent's `chi send`, `chi self`
|
|
16
|
+
# or `chi answer` still runs the home's chi. The dir holds `chi` only: the
|
|
17
|
+
# project's ruby, gem and bundle stay the project's; bin/chi, a checkout
|
|
18
|
+
# path or `bundle exec chi` stay themselves.
|
|
19
|
+
module ChiShim
|
|
20
|
+
NAME = "chi"
|
|
21
|
+
HEADER = "# chi's own chi for its tools (ChiShim): rewritten at each worker start from the chi home.\n"
|
|
22
|
+
# A project's RUBYOPT/RUBYLIB/RUBYGEMS_GEMDEPS/GEMRC/BUNDLE_* (a `bundle
|
|
23
|
+
# exec` the tool runs under, a mise [env]) would load the project's gems
|
|
24
|
+
# or settings into the home's chi, its LD_*/DYLD_* libraries into its
|
|
25
|
+
# ruby: unset before the exec.
|
|
26
|
+
UNSET = "unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS GEMRC LD_PRELOAD LD_LIBRARY_PATH DYLD_INSERT_LIBRARIES " \
|
|
27
|
+
"DYLD_LIBRARY_PATH\n" \
|
|
28
|
+
"for v in $(/usr/bin/env | /usr/bin/sed -n 's/^\\(BUNDLE[A-Za-z0-9_]*\\)=.*/\\1/p'); do unset \"$v\"; done\n"
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# @return [String] $XDG_STATE_HOME/samagotchi/bin
|
|
33
|
+
def dir(env: ENV) = File.join(Paths.state_dir(env: env), "bin")
|
|
34
|
+
|
|
35
|
+
def path(dir: self.dir) = File.join(dir, NAME)
|
|
36
|
+
|
|
37
|
+
# The script for +home+.
|
|
38
|
+
# @param home [ChiHome::Record]
|
|
39
|
+
def content(home)
|
|
40
|
+
argv = home.boot_argv(run: ChiHome::CHI_BOOT) + ["--"]
|
|
41
|
+
"#!/bin/sh\n#{HEADER}#{UNSET}exec #{argv.map { |arg| Shellwords.escape(arg) }.join(" ")} \"$@\"\n"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Writes the shim for +home+ unless it is already that (atomically: a
|
|
45
|
+
# tool running `chi` meanwhile gets the old one or the new one).
|
|
46
|
+
# @return [Boolean] whether the shim is there and current
|
|
47
|
+
def ensure!(home, dir: self.dir)
|
|
48
|
+
file = path(dir: dir)
|
|
49
|
+
want = content(home)
|
|
50
|
+
return true if current?(file, want)
|
|
51
|
+
|
|
52
|
+
FileUtils.mkdir_p(dir)
|
|
53
|
+
AtomicFile.write(file, want, perm: 0o755)
|
|
54
|
+
true
|
|
55
|
+
rescue SystemCallError => e
|
|
56
|
+
Log.warn(:worker, "chi_shim_failed", path: file, error: e.class.name, msg: e.message)
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Why the shim isn't +home+'s (chi home doctor), or nil: nil too
|
|
61
|
+
# without one (the first worker start writes it).
|
|
62
|
+
# @return [String, nil]
|
|
63
|
+
def problem(home, dir: self.dir)
|
|
64
|
+
file = path(dir: dir)
|
|
65
|
+
return nil if !File.exist?(file) || current?(file, content(home))
|
|
66
|
+
|
|
67
|
+
"the chi script tools run (#{Paths.tilde(file)}) boots another chi than the home: " \
|
|
68
|
+
"the next worker start rewrites it (chi sessions restart ID for a running session)"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def current?(file, want)
|
|
72
|
+
File.file?(file) && File.executable?(file) && File.read(file) == want
|
|
73
|
+
rescue SystemCallError
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/samagotchi/child_env.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "installed_gem"
|
|
4
4
|
require_relative "installed_versions"
|
|
5
|
+
require_relative "project_env"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
# What a chi process another chi starts must not take from its spawner's
|
|
@@ -23,11 +24,12 @@ module Samagotchi
|
|
|
23
24
|
module_function
|
|
24
25
|
|
|
25
26
|
# The keys of +env+ a child chi must not inherit: the guardrails
|
|
26
|
-
# settings, the session markers
|
|
27
|
-
# (InstalledVersions::ENV_KEY, for the spawner's notices only)
|
|
27
|
+
# settings, the session markers, a smoke's fake "newest installed"
|
|
28
|
+
# (InstalledVersions::ENV_KEY, for the spawner's notices only) and the
|
|
29
|
+
# worker's project env marker and record (a child worker applies its own).
|
|
28
30
|
# @return [Array<String>]
|
|
29
31
|
def spawner_keys(env = ENV)
|
|
30
|
-
env.each_key.select { |key| key.start_with?(GUARDRAILS_PREFIX) } + SESSION_MARKERS + [InstalledVersions::ENV_KEY]
|
|
32
|
+
env.each_key.select { |key| key.start_with?(GUARDRAILS_PREFIX) } + SESSION_MARKERS + [InstalledVersions::ENV_KEY, ProjectEnv::MARKER_ENV, ProjectEnv::ORIG_ENV]
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
# #spawner_keys mapped to nil: Process.spawn unsets a key mapped to nil.
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -138,6 +138,8 @@ module Samagotchi
|
|
|
138
138
|
Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "", expose: %i[env config cli], lifecycle: :next_retention_sweep),
|
|
139
139
|
Entry.new(key: "session.sweep_interval_hours", yaml_path: %w[session sweep_interval_hours], type: :integer, default: 24, expose: %i[env config cli], lifecycle: :next_retention_sweep),
|
|
140
140
|
Entry.new(key: "session.idle_exit_minutes", yaml_path: %w[session idle_exit_minutes], type: :float, default: 30.0, expose: %i[env config cli], lifecycle: :worker_start),
|
|
141
|
+
# Tools follow the project: a worker applies its folder's `mise env` at start (ProjectEnv); off keeps the spawner's env.
|
|
142
|
+
Entry.new(key: "session.project_env", yaml_path: %w[session project_env], type: :enum, default: "mise", expose: %i[env config], enum_values: %w[mise off], lifecycle: :worker_start),
|
|
141
143
|
# Plain `chi` runs like `chi --shared` (bin/chi, LaunchMode); false, or --no-shared per run, keeps the plain REPL. No CLI flag: that would duplicate --shared.
|
|
142
144
|
Entry.new(key: "session.shared", yaml_path: %w[session shared], type: :bool, default: true, expose: %i[env config], lifecycle: :next_invocation),
|
|
143
145
|
# false: a session nothing happened in is deleted when it is left (SessionManager.discardable?).
|
|
@@ -5,6 +5,7 @@ require_relative "context_sources"
|
|
|
5
5
|
require_relative "context_providers"
|
|
6
6
|
require_relative "log"
|
|
7
7
|
require_relative "process_group"
|
|
8
|
+
require_relative "project_env"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
# Runs an attached context source's command once and records the result
|
|
@@ -108,8 +109,9 @@ module Samagotchi
|
|
|
108
109
|
|
|
109
110
|
def env_for(location, name)
|
|
110
111
|
previous = location.snapshot_path(name)
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
# The session's project env (ProjectEnv) under the source's own keys.
|
|
113
|
+
ProjectEnv.tools_env.merge("SAMAGOTCHI_CONTEXT_NAME" => name,
|
|
114
|
+
"SAMAGOTCHI_CONTEXT_PREVIOUS" => File.exist?(previous) ? previous : nil)
|
|
113
115
|
end
|
|
114
116
|
|
|
115
117
|
# @return [Run]
|
|
@@ -233,6 +233,32 @@ module Samagotchi
|
|
|
233
233
|
argv && argv_problem(argv)
|
|
234
234
|
end
|
|
235
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?
|
|
258
|
+
|
|
259
|
+
"the desktop helper's launch file sets #{baked.join(", ")} for its sessions' tools: chi desktop upgrade"
|
|
260
|
+
end
|
|
261
|
+
|
|
236
262
|
# The chi version the launch file names, nil without one.
|
|
237
263
|
def launch_version = read_launch["version"]
|
|
238
264
|
|
|
@@ -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.-]*)
|
|
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),
|
|
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)
|
|
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
|
|
|
@@ -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
|
|
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)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "rbconfig"
|
|
4
4
|
require_relative "chi_home"
|
|
5
|
+
require_relative "home_doctor"
|
|
5
6
|
require_relative "cli/command"
|
|
6
7
|
require_relative "cli/exit"
|
|
7
8
|
require_relative "cli/flags"
|
|
@@ -12,7 +13,7 @@ require_relative "version"
|
|
|
12
13
|
module Samagotchi
|
|
13
14
|
# `chi home`: the chi home (ChiHome), the Ruby every chi launch boots.
|
|
14
15
|
# Shows it, sets it (`chi home set [--ruby PATH]`), and checks it
|
|
15
|
-
# (`chi home doctor
|
|
16
|
+
# (`chi home doctor`, HomeDoctor's rows).
|
|
16
17
|
class HomeCommand
|
|
17
18
|
include CLI::Command
|
|
18
19
|
|
|
@@ -23,7 +24,9 @@ module Samagotchi
|
|
|
23
24
|
GEM_HOME/GEM_PATH are part of it)
|
|
24
25
|
set --ruby PATH record that Ruby (its own gem dirs) as the home; it must have samagotchi
|
|
25
26
|
(gem install samagotchi with it first)
|
|
26
|
-
doctor check the home
|
|
27
|
+
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
|
|
27
30
|
Every chi launch (workers, chi web's restart and Update, the macOS helper's next
|
|
28
31
|
chi desktop install or upgrade) boots the home's Ruby with the home's gems. Saved in
|
|
29
32
|
#{Paths.tilde(ChiHome.path)}.
|
|
@@ -38,8 +41,9 @@ module Samagotchi
|
|
|
38
41
|
|
|
39
42
|
# @param path [String] home.json
|
|
40
43
|
# @param probe [#call] (ruby) → ChiHome::Record or nil (ChiHome.probe)
|
|
44
|
+
# @param doctor [#call] (home) → HomeDoctor
|
|
41
45
|
def initialize(argv, stdout: $stdout, stderr: $stderr, path: ChiHome.path, gem_spec: InstalledGem.spec,
|
|
42
|
-
env: ENV, probe: nil)
|
|
46
|
+
env: ENV, probe: nil, doctor: nil)
|
|
43
47
|
@argv = argv.dup
|
|
44
48
|
@stdout = stdout
|
|
45
49
|
@stderr = stderr
|
|
@@ -47,6 +51,9 @@ module Samagotchi
|
|
|
47
51
|
@gem_spec = gem_spec
|
|
48
52
|
@env = env
|
|
49
53
|
@probe = probe || ->(ruby) { ChiHome.probe(ruby, env: env) }
|
|
54
|
+
@doctor = doctor || lambda { |home|
|
|
55
|
+
HomeDoctor.new(home: home, env: env, gem_spec: gem_spec, helper: HomeDoctor.default_helper(env: env))
|
|
56
|
+
}
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
# @return [Integer] 0, 1 (no usable home, a refusal), 2 usage
|
|
@@ -127,23 +134,12 @@ module Samagotchi
|
|
|
127
134
|
home
|
|
128
135
|
end
|
|
129
136
|
|
|
130
|
-
#
|
|
131
|
-
#
|
|
137
|
+
# The home's rows and what should follow it (HomeDoctor); exit 1 when
|
|
138
|
+
# one fails.
|
|
132
139
|
def run_doctor
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return CLI::Exit::OK
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
if (problem = ChiHome.problem(home))
|
|
140
|
-
@stdout.puts("fail #{problem}: after a Ruby upgrade, gem install samagotchi with the new Ruby, then " \
|
|
141
|
-
"chi home set from it (chi home set --ruby PATH); until then launches use the Ruby that starts them")
|
|
142
|
-
return CLI::Exit::FAILED
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
@stdout.puts("ok chi home #{home.ruby} runs chi #{home.newest_version}")
|
|
146
|
-
CLI::Exit::OK
|
|
140
|
+
rows = @doctor.call(ChiHome.read(path: @path)).rows
|
|
141
|
+
rows.each { |row| @stdout.puts(row.to_s) }
|
|
142
|
+
rows.any? { |row| row.status == :fail } ? CLI::Exit::FAILED : CLI::Exit::OK
|
|
147
143
|
end
|
|
148
144
|
end
|
|
149
145
|
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require_relative "chi_home"
|
|
5
|
+
require_relative "chi_shim"
|
|
6
|
+
require_relative "config"
|
|
7
|
+
require_relative "desktop"
|
|
8
|
+
require_relative "home_notice"
|
|
9
|
+
require_relative "installed_gem"
|
|
10
|
+
require_relative "log_path"
|
|
11
|
+
require_relative "paths"
|
|
12
|
+
require_relative "project_env"
|
|
13
|
+
require_relative "session"
|
|
14
|
+
require_relative "version"
|
|
15
|
+
|
|
16
|
+
module Samagotchi
|
|
17
|
+
# `chi home doctor`'s rows: the chi home (ChiHome) and what should follow
|
|
18
|
+
# it, each one line with a status and what to run. One check each; the
|
|
19
|
+
# text is the condition owner's (ChiHome, HomeNotice, Desktop::MacOS,
|
|
20
|
+
# ChiShim, ProjectEnv) where another surface says the same.
|
|
21
|
+
class HomeDoctor
|
|
22
|
+
# +status+ :ok, :warn (works, not as intended) or :fail (a launch can't
|
|
23
|
+
# boot what it should).
|
|
24
|
+
Row = Data.define(:status, :text) do
|
|
25
|
+
def to_s = "#{status.to_s.ljust(5)} #{text}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# What a Gemfile.lock that bundles chi has (a GEM or PATH spec line).
|
|
29
|
+
LOCKED_CHI = /^ {4}samagotchi \(/
|
|
30
|
+
|
|
31
|
+
# @param home [ChiHome::Record, nil] home.json's record
|
|
32
|
+
# @param helper [Desktop::MacOS, nil] the desktop helper (nil off macOS)
|
|
33
|
+
# @param sessions [#call] () → [Session] this machine's sessions
|
|
34
|
+
def initialize(home:, env: ENV, cwd: Dir.pwd, gem_spec: InstalledGem.spec, helper: nil,
|
|
35
|
+
sessions: -> { Session.list(state_dir: Session.default_state_dir(env: env)) },
|
|
36
|
+
shim_dir: ChiShim.dir(env: env), log_path: LogPath.resolve(env: env),
|
|
37
|
+
ruby: RbConfig.ruby, gem_dir: Gem.dir)
|
|
38
|
+
@home = home
|
|
39
|
+
@env = env
|
|
40
|
+
@cwd = cwd
|
|
41
|
+
@gem_spec = gem_spec
|
|
42
|
+
@helper = helper
|
|
43
|
+
@sessions = sessions
|
|
44
|
+
@shim_dir = shim_dir
|
|
45
|
+
@log_path = log_path
|
|
46
|
+
@ruby = ruby
|
|
47
|
+
@gem_dir = gem_dir
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Array<Row>]
|
|
51
|
+
def rows
|
|
52
|
+
list = [home_row]
|
|
53
|
+
if @home && !ChiHome.problem(@home)
|
|
54
|
+
list.concat([running_row, helper_row, sessions_row, shim_row].compact)
|
|
55
|
+
end
|
|
56
|
+
list.concat([gemfile_row, mise_row, project_env_row].compact)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The desktop helper chi home doctor checks: this machine's, when it
|
|
60
|
+
# is installed.
|
|
61
|
+
def self.default_helper(env: ENV)
|
|
62
|
+
return nil unless Desktop.supported?
|
|
63
|
+
|
|
64
|
+
helper = Desktop::MacOS.new(env: env, register: false)
|
|
65
|
+
File.file?(helper.launch_path) ? helper : nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def ok_row(text) = Row.new(status: :ok, text: text)
|
|
71
|
+
def warn_row(text) = Row.new(status: :warn, text: text)
|
|
72
|
+
def fail_row(text) = Row.new(status: :fail, text: text)
|
|
73
|
+
|
|
74
|
+
def home_row
|
|
75
|
+
return ok_row("no chi home: each launch uses the Ruby that starts it (chi home set to record one)") unless @home
|
|
76
|
+
|
|
77
|
+
if (problem = ChiHome.problem(@home))
|
|
78
|
+
return fail_row("#{problem}: after a Ruby upgrade, gem install samagotchi with the new Ruby, then " \
|
|
79
|
+
"chi home set from it (chi home set --ruby PATH); until then launches use the Ruby that starts them")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
ok_row("chi home #{@home.ruby} runs chi #{@home.newest_version}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def running_row
|
|
86
|
+
return ok_row("this chi runs from a source checkout: it starts its own code, not the home") unless @gem_spec
|
|
87
|
+
|
|
88
|
+
text = HomeNotice.mismatch(@home, ruby: @ruby, gem_dir: @gem_dir)
|
|
89
|
+
text && warn_row("#{text}: launches boot the home; chi home set makes this chi the home")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def helper_row
|
|
93
|
+
return nil unless @helper
|
|
94
|
+
|
|
95
|
+
if (problem = @helper.launch_problem)
|
|
96
|
+
fail_row(problem)
|
|
97
|
+
elsif (problem = @helper.home_problem(@home))
|
|
98
|
+
warn_row(problem)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Sessions last saved by a newer chi than the home's: their workers
|
|
103
|
+
# (the home's chi) run them without what that chi added.
|
|
104
|
+
def sessions_row
|
|
105
|
+
newest = @home.newest_version
|
|
106
|
+
newer = @sessions.call.filter_map do |session|
|
|
107
|
+
version = session.written_by
|
|
108
|
+
Gem::Version.new(version) if version.is_a?(String) && Gem::Version.correct?(version)
|
|
109
|
+
end.select { |version| version > newest }
|
|
110
|
+
return nil if newer.empty?
|
|
111
|
+
|
|
112
|
+
count = newer.size == 1 ? "1 session was" : "#{newer.size} sessions were"
|
|
113
|
+
warn_row("#{count} last saved by a newer chi (#{newer.max}) than the home's #{newest}: " \
|
|
114
|
+
"their workers run without what it added; chi update brings the home up to date")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def shim_row
|
|
118
|
+
problem = ChiShim.problem(@home, dir: @shim_dir)
|
|
119
|
+
problem && warn_row(problem)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The cwd's project bundling chi (a Gemfile.lock with samagotchi):
|
|
123
|
+
# `bundle exec chi` there runs the bundle's chi, off the home.
|
|
124
|
+
def gemfile_row
|
|
125
|
+
lock = project_lockfile
|
|
126
|
+
return nil unless lock && File.read(lock).match?(LOCKED_CHI)
|
|
127
|
+
|
|
128
|
+
warn_row("#{Paths.tilde(lock)} bundles samagotchi: chi under the project's bundle (bundle exec chi) isn't " \
|
|
129
|
+
"supported; run chi itself, and bundle remove samagotchi there")
|
|
130
|
+
rescue SystemCallError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The nearest Gemfile.lock from the cwd up to its git root; nil for
|
|
135
|
+
# chi's own source tree (its gemspec is next to it).
|
|
136
|
+
def project_lockfile
|
|
137
|
+
dir = project_dirs.find { |candidate| File.file?(File.join(candidate, "Gemfile.lock")) }
|
|
138
|
+
return nil if dir.nil? || File.file?(File.join(dir, "samagotchi.gemspec"))
|
|
139
|
+
|
|
140
|
+
File.join(dir, "Gemfile.lock")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The cwd and the folders above it up to its git root (the cwd alone
|
|
144
|
+
# outside git).
|
|
145
|
+
def project_dirs
|
|
146
|
+
@project_dirs ||= begin
|
|
147
|
+
dirs = []
|
|
148
|
+
dir = File.expand_path(@cwd)
|
|
149
|
+
loop do
|
|
150
|
+
dirs << dir
|
|
151
|
+
break if File.exist?(File.join(dir, ".git"))
|
|
152
|
+
|
|
153
|
+
parent = File.dirname(dir)
|
|
154
|
+
break dirs = [dirs.first] if parent == dir
|
|
155
|
+
|
|
156
|
+
dir = parent
|
|
157
|
+
end
|
|
158
|
+
dirs
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def mise_row
|
|
163
|
+
mode, origin = Config.get_with_origin("session.project_env")
|
|
164
|
+
return ok_row("project env off (session.project_env: off): tools keep the env of whoever starts the worker") if mode.to_s == "off"
|
|
165
|
+
|
|
166
|
+
mise = ProjectEnv.find_mise(env: @env)
|
|
167
|
+
return ok_row("project env: mise (#{mise})") if mise
|
|
168
|
+
|
|
169
|
+
origin == :default ? ok_row(ProjectEnv::NO_MISE) : warn_row(ProjectEnv::NO_MISE)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def project_env_row
|
|
173
|
+
warning = ProjectEnv.last_warning(project_dirs, log_path: @log_path)
|
|
174
|
+
warning && warn_row(warning.message)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|