samagotchi 0.103.0 → 0.104.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -24
  5. data/docs/cli.md +86 -12
  6. data/docs/guardrails.md +2 -1
  7. data/docs/internals/context-telemetry.md +6 -2
  8. data/docs/internals/engine.md +4 -1
  9. data/docs/internals/participants.md +137 -0
  10. data/docs/internals/remote-sessions.md +153 -27
  11. data/docs/plugins.md +7 -1
  12. data/docs/sessions.md +4 -2
  13. data/docs/sub-agent.md +8 -0
  14. data/docs/testing.md +68 -4
  15. data/lib/samagotchi/bridge.rb +7 -3
  16. data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
  17. data/lib/samagotchi/bridge_client.rb +4 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_invocation.rb +13 -16
  20. data/lib/samagotchi/context_status.rb +15 -2
  21. data/lib/samagotchi/engine.rb +16 -6
  22. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_scheduler.rb +34 -9
  25. data/lib/samagotchi/kernel_loop.rb +8 -1
  26. data/lib/samagotchi/launch_mode.rb +4 -0
  27. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  28. data/lib/samagotchi/owner_lock.rb +65 -5
  29. data/lib/samagotchi/participants.rb +122 -0
  30. data/lib/samagotchi/remote_attach_command.rb +119 -0
  31. data/lib/samagotchi/run_options.rb +14 -2
  32. data/lib/samagotchi/send_command.rb +1 -1
  33. data/lib/samagotchi/session.rb +8 -4
  34. data/lib/samagotchi/session_access/contract.rb +42 -4
  35. data/lib/samagotchi/session_access/dispatch.rb +29 -10
  36. data/lib/samagotchi/session_access/local.rb +52 -1
  37. data/lib/samagotchi/session_access/remote.rb +112 -16
  38. data/lib/samagotchi/session_access/web_bridge_client.rb +56 -1
  39. data/lib/samagotchi/session_access.rb +85 -3
  40. data/lib/samagotchi/session_continue.rb +1 -1
  41. data/lib/samagotchi/session_inbox.rb +18 -9
  42. data/lib/samagotchi/session_manager.rb +21 -5
  43. data/lib/samagotchi/sessions_command.rb +4 -4
  44. data/lib/samagotchi/share_command.rb +174 -0
  45. data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
  46. data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +83 -20
  48. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  49. data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
  50. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  51. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  52. data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
  53. data/lib/samagotchi/terminal_ui.rb +4 -1
  54. data/lib/samagotchi/testing/waiting.rb +92 -9
  55. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +187 -74
  58. data/lib/samagotchi/web/grants.rb +302 -0
  59. data/lib/samagotchi/web/participant_access.rb +286 -0
  60. data/lib/samagotchi/web/principal.rb +29 -0
  61. data/lib/samagotchi/web/server.rb +3 -1
  62. data/lib/samagotchi/web/session_access_routes.rb +120 -4
  63. data/lib/samagotchi/worker.rb +26 -5
  64. metadata +10 -1
@@ -5,12 +5,71 @@ require_relative "../process_group"
5
5
  module Samagotchi
6
6
  module Testing
7
7
  # Waiting with a deadline, for a test that waits on a thread or a
8
- # process (Helpers has them).
8
+ # process (Helpers has them). Every deadline is the given +timeout+ ×
9
+ # Waiting.time_scale (SPEC_TIME_SCALE, CI sets 2): a slow runner gets
10
+ # more headroom, a fast one never waits longer than its condition takes.
9
11
  module Waiting
12
+ # A wait whose deadline passed: its message names what it waited for
13
+ # (Waiting.timed_out_message).
14
+ class TimedOut < StandardError; end
15
+
16
+ # The most of a +seen:+ value's inspect a message shows.
17
+ SEEN_LIMIT = 300
18
+
19
+ class << self
20
+ # SPEC_TIME_SCALE as a Float, at least 1 (unset or empty: 1). A value
21
+ # that isn't a number gives 1 and one warning. Read per call.
22
+ def time_scale
23
+ raw = ENV.fetch("SPEC_TIME_SCALE", "").strip
24
+ return 1.0 if raw.empty?
25
+
26
+ [Float(raw), 1.0].max
27
+ rescue ArgumentError
28
+ unless @warned_scale
29
+ @warned_scale = true
30
+ warn("SPEC_TIME_SCALE=#{raw.inspect} is not a number: waiting with scale 1")
31
+ end
32
+ 1.0
33
+ end
34
+
35
+ # +timeout+ s scaled: the deadline a helper waits for.
36
+ def scaled(timeout, scale = time_scale) = timeout * scale
37
+
38
+ # How long a wait took to give up: "5 s", or with a scale
39
+ # "10.0 s (5 s × SPEC_TIME_SCALE 2)".
40
+ def waited(timeout, scale = time_scale)
41
+ return "#{timeout} s" if scale == 1
42
+
43
+ "#{scaled(timeout, scale).round(3).to_f} s (#{timeout} s × SPEC_TIME_SCALE #{number(scale)})"
44
+ end
45
+
46
+ # The one failure text of wait_for, join! and pop!:
47
+ # "timed out after 5 s waiting for <what>", then "; last saw: <value>"
48
+ # when +seen+ (a callable) is given, its value's inspect cut to
49
+ # SEEN_LIMIT chars.
50
+ def timed_out_message(what, timeout, scale: time_scale, seen: nil)
51
+ message = "timed out after #{waited(timeout, scale)} waiting for #{what}"
52
+ seen ? "#{message}; last saw: #{seen_text(seen)}" : message
53
+ end
54
+
55
+ private
56
+
57
+ def number(value) = value == value.to_i ? value.to_i.to_s : value.to_s
58
+
59
+ def seen_text(seen)
60
+ text = seen.call.inspect
61
+ text.length > SEEN_LIMIT ? "#{text[0, SEEN_LIMIT]}…" : text
62
+ rescue StandardError => e
63
+ "(raised #{e.class}: #{e.message})"
64
+ end
65
+ end
66
+
10
67
  # The block's value once truthy, polled every +interval+ s; after
11
- # +timeout+ s its last (falsy) value.
68
+ # +timeout+ s (scaled) its last (falsy) value. For a wait whose
69
+ # timeout is fine (a bounded best-effort wait); a wait that must hold
70
+ # is #wait_for.
12
71
  def wait_until(timeout: 5, interval: 0.01)
13
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
72
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Waiting.scaled(timeout)
14
73
  until (value = yield)
15
74
  return yield if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
16
75
 
@@ -19,15 +78,39 @@ module Samagotchi
19
78
  value
20
79
  end
21
80
 
22
- # #wait_until, raising "timed out after N s waiting for <what>" when
23
- # the time runs out: for a wait no check follows.
24
- def wait_until!(what, timeout: 5, interval: 0.01, &)
25
- wait_until(timeout: timeout, interval: interval, &) || raise("timed out after #{timeout} s waiting for #{what}")
81
+ # Polls the block every +interval+ s until it returns truthy and
82
+ # returns that value. After +timeout+ s (scaled) it raises
83
+ # Waiting::TimedOut naming +what+ ("timed out after 5 s waiting for
84
+ # <what>"); +seen+ (a callable) adds what it last saw, since a falsy
85
+ # block value tells nothing.
86
+ def wait_for(what, timeout: 5, interval: 0.01, seen: nil, &)
87
+ wait_until(timeout: timeout, interval: interval, &) ||
88
+ raise(TimedOut, Waiting.timed_out_message(what, timeout, seen: seen))
89
+ end
90
+
91
+ # Thread#join with a deadline (scaled): a thread that never ends fails
92
+ # the test with "<what> to finish" named, not after a suite-wide guard.
93
+ # Raises what the thread raised, as #join does.
94
+ # @return [Thread]
95
+ def join!(thread, what, timeout: 5)
96
+ thread.join(Waiting.scaled(timeout)) || raise(TimedOut, Waiting.timed_out_message("#{what} to finish", timeout))
97
+ end
98
+
99
+ # Queue#pop with a deadline (scaled), for a queue that never carries
100
+ # nil: nothing pushed in time fails with +what+ named. A gate a stubbed
101
+ # lib thread waits on while the test works takes a longer +timeout+
102
+ # (30): it only has to end a hang, never cut a slow test short.
103
+ # @return the value popped.
104
+ def pop!(queue, what, timeout: 5)
105
+ value = queue.pop(timeout: Waiting.scaled(timeout))
106
+ raise(TimedOut, Waiting.timed_out_message(what, timeout)) if value.nil?
107
+
108
+ value
26
109
  end
27
110
 
28
111
  # Whether no process +pid+ is left (a killed grandchild is reaped a
29
- # moment later: wait_until { process_gone?(pid) }). One that isn't
30
- # ours to signal is still there (ProcessGroup.alive?).
112
+ # moment later: wait_for("pid gone") { process_gone?(pid) }). One that
113
+ # isn't ours to signal is still there (ProcessGroup.alive?).
31
114
  def process_gone?(pid) = !ProcessGroup.alive?(pid)
32
115
  end
33
116
  end
@@ -99,9 +99,10 @@ module Samagotchi
99
99
  # status. Only while this chi runs. A record a refresh finished in
100
100
  # between (failed, no exit code) is corrected; a stopped task (its
101
101
  # stop flag on disk) is left as task_stop records it.
102
- # The thread is named EXIT_STATUS_THREAD (a spec joins them before it
103
- # removes the tasks' folder) here, before it returns: named from
104
- # inside, a thread not yet scheduled was missed by that join.
102
+ # The thread is named EXIT_STATUS_THREAD (a spec joins them with
103
+ # #quiesce! before it removes the tasks' folder) here, before it
104
+ # returns: named from inside, a thread not yet scheduled was missed by
105
+ # that join.
105
106
  # @return [Thread]
106
107
  def record_exit_status(waiter, path, task_id:)
107
108
  # Absolute now: safe against a later chdir.
@@ -132,6 +133,19 @@ module Samagotchi
132
133
  # record_exit_status's threads' name.
133
134
  EXIT_STATUS_THREAD = "task_exit_status"
134
135
 
136
+ # Joins every live EXIT_STATUS_THREAD, for up to +timeout+ s in all: a
137
+ # spec calls it (with_task_dir) after stopping its tasks and before it
138
+ # removes their folder, which a recorder may still write into. chi
139
+ # itself doesn't call it.
140
+ # @return [Array<Thread>] the ones still alive after +timeout+ (empty
141
+ # when quiet)
142
+ def quiesce!(timeout: 5)
143
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
144
+ threads = Thread.list.select { |thread| thread.name == EXIT_STATUS_THREAD && thread != Thread.current }
145
+ threads.each { |thread| thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
146
+ threads.select(&:alive?)
147
+ end
148
+
135
149
  # Reasons a refresh finishes a record with when it read no exit code.
136
150
  MISSING_CODE_REASONS = ["process_ended_without_exit_code", NOT_CHIS_PROCESS].freeze
137
151
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.103.0"
4
+ VERSION = "0.104.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -53,6 +53,8 @@ require_relative "editor"
53
53
  require_relative "../message_parts"
54
54
  require_relative "session_summary"
55
55
  require_relative "session_access_routes"
56
+ require_relative "principal"
57
+ require_relative "participant_access"
56
58
  require_relative "../log"
57
59
  require_relative "../paths"
58
60
  require_relative "../redacted_inspect"
@@ -70,6 +72,7 @@ module Samagotchi
70
72
  # @lan holds the LAN access token.
71
73
  include RedactedInspect
72
74
  include SessionAccessRoutes
75
+ include ParticipantAccess
73
76
 
74
77
  DEFAULT_HOST = BridgeClient::HOST
75
78
  BRIDGE_WAIT_TIMEOUT = 10.0
@@ -83,7 +86,11 @@ module Samagotchi
83
86
  IMAGE_TYPES = { "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif",
84
87
  "webp" => "image/webp" }.freeze
85
88
 
86
- # POST /stop waits this long for the worker to let go of the session.
89
+ # POST /stop waits this long for the worker to let go of the session
90
+ # when the body names no wait (the page's Stop: it shows the stop at
91
+ # once, and a Rack thread isn't held for it). A CLI through --remote
92
+ # sends its own, SessionManager::STOP_WAIT at most, so chi sessions
93
+ # stop waits as long as here.
87
94
  STOP_WAIT_SECONDS = 2.0
88
95
 
89
96
  # GET /api/events: a `: ping` this often while idle, a queue this deep
@@ -126,13 +133,16 @@ module Samagotchi
126
133
  # the address the server also listens on and its access token (a
127
134
  # String, or a Web::Token::Source that follows the file); nil:
128
135
  # loopback only, no token
136
+ # @param grants [Grants, nil] the participants' grants (default: the
137
+ # store next to +state_dir+, Grants.path)
129
138
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
130
139
  bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
131
140
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
132
141
  editor: Config::BY_KEY["web.editor"].default,
133
142
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
134
143
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
135
- working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL)
144
+ working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL,
145
+ grants: nil)
136
146
  @manager = manager || SessionManager
137
147
  @registry = registry
138
148
  @registry_given = !registry.nil?
@@ -156,6 +166,7 @@ module Samagotchi
156
166
  @refused_mutex = Mutex.new
157
167
  @refused_logged = {}
158
168
  @wait_poll_interval = wait_poll_interval
169
+ init_participant_access(grants, state_dir)
159
170
  end
160
171
 
161
172
  def call(env)
@@ -168,47 +179,63 @@ module Samagotchi
168
179
  private
169
180
 
170
181
  # The routes, first match wins: a handler takes the request and the
171
- # path's captures (the session id, an image's name). A :session route
172
- # runs in #session_route: an ArgumentError is its unknown session.
182
+ # path's captures (the session id, an image's name). The fourth column
183
+ # is who may use the route (ROUTE_PERMISSIONS, checked against the
184
+ # request's Principal): :owner, or :participant for one a participant
185
+ # may use too, its handler answering them as theirs (the participant
186
+ # page, the trimmed info, the filtered show; docs/internals/
187
+ # participants.md). A :session route runs in #session_route: an
188
+ # ArgumentError is its unknown session.
189
+ ROUTE_PERMISSIONS = %i[owner participant].freeze
173
190
  ROUTES = [
174
- ["GET", %r{\A/(?:index\.html)?\z}, :serve_index],
175
- ["GET", %r{\A/api/sessions\z}, :handle_list],
176
- ["POST", %r{\A/api/sessions\z}, :handle_create],
177
- ["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve],
178
- ["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries],
179
- ["GET", %r{\A/api/info\z}, :handle_info],
180
- ["GET", %r{\A/api/models\z}, :handle_models],
181
- ["GET", %r{\A/api/history\z}, :handle_history],
182
- ["GET", %r{\A/api/events\z}, :handle_events],
183
- ["POST", %r{\A/api/render\z}, :handle_render],
184
- ["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
185
- ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output, :session],
186
- ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel, :session],
187
- ["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop, :session],
188
- ["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :session],
189
- ["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop, :session],
190
- ["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart, :session],
191
- ["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive, :session],
192
- ["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive, :session],
193
- ["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn, :session],
194
- ["POST", %r{\A/api/sessions/([^/]+)/wait/prepare\z}, :handle_wait_prepare, :session],
195
- ["POST", %r{\A/api/sessions/([^/]+)/wait\z}, :handle_wait, :session],
196
- ["POST", %r{\A/api/sessions/([^/]+)/notes\z}, :handle_notes, :session],
197
- ["POST", %r{\A/api/sessions/([^/]+)/wake\z}, :handle_wake, :session],
198
- ["GET", %r{\A/api/sessions/([^/]+)/worker\z}, :handle_worker, :session],
199
- ["POST", %r{\A/api/sessions/([^/]+)/answer\z}, :handle_question_answer],
200
- ["POST", %r{\A/api/sessions/([^/]+)/question/dismiss\z}, :handle_question_dismiss],
201
- ["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command, :session],
202
- ["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload],
203
- ["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image],
204
- ["GET", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_list, :session],
205
- ["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show, :session],
206
- ["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add, :session],
207
- ["POST", %r{\A/api/sessions/([^/]+)/context/([^/]+)/push\z}, :handle_context_push, :session],
208
- ["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete, :session],
209
- ["GET", %r{\A/api/sessions/([^/]+)/working_set\z}, :handle_working_set, :session],
210
- ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :session],
211
- ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :session]
191
+ ["GET", %r{\A/(?:index\.html)?\z}, :serve_index, :participant],
192
+ ["GET", %r{\A/api/sessions\z}, :handle_list, :owner],
193
+ ["POST", %r{\A/api/sessions\z}, :handle_create, :owner],
194
+ ["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :owner],
195
+ ["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
196
+ ["GET", %r{\A/api/info\z}, :handle_info, :participant],
197
+ ["GET", %r{\A/api/models\z}, :handle_models, :owner],
198
+ ["GET", %r{\A/api/history\z}, :handle_history, :owner],
199
+ ["GET", %r{\A/api/events\z}, :handle_events, :owner],
200
+ ["POST", %r{\A/api/render\z}, :handle_render, :owner],
201
+ ["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream, :owner],
202
+ ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output, :owner, :session],
203
+ ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel, :owner, :session],
204
+ ["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop, :owner, :session],
205
+ ["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :owner, :session],
206
+ ["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop, :owner, :session],
207
+ ["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart, :owner, :session],
208
+ ["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive, :owner, :session],
209
+ ["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive, :owner, :session],
210
+ ["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn, :owner, :session],
211
+ ["POST", %r{\A/api/sessions/([^/]+)/wait/prepare\z}, :handle_wait_prepare, :owner, :session],
212
+ ["POST", %r{\A/api/sessions/([^/]+)/wait\z}, :handle_wait, :owner, :session],
213
+ ["POST", %r{\A/api/sessions/([^/]+)/notes\z}, :handle_notes, :owner, :session],
214
+ ["POST", %r{\A/api/sessions/([^/]+)/wake\z}, :handle_wake, :owner, :session],
215
+ ["GET", %r{\A/api/sessions/([^/]+)/worker\z}, :handle_worker, :owner, :session],
216
+ ["POST", %r{\A/api/sessions/([^/]+)/exit\z}, :handle_exit, :owner, :session],
217
+ ["POST", %r{\A/api/sessions/([^/]+)/recap\z}, :handle_recap, :owner, :session],
218
+ ["GET", %r{\A/api/sessions/([^/]+)/stats\z}, :handle_stats, :owner, :session],
219
+ ["GET", %r{\A/api/sessions/([^/]+)/children/counts\z}, :handle_children_counts, :owner, :session],
220
+ ["GET", %r{\A/api/sessions/([^/]+)/turns/([^/]+)/records\z}, :handle_turn_records, :owner, :session],
221
+ ["POST", %r{\A/api/sessions/([^/]+)/answer\z}, :handle_question_answer, :owner],
222
+ ["POST", %r{\A/api/sessions/([^/]+)/question/dismiss\z}, :handle_question_dismiss, :owner],
223
+ ["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command, :owner, :session],
224
+ ["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload, :owner],
225
+ ["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image, :owner],
226
+ ["GET", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_list, :owner, :session],
227
+ ["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show, :owner, :session],
228
+ ["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add, :owner, :session],
229
+ ["POST", %r{\A/api/sessions/([^/]+)/context/([^/]+)/push\z}, :handle_context_push, :owner, :session],
230
+ ["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete, :owner, :session],
231
+ ["GET", %r{\A/api/sessions/([^/]+)/working_set\z}, :handle_working_set, :owner, :session],
232
+ ["GET", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_list, :owner, :session],
233
+ ["POST", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_invite, :owner, :session],
234
+ ["DELETE", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_revoke, :owner, :session],
235
+ ["GET", %r{\A/api/participants\z}, :handle_participants_list, :owner],
236
+ ["DELETE", %r{\A/api/participants\z}, :handle_participants_revoke, :owner],
237
+ ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :participant, :session],
238
+ ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :owner, :session]
212
239
  ].freeze
213
240
 
214
241
  def route(env)
@@ -216,13 +243,18 @@ module Samagotchi
216
243
  return forbidden unless local_host_header?(env)
217
244
  return cross_origin if cross_site?(env)
218
245
 
219
- if @lan && !LOOPBACK_PEERS.include?(env["REMOTE_ADDR"].to_s)
220
- denied = token_gate(req)
221
- return denied if denied
222
- end
246
+ principal, denied = token_gate(req)
247
+ return denied if denied
223
248
 
224
- ROUTES.each do |verb, pattern, handler, kind|
249
+ # The principal the handlers read (#principal): never the body's.
250
+ env[PRINCIPAL_ENV] = principal
251
+ participant_seen(req, principal) unless principal.owner?
252
+ ROUTES.each do |verb, pattern, handler, permission, kind|
225
253
  next unless req.request_method == verb && (m = pattern.match(req.path_info))
254
+ # Before the permission: a participant learns nothing of another session.
255
+ return unknown_session(m.captures.first) if other_session?(principal, req.path_info, m.captures)
256
+ return not_permitted_for(req, principal) unless principal.permits?(permission)
257
+
226
258
  # Every /api/sessions/:id route: an id that isn't one (Session.valid_id?,
227
259
  # "..", a %2F) is an unknown session before any handler makes a path.
228
260
  # (/api/sessions/resolve and /summaries take no id.)
@@ -312,25 +344,57 @@ module Samagotchi
312
344
  TOKEN_COOKIE_ATTRIBUTES = "HttpOnly; SameSite=Lax; Path=/; Max-Age=34560000"
313
345
  REFUSED_LOG_INTERVAL = 60.0
314
346
 
347
+ # Where #route keeps the request's Principal for its handlers.
348
+ PRINCIPAL_ENV = "samagotchi.principal"
349
+ PAGE_PATHS = ["/", "/index.html"].freeze
350
+
351
+ # The request's Principal, as #route resolved it.
352
+ def principal(req) = req.env.fetch(PRINCIPAL_ENV)
353
+
315
354
  # LAN mode, a peer that isn't this machine (the peer is the socket's
316
355
  # address, REMOTE_ADDR: never X-Forwarded-For or req.ip, which trust
317
356
  # headers a phone could send): every route needs the access token, as
318
357
  # the chi_token cookie or an Authorization: Bearer header. The page
319
358
  # with ?token= trades it for the cookie and a 303 to itself without
320
359
  # it, so the token doesn't stay in the phone's history.
321
- # @return [Array, nil] the response that refuses it, or nil to go on
360
+ # @return [Array(Principal, nil), Array(nil, Array)] the request's
361
+ # principal (#principal_for), or the response that refuses it
322
362
  def token_gate(req)
363
+ principal = principal_for(req)
364
+ return [nil, grant_ended_response(req) || unauthorized(req)] if principal.nil?
365
+ # The cookie gets the token that was presented: the owner's, or the
366
+ # participant's grant's.
367
+ return [nil, token_redirect(req, req.env[GRANT_TOKEN_ENV] || current_token)] if token_trade?(req)
368
+
369
+ [principal, nil]
370
+ end
371
+
372
+ # Who the request is: the owner for a peer the gate doesn't ask (no
373
+ # LAN mode, or this machine's loopback) and for the access token (the
374
+ # page's ?token= on a trade, else the Bearer header or the cookie);
375
+ # a participant for a live grant's token (Grants, looked up only when
376
+ # no owner token was given); nil for anyone else.
377
+ # @return [Principal, nil]
378
+ def principal_for(req)
379
+ return Principal::OWNER unless token_required?(req)
380
+
323
381
  token = current_token
324
- if req.get? && ["/", "/index.html"].include?(req.path_info) && req.params.key?("token")
325
- return token_redirect(req, token) if token_match?(req.params["token"], token)
326
- else
327
- auth = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer +(\S+)\z/, 1]
328
- return nil if token_match?(auth, token) || token_match?(req.cookies[TOKEN_COOKIE], token)
329
- end
382
+ given = token_trade?(req) ? [req.params["token"]] : [bearer_token(req), req.cookies[TOKEN_COOKIE]]
383
+ return Principal::OWNER if given.any? { |value| token_match?(value, token) }
330
384
 
331
- unauthorized(req)
385
+ participant_principal(req, given.select { |value| Grants.token?(value) })
332
386
  end
333
387
 
388
+ def token_required?(req) = @lan && !LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s)
389
+
390
+ # The page opened with ?token= (a gated request): its token is traded
391
+ # for the cookie, whatever else the request carries.
392
+ def token_trade?(req)
393
+ token_required?(req) && req.get? && PAGE_PATHS.include?(req.path_info) && req.params.key?("token")
394
+ end
395
+
396
+ def bearer_token(req) = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer +(\S+)\z/, 1]
397
+
334
398
  def current_token
335
399
  token = @lan[:token]
336
400
  token.respond_to?(:current) ? token.current : token
@@ -417,6 +481,11 @@ module Samagotchi
417
481
  error_response(403, "cross_origin", "requests from other websites are refused")
418
482
  end
419
483
 
484
+ # A route the request's principal may not use (Principal#permits?).
485
+ def not_permitted
486
+ error_response(403, "not_permitted", "only the session's owner can do that")
487
+ end
488
+
420
489
  def forbidden
421
490
  names = @lan ? "127.0.0.1, [::1], localhost and #{@lan[:ip]}" : "127.0.0.1, [::1] and localhost"
422
491
  error_response(403, "forbidden", "only the host names #{names} are answered")
@@ -473,14 +542,17 @@ module Samagotchi
473
542
  # session_commands: the routes chi --remote needs
474
543
  # (SessionAccessRoutes::FEATURE); remote_attach: the ones a remote
475
544
  # attached TUI needs (ATTACH_FEATURE). lan: its LAN address, nil when it
476
- # listens on loopback only.
477
- def handle_info(_req)
545
+ # listens on loopback only. A participant gets app, version and
546
+ # features only (#participant_info).
547
+ def handle_info(req)
548
+ return participant_info unless principal(req).owner?
549
+
478
550
  json_response(200, { app: "chi-web", version: Samagotchi::VERSION, installed: @hub&.installed, pid: Process.pid,
479
- cwd: Dir.pwd, features: ["dir", "newest_workers", SessionAccessRoutes::FEATURE,
480
- SessionAccessRoutes::ATTACH_FEATURE],
481
- lan: @lan && @lan[:ip] })
551
+ cwd: Dir.pwd, features: INFO_FEATURES, lan: @lan && @lan[:ip] })
482
552
  end
483
553
 
554
+ INFO_FEATURES = ["dir", "newest_workers", SessionAccessRoutes::FEATURE, SessionAccessRoutes::ATTACH_FEATURE].freeze
555
+
484
556
  # The models a new session can start on, spelled as chi spells them
485
557
  # elsewhere: bare for the default host (what default.model holds), and
486
558
  # host:model for another host. The default is the configured model, or
@@ -945,6 +1017,8 @@ module Samagotchi
945
1017
  end
946
1018
 
947
1019
  def handle_show(req, id)
1020
+ return participant_show(id) unless principal(req).owner?
1021
+
948
1022
  # The page's light re-reads: never the whole conversation.
949
1023
  return handle_cards_read(id) if req.params["cards"] == "1"
950
1024
  return handle_tail_read(req, id) if req.params["tail"] == "1"
@@ -1462,8 +1536,10 @@ module Samagotchi
1462
1536
  return refused if refused
1463
1537
 
1464
1538
  # no_interrupt: an attached TUI's --no-interrupt (chi --remote --attach).
1539
+ # author: the principal's (nil for the owner), never the body's.
1465
1540
  result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
1466
1541
  delivery: delivery, no_interrupt: body["no_interrupt"] == true,
1542
+ author: principal(req).author,
1467
1543
  state_dir: @state_dir, manager: @manager,
1468
1544
  bridge: -> { live_bridge_client(id) })
1469
1545
  case result.status
@@ -1635,20 +1711,29 @@ module Samagotchi
1635
1711
  end
1636
1712
  end
1637
1713
 
1638
- # POST /api/sessions/:id/stop: 200 {status: "stopped", session_id,
1639
- # released}: released, whether the worker let go of the session
1640
- # within STOP_WAIT_SECONDS (chi sessions stop's two wordings); 409
1714
+ # POST /api/sessions/:id/stop {wait?}: 200 {status: "stopped",
1715
+ # session_id, released}: released, whether the worker let go of the
1716
+ # session within the wait (STOP_WAIT_SECONDS, or the body's seconds up
1717
+ # to SessionManager::STOP_WAIT: chi sessions stop's two wordings); 409
1641
1718
  # owned_by_tui.
1642
- def handle_stop(_req, id)
1719
+ def handle_stop(req, id)
1643
1720
  # Bounded, so a Rack thread isn't held long; a worker still exiting
1644
1721
  # after it just means an immediate resume finds it (rare).
1645
- released = @manager.stop_session(id, state_dir: @state_dir, wait: STOP_WAIT_SECONDS)
1722
+ released = @manager.stop_session(id, state_dir: @state_dir, wait: stop_wait(req))
1646
1723
  @hub&.touch(id)
1647
1724
  json_response(200, { status: "stopped", session_id: id, released: released == true })
1648
1725
  rescue SessionManager::OwnedByTUI => e
1649
1726
  owned_by_tui(e)
1650
1727
  end
1651
1728
 
1729
+ # The body's wait (a number of seconds), capped at
1730
+ # SessionManager::STOP_WAIT; STOP_WAIT_SECONDS without one.
1731
+ def stop_wait(req)
1732
+ body = parse_json(request_body(req, 4096))
1733
+ wait = body["wait"] if body.is_a?(Hash)
1734
+ wait.is_a?(Numeric) && wait.positive? ? [wait, SessionManager::STOP_WAIT].min : STOP_WAIT_SECONDS
1735
+ end
1736
+
1652
1737
  # POST /api/sessions/:id/restart: hand the session to a new worker on
1653
1738
  # the newest chi installed (SessionManager.restart_session). 200
1654
1739
  # {status: "restarted", session_id, from_version, version} (version nil:
@@ -1945,9 +2030,25 @@ module Samagotchi
1945
2030
  return [200, headers.merge("rack.hijack" => hijack), []]
1946
2031
  end
1947
2032
 
1948
- query = stream_query(req)
1949
- [200, headers, ProxyStreamBody.new(host: DEFAULT_HOST, port: bridge_port, session_id: id, query: query, headers: req.env,
1950
- server_running: @server_running)]
2033
+ [200, headers, proxy_stream_body(id, req, bridge_port)]
2034
+ end
2035
+
2036
+ # The one ProxyStreamBody both of #handle_stream's transports send:
2037
+ # the session's Bridge stream, through the request principal's frame
2038
+ # filter (#stream_filter).
2039
+ def proxy_stream_body(id, req, port)
2040
+ ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: stream_query(req), headers: req.env,
2041
+ server_running: stream_running(principal(req)), filter: stream_filter(principal(req)))
2042
+ end
2043
+
2044
+ # What a principal's stream goes through on its way from the Bridge:
2045
+ # nil, the raw bytes unchanged, for the owner (the only principal
2046
+ # today). A filter is anything with #call(chunk) -> String (nil or
2047
+ # "" sends nothing); see ProxyStreamBody.
2048
+ def stream_filter(principal)
2049
+ return nil if principal.owner?
2050
+
2051
+ raise ArgumentError, "no stream filter for a #{principal.kind} principal"
1951
2052
  end
1952
2053
 
1953
2054
  # GET /api/events: the session list as one SSE stream per tab. The
@@ -2007,10 +2108,10 @@ module Samagotchi
2007
2108
  # (WEBrick via rackup) has already sent the status line + headers, so
2008
2109
  # only body bytes go to the socket here.
2009
2110
  def sse_hijack(id, req, port)
2111
+ # Made before the status line goes out: a principal with no filter
2112
+ # is refused (#stream_filter), never streamed.
2113
+ body = proxy_stream_body(id, req, port)
2010
2114
  lambda do |io|
2011
- query = stream_query(req)
2012
- body = ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: query, headers: req.env,
2013
- server_running: @server_running)
2014
2115
  body.each { |chunk| io.write(chunk) }
2015
2116
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
2016
2117
  nil # client went away — end the stream quietly
@@ -2068,6 +2169,8 @@ module Samagotchi
2068
2169
  end
2069
2170
 
2070
2171
  def serve_index(req)
2172
+ return participant_page(principal(req)) unless principal(req).owner?
2173
+
2071
2174
  path = File.join(@public_dir, "index.html")
2072
2175
  if File.file?(path)
2073
2176
  body = File.read(path).sub("<body>", "<body #{index_data_attributes(req)}>")
@@ -2614,15 +2717,20 @@ module Samagotchi
2614
2717
  # ends when the Bridge closes the stream or, as EventsBody, when the
2615
2718
  # server leaves :Running: rackup's WEBrick joins every request thread
2616
2719
  # before returning, and a read blocked on a quiet worker would hang
2617
- # Ctrl-C of chi web. The bytes go through unchanged.
2720
+ # Ctrl-C of chi web. Without a +filter+ the bytes go through unchanged
2721
+ # (the owner's stream); with one, each chunk read from the Bridge goes
2722
+ # through it, and what it returns is sent.
2618
2723
  class ProxyStreamBody
2619
2724
  # @param headers [Hash] the request's Rack env; only its Last-Event-ID
2620
2725
  # is kept (the env carries the token cookie, never held or inspected)
2621
- def initialize(host:, port:, session_id:, query:, headers:, server_running: -> { true })
2726
+ # @param filter [#call, nil] chunk -> String (nil or "": nothing sent);
2727
+ # it sees the Bridge's reads as they come, not whole frames
2728
+ def initialize(host:, port:, session_id:, query:, headers:, server_running: -> { true }, filter: nil)
2622
2729
  @client = BridgeClient.new(session_id: session_id, port: port, host: host)
2623
2730
  @query = query
2624
2731
  @last_event_id = headers["HTTP_LAST_EVENT_ID"]
2625
2732
  @server_running = server_running
2733
+ @filter = filter
2626
2734
  end
2627
2735
 
2628
2736
  def each(&)
@@ -2630,7 +2738,12 @@ module Samagotchi
2630
2738
  # Last-Event-ID header over ?from_seq, and the reconnect URL carries a
2631
2739
  # stale initial cursor — without this the bridge would replay content
2632
2740
  # already delivered (duplicate bubbles).
2633
- @client.stream(query: @query, last_event_id: @last_event_id, running: @server_running, &)
2741
+ return @client.stream(query: @query, last_event_id: @last_event_id, running: @server_running, &) unless @filter
2742
+
2743
+ @client.stream(query: @query, last_event_id: @last_event_id, running: @server_running) do |chunk|
2744
+ out = @filter.call(chunk)
2745
+ yield(out) unless out.nil? || out.empty?
2746
+ end
2634
2747
  end
2635
2748
  end
2636
2749
  end