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
@@ -3,7 +3,8 @@
3
3
  require_relative "../config"
4
4
  require_relative "../memory_paths"
5
5
  require_relative "../paths"
6
- require_relative "../guardrails/protected_paths"
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 resolved as Guardrails::ShellPaths
21
- # resolves them (cd chains, ~, $HOME and the XDG dirs, symlinks, `..`
22
- # both ways), under chi's env and the call's env: when the caller hands
23
- # its Targets; one landing in chi's dirs outside the memories refuses.
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::ProtectedPaths.real(MemoryPaths.system_dir)
92
- bundles = Guardrails::ProtectedPaths.real(MemoryPaths.bundles_dir)
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::ProtectedPaths.reals(path).all? { |real| memory?(real, memories, bundles) }
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::ProtectedPaths.real(MemoryPaths.system_dir)
111
- bundles = Guardrails::ProtectedPaths.real(MemoryPaths.bundles_dir)
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::ProtectedPaths.reals(path).any? do |real|
128
- dirs.any? { |dir| Guardrails::ShellPaths.within?(real, dir) } && !memory?(real, memories, bundles)
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::ProtectedPaths.within?(real, memories) && !Guardrails::ProtectedPaths.within?(real, bundles)
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::ProtectedPaths.real(dir)]
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, and the activity clock the idle jobs read.
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 sink, in one step.
71
- def begin!(controller:, sink:)
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)" }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.129.0"
4
+ VERSION = "0.130.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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, a peer that isn't this machine (the peer is the socket's
463
- # address, REMOTE_ADDR: never X-Forwarded-For or req.ip, which trust
464
- # headers a phone could send): every route needs the access token, as
465
- # the chi_token cookie or an Authorization: Bearer header. The page
466
- # with ?token= trades it for the cookie and a 303 to itself without
467
- # it, so the token doesn't stay in the phone's history.
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 token that was presented: the owner's, or the
475
- # participant's grant's.
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] || current_token
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 for a peer the gate doesn't ask (no LAN mode, or this
493
- # machine's loopback), else nil. An ended or unknown grant's token is
494
- # nil anywhere: it never falls back to the owner.
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
- [bearer_token(req), req.cookies[token_cookie_name(req)]]
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
- def token_required?(req)
599
- return true if @public_url
600
-
601
- @lan && !LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s)
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
- return response if token_required?(req) || !Grants.token?(req.cookies[token_cookie_name(req)])
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
- return "chi web on the LAN needs its access token" unless @public_url
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 Proxied behind an\nauth proxy), or scan its QR code.",
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 Proxied behind an
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: [(@lan || @public_url) && current_token].compact)
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 behind a proxy (InfoProbe: a second chi
128
- # web, chi share, chi update) sends no token: GET /api/info from this
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), so no probe ever hands the token
131
- # to whatever listens on the port.
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)