samagotchi 0.119.0 → 0.120.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 +66 -1
- data/bin/chi +6 -1
- data/docs/architecture.md +35 -5
- data/docs/cli.md +33 -8
- data/docs/configuration.md +45 -10
- data/docs/internals/participants.md +109 -12
- data/docs/internals/remote-sessions.md +6 -1
- data/docs/internals/web-push.md +4 -2
- data/docs/sessions.md +4 -3
- data/docs/testing.md +10 -1
- data/lib/samagotchi/answer_command.rb +2 -0
- data/lib/samagotchi/bridge.rb +56 -13
- data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
- data/lib/samagotchi/bridge_client.rb +58 -19
- data/lib/samagotchi/bridge_secret.rb +35 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/participant_messages.rb +5 -0
- data/lib/samagotchi/participants.rb +16 -6
- data/lib/samagotchi/review_command.rb +3 -1
- data/lib/samagotchi/run_options.rb +3 -1
- data/lib/samagotchi/session_access/contract.rb +4 -1
- data/lib/samagotchi/session_access/local.rb +15 -4
- data/lib/samagotchi/session_access/remote.rb +43 -7
- data/lib/samagotchi/session_access.rb +11 -1
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/sessions_command.rb +1 -2
- data/lib/samagotchi/share_command.rb +29 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
- data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -43
- data/lib/samagotchi/web/grants.rb +127 -16
- data/lib/samagotchi/web/link_codes.rb +24 -15
- data/lib/samagotchi/web/participant_access.rb +69 -13
- data/lib/samagotchi/web/participant_exposure.rb +44 -0
- data/lib/samagotchi/web/participant_review.rb +66 -8
- data/lib/samagotchi/web/participants_admin.rb +12 -6
- data/lib/samagotchi/web/proxy_gate.rb +18 -3
- data/lib/samagotchi/web/public/annotate_bar.css +18 -0
- data/lib/samagotchi/web/public/annotate_bar.js +10 -3
- data/lib/samagotchi/web/public/app.js +66 -155
- data/lib/samagotchi/web/public/composer.css +73 -0
- data/lib/samagotchi/web/public/composer_grip.js +21 -15
- data/lib/samagotchi/web/public/composer_mount.js +196 -0
- data/lib/samagotchi/web/public/index.html +7 -83
- data/lib/samagotchi/web/public/notifications.js +58 -8
- data/lib/samagotchi/web/public/participant.html +45 -10
- data/lib/samagotchi/web/public/participant.js +92 -18
- data/lib/samagotchi/web/public/participant_annotate.js +75 -0
- data/lib/samagotchi/web/public/participant_model.js +53 -1
- data/lib/samagotchi/web/public/participants_panel.js +34 -10
- data/lib/samagotchi/web/public/review_history.js +52 -0
- data/lib/samagotchi/web/public/scope.js +12 -8
- data/lib/samagotchi/web/public/stage_frame.js +9 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -4
- data/lib/samagotchi/web/push/notifier.rb +17 -3
- data/lib/samagotchi/web/running.rb +7 -4
- data/lib/samagotchi/web/server.rb +34 -11
- data/lib/samagotchi/web/session_create.rb +7 -7
- data/lib/samagotchi/web/start_dir.rb +26 -0
- data/lib/samagotchi/worker_sidecar.rb +22 -11
- metadata +9 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "uri"
|
|
5
|
+
require_relative "../bridge_secret"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
class BridgeClient
|
|
@@ -13,8 +14,9 @@ module Samagotchi
|
|
|
13
14
|
# what was missed, or sends a `reset` frame carrying a fresh snapshot when
|
|
14
15
|
# it can't. Either snapshot kind means "re-render from here".
|
|
15
16
|
#
|
|
16
|
-
# When the Bridge stays unreachable through every reconnect delay,
|
|
17
|
-
# not serve the session (404), it
|
|
17
|
+
# When the Bridge stays unreachable through every reconnect delay, does
|
|
18
|
+
# not serve the session (404), or refuses it for want of its secret (403
|
|
19
|
+
# bridge_secret, after rediscovery found no new worker), it yields one synthetic
|
|
18
20
|
# `{"type" => "stream_closed", "reason" => ...}` and stops. #close stops
|
|
19
21
|
# it without that event.
|
|
20
22
|
#
|
|
@@ -120,6 +122,16 @@ module Samagotchi
|
|
|
120
122
|
break if closed?
|
|
121
123
|
return finish("unknown_session") if outcome == :unknown_session
|
|
122
124
|
|
|
125
|
+
# Refused for want of the secret: a new worker (a new sidecar, a
|
|
126
|
+
# new secret) is followed at once; else no retry would get in.
|
|
127
|
+
if outcome == :secret_refused
|
|
128
|
+
return finish(BridgeSecret::ERROR) unless rediscover_worker == :moved
|
|
129
|
+
|
|
130
|
+
failures = 0
|
|
131
|
+
@reason = nil
|
|
132
|
+
next
|
|
133
|
+
end
|
|
134
|
+
|
|
123
135
|
if outcome == :events
|
|
124
136
|
failures = 0
|
|
125
137
|
@reason = nil
|
|
@@ -146,7 +158,7 @@ module Samagotchi
|
|
|
146
158
|
|
|
147
159
|
# One connection: :events when it yielded any, else why it failed.
|
|
148
160
|
def read_connection
|
|
149
|
-
sock, status = @client.connect_stream(query: @query, last_event_id: @last_event_id, timeout: HEADER_TIMEOUT)
|
|
161
|
+
sock, status, headers = @client.connect_stream(query: @query, last_event_id: @last_event_id, timeout: HEADER_TIMEOUT)
|
|
150
162
|
return :unreachable unless sock
|
|
151
163
|
|
|
152
164
|
unless attach(sock)
|
|
@@ -154,6 +166,7 @@ module Samagotchi
|
|
|
154
166
|
return :closed
|
|
155
167
|
end
|
|
156
168
|
return :unknown_session if status == 404
|
|
169
|
+
return :secret_refused if status == 403 && BridgeSecret.refused?(status, refusal_body(sock, headers))
|
|
157
170
|
return :unreachable unless status == 200
|
|
158
171
|
|
|
159
172
|
# Back as soon as the stream answers: a replay may bring nothing
|
|
@@ -181,6 +194,24 @@ module Samagotchi
|
|
|
181
194
|
detach(sock)
|
|
182
195
|
end
|
|
183
196
|
|
|
197
|
+
# A refusal's JSON body: its Content-Length's worth, else up to the
|
|
198
|
+
# Bridge's close, within HEADER_TIMEOUT.
|
|
199
|
+
def refusal_body(sock, headers)
|
|
200
|
+
body = +""
|
|
201
|
+
length = headers.to_h["content-length"]&.to_i
|
|
202
|
+
limit = length&.between?(0, 16_384) ? length : 16_384
|
|
203
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HEADER_TIMEOUT
|
|
204
|
+
while body.bytesize < limit
|
|
205
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
206
|
+
break unless left.positive? && sock.wait_readable(left)
|
|
207
|
+
|
|
208
|
+
body << sock.readpartial(limit - body.bytesize)
|
|
209
|
+
end
|
|
210
|
+
body
|
|
211
|
+
rescue IOError, SystemCallError
|
|
212
|
+
body
|
|
213
|
+
end
|
|
214
|
+
|
|
184
215
|
# Publish the socket for #close; false when already closed.
|
|
185
216
|
def attach(sock)
|
|
186
217
|
@mutex.synchronize do
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "json"
|
|
4
5
|
require "socket"
|
|
5
6
|
|
|
6
7
|
require_relative "worker_sidecar"
|
|
8
|
+
require_relative "bridge_secret"
|
|
7
9
|
require_relative "delivery"
|
|
8
10
|
require_relative "chi_invocation"
|
|
9
11
|
require_relative "bridge_client/sse_parser"
|
|
@@ -22,6 +24,8 @@ module Samagotchi
|
|
|
22
24
|
HOST = "127.0.0.1"
|
|
23
25
|
# A reply's status line; its code is group 1.
|
|
24
26
|
STATUS_LINE = StreamRequest::STATUS_LINE
|
|
27
|
+
# The request header that carries the Bridge's secret (Bridge#secret_ok?).
|
|
28
|
+
SECRET_HEADER = BridgeSecret::HEADER
|
|
25
29
|
STREAM_CONNECT_ATTEMPTS = 3
|
|
26
30
|
# Seconds #stream waits for the Bridge's response headers, per attempt:
|
|
27
31
|
# a worker that took the connection and froze must not hold the web's
|
|
@@ -45,6 +49,9 @@ module Samagotchi
|
|
|
45
49
|
Response = Struct.new(:status, :body, keyword_init: true) do
|
|
46
50
|
def ok? = status == 200
|
|
47
51
|
|
|
52
|
+
# The Bridge refused it for want of its secret (BridgeSecret::REFUSED says what to do).
|
|
53
|
+
def secret_refused? = BridgeSecret.refused?(status, body)
|
|
54
|
+
|
|
48
55
|
# @return [Hash, nil] the body parsed as JSON, or nil when it is not
|
|
49
56
|
def json
|
|
50
57
|
JSON.parse(body.to_s)
|
|
@@ -68,19 +75,28 @@ module Samagotchi
|
|
|
68
75
|
"this session's worker runs an older chi and can't #{cant}; restart it: #{restart} (its turns still work)"
|
|
69
76
|
end
|
|
70
77
|
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
# and is removed.
|
|
78
|
+
# The live Bridge's sidecar in +session_dir+, or nil. A sidecar whose
|
|
79
|
+
# port refuses a quick connect is stale (its worker died) and is removed.
|
|
74
80
|
# @param session_dir [String]
|
|
75
|
-
# @return [
|
|
76
|
-
def self.
|
|
77
|
-
WorkerSidecar.
|
|
81
|
+
# @return [WorkerSidecar, nil]
|
|
82
|
+
def self.live_sidecar(session_dir, host: HOST)
|
|
83
|
+
WorkerSidecar.live(session_dir, unlink: true, host: host)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The one way a client is made for a worker: its port and its secret
|
|
87
|
+
# come from the same sidecar read, so a respawned worker (a new port, a
|
|
88
|
+
# new secret) is reached by a client made after it, never by hand.
|
|
89
|
+
# @param sidecar [WorkerSidecar, nil]
|
|
90
|
+
# @return [BridgeClient, nil] nil without a sidecar
|
|
91
|
+
def self.for_sidecar(session_id, sidecar, host: HOST, read_timeout: READ_TIMEOUT)
|
|
92
|
+
return nil unless sidecar
|
|
93
|
+
|
|
94
|
+
new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout)
|
|
78
95
|
end
|
|
79
96
|
|
|
80
97
|
# @return [BridgeClient, nil] a client for the live Bridge in +session_dir+
|
|
81
|
-
def self.discover(session_id, session_dir:, host: HOST)
|
|
82
|
-
|
|
83
|
-
port && new(session_id: session_id, port: port, host: host)
|
|
98
|
+
def self.discover(session_id, session_dir:, host: HOST, read_timeout: READ_TIMEOUT)
|
|
99
|
+
for_sidecar(session_id, live_sidecar(session_dir, host: host), host: host, read_timeout: read_timeout)
|
|
84
100
|
end
|
|
85
101
|
|
|
86
102
|
# How often a wait for a new worker's Bridge looks: a missing sidecar is
|
|
@@ -92,8 +108,8 @@ module Samagotchi
|
|
|
92
108
|
# @param timeout [Float] seconds
|
|
93
109
|
# @return [BridgeClient, nil] nil when no live sidecar appeared in time
|
|
94
110
|
def self.wait_for(session_id, session_dir:, timeout:, host: HOST)
|
|
95
|
-
|
|
96
|
-
|
|
111
|
+
sidecar = poll(timeout, interval: SPAWN_POLL_INTERVAL) { live_sidecar(session_dir, host: host) }
|
|
112
|
+
for_sidecar(session_id, sidecar, host: host)
|
|
97
113
|
end
|
|
98
114
|
|
|
99
115
|
# Call the block every +interval+ seconds until it returns a truthy value
|
|
@@ -113,17 +129,27 @@ module Samagotchi
|
|
|
113
129
|
attr_reader :session_id, :port, :host
|
|
114
130
|
|
|
115
131
|
# Which worker this client talks to, for EventStream: a rediscovered
|
|
116
|
-
# client with another key is a new worker. A Bridge's port
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
# client with another key is a new worker. A Bridge's port, and a
|
|
133
|
+
# fingerprint of its secret when it has one: a new worker that got the
|
|
134
|
+
# old one's port back has another secret, so it is still a new worker.
|
|
135
|
+
def worker_key = @secret ? "#{@port}-#{Digest::SHA256.hexdigest(@secret)[0, 12]}" : @port
|
|
136
|
+
|
|
137
|
+
# @param secret [String, nil] the Bridge's secret, sent in SECRET_HEADER
|
|
138
|
+
# with every request (nil: a worker from before secrets, which asks for
|
|
139
|
+
# none); from the worker's sidecar (.for_sidecar), never typed
|
|
119
140
|
# @param read_timeout [Numeric] see READ_TIMEOUT
|
|
120
|
-
def initialize(session_id:, port:, host: HOST, read_timeout: READ_TIMEOUT)
|
|
141
|
+
def initialize(session_id:, port:, secret:, host: HOST, read_timeout: READ_TIMEOUT)
|
|
121
142
|
@session_id = session_id
|
|
122
143
|
@port = port
|
|
144
|
+
@secret = secret
|
|
123
145
|
@host = host
|
|
124
146
|
@read_timeout = read_timeout
|
|
125
147
|
end
|
|
126
148
|
|
|
149
|
+
# The secret never shows in an inspect (a log line, an error's message).
|
|
150
|
+
def inspect = "#<#{self.class.name} session_id=#{@session_id.inspect} port=#{@port}>"
|
|
151
|
+
alias to_s inspect
|
|
152
|
+
|
|
127
153
|
# POST /session/:id/answer. 200 = recorded, 409 = another client answered
|
|
128
154
|
# first (or the question is gone), 400 = invalid selection, 408
|
|
129
155
|
# deadline_passed = read too late and dropped (see DEADLINE_SHARE), 403
|
|
@@ -289,7 +315,7 @@ module Samagotchi
|
|
|
289
315
|
# (nil when it isn't JSON); [nil, nil] when no reply came
|
|
290
316
|
def get(path)
|
|
291
317
|
sock = TCPSocket.new(@host, @port)
|
|
292
|
-
sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\
|
|
318
|
+
sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\n#{secret_line}Connection: close\r\n\r\n")
|
|
293
319
|
response = read_reply(sock, path)
|
|
294
320
|
sock.close rescue nil
|
|
295
321
|
# A 500 whose text has "200" in it is not a 200.
|
|
@@ -332,14 +358,22 @@ module Samagotchi
|
|
|
332
358
|
# the bridge socket briefly refuses). A few quick bounded retries
|
|
333
359
|
# avoid the silent empty-200 that EventSource would otherwise keep
|
|
334
360
|
# re-opening.
|
|
361
|
+
status = nil
|
|
335
362
|
STREAM_CONNECT_ATTEMPTS.times do |attempt|
|
|
336
|
-
sock, = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
|
|
363
|
+
sock, status = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
|
|
337
364
|
break if sock
|
|
338
365
|
|
|
339
366
|
sleep(0.15 * (attempt + 1))
|
|
340
367
|
end
|
|
341
368
|
return unless sock
|
|
342
369
|
|
|
370
|
+
# A refusal (a 403 such as bridge_secret) is no event stream: its JSON
|
|
371
|
+
# isn't passed on as one.
|
|
372
|
+
if status == 403
|
|
373
|
+
sock.close rescue nil
|
|
374
|
+
return
|
|
375
|
+
end
|
|
376
|
+
|
|
343
377
|
begin
|
|
344
378
|
loop do
|
|
345
379
|
if running
|
|
@@ -375,11 +409,16 @@ module Samagotchi
|
|
|
375
409
|
# nil when the Bridge can't be reached
|
|
376
410
|
def connect_stream(query: "", last_event_id: nil, timeout: nil)
|
|
377
411
|
StreamRequest.open(host: @host, port: @port, path: "/session/#{@session_id}/stream#{query}",
|
|
378
|
-
last_event_id: last_event_id, timeout: timeout)
|
|
412
|
+
last_event_id: last_event_id, headers: secret_headers, timeout: timeout)
|
|
379
413
|
end
|
|
380
414
|
|
|
381
415
|
private
|
|
382
416
|
|
|
417
|
+
# The request header naming the Bridge's secret (none without one).
|
|
418
|
+
def secret_headers = @secret ? { SECRET_HEADER => @secret } : {}
|
|
419
|
+
|
|
420
|
+
def secret_line = secret_headers.map { |name, value| "#{name}: #{value}\r\n" }.join
|
|
421
|
+
|
|
383
422
|
# When this client stops waiting for a reply, as epoch seconds (see
|
|
384
423
|
# DEADLINE_SHARE). Wall clock: the worker runs on this machine and reads
|
|
385
424
|
# the same one.
|
|
@@ -388,7 +427,7 @@ module Samagotchi
|
|
|
388
427
|
def post(path, payload, read_body:, read_timeout: @read_timeout)
|
|
389
428
|
sock = TCPSocket.new(@host, @port)
|
|
390
429
|
json_body = JSON.generate(payload)
|
|
391
|
-
sock.write("POST /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\
|
|
430
|
+
sock.write("POST /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\n#{secret_line}Content-Type: application/json\r\nContent-Length: #{json_body.bytesize}\r\nConnection: close\r\n\r\n#{json_body}")
|
|
392
431
|
reply = read_reply(sock, path, whole: read_body, timeout: read_timeout)
|
|
393
432
|
body = read_body ? reply.split("\r\n\r\n", 2)[1] : nil
|
|
394
433
|
Response.new(status: reply[STATUS_LINE, 1].to_i, body: body)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A session Bridge's secret, as both sides name it: the request header
|
|
7
|
+
# that carries it (Bridge#secret_ok?, BridgeClient), the error a Bridge
|
|
8
|
+
# answers without it (403), and the one message every client shows for
|
|
9
|
+
# that refusal.
|
|
10
|
+
module BridgeSecret
|
|
11
|
+
# The request header (Bridge's #read_request keeps names lowercased).
|
|
12
|
+
HEADER = "X-Chi-Bridge"
|
|
13
|
+
# The 403's error.
|
|
14
|
+
ERROR = "bridge_secret"
|
|
15
|
+
# What a request without this Bridge's secret, or with another one, is
|
|
16
|
+
# told, and what a client shows when it gets that: the asker comes from
|
|
17
|
+
# a chi that reads no secret, a chi web, an attached terminal or a
|
|
18
|
+
# session worker (a delegate's parent) started before chi was updated.
|
|
19
|
+
REFUSED = "the request didn't name this session worker's secret: a chi web, an attached terminal or a session " \
|
|
20
|
+
"worker started before chi was updated can't reach it; restart chi web, re-attach terminals " \
|
|
21
|
+
"(chi --attach ID) and restart the older session workers (chi sessions restart ID...)"
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Whether a Bridge reply (+status+, raw +body+) is this refusal.
|
|
26
|
+
def refused?(status, body)
|
|
27
|
+
return false unless status.to_i == 403
|
|
28
|
+
|
|
29
|
+
parsed = body.is_a?(Hash) ? body : JSON.parse(body.to_s)
|
|
30
|
+
parsed.is_a?(Hash) && parsed["error"] == ERROR
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -326,6 +326,9 @@ module Samagotchi
|
|
|
326
326
|
Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli], lifecycle: :web_start),
|
|
327
327
|
# What a file ref (a tool row's file, a diff line) opens on this machine: a preset (vscode, vscode-insiders, cursor, zed, none) or a URL template with {path} and an optional {line} (Web::Editor).
|
|
328
328
|
Entry.new(key: "web.editor", yaml_path: %w[web editor], type: :string, default: "vscode", expose: %i[env config cli], lifecycle: :web_start),
|
|
329
|
+
# The folder chi web starts in (Web::StartDir): its bare page shows that folder's project, and a new session
|
|
330
|
+
# without a folder (the all view's) starts there. Unset: the folder chi web runs in. chi web --start-dir wins.
|
|
331
|
+
Entry.new(key: "web.start_dir", yaml_path: %w[web start_dir], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
|
|
329
332
|
# Participants (chi share): a new grant's lifetime (Nh or Nd, 1h to 30d), and what a grant without its own
|
|
330
333
|
# takes: its spending cap in dollars ($0 to $1000) and how many of its turns may wait (0 to 20). Read when used.
|
|
331
334
|
Entry.new(key: "web.participants.expires", yaml_path: %w[web participants expires], type: :string, default: "7d", expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
|
|
@@ -10,6 +10,11 @@ module Samagotchi
|
|
|
10
10
|
TOKEN_REVOKED = "this review link was revoked by the session's owner"
|
|
11
11
|
# 401 token_expired: the grant's time ran out.
|
|
12
12
|
TOKEN_EXPIRED = "this review link expired: ask the session's owner for a new one"
|
|
13
|
+
# 401 token_bound: a link bound to one account (chi share --identity)
|
|
14
|
+
# opened by another, or not through the auth proxy (the LAN, this
|
|
15
|
+
# machine). Never names either identity.
|
|
16
|
+
TOKEN_BOUND = "this review link is for another account: open it through the proxy, signed in as the person " \
|
|
17
|
+
"it was sent to"
|
|
13
18
|
# Either of those, where only that the link ended is known: the
|
|
14
19
|
# worker's cancel_reason "revoked" (participants.json reads them
|
|
15
20
|
# removed or past expires_at), as their chi send --wait reports it.
|
|
@@ -98,21 +98,29 @@ module Samagotchi
|
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
# Invites a participant to +session_id+: the grant (+grants+, a
|
|
101
|
-
# Web::Grants), the sidecar and the audit line.
|
|
101
|
+
# Web::Grants), the sidecar and the audit line. A grant bound to
|
|
102
|
+
# +identity+ needs +public_url+ (the chi web's Web::PublicUrl) with an
|
|
103
|
+
# identity header: only a request through that proxy can carry it.
|
|
102
104
|
# @return [Web::Grants::Issued]
|
|
103
105
|
# @raise [Web::Grants::Invalid] an archived session, or bad values
|
|
106
|
+
# (Unbindable: an identity without such a public_url)
|
|
104
107
|
# @raise [ArgumentError] no such session
|
|
105
|
-
def invite(grants, session_id:, name:, kind: "human", ttl: nil, cap: nil,
|
|
108
|
+
def invite(grants, session_id:, name:, kind: "human", ttl: nil, cap: nil, identity: nil, public_url: nil,
|
|
109
|
+
state_dir: Session.default_state_dir)
|
|
106
110
|
raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(Session.session_file(session_id, state_dir: state_dir))
|
|
111
|
+
|
|
112
|
+
identity = Web::Grants.clean_identity(identity) unless identity.nil?
|
|
113
|
+
raise Web::Grants::Unbindable if !identity.nil? && public_url&.identity_header.nil?
|
|
107
114
|
if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
|
|
108
115
|
raise Web::Grants::Invalid, "session #{session_id[0, 8]} is archived: unarchive it to invite anyone"
|
|
109
116
|
end
|
|
110
117
|
|
|
111
|
-
issued = grants.issue(session_id: session_id, name: name, kind: kind, ttl: ttl, cap: cap,
|
|
118
|
+
issued = grants.issue(session_id: session_id, name: name, kind: kind, ttl: ttl, cap: cap, identity: identity,
|
|
112
119
|
on_write: sidecar_writer(state_dir, [session_id]))
|
|
113
120
|
grant = issued.grant
|
|
114
121
|
log(session_id, :issued, state_dir: state_dir, participant: grant.id, name: grant.name, kind: grant.kind,
|
|
115
|
-
role: grant.role, expires_at: grant.expires_at, cap: grant.spend_cap
|
|
122
|
+
role: grant.role, expires_at: grant.expires_at, cap: grant.spend_cap,
|
|
123
|
+
**(grant.identity ? { identity: grant.identity } : {}))
|
|
116
124
|
issued
|
|
117
125
|
end
|
|
118
126
|
|
|
@@ -148,11 +156,13 @@ module Samagotchi
|
|
|
148
156
|
# session nobody was invited to.
|
|
149
157
|
# A grants store that doesn't read (Web::Grants::Corrupt) doesn't stop
|
|
150
158
|
# the archive or delete: its links already find no grant, and the
|
|
151
|
-
# store itself says so (Log.warn) until it's fixed.
|
|
159
|
+
# store itself says so (Log.warn) until it's fixed. Nor does one a newer
|
|
160
|
+
# chi wrote (Newer): chi web's gate refuses a grant whose session is
|
|
161
|
+
# archived or gone even when it can't write the revoke.
|
|
152
162
|
def end_sessions(session_ids, by:, state_dir: Session.default_state_dir)
|
|
153
163
|
grants = Web::Grants.new(Web::Grants.path(state_dir: state_dir))
|
|
154
164
|
session_ids.each { |sid| revoke(grants, session_id: sid, by: by, state_dir: state_dir) }
|
|
155
|
-
rescue Web::Grants::
|
|
165
|
+
rescue Web::Grants::Unwritable
|
|
156
166
|
nil
|
|
157
167
|
end
|
|
158
168
|
end
|
|
@@ -110,7 +110,9 @@ module Samagotchi
|
|
|
110
110
|
@stdout.puts("#{id} #{what}#{subjects} · idle")
|
|
111
111
|
@stdout.puts("warning: #{created.model_warning}") if created.model_warning
|
|
112
112
|
@stdout.puts("open it: #{@access.invocation.open_it(id[0, 8])}")
|
|
113
|
-
|
|
113
|
+
# Behind a proxy that names the user, a link can be bound to them.
|
|
114
|
+
identity = " --identity EMAIL" if @access.identity_header
|
|
115
|
+
@stdout.puts("invite someone: #{@access.invocation.command("share #{id[0, 8]} --name NAME#{identity}")}")
|
|
114
116
|
end
|
|
115
117
|
|
|
116
118
|
def refused(message)
|
|
@@ -14,7 +14,7 @@ module Samagotchi
|
|
|
14
14
|
module RunOptions
|
|
15
15
|
# The flags an underscored spelling is refused for (--recap_base_url),
|
|
16
16
|
# with the config registry's.
|
|
17
|
-
KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token --tunnel].freeze
|
|
17
|
+
KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token --tunnel --start-dir].freeze
|
|
18
18
|
|
|
19
19
|
module_function
|
|
20
20
|
|
|
@@ -223,6 +223,8 @@ module Samagotchi
|
|
|
223
223
|
opts.on("--tunnel PROVIDER", %w[tailscale],
|
|
224
224
|
"`chi web`: reach it over https from your tailnet through Tailscale Serve (never Funnel), for this run; " \
|
|
225
225
|
"turned off on exit") { |v| options[:tunnel] = v }
|
|
226
|
+
opts.on("--start-dir DIR", "`chi web`: the folder it starts in (web.start_dir; default: this one): its page opens on that " \
|
|
227
|
+
"folder's project, and a new session from the all view starts there") { |v| options[:start_dir] = v }
|
|
226
228
|
opts.on("--scope VALUE", %w[project all],
|
|
227
229
|
"Which sessions `chi web` and `chi sessions list` show: project (the default: this folder's git project; " \
|
|
228
230
|
"every session outside a repo) or all") { |v| options[:scope] = v }
|
|
@@ -123,6 +123,9 @@ module Samagotchi
|
|
|
123
123
|
"share --agent" => SUPPORTED,
|
|
124
124
|
"share --expires" => SUPPORTED,
|
|
125
125
|
"share --cap" => SUPPORTED,
|
|
126
|
+
# A chi web without bound grants (Remote#invite checks its
|
|
127
|
+
# bound_grants feature) is refused, never sent the identity.
|
|
128
|
+
"share --identity" => SUPPORTED,
|
|
126
129
|
"share cap" => SUPPORTED,
|
|
127
130
|
"share ls" => SUPPORTED,
|
|
128
131
|
"share ls --format" => SUPPORTED,
|
|
@@ -150,7 +153,7 @@ module Samagotchi
|
|
|
150
153
|
--llm-context-budget --toolkit --tool --no-tool --set --parent-answers --no-interrupt
|
|
151
154
|
--no-default-input --dir --version -h --help],
|
|
152
155
|
LOCAL_ONLY => %w[--no-shared --non-interactive -v --verbose --append-system-prompt --memory --mute --profile
|
|
153
|
-
--port --open --new-token --tunnel --scope --web-markdown --no-web-markdown]
|
|
156
|
+
--port --open --new-token --tunnel --start-dir --scope --web-markdown --no-web-markdown]
|
|
154
157
|
}.freeze
|
|
155
158
|
# A short run flag's name in a refusal.
|
|
156
159
|
RUN_WORDS = { "-v" => "--verbose" }.freeze
|
|
@@ -370,12 +370,16 @@ module Samagotchi
|
|
|
370
370
|
# and port when one runs on the LAN (the port it recorded, else
|
|
371
371
|
# web.port), else 127.0.0.1 (+lan+ false: the link opens on this
|
|
372
372
|
# machine only).
|
|
373
|
+
# A grant bound to +identity+ needs that public_url with an identity
|
|
374
|
+
# header (Participants.invite).
|
|
373
375
|
# @return [Invitation]
|
|
374
376
|
# @raise [ArgumentError] no such session
|
|
375
|
-
# @raise [BadRequest] a bad name, kind or
|
|
376
|
-
|
|
377
|
-
|
|
377
|
+
# @raise [BadRequest] a bad name, kind, ttl or identity, an archived
|
|
378
|
+
# session, an identity with no proxy to check it
|
|
379
|
+
def invite(id, name:, kind:, ttl: nil, cap: nil, identity: nil)
|
|
378
380
|
public_url = configured_public_url
|
|
381
|
+
issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, cap: cap,
|
|
382
|
+
identity: identity, public_url: public_url, state_dir: state_dir!)
|
|
379
383
|
web = public_url ? nil : local_web
|
|
380
384
|
link = if public_url then public_url.link(link_code(issued), "s=#{id}")
|
|
381
385
|
else "http://#{web.ip || "127.0.0.1"}:#{web.port}/?token=#{issued.token}#s=#{id}"
|
|
@@ -387,6 +391,13 @@ module Samagotchi
|
|
|
387
391
|
raise BadRequest.new(e.message, code: "invalid_participant")
|
|
388
392
|
end
|
|
389
393
|
|
|
394
|
+
# The header this machine's chi web names the signed-in user in (a
|
|
395
|
+
# link can be bound to an email: chi share --identity), nil when it
|
|
396
|
+
# has no such proxy: config's web.public_url with
|
|
397
|
+
# web.proxy.identity_header, or a running chi web --tunnel's.
|
|
398
|
+
# @return [String, nil]
|
|
399
|
+
def identity_header = configured_public_url&.identity_header
|
|
400
|
+
|
|
390
401
|
# @return [Array<ParticipantView>] the ones it revoked
|
|
391
402
|
def revoke_participants(id = nil, participant: nil)
|
|
392
403
|
Participants.revoke(grants, session_id: id, id: participant, state_dir: state_dir!)
|
|
@@ -440,7 +451,7 @@ module Samagotchi
|
|
|
440
451
|
configured = Config.get("web.public_url")
|
|
441
452
|
return Web::Running.live_public_url(path: Web::Running.path(state_dir: state_dir!)) if configured.to_s.strip.empty?
|
|
442
453
|
|
|
443
|
-
Web::PublicUrl.from_config(url: configured)
|
|
454
|
+
Web::PublicUrl.from_config(url: configured, identity_header: Config.get("web.proxy.identity_header"))
|
|
444
455
|
rescue Web::PublicUrl::Invalid
|
|
445
456
|
nil
|
|
446
457
|
end
|
|
@@ -122,7 +122,7 @@ module Samagotchi
|
|
|
122
122
|
end
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
-
ACCESS_ERRORS = [TokenRevoked, TokenExpired, NotPermitted, TooManyTurns, CapReached].freeze
|
|
125
|
+
ACCESS_ERRORS = [TokenRevoked, TokenExpired, TokenBound, NotPermitted, TooManyTurns, CapReached].freeze
|
|
126
126
|
|
|
127
127
|
# --- ids and sessions
|
|
128
128
|
|
|
@@ -252,15 +252,37 @@ module Samagotchi
|
|
|
252
252
|
end
|
|
253
253
|
|
|
254
254
|
# @param ttl [Integer, nil] seconds; nil: the grant's default
|
|
255
|
+
# @param identity [String, nil] bind the grant to this email: only a
|
|
256
|
+
# chi web with bound grants (BOUND_FEATURE) is asked; an older one
|
|
257
|
+
# would ignore it and issue a grant anyone could open
|
|
255
258
|
# @return [Invitation]
|
|
256
|
-
# @raise [BadRequest] a bad name, kind or
|
|
257
|
-
|
|
258
|
-
|
|
259
|
+
# @raise [BadRequest] a bad name, kind, ttl or identity, an archived
|
|
260
|
+
# session, an identity that chi web has no proxy to check
|
|
261
|
+
# @raise [RemoteTooOld] an identity, and a chi web without bound grants
|
|
262
|
+
def invite(id, name:, kind:, ttl: nil, cap: nil, identity: nil)
|
|
263
|
+
require_feature!(BOUND_FEATURE) unless identity.nil?
|
|
264
|
+
body = { name: name, kind: kind, ttl: ttl, cap: cap, identity: identity }.compact
|
|
265
|
+
reply = request(:post, "#{session_path(id)}/participants", body: body)
|
|
259
266
|
raise_refusal(reply) unless reply.status == 201
|
|
260
267
|
|
|
261
268
|
json = reply.json
|
|
262
|
-
|
|
263
|
-
|
|
269
|
+
participant = ParticipantView.from_h(json.fetch("participant"))
|
|
270
|
+
check_bound!(id, participant, identity)
|
|
271
|
+
Invitation.new(participant: participant, token: json.fetch("token"), link: json.fetch("link"),
|
|
272
|
+
lan: json["lan"] == true, warning: json["warning"])
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Web::App::BOUND_FEATURE: POST …/participants takes an identity.
|
|
276
|
+
BOUND_FEATURE = "bound_grants"
|
|
277
|
+
|
|
278
|
+
# The header that chi web names the signed-in user in (GET
|
|
279
|
+
# /api/info's proxy), nil when it has no such proxy, or is a chi
|
|
280
|
+
# web from before it said.
|
|
281
|
+
# @return [String, nil]
|
|
282
|
+
def identity_header
|
|
283
|
+
check_server!
|
|
284
|
+
header = @proxy.is_a?(Hash) ? @proxy["identity_header"] : nil
|
|
285
|
+
header.is_a?(String) && !header.empty? ? header : nil
|
|
264
286
|
end
|
|
265
287
|
|
|
266
288
|
# Revokes one participant (+participant+), a session's, or with no
|
|
@@ -602,6 +624,7 @@ module Samagotchi
|
|
|
602
624
|
if reply.json.is_a?(Hash)
|
|
603
625
|
@version = reply.json["version"]
|
|
604
626
|
@features = Array(reply.json["features"])
|
|
627
|
+
@proxy = reply.json["proxy"]
|
|
605
628
|
end
|
|
606
629
|
problem = Remote.info_problem(@entry, reply)
|
|
607
630
|
raise problem if problem
|
|
@@ -611,6 +634,18 @@ module Samagotchi
|
|
|
611
634
|
|
|
612
635
|
def too_old(version) = Remote.too_old_message(@entry, version)
|
|
613
636
|
|
|
637
|
+
# A grant asked bound to +identity+ came back bound to it, or is
|
|
638
|
+
# revoked at once (its link never printed) and refused: belt and
|
|
639
|
+
# braces behind BOUND_FEATURE.
|
|
640
|
+
# @raise [RemoteFailed]
|
|
641
|
+
def check_bound!(id, participant, identity)
|
|
642
|
+
return if identity.nil? || participant.identity == identity.to_s.strip.downcase
|
|
643
|
+
|
|
644
|
+
revoke_participants(id, participant: participant.id)
|
|
645
|
+
raise RemoteFailed, "#{@entry.label}: its chi web issued #{participant.id} unbound, so chi revoked it: " \
|
|
646
|
+
"update chi there (chi update)"
|
|
647
|
+
end
|
|
648
|
+
|
|
614
649
|
def strategy_or_hard(json) = strategy_of(json) || Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
|
|
615
650
|
|
|
616
651
|
# chi web's 404 for a path no route takes (#serve_static's), not a
|
|
@@ -730,7 +765,7 @@ module Samagotchi
|
|
|
730
765
|
# chi web's answer/dismiss reply as the worker's Bridge would have
|
|
731
766
|
# given it: 503 not_live is no worker (nil), 504 the Bridge's 408, 501
|
|
732
767
|
# not_supported its 404 for a route it lacks, 409 question_not_pending
|
|
733
|
-
# its 409; the rest as it came.
|
|
768
|
+
# its 409, 502 bridge_secret its 403 (BridgeSecret); the rest as it came.
|
|
734
769
|
def bridge_reply(reply)
|
|
735
770
|
json = reply.json.is_a?(Hash) ? reply.json : {}
|
|
736
771
|
case [reply.status, json["error"]]
|
|
@@ -738,6 +773,7 @@ module Samagotchi
|
|
|
738
773
|
in [404, "not_found"] then nil
|
|
739
774
|
in [504, _] then BridgeClient::Response.new(status: 408, body: reply.body)
|
|
740
775
|
in [501, "not_supported"] then BridgeClient::Response.new(status: 404, body: JSON.generate(json.merge("error" => "not_found")))
|
|
776
|
+
in [502, BridgeSecret::ERROR] then BridgeClient::Response.new(status: 403, body: reply.body)
|
|
741
777
|
else BridgeClient::Response.new(status: reply.status, body: reply.body)
|
|
742
778
|
end
|
|
743
779
|
end
|
|
@@ -51,6 +51,13 @@ module Samagotchi
|
|
|
51
51
|
def initialize(label) = super("#{label}: #{ParticipantMessages::TOKEN_EXPIRED}")
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# 401 token_bound: a link bound to one account, used without that
|
|
55
|
+
# account's sign-in (chi --remote doesn't go through the proxy).
|
|
56
|
+
class TokenBound < AuthRefused
|
|
57
|
+
CODE = "token_bound"
|
|
58
|
+
def initialize(label) = super("#{label}: #{ParticipantMessages::TOKEN_BOUND}")
|
|
59
|
+
end
|
|
60
|
+
|
|
54
61
|
# 403 not_permitted: a participant's token on a route only the session's
|
|
55
62
|
# owner may use.
|
|
56
63
|
class NotPermitted < RemoteFailed
|
|
@@ -94,8 +101,11 @@ module Samagotchi
|
|
|
94
101
|
# (Web::Grants::Grant#public_h): never its token or digest. +state+
|
|
95
102
|
# "active", "revoked" or "expired".
|
|
96
103
|
# +cap+ dollars and +queue+ turns (nil from a chi web before them).
|
|
104
|
+
# +identity+ the email the grant is bound to, nil for any.
|
|
97
105
|
ParticipantView = Data.define(:id, :session_id, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
|
|
98
|
-
:revoked_by, :last_used_at, :state, :cap, :queue) do
|
|
106
|
+
:revoked_by, :last_used_at, :state, :cap, :queue, :identity) do
|
|
107
|
+
def initialize(identity: nil, **) = super
|
|
108
|
+
|
|
99
109
|
def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key] || hash[key.to_s]] })
|
|
100
110
|
end
|
|
101
111
|
|
|
@@ -689,7 +689,7 @@ module Samagotchi
|
|
|
689
689
|
raise RestartRefused.new(session_id, :unsupported, version: sidecar.version)
|
|
690
690
|
end
|
|
691
691
|
|
|
692
|
-
reply = BridgeClient.
|
|
692
|
+
reply = BridgeClient.for_sidecar(session_id, sidecar).request_exit(client_id: client_id, restart: true)
|
|
693
693
|
raise RestartRefused.new(session_id, reply.json&.fetch("reason", nil) || :failed) if reply.status == 409
|
|
694
694
|
raise RestartRefused.new(session_id, :failed) unless reply.status == 200
|
|
695
695
|
|
|
@@ -371,8 +371,7 @@ module Samagotchi
|
|
|
371
371
|
# @return [Array(Hash, nil, Boolean, String, nil)]
|
|
372
372
|
def stats_snapshot_for(session)
|
|
373
373
|
dir = Samagotchi::Session.session_dir(session.id)
|
|
374
|
-
if (
|
|
375
|
-
client = Samagotchi::BridgeClient.new(session_id: session.id, port: port, read_timeout: STATS_TIMEOUT)
|
|
374
|
+
if (client = Samagotchi::BridgeClient.discover(session.id, session_dir: dir, read_timeout: STATS_TIMEOUT))
|
|
376
375
|
body = client.get_json("stats")&.dig("metrics")
|
|
377
376
|
if body.is_a?(Hash)
|
|
378
377
|
return [TerminalUI::EventRenderer.deep_symbolize_keys(body), true, nil]
|