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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -24
- data/docs/cli.md +86 -12
- data/docs/guardrails.md +2 -1
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +4 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +153 -27
- data/docs/plugins.md +7 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/bridge.rb +7 -3
- data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +13 -16
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +16 -6
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +8 -1
- data/lib/samagotchi/launch_mode.rb +4 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/run_options.rb +14 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +8 -4
- data/lib/samagotchi/session_access/contract.rb +42 -4
- data/lib/samagotchi/session_access/dispatch.rb +29 -10
- data/lib/samagotchi/session_access/local.rb +52 -1
- data/lib/samagotchi/session_access/remote.rb +112 -16
- data/lib/samagotchi/session_access/web_bridge_client.rb +56 -1
- data/lib/samagotchi/session_access.rb +85 -3
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +21 -5
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
- data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +83 -20
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
- data/lib/samagotchi/terminal_ui.rb +4 -1
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -74
- data/lib/samagotchi/web/grants.rb +302 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +120 -4
- data/lib/samagotchi/worker.rb +26 -5
- 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
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
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
|
|
103
|
-
# removes the tasks' folder) here, before it
|
|
104
|
-
# inside, a thread not yet scheduled was missed by
|
|
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
|
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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).
|
|
172
|
-
#
|
|
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/([^/]+)/
|
|
200
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
201
|
-
["
|
|
202
|
-
["
|
|
203
|
-
["GET", %r{\A/api/sessions/([^/]+)/
|
|
204
|
-
["
|
|
205
|
-
["
|
|
206
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
207
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
208
|
-
["
|
|
209
|
-
["GET", %r{\A/api/sessions/([^/]+)/
|
|
210
|
-
["GET", %r{\A/api/sessions/([^/]+)\z}, :
|
|
211
|
-
["
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
return denied if denied
|
|
222
|
-
end
|
|
246
|
+
principal, denied = token_gate(req)
|
|
247
|
+
return denied if denied
|
|
223
248
|
|
|
224
|
-
|
|
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
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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",
|
|
1639
|
-
# released}: released, whether the worker let go of the
|
|
1640
|
-
# within
|
|
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(
|
|
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:
|
|
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
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
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.
|
|
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
|
-
|
|
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
|