samagotchi 0.129.0 → 0.130.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +29 -11
  5. data/docs/configuration.md +7 -2
  6. data/docs/desktop.md +28 -9
  7. data/docs/guardrails.md +60 -16
  8. data/docs/internals/background-tasks.md +12 -0
  9. data/docs/internals/engine.md +4 -3
  10. data/docs/internals/participants.md +25 -6
  11. data/docs/internals/remote-sessions.md +5 -4
  12. data/docs/sessions.md +5 -3
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/desktop/macos.rb +99 -19
  15. data/lib/samagotchi/desktop_command.rb +7 -2
  16. data/lib/samagotchi/engine.rb +20 -4
  17. data/lib/samagotchi/engine_peers.rb +3 -1
  18. data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
  19. data/lib/samagotchi/guardrails/child_boundary.rb +5 -5
  20. data/lib/samagotchi/guardrails/outside.rb +22 -20
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +14 -3
  22. data/lib/samagotchi/guardrails/pair_command.rb +70 -10
  23. data/lib/samagotchi/guardrails/parent_approvals.rb +8 -11
  24. data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
  25. data/lib/samagotchi/guardrails/protected_paths.rb +17 -116
  26. data/lib/samagotchi/guardrails/review_paths.rb +13 -11
  27. data/lib/samagotchi/guardrails/rm_targets.rb +5 -5
  28. data/lib/samagotchi/guardrails/rules.rb +23 -5
  29. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -2
  30. data/lib/samagotchi/guardrails/shell_git_dirs.rb +17 -12
  31. data/lib/samagotchi/guardrails/shell_paths.rb +18 -29
  32. data/lib/samagotchi/guardrails/targets.rb +24 -13
  33. data/lib/samagotchi/remote_command.rb +13 -3
  34. data/lib/samagotchi/remotes.rb +3 -2
  35. data/lib/samagotchi/review_start.rb +3 -2
  36. data/lib/samagotchi/session.rb +49 -4
  37. data/lib/samagotchi/session_inbox.rb +17 -0
  38. data/lib/samagotchi/system_one/self_protect.rb +34 -19
  39. data/lib/samagotchi/terminal_ui.rb +2 -0
  40. data/lib/samagotchi/tool_declarations.rb +1 -1
  41. data/lib/samagotchi/tools/builtins.rb +10 -1
  42. data/lib/samagotchi/tools/execute.rb +5 -2
  43. data/lib/samagotchi/tools/peers.rb +4 -0
  44. data/lib/samagotchi/tools/task_runtime.rb +4 -2
  45. data/lib/samagotchi/tools/task_wait.rb +26 -1
  46. data/lib/samagotchi/turn_state.rb +23 -4
  47. data/lib/samagotchi/update_command.rb +9 -1
  48. data/lib/samagotchi/version.rb +1 -1
  49. data/lib/samagotchi/web/app.rb +69 -25
  50. data/lib/samagotchi/web/participant_review.rb +9 -2
  51. data/lib/samagotchi/web/proxy_gate.rb +14 -6
  52. data/lib/samagotchi/web/server.rb +29 -4
  53. data/lib/samagotchi/web/token.rb +20 -1
  54. data/lib/samagotchi/worker.rb +16 -1
  55. metadata +3 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "protected_paths"
3
+ require_relative "chi_dirs"
4
+ require_relative "path_resolve"
4
5
  require_relative "shell_paths"
5
6
  require_relative "../paths"
6
7
 
@@ -11,7 +12,7 @@ module Samagotchi
11
12
  # (chi's state and config dirs, home secret dirs, project secret files),
12
13
  # checked on the path as given and as it resolves (a symlink in the
13
14
  # project to ~/.ssh is ~/.ssh; a `..` after a symlinked dir climbs out
14
- # of its target, as the kernel opens it: ProtectedPaths.reals).
15
+ # of its target, as the kernel opens it: PathResolve.reals).
15
16
  # ReviewBoundary asks it for every read of a participant's turn; a
16
17
  # review session's subjects (chi review, the participant page's Plan)
17
18
  # go through the same check.
@@ -45,8 +46,9 @@ module Samagotchi
45
46
  SECRET = :secret
46
47
  HOME_ROOT = :home_root
47
48
 
48
- # chi's state dir and config dir, as Paths has them now.
49
- def self.chi_dirs = [Paths.state_dir, File.join(Paths.config_home, Paths::APP_DIR)]
49
+ # chi's state dir, config dir and a hooks dir configured outside it
50
+ # (ChiDirs.review).
51
+ def self.chi_dirs = ChiDirs.review
50
52
 
51
53
  # Whether +root+ is the home folder or a folder above it (symlinks
52
54
  # resolved on both): no project to keep a participant's turn in. A
@@ -56,7 +58,7 @@ module Samagotchi
56
58
  def self.home_root?(root, home)
57
59
  return false if home.to_s.empty? || root.to_s.empty?
58
60
 
59
- ShellPaths.within?(ProtectedPaths.real(home.to_s), ProtectedPaths.real(root.to_s))
61
+ PathResolve.under?(PathResolve.real(home.to_s), PathResolve.real(root.to_s))
60
62
  end
61
63
 
62
64
  # @param root [String, nil] the session's project root (the work tree
@@ -81,12 +83,12 @@ module Samagotchi
81
83
 
82
84
  return HOME_ROOT if ReviewPaths.home_root?(@root, @home)
83
85
 
84
- real_root = ProtectedPaths.real(@root)
86
+ real_root = PathResolve.real(@root)
85
87
 
86
88
  paths.each do |path|
87
89
  given = File.expand_path(path)
88
- reals = ProtectedPaths.reals(path)
89
- return OUTSIDE unless reals.all? { |real| ShellPaths.within?(real, real_root) }
90
+ reals = PathResolve.reals(path)
91
+ return OUTSIDE unless reals.all? { |real| PathResolve.under?(real, real_root) }
90
92
  return SECRET if secret?(given) || reals.any? { |real| secret?(real) }
91
93
  end
92
94
  nil
@@ -100,7 +102,7 @@ module Samagotchi
100
102
  def secret_refusal(paths)
101
103
  paths.each do |path|
102
104
  given = File.expand_path(path)
103
- return SECRET if secret?(given) || ProtectedPaths.reals(path).any? { |real| secret?(real) }
105
+ return SECRET if secret?(given) || PathResolve.reals(path).any? { |real| secret?(real) }
104
106
  end
105
107
  nil
106
108
  end
@@ -113,13 +115,13 @@ module Samagotchi
113
115
  @denied_roots ||= begin
114
116
  roots = @denied_dirs.compact.map { |dir| File.expand_path(dir) }
115
117
  roots += (HOME_SECRET_DIRS + HOME_SECRET_FILES).map { |name| File.join(@home, name) } unless @home.empty?
116
- (roots + roots.map { |root| ProtectedPaths.real(root) }).uniq
118
+ (roots + roots.map { |root| PathResolve.real(root) }).uniq
117
119
  end
118
120
  end
119
121
 
120
122
  # Whether +path+ (absolute) is on the read denylist.
121
123
  def secret?(path)
122
- return true if denied_roots.any? { |root| ShellPaths.within?(path, root) }
124
+ return true if denied_roots.any? { |root| PathResolve.under?(path, root) }
123
125
 
124
126
  parts = path.split(File::SEPARATOR)
125
127
  return true if parts.any? { |part| SECRET_FILE.match?(part) }
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "shell_lex"
4
4
  require_relative "shell_paths"
5
- require_relative "protected_paths"
5
+ require_relative "path_resolve"
6
6
 
7
7
  module Samagotchi
8
8
  module Guardrails
@@ -38,8 +38,8 @@ module Samagotchi
38
38
  code = ShellLex.code_text(command.to_s)
39
39
  return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && code.match?(/\brm\b/)
40
40
 
41
- session = ProtectedPaths.real(session_root.to_s)
42
- roots = tmp_roots.reject { |tmp| ShellPaths.within?(session, tmp) }
41
+ session = PathResolve.real(session_root.to_s)
42
+ roots = PathResolve.tmp_roots(session: session, roots: tmp_roots)
43
43
  walk = ShellPaths::Walk.new(cwd, home, env)
44
44
  ShellLex.simple_commands(tokens).any? do |words|
45
45
  wide = words.is_a?(Array) && words.any?(&RM_WORD) && wide?(words, walk, roots)
@@ -62,8 +62,8 @@ module Samagotchi
62
62
 
63
63
  targets.empty? || targets.any? do |target|
64
64
  paths = walk.paths(target)
65
- paths.empty? || paths.flat_map { |path| ProtectedPaths.reals(path) }.any? do |real|
66
- roots.none? { |tmp| real.start_with?(File.join(tmp, "")) }
65
+ paths.empty? || paths.flat_map { |path| PathResolve.reals(path) }.any? do |real|
66
+ roots.none? { |tmp| PathResolve.strictly_under?(real, tmp, fold: File.exist?(tmp)) }
67
67
  end
68
68
  end
69
69
  end
@@ -16,9 +16,12 @@ module Samagotchi
16
16
  # command: a Ruby regex on a shell tool's command, the body of a
17
17
  # heredoc its command only reads as data left out
18
18
  # (Targets#rule_command, ShellLex.code_text)
19
- # path: "outside_repo", or a glob on the resolved paths (absolute
20
- # or "**/…" globs match the absolute path; others the path
21
- # relative to the repo root, the cwd outside one)
19
+ # path: "outside_repo", or a glob on the paths (absolute or "**/…"
20
+ # globs match the absolute path; others the path relative
21
+ # to the repo root, the cwd outside one): as typed
22
+ # (Targets#paths) and, for an ask or deny, as they resolve
23
+ # (PathResolve.reals, relative to the root as it resolves),
24
+ # so a symlink into .git/hooks doesn't slip past a hooks rule
22
25
  # git: "outside_repo": a shell call runs a mutating git
23
26
  # subcommand outside the session's repo (ShellGitDirs)
24
27
  # models: "small" (Guardrails::ModelSize, guardrails.small_models),
@@ -102,12 +105,27 @@ module Samagotchi
102
105
 
103
106
  base = targets.repo_root || targets.cwd
104
107
  glob = path.start_with?("~") ? File.expand_path(path) : path
108
+ absolute = glob.start_with?("/", "**")
109
+ real_base = nil
105
110
  targets.paths.any? do |p|
106
- candidate = glob.start_with?("/", "**") ? p : relative(p, base)
107
- candidate && File.fnmatch(glob, candidate, GLOB_FLAGS)
111
+ forms = [[p, base]]
112
+ # A resolved match only adds an ask or a deny; were a verdict
113
+ # ever to let a call through, a symlink into its glob must not.
114
+ if resolved_match?
115
+ real_base ||= targets.real_rule_base
116
+ forms += targets.real_forms(p).map { |real| [real, real_base] }
117
+ end
118
+ forms.any? do |form, from|
119
+ candidate = absolute ? form : relative(form, from)
120
+ candidate && File.fnmatch(glob, candidate, GLOB_FLAGS)
121
+ end
108
122
  end
109
123
  end
110
124
 
125
+ # Whether a path: glob also matches the paths as they resolve: for
126
+ # an ask or a deny only.
127
+ def resolved_match? = VERDICTS.include?(verdict.to_s)
128
+
111
129
  def relative(path, base)
112
130
  prefix = File.join(base, "")
113
131
  path.start_with?(prefix) ? path.delete_prefix(prefix) : nil
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "path_resolve"
3
4
  require_relative "protected_paths"
4
5
  require_relative "../memory_paths"
5
6
 
@@ -33,8 +34,8 @@ module Samagotchi
33
34
  targets = verdict.targets
34
35
  return false unless targets && %w[write edit].include?(targets.tool)
35
36
 
36
- targets.paths.flat_map { |path| ProtectedPaths.reals(path) }.any? do |real|
37
- ProtectedPaths.within?(real, @memories_dir.call)
37
+ targets.paths.flat_map { |path| PathResolve.reals(path) }.any? do |real|
38
+ PathResolve.within?(real, @memories_dir.call)
38
39
  end
39
40
  end
40
41
  end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "shell_lex"
4
- require_relative "protected_paths"
4
+ require_relative "path_resolve"
5
+ require_relative "shell_paths"
5
6
 
6
7
  module Samagotchi
7
8
  module Guardrails
@@ -11,11 +12,13 @@ module Samagotchi
11
12
  # it follows cd/pushd/popd, ( … ) subshells, git -C/--git-dir/--work-tree
12
13
  # and GIT_DIR/GIT_WORK_TREE. Where a `..` climbs out of a symlinked
13
14
  # dir's target, its lexical path and its physical one
14
- # (ProtectedPaths.expand, the only look at the disk) both count: a cd
15
+ # (PathResolve.expand, the only look at the disk) both count: a cd
15
16
  # may land at either (the shell's logical cd chdirs to the physical
16
17
  # path when the logical one doesn't exist, and always with -P), so the
17
18
  # walk keeps every dir the command may be in, and git's -C and dir
18
- # options are opened the kernel's way; such a git counts from each. It
19
+ # options are opened the kernel's way; such a git counts from each. A
20
+ # leading ~, $HOME, XDG dir or $TMPDIR is expanded as ShellPaths::Walk
21
+ # expands it (PathResolve.expand_word). It
19
22
  # catches honest mistakes (cd into another checkout and commit
20
23
  # there); sh -c, scripts, aliases and $(…) bodies get through. Never
21
24
  # raises on odd input.
@@ -32,9 +35,11 @@ module Samagotchi
32
35
  # @param home [String] for ~, $HOME, ${HOME} and a bare cd
33
36
  # @param env [Hash{String=>String}] the call's env: (task_create's):
34
37
  # its GIT_DIR / GIT_WORK_TREE hold for each git unless it sets its own
38
+ # @param vars [Hash{String=>String}] the shell's variables a word's
39
+ # $XDG_* / $TMPDIR expand from (ENV, with the call's env: over it)
35
40
  # @return [Array<String, Symbol>] absolute dirs and :unknown, unique
36
- def self.for(command, cwd:, home: Dir.home, env: {})
37
- Walk.new(cwd, home, env).run(ShellLex.simple_commands(ShellLex.lex(command.to_s)))
41
+ def self.for(command, cwd:, home: Dir.home, env: {}, vars: ENV)
42
+ Walk.new(cwd, home, env, vars).run(ShellLex.simple_commands(ShellLex.lex(command.to_s)))
38
43
  rescue ArgumentError, EncodingError
39
44
  [] # a NUL byte, invalid UTF-8: the shell would refuse it too
40
45
  end
@@ -45,9 +50,10 @@ module Samagotchi
45
50
  # More dirs than this after a cd: the dir is unknown.
46
51
  MAX_DIRS = 8
47
52
 
48
- def initialize(cwd, home, env = {})
53
+ def initialize(cwd, home, env = {}, vars = ENV)
49
54
  @dirs = [cwd]
50
55
  @home = home
56
+ @vars = vars
51
57
  @env = env.slice("GIT_DIR", "GIT_WORK_TREE")
52
58
  @subshells = []
53
59
  @pushed = []
@@ -149,17 +155,16 @@ module Samagotchi
149
155
  end
150
156
 
151
157
  # An absolute path for +arg+ against +dir+ (`..` lexical, or
152
- # +physical+: ProtectedPaths.expand); nil when it can't
153
- # be told from the text (a variable, a substitution, a relative path
154
- # against an unknown dir).
158
+ # +physical+: PathResolve.expand); nil when it can't
159
+ # be told from the text (a variable but those PathResolve.expand_word
160
+ # expands, a substitution, a relative path against an unknown dir).
155
161
  def expand(arg, dir, physical: false)
156
162
  return nil if arg.include?(ShellLex::SUBST)
157
163
 
158
- arg = arg.sub(%r{\A(~|\$HOME|\$\{HOME\})(?=/|\z)}) { @home }
159
- return nil if arg.include?("$") || arg.start_with?("~")
164
+ arg = PathResolve.expand_word(arg, home: @home, env: @vars) or return nil
160
165
  return nil unless arg.start_with?("/") || dir
161
166
 
162
- physical ? ProtectedPaths.expand(arg, dir) : File.expand_path(arg, dir)
167
+ physical ? PathResolve.expand(arg, dir) : File.expand_path(arg, dir)
163
168
  end
164
169
  end
165
170
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "shell_lex"
4
- require_relative "protected_paths"
4
+ require_relative "path_resolve"
5
5
  require_relative "../redacted_inspect"
6
6
 
7
7
  module Samagotchi
@@ -24,11 +24,10 @@ module Samagotchi
24
24
  SCRIPT = /[\s;&|<>()'"`]/
25
25
  GLOB = /[*?\[\]{}]/
26
26
  HOOKS_PART = %r{(?:\A|/)\.git/hooks(?:/|\z)}
27
- # $HOME, ${HOME} and the XDG dirs at a word's start.
28
- EXPANSION = /\A\$(?:\{(HOME|XDG_[A-Z_]+|TMPDIR)\}|(HOME|XDG_[A-Z_]+|TMPDIR)\b)/
27
+ # $HOME, ${HOME} and the XDG dirs at a word's start (PathResolve's).
28
+ EXPANSION = PathResolve::EXPANSION
29
29
  # Where XDG dirs default when unset.
30
- XDG_DEFAULTS = { "XDG_CONFIG_HOME" => ".config", "XDG_STATE_HOME" => ".local/state",
31
- "XDG_DATA_HOME" => ".local/share", "XDG_CACHE_HOME" => ".cache" }.freeze
30
+ XDG_DEFAULTS = PathResolve::XDG_DEFAULTS
32
31
  # A redirection's operator at a word's start (>>file, 2>file, <file).
33
32
  REDIRECT_PREFIX = /\A\d*(?:&>>?|>>?&?|<)/
34
33
 
@@ -40,7 +39,7 @@ module Samagotchi
40
39
  # @param cwd [String, nil] where the command starts
41
40
  # @param tmp_roots [Array<String>] where a .git/hooks part doesn't count
42
41
  def touches?(command, dirs:, text:, cwd:, home: Dir.home, env: ENV, tmp_roots: [])
43
- roots = dirs.compact.map { |dir| ProtectedPaths.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
42
+ roots = dirs.compact.map { |dir| PathResolve.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
44
43
  any_path?(command, text: text, cwd: cwd, home: home, env: env) { |path| names?(path, roots, tmp_roots) }
45
44
  end
46
45
 
@@ -64,20 +63,22 @@ module Samagotchi
64
63
  end
65
64
 
66
65
  def names?(path, roots, tmp_roots)
67
- ProtectedPaths.reals(path).any? do |real|
68
- next true if roots.any? { |root| within?(real, root) }
66
+ PathResolve.reals(path).any? do |real|
67
+ next true if roots.any? { |root| PathResolve.under?(real, root) }
69
68
 
70
- real.match?(HOOKS_PART) && tmp_roots.none? { |tmp| within?(real, tmp) }
69
+ real.match?(HOOKS_PART) && tmp_roots.none? { |tmp| PathResolve.under?(real, tmp) }
71
70
  end
72
71
  end
73
72
 
74
- def within?(path, root) = path == root || path.start_with?(File.join(root, ""))
73
+ # Whether +path+ is +root+ or in it, cased alike on a disk that folds
74
+ # case (PathResolve.under?).
75
+ def within?(path, root) = PathResolve.under?(path, root)
75
76
 
76
77
  # Follows the current dir and gives, per simple command, each word
77
78
  # with its path (nil when it can't be resolved). A word is opened the
78
79
  # kernel's way, so a `..` after a symlinked dir climbs out of its
79
80
  # target: such a word comes twice, with its lexical path and its
80
- # physical one (ProtectedPaths.expand), and a check reads both. A cd
81
+ # physical one (PathResolve.expand), and a check reads both. A cd
81
82
  # or pushd to such a word may land at either (the shell's logical cd
82
83
  # chdirs to the physical path when the logical one doesn't exist, and
83
84
  # always with -P, set -P or zsh's chase_links/chase_dots), so the walk
@@ -165,12 +166,12 @@ module Samagotchi
165
166
 
166
167
  # +word+ as a file argument against +dir+: its lexical path, and its
167
168
  # physical one when a `..` in it climbs out of a symlinked dir's
168
- # target (ProtectedPaths.expand); [] when it can't be resolved.
169
+ # target (PathResolve.expand); [] when it can't be resolved.
169
170
  def paths_for(word, dir)
170
171
  lexical = resolve(word, dir) or return []
171
172
  return [lexical] unless word.include?("..")
172
173
 
173
- [lexical, ProtectedPaths.expand(expand(word), dir)].uniq
174
+ [lexical, PathResolve.expand(expand(word), dir)].uniq
174
175
  end
175
176
 
176
177
  # An absolute path for +word+ against +dir+ with `..` taken
@@ -185,23 +186,11 @@ module Samagotchi
185
186
  dir && File.expand_path(word, dir)
186
187
  end
187
188
 
188
- def expand(word)
189
- word = word.sub(%r{\A~(?=/|\z)}) { @home }
190
- word = word.sub(EXPANSION) { value(Regexp.last_match(1) || Regexp.last_match(2)) || "$" }
191
- return nil if word.include?("$") || word.start_with?("~")
192
-
193
- word
194
- end
195
-
196
- def value(name)
197
- return @home if name == "HOME"
198
-
199
- set = @env[name].to_s
200
- return set unless set.empty?
201
-
202
- XDG_DEFAULTS[name] && File.join(@home, XDG_DEFAULTS[name])
203
- end
189
+ def expand(word) = PathResolve.expand_word(word, home: @home, env: @env)
204
190
  end
191
+
192
+ # PathResolve.expand_word, as this module has long named it.
193
+ def expand_word(word, home:, env:) = PathResolve.expand_word(word, home: home, env: env)
205
194
  end
206
195
  end
207
196
  end
@@ -13,7 +13,8 @@ require_relative "shell_lex"
13
13
  require_relative "read_only_shell"
14
14
  require_relative "shell_paths"
15
15
  require_relative "rm_targets"
16
- require_relative "protected_paths"
16
+ require_relative "chi_dirs"
17
+ require_relative "path_resolve"
17
18
  require_relative "../memory_paths"
18
19
  require_relative "../redacted_inspect"
19
20
 
@@ -234,11 +235,8 @@ module Samagotchi
234
235
  end
235
236
 
236
237
  # chi's own dirs (ParentApprovals.chi_dirs) and the session repo's
237
- # git hooks dir.
238
- def self.chi_dirs(context, base)
239
- require_relative "parent_approvals"
240
- ParentApprovals.chi_dirs + [context.hooks_dir(base)].compact
241
- end
238
+ # git hooks dir (ChiDirs.touches).
239
+ def self.chi_dirs(context, base) = ChiDirs.touches(context, base)
242
240
 
243
241
  # A shell call's env: as the tool reads it (an object, or JSON text
244
242
  # for one): {} for none, and for one the tool would refuse (it then
@@ -260,13 +258,13 @@ module Samagotchi
260
258
  # +path+ (a file tool's, as Tools::ToolPath has it) absolute against
261
259
  # +base+: its lexical path (File.expand_path), and the one the kernel
262
260
  # opens when a `..` in it climbs out of a symlinked dir's target
263
- # (ProtectedPaths.expand) after it; [] for a blank path. Every check
261
+ # (PathResolve.expand) after it; [] for a blank path. Every check
264
262
  # reads all of #paths, so it judges both.
265
263
  def self.absolutes(path, base)
266
264
  path = Tools::ToolPath.normalize(path)
267
265
  return [] if path.empty?
268
266
 
269
- [File.expand_path(path, base), ProtectedPaths.expand(path, base)].uniq
267
+ [File.expand_path(path, base), PathResolve.expand(path, base)].uniq
270
268
  end
271
269
 
272
270
  # The file memory_write would write; nil when the call is invalid.
@@ -381,13 +379,26 @@ module Samagotchi
381
379
  @paths.any? { |p| Outside.outside?(p, root: @session_root) }
382
380
  end
383
381
 
382
+ # The resolved forms used by rules' path: globs, once per target path.
383
+ def real_forms(path)
384
+ @real_forms ||= {}
385
+ @real_forms[path] ||= PathResolve.reals(path)
386
+ end
387
+
388
+ # The resolved base used by rules' path: globs.
389
+ def real_rule_base
390
+ return @real_rule_base if defined?(@real_rule_base)
391
+
392
+ @real_rule_base = PathResolve.real(@repo_root || @cwd)
393
+ end
394
+
384
395
  # Where a shell call runs mutating git (ShellGitDirs: dirs, and
385
396
  # :unknown for one the text doesn't tell); [] for other tools.
386
397
  def git_dirs
387
398
  @git_dirs ||= if shell?
388
399
  # chi's own GIT_DIR is not the command's concern
389
- shell_envs.each_with_index.flat_map do |(home, _), i|
390
- ShellGitDirs.for(@command, cwd: @cwd, home: home, env: i.zero? ? {} : @env)
400
+ shell_envs.each_with_index.flat_map do |(home, vars), i|
401
+ ShellGitDirs.for(@command, cwd: @cwd, home: home, env: i.zero? ? {} : @env, vars: vars)
391
402
  end.uniq
392
403
  else
393
404
  []
@@ -435,12 +446,12 @@ module Samagotchi
435
446
  # disk MODEL_NOTES_x.md in Samagotchi/Memories is the same file chi
436
447
  # loads.
437
448
  def self.prompt_memory_path?(path)
438
- ProtectedPaths.reals(path).any? do |real|
449
+ PathResolve.reals(path).any? do |real|
439
450
  next false unless File.basename(real).downcase.match?(PROMPT_MEMORY)
440
451
 
441
452
  dir = File.dirname(real)
442
- dir.casecmp?(ProtectedPaths.real(MemoryPaths.system_dir)) ||
443
- File.dirname(dir).casecmp?(ProtectedPaths.real(MemoryPaths.projects_dir))
453
+ dir.casecmp?(PathResolve.real(MemoryPaths.system_dir)) ||
454
+ File.dirname(dir).casecmp?(PathResolve.real(MemoryPaths.projects_dir))
444
455
  end
445
456
  rescue StandardError
446
457
  false
@@ -122,8 +122,9 @@ module Samagotchi
122
122
  0
123
123
  end
124
124
 
125
- # The token from its file or stdin; nil for none (a loopback chi web
126
- # needs none), false after the error line.
125
+ # The token from its file or stdin; nil for none (a chi web on
126
+ # loopback only, no LAN mode or proxy, needs none), false after the
127
+ # error line.
127
128
  def read_token(options)
128
129
  text = if options[:token_file]
129
130
  begin
@@ -145,17 +146,26 @@ module Samagotchi
145
146
  end
146
147
 
147
148
  # GET /api/info with the token: the answer, or nil after the line that
148
- # says why it can't be used.
149
+ # says why it can't be used. With no token, a chi web on the LAN or
150
+ # behind a proxy reached on its loopback (an SSH tunnel) answers chi's
151
+ # probe (its checked fields, no cwd) and every other route 401: it
152
+ # needs the token.
149
153
  def check(entry)
150
154
  reply = @transport.call(entry).call(:get, "/api/info")
151
155
  problem = SessionAccess::Remote.info_problem(entry, reply)
152
156
  return fail_line(problem.message) if problem
157
+ return fail_line(token_needed(entry)) unless reply.json.key?("cwd")
153
158
 
154
159
  reply.json
155
160
  rescue SessionAccess::RemoteFailed => e
156
161
  fail_line("#{e.message}: start chi web on #{entry.host} with web.host: lan")
157
162
  end
158
163
 
164
+ def token_needed(entry)
165
+ "#{entry.label}: its chi web needs its access token (its LAN link carries it, after ?token=; or its " \
166
+ "web-token file): chi remote add #{entry.name} #{entry.url} --token-file FILE"
167
+ end
168
+
159
169
  def lan_words(found, entry)
160
170
  return "loopback only" unless found["lan"]
161
171
 
@@ -25,8 +25,9 @@ module Samagotchi
25
25
  NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]{0,39}\z/
26
26
 
27
27
  # One remote: its +name+, chi web's +url+ (scheme, host and port, no
28
- # path) and its access +token+ (nil for a loopback chi web, which needs
29
- # none). #inspect leaves the token out.
28
+ # path) and its access +token+ (nil for a chi web on loopback only, no
29
+ # LAN mode or proxy: it alone needs none, its loopback reached through
30
+ # an SSH tunnel). #inspect leaves the token out.
30
31
  Entry = Data.define(:name, :url, :token) do
31
32
  # The host as an address, an IPv6 one bare (::1, not [::1]).
32
33
  def host = URI(url).hostname
@@ -6,6 +6,7 @@ require_relative "session_setup"
6
6
  require_relative "session_chain"
7
7
  require_relative "recap_store"
8
8
  require_relative "guardrails/context"
9
+ require_relative "guardrails/path_resolve"
9
10
  require_relative "guardrails/review_paths"
10
11
  require_relative "participant_mode"
11
12
 
@@ -185,8 +186,8 @@ module Samagotchi
185
186
  "only reads."
186
187
 
187
188
  def relative(full, root)
188
- real_root = Guardrails::ProtectedPaths.real(root)
189
- real = Guardrails::ProtectedPaths.real(full)
189
+ real_root = Guardrails::PathResolve.real(root)
190
+ real = Guardrails::PathResolve.real(full)
190
191
  real.delete_prefix(File.join(real_root, ""))
191
192
  end
192
193
  end
@@ -14,6 +14,7 @@ require_relative "session_setup"
14
14
  require_relative "prompt_note"
15
15
  require_relative "working_set_pin"
16
16
  require_relative "session_review"
17
+ require_relative "version"
17
18
 
18
19
  require_relative "paths"
19
20
  require_relative "project_scope"
@@ -351,17 +352,27 @@ module Samagotchi
351
352
  # saved.
352
353
  attr_accessor :opener
353
354
 
355
+ # The top-level keys of the session file this chi doesn't know (a newer
356
+ # chi's fields), frozen, string keys: #to_h writes them back after the
357
+ # FIELDS as they were. Never read by this chi.
358
+ attr_reader :extra_fields
359
+
360
+ # The chi version that last saved the loaded file (#to_h writes this
361
+ # chi's VERSION); nil for a file from 0.130.0 or older.
362
+ attr_reader :written_by
363
+
354
364
  def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
355
- metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
365
+ metadata_version: METADATA_VERSION, written_by: nil, status: STATUS_IDLE, last_prompt: "",
356
366
  first_preview: "", first_turn_id: nil, test_run: false, pending_question: nil,
357
367
  used_memory_names: [], project_root: nil,
358
368
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
359
369
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
360
370
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
361
371
  toolkit: nil, toolkit_origin: "chosen", settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
362
- working_set_pins: [], review: nil, verdicts: nil, verify_fresh: nil)
372
+ working_set_pins: [], review: nil, verdicts: nil, verify_fresh: nil, extra_fields: {})
363
373
  @id = id
364
374
  @metadata_version = metadata_version
375
+ @written_by = written_by
365
376
  @mode = mode
366
377
  @model_name = model_name
367
378
  @model_typed = model_typed
@@ -399,12 +410,25 @@ module Samagotchi
399
410
  self.verdicts = verdicts
400
411
  self.verify_fresh = verify_fresh
401
412
  self.interrupted_turn = interrupted_turn
413
+ @extra_fields = extra_fields.to_h { |key, value| [key.to_s, value] }.freeze
402
414
  @turn_checkpoint = nil
403
415
  @archived = false
404
416
  # #save's lock: one snapshot-and-write at a time (see #save).
405
417
  @save_lock = Mutex.new
406
418
  end
407
419
 
420
+ # Whether a chi newer than this one saved the loaded file: its
421
+ # written_by is a later version, or it has keys this chi doesn't know
422
+ # (a hand-edited or future file). The worker logs it (newer_writer).
423
+ def newer_writer?
424
+ return true unless @extra_fields.empty?
425
+
426
+ # correct?(nil) is true (a 0 version): a file without the field is no newer.
427
+ return false unless @written_by.is_a?(String) && Gem::Version.correct?(@written_by)
428
+
429
+ Gem::Version.new(@written_by) > Gem::Version.new(VERSION)
430
+ end
431
+
408
432
  # A `delegate` child: its turns report to its parent (ChildReports). A
409
433
  # child saved before the field is known by the memory every delegate
410
434
  # starts with; a fork is neither.
@@ -543,8 +567,24 @@ module Samagotchi
543
567
  # the way (symbols in memory, strings on disk); last_turn, prompt_notes
544
568
  # and pending_question are value objects in memory (LastTurn,
545
569
  # PromptNote, PendingQuestion).
570
+ #
571
+ # A new field: a chi older than it (from the release after 0.130.0)
572
+ # keeps the key as it was (#extra_fields) but doesn't honour it, and the
573
+ # next newer chi reads it again. So it must be safe to come back
574
+ # unchanged after an older chi ran turns on the session: static data, or
575
+ # append-only data the older chi never appends to (review, verify_fresh,
576
+ # first_turn_id, verdicts); a cursor into messages, which an older chi
577
+ # would have moved, needs a check against what it points at. Only
578
+ # top-level keys are kept: the value objects (SessionReview,
579
+ # VerifyFreshOrigin, Toolkit, LastTurn, PendingQuestion, InterruptedTurn,
580
+ # PromptNote, WorkingSetPin, LLMContextOverride) write their own members
581
+ # only, so an older chi still drops a new sub-key of one of them; add a
582
+ # top-level field when an older chi must keep it. A removed key would
583
+ # stay an extra forever: leave it out of the extras in .from_h then (no
584
+ # key was ever removed).
546
585
  FIELDS = {
547
586
  "metadata_version" => 1,
587
+ "written_by" => nil,
548
588
  "id" => REQUIRED,
549
589
  "mode" => REQUIRED,
550
590
  "model_name" => REQUIRED,
@@ -599,6 +639,7 @@ module Samagotchi
599
639
  end
600
640
  attrs[:settings] = SessionConfig::Settings.new(core: attrs.delete(:config_settings), bundles: attrs.delete(:bundle_settings))
601
641
  attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
642
+ attrs[:extra_fields] = data.reject { |key, _| FIELDS.key?(key) }
602
643
  new(**attrs)
603
644
  end
604
645
 
@@ -720,8 +761,10 @@ module Samagotchi
720
761
  self
721
762
  end
722
763
 
723
- # The session as #save writes it (FIELDS, in their order); a running
724
- # turn's checkpoint (#turn_checkpoint) in place of its messages.
764
+ # The session as #save writes it: FIELDS, in their order, then the keys
765
+ # this chi doesn't know (#extra_fields, in the file's order; a FIELDS key
766
+ # wins); a running turn's checkpoint (#turn_checkpoint) in place of its
767
+ # messages.
725
768
  def to_h
726
769
  checkpoint = checkpointed
727
770
  FIELDS.keys.to_h do |key|
@@ -730,6 +773,7 @@ module Samagotchi
730
773
  (checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
731
774
  when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
732
775
  when "pending_question" then @pending_question && scrub_utf8(@pending_question.to_file)
776
+ when "written_by" then VERSION
733
777
  when "test_run" then !!@test_run
734
778
  when "delegate" then @delegate
735
779
  when "llm_context" then llm_context_file
@@ -755,6 +799,7 @@ module Samagotchi
755
799
  end
756
800
  [key, value]
757
801
  end.reject { |key, value| value.nil? && OPTIONAL_FIELDS.include?(key) }
802
+ .merge(@extra_fields.reject { |key, _| FIELDS.key?(key) })
758
803
  end
759
804
 
760
805
  # The running turn's checkpoint, while the turn runs (#turn_checkpoint).
@@ -289,6 +289,23 @@ module Samagotchi
289
289
  data["verify"].is_a?(Hash)
290
290
  end
291
291
 
292
+ # Whether a message waits that a running turn's next boundary merges and
293
+ # that a person (or chi send, or a delegating parent) sent: an unclaimed
294
+ # input file not held for the turn's end (#turn_end_data?), with a
295
+ # prompt, from a sender that may cut (Steer.cuts?). A running task_wait
296
+ # ends on it (Worker#input_waiting?). It only peeks: nothing is claimed;
297
+ # a file the drain or a dequeue claims meanwhile (ENOENT) or one that
298
+ # doesn't parse is skipped.
299
+ def self.steer_waiting?(session_dir)
300
+ find_new_input_files(session_dir).any? do |file|
301
+ data = JSON.parse(File.read(file))
302
+ data.is_a?(Hash) && !turn_end_data?(data) && !data["prompt"].to_s.strip.empty? &&
303
+ Steer.cuts?(Steer.source_for_client(data["client_id"]))
304
+ rescue JSON::ParserError, SystemCallError
305
+ false
306
+ end
307
+ end
308
+
292
309
  # How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
293
310
  # (a queue or an image message: it runs as a turn of its own, never
294
311
  # merged, #waits_for_turn_end?), :merges (a running turn's drain may