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
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
require_relative "../config"
|
|
4
4
|
require_relative "../memory_paths"
|
|
5
5
|
require_relative "../paths"
|
|
6
|
-
require_relative "../guardrails/
|
|
6
|
+
require_relative "../guardrails/chi_dirs"
|
|
7
|
+
require_relative "../guardrails/path_resolve"
|
|
7
8
|
require_relative "../guardrails/parent_approvals"
|
|
8
9
|
require_relative "../guardrails/shell_lex"
|
|
9
10
|
require_relative "../guardrails/shell_paths"
|
|
@@ -17,10 +18,12 @@ module Samagotchi
|
|
|
17
18
|
# "System 1"). The memories dir is deliberately not one of them:
|
|
18
19
|
# reading and appending a memory file stays eligible.
|
|
19
20
|
#
|
|
20
|
-
# A shell command's words are
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
21
|
+
# A file tool's paths and a shell command's words are judged against
|
|
22
|
+
# the same dirs (.chi_dirs: chi's whole state dir among them). The
|
|
23
|
+
# words are resolved as Guardrails::ShellPaths resolves them (cd
|
|
24
|
+
# chains, ~, $HOME and the XDG dirs, symlinks, `..` both ways), under
|
|
25
|
+
# chi's env and the call's env: when the caller hands its Targets; one
|
|
26
|
+
# landing in chi's dirs outside the memories refuses.
|
|
24
27
|
# The memories text is taken out of the command (the exemption) only
|
|
25
28
|
# when its text shows no climb (CLIMB: a `..`, a `$`, backticks, a `~`
|
|
26
29
|
# other than the home one, `cd -`) and every word either resolved or is
|
|
@@ -77,6 +80,7 @@ module Samagotchi
|
|
|
77
80
|
command = fetch(approval, :command).to_s
|
|
78
81
|
words = command.empty? ? nil : words(command, cwd: fetch(approval, :cwd), targets: targets)
|
|
79
82
|
return true if words&.chi
|
|
83
|
+
return true if chi_paths?(approval)
|
|
80
84
|
# The test that makes an ask user_only (ParentApprovals#chi_files?:
|
|
81
85
|
# chi's dirs as the command spells them), minus the memories.
|
|
82
86
|
return true if Guardrails::ParentApprovals.chi_files?(beyond_memories(approval, exempt: exempt?(command, words)))
|
|
@@ -84,14 +88,28 @@ module Samagotchi
|
|
|
84
88
|
command.match?(DEFAULT_STATE_TEXT)
|
|
85
89
|
end
|
|
86
90
|
|
|
91
|
+
# Whether a path of the approval lands in chi's dirs (#chi_dirs: its
|
|
92
|
+
# whole state dir too, as a command's words are judged) outside the
|
|
93
|
+
# memories dir, or in its installed bundles.
|
|
94
|
+
def chi_paths?(approval)
|
|
95
|
+
memories = Guardrails::PathResolve.real(MemoryPaths.system_dir)
|
|
96
|
+
bundles = Guardrails::PathResolve.real(MemoryPaths.bundles_dir)
|
|
97
|
+
dirs = chi_dirs
|
|
98
|
+
Array(fetch(approval, :paths)).map(&:to_s).reject(&:empty?).any? do |path|
|
|
99
|
+
Guardrails::PathResolve.reals(path).any? do |real|
|
|
100
|
+
dirs.any? { |dir| Guardrails::PathResolve.within?(real, dir) } && !memory?(real, memories, bundles)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
87
105
|
# The approval's facts without what is in the memories dir (not its
|
|
88
106
|
# installed bundles): the paths under it, and its spellings in the
|
|
89
107
|
# command when +exempt+ (.exempt?).
|
|
90
108
|
def beyond_memories(approval, exempt:)
|
|
91
|
-
memories = Guardrails::
|
|
92
|
-
bundles = Guardrails::
|
|
109
|
+
memories = Guardrails::PathResolve.real(MemoryPaths.system_dir)
|
|
110
|
+
bundles = Guardrails::PathResolve.real(MemoryPaths.bundles_dir)
|
|
93
111
|
paths = Array(fetch(approval, :paths)).map(&:to_s).reject do |path|
|
|
94
|
-
Guardrails::
|
|
112
|
+
Guardrails::PathResolve.reals(path).all? { |real| memory?(real, memories, bundles) }
|
|
95
113
|
end
|
|
96
114
|
command = fetch(approval, :command).to_s
|
|
97
115
|
command = command.gsub(memories_text) { |text| text.include?("/.bundles") ? text : "" } if exempt
|
|
@@ -107,8 +125,8 @@ module Samagotchi
|
|
|
107
125
|
# The command's words resolved (ShellPaths::Walk) from +cwd+ (else
|
|
108
126
|
# this process's dir) under each of the call's shell envs: Words.
|
|
109
127
|
def words(command, cwd:, targets:)
|
|
110
|
-
memories = Guardrails::
|
|
111
|
-
bundles = Guardrails::
|
|
128
|
+
memories = Guardrails::PathResolve.real(MemoryPaths.system_dir)
|
|
129
|
+
bundles = Guardrails::PathResolve.real(MemoryPaths.bundles_dir)
|
|
112
130
|
dirs = chi_dirs
|
|
113
131
|
tokens = Guardrails::ShellLex.lex(command)
|
|
114
132
|
plain = !Guardrails::ShellLex.unterminated?(command) &&
|
|
@@ -124,8 +142,8 @@ module Samagotchi
|
|
|
124
142
|
walk.step(simple).each do |word, path|
|
|
125
143
|
next plain &&= known && data?(word) unless path
|
|
126
144
|
|
|
127
|
-
chi ||= Guardrails::
|
|
128
|
-
dirs.any? { |dir| Guardrails::
|
|
145
|
+
chi ||= Guardrails::PathResolve.reals(path).any? do |real|
|
|
146
|
+
dirs.any? { |dir| Guardrails::PathResolve.under?(real, dir) } && !memory?(real, memories, bundles)
|
|
129
147
|
end
|
|
130
148
|
end
|
|
131
149
|
end
|
|
@@ -148,15 +166,12 @@ module Samagotchi
|
|
|
148
166
|
end
|
|
149
167
|
|
|
150
168
|
# chi's own dirs (ParentApprovals.chi_dirs) and its whole state dir
|
|
151
|
-
# (DEFAULT_STATE_TEXT's), resolved.
|
|
152
|
-
def chi_dirs
|
|
153
|
-
[*Guardrails::ParentApprovals.chi_dirs, Paths.state_dir]
|
|
154
|
-
.map { |dir| Guardrails::ProtectedPaths.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
|
|
155
|
-
end
|
|
169
|
+
# (DEFAULT_STATE_TEXT's), resolved (Guardrails::ChiDirs.self_protect).
|
|
170
|
+
def chi_dirs = Guardrails::ChiDirs.self_protect
|
|
156
171
|
|
|
157
172
|
# Whether +real+ is in the memories dir and not in its installed bundles.
|
|
158
173
|
def memory?(real, memories, bundles)
|
|
159
|
-
Guardrails::
|
|
174
|
+
Guardrails::PathResolve.within?(real, memories) && !Guardrails::PathResolve.within?(real, bundles)
|
|
160
175
|
end
|
|
161
176
|
|
|
162
177
|
# The memories dir as a command spells it: the default
|
|
@@ -164,7 +179,7 @@ module Samagotchi
|
|
|
164
179
|
# rest of that shell word.
|
|
165
180
|
def memories_text
|
|
166
181
|
dir = ConfigFile.config_dir
|
|
167
|
-
prefixes = [".config/samagotchi", dir, Guardrails::
|
|
182
|
+
prefixes = [".config/samagotchi", dir, Guardrails::PathResolve.real(dir)]
|
|
168
183
|
.map { |prefix| Regexp.escape(prefix.to_s.chomp("/")) }.uniq
|
|
169
184
|
word = WORD_END.sub("[", "[^")
|
|
170
185
|
Regexp.new("(?:#{prefixes.join("|")})/memories(?=/|#{WORD_END}|\\z)(?:/#{word}*)?")
|
|
@@ -716,6 +716,8 @@ module Samagotchi
|
|
|
716
716
|
max_iterations: max_iterations,
|
|
717
717
|
cancel_controller: cancellation_controller,
|
|
718
718
|
pending_input: method(:drain_steering),
|
|
719
|
+
# A steering line waits (#steer_line): a running task_wait ends for it.
|
|
720
|
+
input_waiting: -> { !@pending_input_queue.empty? },
|
|
719
721
|
continue: continue,
|
|
720
722
|
images: images,
|
|
721
723
|
resumed: resumed,
|
|
@@ -221,7 +221,7 @@ module Samagotchi
|
|
|
221
221
|
},
|
|
222
222
|
{
|
|
223
223
|
name: "task_wait",
|
|
224
|
-
description: "Wait for a background task to finish. Polls every 0.5s until completion, a log pattern matches, or timeout. Timed-out waits include a bounded output tail.",
|
|
224
|
+
description: "Wait for a background task to finish. Polls every 0.5s until completion, a log pattern matches, a message from the user arrives, or timeout. Timed-out waits include a bounded output tail.",
|
|
225
225
|
parameters: {
|
|
226
226
|
type: "object",
|
|
227
227
|
properties: {
|
|
@@ -86,7 +86,8 @@ module Samagotchi
|
|
|
86
86
|
end,
|
|
87
87
|
TaskWait::NAME => lambda do |call, kctx|
|
|
88
88
|
TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
|
|
89
|
-
done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx)
|
|
89
|
+
done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx),
|
|
90
|
+
message_waiting: input_waiting_proc(kctx))
|
|
90
91
|
end,
|
|
91
92
|
Execute::NAME => lambda do |call, kctx|
|
|
92
93
|
Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), stop_reason: stop_reason_proc(kctx),
|
|
@@ -188,6 +189,14 @@ module Samagotchi
|
|
|
188
189
|
-> { !!peers&.cancelled? }
|
|
189
190
|
end
|
|
190
191
|
|
|
192
|
+
# A person's message waits for the turn's next boundary
|
|
193
|
+
# (Engine#input_waiting?, through its PeerView): a task_wait returns on
|
|
194
|
+
# it. A kernel whose peers can't tell: never.
|
|
195
|
+
def input_waiting_proc(kctx)
|
|
196
|
+
peers = kctx.peers
|
|
197
|
+
-> { peers.respond_to?(:input_waiting?) && !!peers.input_waiting? }
|
|
198
|
+
end
|
|
199
|
+
|
|
191
200
|
# The running turn's stop reason (:signal, :manual, …), nil without one.
|
|
192
201
|
def stop_reason_proc(kctx)
|
|
193
202
|
peers = kctx.peers
|
|
@@ -85,7 +85,10 @@ module Samagotchi
|
|
|
85
85
|
# @param stop_reason [#call] the stop's reason (:signal: a signal to
|
|
86
86
|
# chi, :session_stopped: a session stop, which the result then names
|
|
87
87
|
# instead of the user)
|
|
88
|
-
# @param env [Hash, nil] added to the command's environment
|
|
88
|
+
# @param env [Hash, nil] added to the command's environment, over
|
|
89
|
+
# chi's Ruby/Bundler variables scrubbed as task_create scrubs them
|
|
90
|
+
# (TaskRuntime.sanitized_spawn_env); chi's callers build it, so it
|
|
91
|
+
# may set those keys (task_create refuses them from the model)
|
|
89
92
|
# @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
|
|
90
93
|
# nil reads execute.on_timeout
|
|
91
94
|
def self.call(command, cwd: nil, cancelled: -> { false }, stop_reason: -> {}, env: nil, on_timeout: nil)
|
|
@@ -267,7 +270,7 @@ module Samagotchi
|
|
|
267
270
|
# file, and each write lands at the end.
|
|
268
271
|
ios = logs.map { |path| File.open(path, "a") }
|
|
269
272
|
ios.each { |io| io.flock(File::LOCK_SH) }
|
|
270
|
-
ProcessGroup.spawn(env || {}, *Shell.argv(TaskRuntime.wrapped_shell_command(command, exit_code_path)),
|
|
273
|
+
ProcessGroup.spawn(TaskRuntime.sanitized_spawn_env.merge(env || {}), *Shell.argv(TaskRuntime.wrapped_shell_command(command, exit_code_path)),
|
|
271
274
|
chdir: cwd, in: File::NULL, out: ios[0], err: ios[1])
|
|
272
275
|
ensure
|
|
273
276
|
ios&.each(&:close)
|
|
@@ -23,6 +23,10 @@ module Samagotchi
|
|
|
23
23
|
def cancelled?
|
|
24
24
|
cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
# A person's message waits for the turn's next boundary: never here
|
|
28
|
+
# (Engine::PeerView asks the running turn).
|
|
29
|
+
def input_waiting? = false
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
end
|
|
@@ -445,9 +445,11 @@ module Samagotchi
|
|
|
445
445
|
"trap #{Shellwords.escape(write)} EXIT\n#{command}\n"
|
|
446
446
|
end
|
|
447
447
|
|
|
448
|
+
# chi's own Ruby/Bundler setup, kept out of a command's environment
|
|
449
|
+
# (execute and task_create both spawn with it): run under `bundle
|
|
450
|
+
# exec`, chi would otherwise hand a project's `ruby -e` or
|
|
451
|
+
# `bundle exec` its Gemfile (RUBYOPT=-rbundler/setup, BUNDLE_GEMFILE).
|
|
448
452
|
def sanitized_spawn_env
|
|
449
|
-
# Running from temporary directories can inherit Bundler-specific env
|
|
450
|
-
# (for example RUBYOPT=-rbundler/setup) that breaks plain `ruby -e`.
|
|
451
453
|
{
|
|
452
454
|
"RUBYOPT" => sanitized_rubyopt,
|
|
453
455
|
"RUBYLIB" => nil,
|
|
@@ -14,13 +14,23 @@ module Samagotchi
|
|
|
14
14
|
# A handed-over execute keeps stderr in a log of its own: at most this
|
|
15
15
|
# many of its last lines follow the output tail.
|
|
16
16
|
STDERR_TAIL_LINES_MAX = 10
|
|
17
|
+
# The note of a wait a waiting message ended (wait_result:
|
|
18
|
+
# message_waiting); %<id>s is the task's id. "The user" whoever sent
|
|
19
|
+
# it: the merged message itself names its sender.
|
|
20
|
+
MESSAGE_WAITING_NOTE = [
|
|
21
|
+
"note: a message from the user is waiting; the task is STILL RUNNING.",
|
|
22
|
+
" Read the message first. To keep waiting after it: task_wait %<id>s. If it is no longer needed: task_stop %<id>s."
|
|
23
|
+
].join("\n").freeze
|
|
17
24
|
|
|
18
25
|
def self.name = NAME
|
|
19
26
|
|
|
20
27
|
# @param cancelled [#call] true once the user stopped the turn: the
|
|
21
28
|
# wait returns then, and the task keeps running
|
|
29
|
+
# @param message_waiting [#call] true once a person's message waits for
|
|
30
|
+
# the turn's next boundary (Engine#input_waiting?): the wait returns
|
|
31
|
+
# then, so the model reads it, and the task keeps running
|
|
22
32
|
def self.call(task_id, timeout: TIMEOUT_DEFAULT, tail_lines: TAIL_LINES_DEFAULT, done_pattern: nil,
|
|
23
|
-
cancelled: -> { false })
|
|
33
|
+
cancelled: -> { false }, message_waiting: -> { false })
|
|
24
34
|
timeout = TIMEOUT_DEFAULT if blank?(timeout)
|
|
25
35
|
tail_lines = TAIL_LINES_DEFAULT if blank?(tail_lines)
|
|
26
36
|
timeout = timeout.to_i
|
|
@@ -48,6 +58,7 @@ module Samagotchi
|
|
|
48
58
|
end
|
|
49
59
|
|
|
50
60
|
return cancelled_response(task_id, tail_lines, started) if cancelled.call
|
|
61
|
+
return message_waiting_response(task_id, tail_lines, started) if message_waiting.call
|
|
51
62
|
|
|
52
63
|
break if monotonic_time > deadline
|
|
53
64
|
|
|
@@ -80,6 +91,20 @@ module Samagotchi
|
|
|
80
91
|
end
|
|
81
92
|
private_class_method :cancelled_response
|
|
82
93
|
|
|
94
|
+
# A message waits: the wait ends so the model reads it at the next
|
|
95
|
+
# boundary. A task that finished meanwhile gives its finished result,
|
|
96
|
+
# as after a Stop.
|
|
97
|
+
def self.message_waiting_response(task_id, tail_lines, started)
|
|
98
|
+
record, error = TaskRuntime.get_record(task_id)
|
|
99
|
+
return error if error
|
|
100
|
+
return finished_response(record, tail_lines) unless record.running?
|
|
101
|
+
|
|
102
|
+
note = [format(MESSAGE_WAITING_NOTE, id: task_id), "waited: #{(monotonic_time - started).round}s"]
|
|
103
|
+
format_response(record, wait_result: "message_waiting", output_tail: output_tail(record, tail_lines),
|
|
104
|
+
tail_lines: tail_lines, extra: note)
|
|
105
|
+
end
|
|
106
|
+
private_class_method :message_waiting_response
|
|
107
|
+
|
|
83
108
|
# A finished task's text, as task_wait gives it: its status lines and
|
|
84
109
|
# always the log's last lines (a test run's summary, an error), then a
|
|
85
110
|
# stderr_tail when the record has a non-empty one. A task the user
|
|
@@ -5,7 +5,8 @@ require "monitor"
|
|
|
5
5
|
module Samagotchi
|
|
6
6
|
# A session's turn as other threads see it: whether one runs, its cancel
|
|
7
7
|
# controller and event sink, the plugin steers waiting for its next
|
|
8
|
-
# boundary,
|
|
8
|
+
# boundary, whether a person's message waits for it (its caller's
|
|
9
|
+
# input_waiting: probe), and the activity clock the idle jobs read.
|
|
9
10
|
#
|
|
10
11
|
# The turn thread writes it (Engine#begin_turn, #release_turn); the idle
|
|
11
12
|
# scheduler, the bridge's request threads, plugin threads and the UIs
|
|
@@ -24,6 +25,8 @@ module Samagotchi
|
|
|
24
25
|
@sink = nil
|
|
25
26
|
@thread = nil
|
|
26
27
|
@ended = false
|
|
28
|
+
@input_waiting = nil
|
|
29
|
+
@input_waiting_warned = false
|
|
27
30
|
@steers = []
|
|
28
31
|
@carried_steers = []
|
|
29
32
|
end
|
|
@@ -67,20 +70,24 @@ module Samagotchi
|
|
|
67
70
|
end
|
|
68
71
|
|
|
69
72
|
# A turn begins, on the calling thread (the turn's own): running, with
|
|
70
|
-
# its controller and
|
|
71
|
-
|
|
73
|
+
# its controller, sink and input_waiting probe, in one step.
|
|
74
|
+
# @param input_waiting [#call, nil] true while a person's message waits
|
|
75
|
+
# for the turn's next boundary (Engine#input_waiting?); nil: never
|
|
76
|
+
def begin!(controller:, sink:, input_waiting: nil)
|
|
72
77
|
@lock.synchronize do
|
|
73
78
|
@running = true
|
|
74
79
|
@controller = controller
|
|
75
80
|
@sink = sink
|
|
76
81
|
@thread = Thread.current
|
|
77
82
|
@ended = false
|
|
83
|
+
@input_waiting = input_waiting
|
|
84
|
+
@input_waiting_warned = false
|
|
78
85
|
@steers.concat(@carried_steers)
|
|
79
86
|
@carried_steers = []
|
|
80
87
|
end
|
|
81
88
|
end
|
|
82
89
|
|
|
83
|
-
# The turn is over: not running, no controller, sink or steers.
|
|
90
|
+
# The turn is over: not running, no controller, sink, probe or steers.
|
|
84
91
|
# @return [Array<Hash>] the steers it never took ({text:, source:})
|
|
85
92
|
def finish!
|
|
86
93
|
@lock.synchronize do
|
|
@@ -89,6 +96,7 @@ module Samagotchi
|
|
|
89
96
|
@sink = nil
|
|
90
97
|
@thread = nil
|
|
91
98
|
@ended = false
|
|
99
|
+
@input_waiting = nil
|
|
92
100
|
@steers.tap { @steers = [] }
|
|
93
101
|
end
|
|
94
102
|
end
|
|
@@ -118,6 +126,17 @@ module Samagotchi
|
|
|
118
126
|
end
|
|
119
127
|
end
|
|
120
128
|
|
|
129
|
+
# @return [#call, nil] the running turn's input_waiting probe (called
|
|
130
|
+
# outside the lock), nil outside a turn or for a turn given none
|
|
131
|
+
def input_waiting
|
|
132
|
+
@lock.synchronize { @running ? @input_waiting : nil }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A probe failed: true the first time in a turn (warn then), false after.
|
|
136
|
+
def input_waiting_failed!
|
|
137
|
+
@lock.synchronize { !@input_waiting_warned.tap { @input_waiting_warned = true } }
|
|
138
|
+
end
|
|
139
|
+
|
|
121
140
|
# Cancel the running turn, if any (outside the lock).
|
|
122
141
|
# @return [Boolean] whether a cancellation was triggered
|
|
123
142
|
def cancel!(reason)
|
|
@@ -250,9 +250,17 @@ module Samagotchi
|
|
|
250
250
|
return row.tap { |r| r.status = "not installed" } unless helper.installed?
|
|
251
251
|
|
|
252
252
|
row.from = helper.app_version
|
|
253
|
+
if (problem = helper.launch_problem)
|
|
254
|
+
return failed(row, problem)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
if helper.launch_newer?
|
|
258
|
+
return up_to_date(row).tap { |r| r.status += " (kept: chi #{helper.launch_version} set it up; this chi is older)" }
|
|
259
|
+
end
|
|
260
|
+
|
|
253
261
|
if helper.stale?
|
|
254
262
|
row.to = VERSION
|
|
255
|
-
done(row, :update, @dry_run ? "rebuilds and restarts it" : "restarted") { helper.upgrade { nil } }
|
|
263
|
+
done(row, :update, @dry_run ? "rebuilds and restarts it" : "restarted") { helper.upgrade(keep_launch: true) { nil } }
|
|
256
264
|
elsif helper.launch_outdated?
|
|
257
265
|
helper.refresh_launch_file unless @dry_run
|
|
258
266
|
up_to_date(row).tap { |r| r.status += @dry_run ? " (would refresh the launch file)" : " (launch file refreshed)" }
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -55,6 +55,7 @@ require_relative "../review_start"
|
|
|
55
55
|
require_relative "../parent_answers_command"
|
|
56
56
|
require_relative "../guardrails/parent_policy"
|
|
57
57
|
require_relative "markdown_renderer"
|
|
58
|
+
require_relative "token"
|
|
58
59
|
require_relative "capabilities"
|
|
59
60
|
require_relative "editor"
|
|
60
61
|
require_relative "../message_parts"
|
|
@@ -431,6 +432,10 @@ module Samagotchi
|
|
|
431
432
|
end
|
|
432
433
|
|
|
433
434
|
TOKEN_COOKIE = "chi_token"
|
|
435
|
+
# The owner's cookie holds the token, but on the LAN a loopback
|
|
436
|
+
# peer's holds Token.loopback_cookie (#owner_cookie_value): a cookie
|
|
437
|
+
# for 127.0.0.1 goes to every server on any of its ports (a dev
|
|
438
|
+
# server that logs headers), and that value works nowhere else.
|
|
434
439
|
# 400 days, the longest a browser keeps a cookie (Chrome's cap). No
|
|
435
440
|
# Secure: the LAN page is plain http. Lax, not Strict: a token link
|
|
436
441
|
# opened from another site (a web messenger) makes its 303 chain
|
|
@@ -459,22 +464,24 @@ module Samagotchi
|
|
|
459
464
|
# The request's Principal, as #route resolved it.
|
|
460
465
|
def principal(req) = req.env.fetch(PRINCIPAL_ENV)
|
|
461
466
|
|
|
462
|
-
# LAN mode
|
|
463
|
-
#
|
|
464
|
-
#
|
|
465
|
-
#
|
|
466
|
-
#
|
|
467
|
-
#
|
|
467
|
+
# LAN mode: every route needs the access token, from a phone and from
|
|
468
|
+
# this machine's loopback too (a pair participant's shell runs here),
|
|
469
|
+
# as the chi_token cookie or an Authorization: Bearer header; only
|
|
470
|
+
# GATE_FREE_PATHS and chi's token-less /api/info probe
|
|
471
|
+
# (ProxyGate#probe_info?) are answered without it. The page with
|
|
472
|
+
# ?token= (or a link's #code=, POST /api/token) trades it for the
|
|
473
|
+
# cookie and a 303 to itself without it, so the token doesn't stay in
|
|
474
|
+
# the browser's history.
|
|
468
475
|
# @return [Array(Principal, nil), Array(nil, Array)] the request's
|
|
469
476
|
# principal (#principal_for), or the response that refuses it
|
|
470
477
|
def token_gate(req)
|
|
471
478
|
principal = principal_for(req)
|
|
472
479
|
return [nil, refused_token(req)] if principal.nil?
|
|
473
480
|
|
|
474
|
-
# The cookie gets the
|
|
475
|
-
#
|
|
481
|
+
# The cookie gets the participant's grant's token, or the owner's
|
|
482
|
+
# cookie value (the loopback one on the LAN's 127.0.0.1).
|
|
476
483
|
if token_trade?(req)
|
|
477
|
-
token = req.env[GRANT_TOKEN_ENV] ||
|
|
484
|
+
token = req.env[GRANT_TOKEN_ENV] || owner_cookie_value(req)
|
|
478
485
|
return [nil, token_redirect(req, token)] unless fragment_trade?(req)
|
|
479
486
|
return [nil, form_trade_redirect(req, token)] if form_post?(req)
|
|
480
487
|
|
|
@@ -489,9 +496,11 @@ module Samagotchi
|
|
|
489
496
|
# the access token; a participant for a live grant's token (a chp_
|
|
490
497
|
# one, looked up only when no owner token was given), wherever the
|
|
491
498
|
# request comes from, this machine's loopback too; with neither, the
|
|
492
|
-
# owner
|
|
493
|
-
#
|
|
494
|
-
#
|
|
499
|
+
# owner only on a chi web that asks no token (loopback only: no LAN
|
|
500
|
+
# mode, no web.public_url), else nil. The owner's cookie on the LAN's
|
|
501
|
+
# loopback is the loopback value (#loopback_cookie: it stands for the
|
|
502
|
+
# token in +given+). An ended or unknown grant's
|
|
503
|
+
# token is nil anywhere: it never falls back to the owner.
|
|
495
504
|
# @return [Principal, nil]
|
|
496
505
|
# With web.public_url (ProxyGate) every request needs a token, and a
|
|
497
506
|
# proxied one is #proxied_principal's.
|
|
@@ -510,12 +519,39 @@ module Samagotchi
|
|
|
510
519
|
# through the proxy (curl, chi's own callers) presents the Bearer
|
|
511
520
|
# header only: no cookie, and no trade that would set one. A cookie for
|
|
512
521
|
# 127.0.0.1 goes to every server on any of its ports. A LAN peer
|
|
513
|
-
# (web.host lan) keeps the LAN's token link and cookie
|
|
522
|
+
# (web.host lan) keeps the LAN's token link and cookie; a loopback
|
|
523
|
+
# peer of a LAN chi web reads its cookie through #loopback_cookie.
|
|
514
524
|
def presented_tokens(req)
|
|
515
525
|
return [traded_token(req)] if token_trade?(req)
|
|
516
526
|
return [bearer_token(req)] if direct_public?(req)
|
|
517
527
|
|
|
518
|
-
|
|
528
|
+
cookie = req.cookies[token_cookie_name(req)]
|
|
529
|
+
[bearer_token(req), loopback_cookie_peer?(req) ? loopback_cookie(cookie) : cookie]
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# A loopback peer of a LAN chi web, whose owner cookie holds the
|
|
533
|
+
# loopback value. With web.public_url, #direct_public? takes the
|
|
534
|
+
# Bearer only. A request with a proxy's trace (one the user forwards
|
|
535
|
+
# to loopback by hand) is the LAN's: a leaked loopback value must not
|
|
536
|
+
# work from off this machine through it.
|
|
537
|
+
def loopback_cookie_peer?(req)
|
|
538
|
+
!@lan.nil? && @public_url.nil? && LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s) &&
|
|
539
|
+
!proxy_trace?(req.env)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# What a trade sets as the owner's cookie.
|
|
543
|
+
def owner_cookie_value(req)
|
|
544
|
+
loopback_cookie_peer?(req) ? Token.loopback_cookie(current_token) : current_token
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# A loopback peer's cookie: the loopback value stands for the owner's
|
|
548
|
+
# token, a grant's chp_ token is itself, and anything else, the token
|
|
549
|
+
# itself included (a 0.130.0 cookie, #refused_token clears it), is
|
|
550
|
+
# nothing.
|
|
551
|
+
def loopback_cookie(value)
|
|
552
|
+
return current_token if token_match?(value, Token.loopback_cookie(current_token))
|
|
553
|
+
|
|
554
|
+
Grants.token?(value) ? value : nil
|
|
519
555
|
end
|
|
520
556
|
|
|
521
557
|
def direct_public?(req)
|
|
@@ -595,11 +631,10 @@ module Samagotchi
|
|
|
595
631
|
"#{token_cookie_name(req)}=; #{token_cookie_attributes(req).sub(/Max-Age=\d+/, "Max-Age=0")}"
|
|
596
632
|
end
|
|
597
633
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
end
|
|
634
|
+
# Whether the gate asks this request for a token: on a chi web on the
|
|
635
|
+
# LAN or behind a proxy, every request does, this machine's loopback
|
|
636
|
+
# too. Only a loopback-only chi web takes loopback as the owner.
|
|
637
|
+
def token_required?(_req) = !(@public_url || @lan).nil?
|
|
603
638
|
|
|
604
639
|
# The page opened with ?token= (a gated request, or a grant's token
|
|
605
640
|
# anywhere): its token is traded for the cookie, whatever else the
|
|
@@ -621,10 +656,15 @@ module Samagotchi
|
|
|
621
656
|
# The 401 for a request #principal_for refused: an ended grant's, else
|
|
622
657
|
# unauthorized. On a peer the gate doesn't ask, a grant's cookie that
|
|
623
658
|
# no longer works is cleared too, so the browser it was left in (the
|
|
624
|
-
# owner's, who opened a participant link) is the owner's again.
|
|
659
|
+
# owner's, who opened a participant link) is the owner's again. A
|
|
660
|
+
# LAN chi web's loopback peer whose cookie is the token itself (set
|
|
661
|
+
# before the loopback value) has it cleared, so it stops riding to
|
|
662
|
+
# the other local ports.
|
|
625
663
|
def refused_token(req)
|
|
626
664
|
response = grant_bound_response(req) || grant_ended_response(req) || unauthorized(req)
|
|
627
|
-
|
|
665
|
+
cookie = req.cookies[token_cookie_name(req)]
|
|
666
|
+
stale = loopback_cookie_peer?(req) && token_match?(cookie, current_token)
|
|
667
|
+
return response unless stale || (!token_required?(req) && Grants.token?(cookie))
|
|
628
668
|
|
|
629
669
|
status, headers, body = response
|
|
630
670
|
[status, headers.merge("Set-Cookie" => cleared_token_cookie(req)), body]
|
|
@@ -903,7 +943,11 @@ module Samagotchi
|
|
|
903
943
|
|
|
904
944
|
def unauthorized_detail(req, reason)
|
|
905
945
|
return "chi web behind the proxy: #{reason || "needs its access token"}" if proxied?(req)
|
|
906
|
-
|
|
946
|
+
|
|
947
|
+
unless @public_url
|
|
948
|
+
here = LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s)
|
|
949
|
+
return "chi web on the LAN needs its access token#{", on this machine too" if here}"
|
|
950
|
+
end
|
|
907
951
|
|
|
908
952
|
detail = "chi web with web.public_url needs its access token, on this machine too"
|
|
909
953
|
direct_public?(req) ? "#{detail}; open its public address #{@public_url.origin}/ instead" : detail
|
|
@@ -941,7 +985,7 @@ module Samagotchi
|
|
|
941
985
|
# the proxy too.
|
|
942
986
|
def self.public_unauthorized_page(page)
|
|
943
987
|
page.sub('<form method="get" action="/">', '<form method="post" action="/api/token">')
|
|
944
|
-
.sub("Open the link chi web printed in its terminal (LAN, or
|
|
988
|
+
.sub("Open the link chi web printed in its terminal (Local on this machine, LAN, or\nProxied behind an auth proxy), or scan its QR code.",
|
|
945
989
|
"Open the Proxied link chi web printed in its terminal (it works once; chi web again prints a new " \
|
|
946
990
|
"one), or paste the token from its web-token file.")
|
|
947
991
|
end
|
|
@@ -961,8 +1005,8 @@ module Samagotchi
|
|
|
961
1005
|
</style></head>
|
|
962
1006
|
<body>
|
|
963
1007
|
<h1>chi web</h1>
|
|
964
|
-
<p>This page needs chi web's access token. Open the link chi web printed in its terminal (LAN, or
|
|
965
|
-
auth proxy), or scan its QR code.</p>
|
|
1008
|
+
<p>This page needs chi web's access token. Open the link chi web printed in its terminal (Local on this machine, LAN, or
|
|
1009
|
+
Proxied behind an auth proxy), or scan its QR code.</p>
|
|
966
1010
|
<p id="trade" hidden></p>
|
|
967
1011
|
<form method="get" action="/">
|
|
968
1012
|
<label for="token">Or paste the token:</label>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "participant_view"
|
|
4
|
+
require_relative "token"
|
|
4
5
|
require_relative "participant_stream_filter"
|
|
5
6
|
require_relative "participant_exposure"
|
|
6
7
|
require_relative "../session_metrics"
|
|
@@ -39,10 +40,16 @@ module Samagotchi
|
|
|
39
40
|
|
|
40
41
|
# Who reads: the participant (their author), or the owner through the
|
|
41
42
|
# participant view (nobody's own). The session's folder places tool
|
|
42
|
-
# paths; the owner's token is never shown
|
|
43
|
+
# paths; the owner's token is never shown, nor (LAN) its loopback
|
|
44
|
+
# cookie value.
|
|
43
45
|
def participant_viewer(req, session)
|
|
44
46
|
author = principal(req).owner? ? nil : principal(req).grant.author
|
|
45
|
-
ParticipantView::Viewer.new(author: author, cwd: session.working_directory, secrets:
|
|
47
|
+
ParticipantView::Viewer.new(author: author, cwd: session.working_directory, secrets: owner_secrets)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def owner_secrets
|
|
51
|
+
token = (@lan || @public_url) && current_token
|
|
52
|
+
[token, @lan && Token.loopback_cookie(token)].compact
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
# GET /api/sessions/:id/review: the participant page's data, always
|
|
@@ -74,6 +74,12 @@ module Samagotchi
|
|
|
74
74
|
return true if @public_url.host?(env["HTTP_HOST"])
|
|
75
75
|
return true if @public_url.identity_env && env.key?(@public_url.identity_env)
|
|
76
76
|
|
|
77
|
+
proxy_trace?(env)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Whether a request carries a proxy's trace (ProxyTrace: X-Forwarded-*,
|
|
81
|
+
# Forwarded, X-Real-IP, a known identity header).
|
|
82
|
+
def proxy_trace?(env)
|
|
77
83
|
names = env[RAW_NAMES_ENV] || env.keys.filter_map { |key| ProxyTrace.env_name(key) }
|
|
78
84
|
names.any? { |name| ProxyTrace.header?(name) }
|
|
79
85
|
end
|
|
@@ -124,13 +130,15 @@ module Samagotchi
|
|
|
124
130
|
participant_principal(req, grant_tokens, identity: identity, proxied: true)
|
|
125
131
|
end
|
|
126
132
|
|
|
127
|
-
# chi's own probe of a chi web
|
|
128
|
-
#
|
|
133
|
+
# chi's own probe of a chi web that asks this machine for its token
|
|
134
|
+
# (behind a proxy, or on the LAN: InfoProbe from a second chi web, chi
|
|
135
|
+
# share, chi update, chi self) sends no token: GET /api/info from this
|
|
129
136
|
# machine, not through the proxy, with no token at all, gets the
|
|
130
|
-
# fields it checks (nothing secret
|
|
131
|
-
# to whatever listens on
|
|
137
|
+
# fields it checks (nothing secret: its start folder is a path on this
|
|
138
|
+
# machine), so no probe ever hands the token to whatever listens on
|
|
139
|
+
# the port. Any token or cookie makes it an ordinary request.
|
|
132
140
|
def probe_info?(req)
|
|
133
|
-
return false unless @public_url && req.get? && req.path_info == "/api/info"
|
|
141
|
+
return false unless (@public_url || @lan) && req.get? && req.path_info == "/api/info"
|
|
134
142
|
return false unless App::LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s) && !proxied?(req)
|
|
135
143
|
|
|
136
144
|
req.get_header("HTTP_AUTHORIZATION").nil? && req.get_header("HTTP_COOKIE").nil?
|
|
@@ -138,7 +146,7 @@ module Samagotchi
|
|
|
138
146
|
|
|
139
147
|
def probe_info
|
|
140
148
|
json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, features: App::INFO_FEATURES,
|
|
141
|
-
lan: @lan && @lan[:ip] })
|
|
149
|
+
lan: @lan && @lan[:ip], start_dir: start_dir })
|
|
142
150
|
end
|
|
143
151
|
|
|
144
152
|
def proxy_refuse(req, reason)
|