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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/bin/chi +6 -1
  4. data/docs/architecture.md +35 -5
  5. data/docs/cli.md +33 -8
  6. data/docs/configuration.md +45 -10
  7. data/docs/internals/participants.md +109 -12
  8. data/docs/internals/remote-sessions.md +6 -1
  9. data/docs/internals/web-push.md +4 -2
  10. data/docs/sessions.md +4 -3
  11. data/docs/testing.md +10 -1
  12. data/lib/samagotchi/answer_command.rb +2 -0
  13. data/lib/samagotchi/bridge.rb +56 -13
  14. data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
  15. data/lib/samagotchi/bridge_client.rb +58 -19
  16. data/lib/samagotchi/bridge_secret.rb +35 -0
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +3 -0
  19. data/lib/samagotchi/participant_messages.rb +5 -0
  20. data/lib/samagotchi/participants.rb +16 -6
  21. data/lib/samagotchi/review_command.rb +3 -1
  22. data/lib/samagotchi/run_options.rb +3 -1
  23. data/lib/samagotchi/session_access/contract.rb +4 -1
  24. data/lib/samagotchi/session_access/local.rb +15 -4
  25. data/lib/samagotchi/session_access/remote.rb +43 -7
  26. data/lib/samagotchi/session_access.rb +11 -1
  27. data/lib/samagotchi/session_manager.rb +1 -1
  28. data/lib/samagotchi/sessions_command.rb +1 -2
  29. data/lib/samagotchi/share_command.rb +29 -8
  30. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
  31. data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
  32. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  33. data/lib/samagotchi/version.rb +1 -1
  34. data/lib/samagotchi/web/app.rb +117 -43
  35. data/lib/samagotchi/web/grants.rb +127 -16
  36. data/lib/samagotchi/web/link_codes.rb +24 -15
  37. data/lib/samagotchi/web/participant_access.rb +69 -13
  38. data/lib/samagotchi/web/participant_exposure.rb +44 -0
  39. data/lib/samagotchi/web/participant_review.rb +66 -8
  40. data/lib/samagotchi/web/participants_admin.rb +12 -6
  41. data/lib/samagotchi/web/proxy_gate.rb +18 -3
  42. data/lib/samagotchi/web/public/annotate_bar.css +18 -0
  43. data/lib/samagotchi/web/public/annotate_bar.js +10 -3
  44. data/lib/samagotchi/web/public/app.js +66 -155
  45. data/lib/samagotchi/web/public/composer.css +73 -0
  46. data/lib/samagotchi/web/public/composer_grip.js +21 -15
  47. data/lib/samagotchi/web/public/composer_mount.js +196 -0
  48. data/lib/samagotchi/web/public/index.html +7 -83
  49. data/lib/samagotchi/web/public/notifications.js +58 -8
  50. data/lib/samagotchi/web/public/participant.html +45 -10
  51. data/lib/samagotchi/web/public/participant.js +92 -18
  52. data/lib/samagotchi/web/public/participant_annotate.js +75 -0
  53. data/lib/samagotchi/web/public/participant_model.js +53 -1
  54. data/lib/samagotchi/web/public/participants_panel.js +34 -10
  55. data/lib/samagotchi/web/public/review_history.js +52 -0
  56. data/lib/samagotchi/web/public/scope.js +12 -8
  57. data/lib/samagotchi/web/public/stage_frame.js +9 -4
  58. data/lib/samagotchi/web/public/stage_view.js +4 -4
  59. data/lib/samagotchi/web/push/notifier.rb +17 -3
  60. data/lib/samagotchi/web/running.rb +7 -4
  61. data/lib/samagotchi/web/server.rb +34 -11
  62. data/lib/samagotchi/web/session_create.rb +7 -7
  63. data/lib/samagotchi/web/start_dir.rb +26 -0
  64. data/lib/samagotchi/worker_sidecar.rb +22 -11
  65. 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, or does
17
- # not serve the session (404), it yields one synthetic
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
- # Port of the live Bridge advertised in +session_dir+'s sidecar, or nil.
72
- # A sidecar whose port refuses a quick connect is stale (its worker died)
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 [Integer, nil]
76
- def self.sidecar_port(session_dir, host: HOST)
77
- WorkerSidecar.live_port(session_dir, unlink: true, host: host)
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
- port = sidecar_port(session_dir, host: host)
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
- port = poll(timeout, interval: SPAWN_POLL_INTERVAL) { sidecar_port(session_dir, host: host) }
96
- port && new(session_id: session_id, port: port, host: host)
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
- def worker_key = @port
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\nConnection: close\r\n\r\n")
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\nContent-Type: application/json\r\nContent-Length: #{json_body.bytesize}\r\nConnection: close\r\n\r\n#{json_body}")
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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.119.0
3
+ version: 0.120.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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, state_dir: Session.default_state_dir)
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::Corrupt
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
- @stdout.puts("invite someone: #{@access.invocation.command("share #{id[0, 8]} --name NAME")}")
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 ttl, an archived session
376
- def invite(id, name:, kind:, ttl: nil, cap: nil)
377
- issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, cap: cap, state_dir: state_dir!)
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 ttl, an archived session
257
- def invite(id, name:, kind:, ttl: nil, cap: nil)
258
- reply = request(:post, "#{session_path(id)}/participants", body: { name: name, kind: kind, ttl: ttl, cap: cap }.compact)
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
- Invitation.new(participant: ParticipantView.from_h(json.fetch("participant")), token: json.fetch("token"),
263
- link: json.fetch("link"), lan: json["lan"] == true, warning: json["warning"])
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.new(session_id: session_id, port: sidecar.port).request_exit(client_id: client_id, restart: true)
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 (port = Samagotchi::BridgeClient.sidecar_port(dir))
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]