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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +29 -11
- data/docs/configuration.md +7 -2
- data/docs/desktop.md +28 -9
- data/docs/guardrails.md +60 -16
- data/docs/internals/background-tasks.md +12 -0
- data/docs/internals/engine.md +4 -3
- data/docs/internals/participants.md +25 -6
- data/docs/internals/remote-sessions.md +5 -4
- data/docs/sessions.md +5 -3
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +99 -19
- data/lib/samagotchi/desktop_command.rb +7 -2
- data/lib/samagotchi/engine.rb +20 -4
- data/lib/samagotchi/engine_peers.rb +3 -1
- data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +5 -5
- data/lib/samagotchi/guardrails/outside.rb +22 -20
- data/lib/samagotchi/guardrails/pair_boundary.rb +14 -3
- data/lib/samagotchi/guardrails/pair_command.rb +70 -10
- data/lib/samagotchi/guardrails/parent_approvals.rb +8 -11
- data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +17 -116
- data/lib/samagotchi/guardrails/review_paths.rb +13 -11
- data/lib/samagotchi/guardrails/rm_targets.rb +5 -5
- data/lib/samagotchi/guardrails/rules.rb +23 -5
- data/lib/samagotchi/guardrails/scratch_writes.rb +3 -2
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +17 -12
- data/lib/samagotchi/guardrails/shell_paths.rb +18 -29
- data/lib/samagotchi/guardrails/targets.rb +24 -13
- data/lib/samagotchi/remote_command.rb +13 -3
- data/lib/samagotchi/remotes.rb +3 -2
- data/lib/samagotchi/review_start.rb +3 -2
- data/lib/samagotchi/session.rb +49 -4
- data/lib/samagotchi/session_inbox.rb +17 -0
- data/lib/samagotchi/system_one/self_protect.rb +34 -19
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/builtins.rb +10 -1
- data/lib/samagotchi/tools/execute.rb +5 -2
- data/lib/samagotchi/tools/peers.rb +4 -0
- data/lib/samagotchi/tools/task_runtime.rb +4 -2
- data/lib/samagotchi/tools/task_wait.rb +26 -1
- data/lib/samagotchi/turn_state.rb +23 -4
- data/lib/samagotchi/update_command.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +69 -25
- data/lib/samagotchi/web/participant_review.rb +9 -2
- data/lib/samagotchi/web/proxy_gate.rb +14 -6
- data/lib/samagotchi/web/server.rb +29 -4
- data/lib/samagotchi/web/token.rb +20 -1
- data/lib/samagotchi/worker.rb +16 -1
- metadata +3 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
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:
|
|
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
|
|
49
|
-
|
|
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
|
-
|
|
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 =
|
|
86
|
+
real_root = PathResolve.real(@root)
|
|
85
87
|
|
|
86
88
|
paths.each do |path|
|
|
87
89
|
given = File.expand_path(path)
|
|
88
|
-
reals =
|
|
89
|
-
return OUTSIDE unless reals.all? { |real|
|
|
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) ||
|
|
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|
|
|
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|
|
|
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 "
|
|
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 =
|
|
42
|
-
roots = tmp_roots
|
|
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|
|
|
66
|
-
roots.none? { |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
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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
|
-
|
|
107
|
-
|
|
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|
|
|
37
|
-
|
|
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 "
|
|
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
|
-
# (
|
|
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.
|
|
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+:
|
|
153
|
-
# be told from the text (a variable
|
|
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 =
|
|
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 ?
|
|
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 "
|
|
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 =
|
|
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 =
|
|
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|
|
|
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
|
-
|
|
68
|
-
next true if roots.any? { |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|
|
|
69
|
+
real.match?(HOOKS_PART) && tmp_roots.none? { |tmp| PathResolve.under?(real, tmp) }
|
|
71
70
|
end
|
|
72
71
|
end
|
|
73
72
|
|
|
74
|
-
|
|
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 (
|
|
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 (
|
|
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,
|
|
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 "
|
|
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
|
-
# (
|
|
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),
|
|
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,
|
|
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
|
-
|
|
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?(
|
|
443
|
-
File.dirname(dir).casecmp?(
|
|
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
|
|
126
|
-
# needs none), false after the
|
|
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
|
|
data/lib/samagotchi/remotes.rb
CHANGED
|
@@ -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
|
|
29
|
-
# none
|
|
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::
|
|
189
|
-
real = Guardrails::
|
|
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
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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
|
|
724
|
-
#
|
|
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
|