samagotchi 0.129.0 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +18 -9
  5. data/docs/configuration.md +7 -2
  6. data/docs/guardrails.md +60 -16
  7. data/docs/internals/background-tasks.md +11 -0
  8. data/docs/internals/engine.md +4 -3
  9. data/docs/internals/participants.md +13 -4
  10. data/docs/internals/remote-sessions.md +5 -4
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/engine.rb +20 -4
  14. data/lib/samagotchi/engine_peers.rb +3 -1
  15. data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
  16. data/lib/samagotchi/guardrails/child_boundary.rb +5 -5
  17. data/lib/samagotchi/guardrails/outside.rb +22 -20
  18. data/lib/samagotchi/guardrails/pair_boundary.rb +14 -3
  19. data/lib/samagotchi/guardrails/pair_command.rb +70 -10
  20. data/lib/samagotchi/guardrails/parent_approvals.rb +8 -11
  21. data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
  22. data/lib/samagotchi/guardrails/protected_paths.rb +17 -116
  23. data/lib/samagotchi/guardrails/review_paths.rb +13 -11
  24. data/lib/samagotchi/guardrails/rm_targets.rb +5 -5
  25. data/lib/samagotchi/guardrails/rules.rb +23 -5
  26. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -2
  27. data/lib/samagotchi/guardrails/shell_git_dirs.rb +17 -12
  28. data/lib/samagotchi/guardrails/shell_paths.rb +18 -29
  29. data/lib/samagotchi/guardrails/targets.rb +11 -13
  30. data/lib/samagotchi/remote_command.rb +13 -3
  31. data/lib/samagotchi/remotes.rb +3 -2
  32. data/lib/samagotchi/review_start.rb +3 -2
  33. data/lib/samagotchi/session_inbox.rb +17 -0
  34. data/lib/samagotchi/system_one/self_protect.rb +34 -19
  35. data/lib/samagotchi/terminal_ui.rb +2 -0
  36. data/lib/samagotchi/tool_declarations.rb +1 -1
  37. data/lib/samagotchi/tools/builtins.rb +10 -1
  38. data/lib/samagotchi/tools/peers.rb +4 -0
  39. data/lib/samagotchi/tools/task_wait.rb +26 -1
  40. data/lib/samagotchi/turn_state.rb +23 -4
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +23 -18
  43. data/lib/samagotchi/web/proxy_gate.rb +8 -6
  44. data/lib/samagotchi/web/server.rb +29 -4
  45. data/lib/samagotchi/worker.rb +9 -1
  46. metadata +3 -1
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -459,12 +459,14 @@ module Samagotchi
459
459
  # The request's Principal, as #route resolved it.
460
460
  def principal(req) = req.env.fetch(PRINCIPAL_ENV)
461
461
 
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.
462
+ # LAN mode: every route needs the access token, from a phone and from
463
+ # this machine's loopback too (a pair participant's shell runs here),
464
+ # as the chi_token cookie or an Authorization: Bearer header; only
465
+ # GATE_FREE_PATHS and chi's token-less /api/info probe
466
+ # (ProxyGate#probe_info?) are answered without it. The page with
467
+ # ?token= (or a link's #code=, POST /api/token) trades it for the
468
+ # cookie and a 303 to itself without it, so the token doesn't stay in
469
+ # the browser's history.
468
470
  # @return [Array(Principal, nil), Array(nil, Array)] the request's
469
471
  # principal (#principal_for), or the response that refuses it
470
472
  def token_gate(req)
@@ -489,9 +491,9 @@ module Samagotchi
489
491
  # the access token; a participant for a live grant's token (a chp_
490
492
  # one, looked up only when no owner token was given), wherever the
491
493
  # 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.
494
+ # owner only on a chi web that asks no token (loopback only: no LAN
495
+ # mode, no web.public_url), else nil. An ended or unknown grant's
496
+ # token is nil anywhere: it never falls back to the owner.
495
497
  # @return [Principal, nil]
496
498
  # With web.public_url (ProxyGate) every request needs a token, and a
497
499
  # proxied one is #proxied_principal's.
@@ -595,11 +597,10 @@ module Samagotchi
595
597
  "#{token_cookie_name(req)}=; #{token_cookie_attributes(req).sub(/Max-Age=\d+/, "Max-Age=0")}"
596
598
  end
597
599
 
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
600
+ # Whether the gate asks this request for a token: on a chi web on the
601
+ # LAN or behind a proxy, every request does, this machine's loopback
602
+ # too. Only a loopback-only chi web takes loopback as the owner.
603
+ def token_required?(_req) = !(@public_url || @lan).nil?
603
604
 
604
605
  # The page opened with ?token= (a gated request, or a grant's token
605
606
  # anywhere): its token is traded for the cookie, whatever else the
@@ -903,7 +904,11 @@ module Samagotchi
903
904
 
904
905
  def unauthorized_detail(req, reason)
905
906
  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
907
+
908
+ unless @public_url
909
+ here = LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s)
910
+ return "chi web on the LAN needs its access token#{", on this machine too" if here}"
911
+ end
907
912
 
908
913
  detail = "chi web with web.public_url needs its access token, on this machine too"
909
914
  direct_public?(req) ? "#{detail}; open its public address #{@public_url.origin}/ instead" : detail
@@ -941,7 +946,7 @@ module Samagotchi
941
946
  # the proxy too.
942
947
  def self.public_unauthorized_page(page)
943
948
  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.",
949
+ .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
950
  "Open the Proxied link chi web printed in its terminal (it works once; chi web again prints a new " \
946
951
  "one), or paste the token from its web-token file.")
947
952
  end
@@ -961,8 +966,8 @@ module Samagotchi
961
966
  </style></head>
962
967
  <body>
963
968
  <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>
969
+ <p>This page needs chi web's access token. Open the link chi web printed in its terminal (Local on this machine, LAN, or
970
+ Proxied behind an auth proxy), or scan its QR code.</p>
966
971
  <p id="trade" hidden></p>
967
972
  <form method="get" action="/">
968
973
  <label for="token">Or paste the token:</label>
@@ -124,13 +124,15 @@ module Samagotchi
124
124
  participant_principal(req, grant_tokens, identity: identity, proxied: true)
125
125
  end
126
126
 
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
127
+ # chi's own probe of a chi web that asks this machine for its token
128
+ # (behind a proxy, or on the LAN: InfoProbe from a second chi web, chi
129
+ # share, chi update, chi self) sends no token: GET /api/info from this
129
130
  # 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.
131
+ # fields it checks (nothing secret: its start folder is a path on this
132
+ # machine), so no probe ever hands the token to whatever listens on
133
+ # the port. Any token or cookie makes it an ordinary request.
132
134
  def probe_info?(req)
133
- return false unless @public_url && req.get? && req.path_info == "/api/info"
135
+ return false unless (@public_url || @lan) && req.get? && req.path_info == "/api/info"
134
136
  return false unless App::LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s) && !proxied?(req)
135
137
 
136
138
  req.get_header("HTTP_AUTHORIZATION").nil? && req.get_header("HTTP_COOKIE").nil?
@@ -138,7 +140,7 @@ module Samagotchi
138
140
 
139
141
  def probe_info
140
142
  json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, features: App::INFO_FEATURES,
141
- lan: @lan && @lan[:ip] })
143
+ lan: @lan && @lan[:ip], start_dir: start_dir })
142
144
  end
143
145
 
144
146
  def proxy_refuse(req, reason)
@@ -118,11 +118,15 @@ module Samagotchi
118
118
  puts "chi web already runs on port #{port} (pid #{found["pid"]}): #{url}"
119
119
  other_start = start_given && other_start_line(found["start_dir"], start_dir)
120
120
  puts other_start if other_start
121
- puts running_lan_lines(found["lan"], port) if found["lan"]
122
121
  running_public = public_url || Running.live_public_url(probe: ->(at) { probe(host, at) })
122
+ # On the LAN this machine's page needs the token too: the link
123
+ # printed and opened here carries an owner's one-time code.
124
+ here = found["lan"] && !running_public && Token.read(Token.path) ? local_owner_link(url) : nil
125
+ puts local_lines(here) if here
126
+ puts running_lan_lines(found["lan"], port) if found["lan"]
123
127
  puts running_public_lines(running_public) if running_public
124
128
  puts lan_off_line(setting) if new_token && !found["lan"]
125
- open_url(url) if open_browser
129
+ open_url(here ? local_owner_link(url) : url) if open_browser
126
130
  0
127
131
  when :other
128
132
  warn in_use_message(port)
@@ -388,6 +392,10 @@ module Samagotchi
388
392
  on_bound&.call
389
393
  record_running(running_path, port, public_url)
390
394
  puts "Chi Web on #{url} (public: #{File.expand_path("public", __dir__)})"
395
+ # On the LAN (no web.public_url) this machine's page needs the
396
+ # token too: a one-time owner's code in the link instead.
397
+ local = lan && !public_url
398
+ puts local_lines(local_owner_link(url, codes: owner_codes(state_dir))) if local
391
399
  puts lan_lines(ip: lan.ip, port: port, token: Token.read(token_path), others: lan.others, public: lan.public) if lan
392
400
  if public_url
393
401
  puts public_lines(public_url, token: Token.read(token_path), codes: owner_codes(state_dir), notes: public_notes)
@@ -395,8 +403,12 @@ module Samagotchi
395
403
  puts "Press Ctrl-C to stop."
396
404
  $stdout.flush # a log file isn't line-buffered
397
405
  if open_browser
398
- # With web.public_url the page opens through the proxy.
399
- open_target = public_url ? public_url.link(owner_codes(state_dir).issue(kind: :owner, ttl: LinkCodes::OWNER_TTL)) : url
406
+ # With web.public_url the page opens through the proxy; on the
407
+ # LAN with a one-time owner's code (#code=, its own).
408
+ open_target = if public_url then public_url.link(owner_codes(state_dir).issue(kind: :owner, ttl: LinkCodes::OWNER_TTL))
409
+ elsif local then local_owner_link(url, codes: owner_codes(state_dir))
410
+ else url
411
+ end
400
412
  Thread.new do
401
413
  sleep 0.8
402
414
  open_url(open_target)
@@ -518,6 +530,19 @@ module Samagotchi
518
530
  lines
519
531
  end
520
532
 
533
+ # This machine's page on the LAN (web.host lan, no web.public_url),
534
+ # whose loopback needs the token too: +url+ with an owner's one-time
535
+ # code in its fragment (token_fragment.js trades it for the cookie on
536
+ # 127.0.0.1), never the token. One code per link.
537
+ def self.local_owner_link(url, codes: owner_codes)
538
+ "#{url}#code=#{codes.issue(kind: :owner, ttl: LinkCodes::OWNER_TTL)}"
539
+ end
540
+
541
+ def self.local_lines(link)
542
+ ["Local: #{link} ← opens chi web as you once, within #{LinkCodes::OWNER_TTL / 60} minutes " \
543
+ "(chi web again prints a new one; the LAN link works here too)"]
544
+ end
545
+
521
546
  # The owner's one-time link codes, next to the grants.
522
547
  def self.owner_codes(state_dir = nil) = LinkCodes.for_grants(Grants.path(state_dir: state_dir))
523
548
 
@@ -1245,7 +1245,8 @@ module Samagotchi
1245
1245
  # status, its rings and every save from here on say so.
1246
1246
  @session.parent_id = Session.parent_override(@session_id, state_dir: @state_dir) || @session.parent_id
1247
1247
  save_session
1248
- result = @engine.run_turn(@session, prompt, pending_input: pending_input_drain, **turn_args)
1248
+ result = @engine.run_turn(@session, prompt, pending_input: pending_input_drain, input_waiting: method(:input_waiting?),
1249
+ **turn_args)
1249
1250
  rescue StandardError => e
1250
1251
  error = e
1251
1252
  # Still running: no turn of the Engine's ended (it never began).
@@ -1468,6 +1469,13 @@ module Samagotchi
1468
1469
  end
1469
1470
  end
1470
1471
 
1472
+ # Whether a person's message waits that the next boundary merges
1473
+ # (SessionInbox.steer_waiting?): a running task_wait ends on it. Never
1474
+ # in a participant's turn, whose drain merges none.
1475
+ def input_waiting?
1476
+ !@participant_turn && SessionInbox.steer_waiting?(@session_dir)
1477
+ end
1478
+
1471
1479
  # A participant's author fields (Engine#author_fields), {} for the owner.
1472
1480
  def turn_author_fields(origin)
1473
1481
  origin.participant? ? @engine.author_fields(origin.author, session: @session) : {}
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.129.0
4
+ version: 0.130.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -342,6 +342,7 @@ files:
342
342
  - lib/samagotchi/guardrails.rb
343
343
  - lib/samagotchi/guardrails/approval.rb
344
344
  - lib/samagotchi/guardrails/approvals.rb
345
+ - lib/samagotchi/guardrails/chi_dirs.rb
345
346
  - lib/samagotchi/guardrails/child_boundary.rb
346
347
  - lib/samagotchi/guardrails/context.rb
347
348
  - lib/samagotchi/guardrails/data_readers.rb
@@ -354,6 +355,7 @@ files:
354
355
  - lib/samagotchi/guardrails/parent_approvals.rb
355
356
  - lib/samagotchi/guardrails/parent_levels.rb
356
357
  - lib/samagotchi/guardrails/parent_policy.rb
358
+ - lib/samagotchi/guardrails/path_resolve.rb
357
359
  - lib/samagotchi/guardrails/protected_paths.rb
358
360
  - lib/samagotchi/guardrails/read_only_shell.rb
359
361
  - lib/samagotchi/guardrails/review_boundary.rb