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
@@ -22,13 +22,16 @@ module Samagotchi
22
22
  include CLI::Command
23
23
 
24
24
  USAGE = <<~TEXT
25
- Usage: chi share ID --name NAME [--agent] [--expires 3d] [--cap 2]
25
+ Usage: chi share ID --name NAME [--identity EMAIL] [--agent] [--expires 3d] [--cap 2]
26
26
  chi share ls [ID] [--format text|json]
27
27
  chi share cap ID PARTICIPANT DOLLARS
28
28
  chi share revoke ID [PARTICIPANT] | --all
29
29
  ID --name NAME invite NAME to review session ID: prints the link they open (chi web on
30
30
  the LAN, web.host: lan) and its QR code at a terminal. The link is shown
31
31
  once; chi keeps only a digest of its token.
32
+ --identity EMAIL bind the link to that account: it opens only through chi web's auth proxy
33
+ signed in as EMAIL (web.public_url with web.proxy.identity_header, or
34
+ chi web --tunnel tailscale); a forwarded link opens for nobody else
32
35
  --agent the participant is an agent (the same link, used as a Bearer token)
33
36
  --expires N Nh or Nd, 1h to 30d (default 7d, web.participants.expires)
34
37
  --cap DOLLARS what their turns may cost you, $0 to $1000 (default $2, web.participants.cap)
@@ -50,6 +53,7 @@ module Samagotchi
50
53
  # The invite's flags (chi share ID …), and ls's and revoke's.
51
54
  FLAGS = CLI::Flags.new(help: HELP_WORDS) do |f|
52
55
  f.value "--name"
56
+ f.value "--identity"
53
57
  f.switch "--agent"
54
58
  f.value "--expires"
55
59
  f.value "--cap"
@@ -91,9 +95,9 @@ module Samagotchi
91
95
  return parsed if parsed.is_a?(Integer)
92
96
 
93
97
  send(:"run_#{@sub || "invite"}", parsed.options, parsed.args)
94
- rescue SessionAccess::BadRequest => e
98
+ rescue SessionAccess::BadRequest, Web::Grants::BadIdentity => e
95
99
  usage_error(e.message)
96
- rescue Web::Grants::Corrupt => e
100
+ rescue Web::Grants::Unwritable => e
97
101
  error_line("#{command_name}: #{e.message}")
98
102
  CLI::Exit::FAILED
99
103
  rescue ArgumentError => e
@@ -116,9 +120,13 @@ module Samagotchi
116
120
  cap = options[:cap] && dollars(options[:cap])
117
121
  return usage_error(CAP_USAGE) if cap == false
118
122
 
123
+ identity = options[:identity] && Web::Grants.clean_identity(options[:identity])
124
+
119
125
  id = @access.resolve(args.first)
120
- invitation = @access.invite(id, name: options[:name], kind: options[:agent] ? "agent" : "human", ttl: ttl, cap: cap)
126
+ invitation = @access.invite(id, name: options[:name], kind: options[:agent] ? "agent" : "human", ttl: ttl, cap: cap,
127
+ identity: identity)
121
128
  print_invitation(id, invitation)
129
+ warn_unbound(invitation.participant)
122
130
  @changes = [[:joined, invitation.participant.id]]
123
131
  0
124
132
  end
@@ -186,10 +194,21 @@ module Samagotchi
186
194
  0
187
195
  end
188
196
 
197
+ # Decision B: an unbound link behind a proxy that could bind it opens
198
+ # for anyone the proxy lets in who holds it.
199
+ def warn_unbound(row)
200
+ return unless row.identity.nil? && (header = @access.identity_header)
201
+
202
+ error_line("#{command_name}: warning: #{format(UNBOUND_WARNING, header: header)}")
203
+ end
204
+
205
+ UNBOUND_WARNING = "this link isn't bound to an account: anyone the proxy lets in who gets it can open it " \
206
+ "(--identity EMAIL binds it to the email %<header>s carries)"
207
+
189
208
  def print_invitation(id, invitation)
190
209
  row = invitation.participant
191
210
  @stdout.puts("#{row.id} #{row.name} (#{row.kind}, #{row.role}) · session #{id[0, 8]} · expires " \
192
- "#{local(row.expires_at, "%Y-%m-%d %H:%M")} · cap #{money(row.cap)}")
211
+ "#{local(row.expires_at, "%Y-%m-%d %H:%M")} · cap #{money(row.cap)}#{" · bound to #{row.identity}" if row.identity}")
193
212
  @stdout.puts(invitation.link)
194
213
  @stdout.puts("(shown once: chi keeps only its digest; chi share revoke #{id[0, 8]} #{row.id} ends it)")
195
214
  if invitation.warning
@@ -201,12 +220,14 @@ module Samagotchi
201
220
  @stdout.puts(Web::QR.lines(invitation.link)) if @tty
202
221
  end
203
222
 
204
- COLUMNS = ["SESSION", "PARTICIPANT", "NAME", "KIND", "ROLE", "CREATED", "EXPIRES", "LAST USED", "CAP", "STATE"].freeze
223
+ COLUMNS = ["SESSION", "PARTICIPANT", "NAME", "IDENTITY", "KIND", "ROLE", "CREATED", "EXPIRES", "LAST USED", "CAP",
224
+ "STATE"].freeze
205
225
 
206
226
  def print_table(rows)
207
227
  cells = rows.map do |row|
208
- [row.session_id[0, 8], row.id, row.name, row.kind, row.role, local(row.created_at), local(row.expires_at),
209
- row.last_used_at ? local(row.last_used_at) : "-", money(row.cap), row.state]
228
+ [row.session_id[0, 8], row.id, row.name, row.identity ? "#{row.identity} · bound" : "-", row.kind, row.role,
229
+ local(row.created_at), local(row.expires_at), row.last_used_at ? local(row.last_used_at) : "-", money(row.cap),
230
+ row.state]
210
231
  end
211
232
  widths = COLUMNS.each_index.map { |i| ([COLUMNS[i]] + cells.map { |c| c[i] }).map(&:length).max }
212
233
  [COLUMNS, *cells].each do |line|
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../client_id"
4
+ require_relative "../bridge_secret"
4
5
  require_relative "../composer_route"
5
6
  require_relative "event_renderer"
6
7
  require_relative "formatting"
@@ -450,6 +451,8 @@ module Samagotchi
450
451
  # The stream ended for good: why, and what brings the session back.
451
452
  def closed_line(reason)
452
453
  id = @client.session_id
454
+ # The worker asked for a secret this terminal's chi doesn't send.
455
+ return "Lost the session's worker: #{BridgeSecret::REFUSED}" if reason == BridgeSecret::ERROR
453
456
  return remote_closed_line(reason, id) if via.remote?
454
457
 
455
458
  # Here a stopped session has always read as unreachable.
@@ -55,12 +55,27 @@ module Samagotchi
55
55
  id = @client.session_id
56
56
  return [:gone, "stopped"] if Session.stopped_marker?(id, state_dir: @state_dir)
57
57
 
58
- [BridgeClient.discover(id, session_dir: session_dir, host: @client.host), nil]
58
+ # Kept: the stream moves to this very client (EventStream), and
59
+ # #switched takes it, port and secret, rather than reading again.
60
+ @found = BridgeClient.discover(id, session_dir: session_dir, host: @client.host)
61
+ [@found, nil]
59
62
  end
60
63
 
61
- # A new worker's Bridge, on the port its event names.
64
+ # A new worker's Bridge: the client #live_worker found for the stream
65
+ # (the event names its port), with that worker's secret. One the event
66
+ # doesn't name (another worker since) is read from the sidecar for that
67
+ # port, none when the sidecar names another one by now: that worker is
68
+ # gone too, and the stream finds the next.
62
69
  def switched(event)
63
- @client = BridgeClient.new(session_id: @client.session_id, port: AttachLink.event_port(event), host: @client.host)
70
+ port = AttachLink.event_port(event)
71
+ found = @found
72
+ @client = if found&.port == port
73
+ found
74
+ else
75
+ sidecar = WorkerSidecar.read(session_dir)
76
+ sidecar = WorkerSidecar.new(port: port) unless sidecar&.port == port
77
+ BridgeClient.for_sidecar(@client.session_id, sidecar, host: @client.host)
78
+ end
64
79
  end
65
80
 
66
81
  # No sidecar to read: true, its Bridge answers (a P0-P2 worker refuses
@@ -139,6 +139,10 @@ module Samagotchi
139
139
  def delivered(response, answer, fields, dismissed)
140
140
  return "not delivered (the child's worker is gone)" unless response
141
141
 
142
+ # The child's Bridge asked for a secret this worker didn't send (one
143
+ # from before the child's chi): not the child's guardrails refusing.
144
+ return "not delivered (#{BridgeSecret::REFUSED})" if response.secret_refused?
145
+
142
146
  hook = fields[:kind] == HOOK_KIND
143
147
  case response.status
144
148
  when 200
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.119.0"
4
+ VERSION = "0.120.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -145,6 +145,8 @@ module Samagotchi
145
145
  # with +public_url+ (the LAN one when both are set)
146
146
  # @param push [Push::Runtime, nil] Web Push for this run (PushRoutes);
147
147
  # nil: off (no https public_url, or web.push.enabled false)
148
+ # @param start_dir [String] the folder chi web starts in (StartDir):
149
+ # where a new session without a dir starts (nil: the cwd)
148
150
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
149
151
  bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
150
152
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
@@ -152,7 +154,8 @@ module Samagotchi
152
154
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
153
155
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
154
156
  working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL,
155
- grants: nil, public_url: nil, public_token: nil, push: nil)
157
+ grants: nil, public_url: nil, public_token: nil, push: nil, start_dir: nil)
158
+ @start_dir = start_dir
156
159
  @manager = manager || SessionManager
157
160
  @registry = registry
158
161
  @registry_given = !registry.nil?
@@ -490,7 +493,9 @@ module Samagotchi
490
493
 
491
494
  # The token a trade presents: the page's ?token=, or POST /api/token's
492
495
  # body's (read once): its token, or the token a one-time link code
493
- # stands for (LinkCodes#redeem: used up here, whatever the gate says).
496
+ # stands for (LinkCodes#redeem: used up here, whatever the gate says,
497
+ # but for a grant's code bound to another identity: decision C, kept
498
+ # for the right one, and refused token_bound, #code_token).
494
499
  def traded_token(req)
495
500
  return req.params["token"] unless fragment_trade?(req)
496
501
 
@@ -501,17 +506,38 @@ module Samagotchi
501
506
  else parse_json(raw)
502
507
  end
503
508
  value = {} unless value.is_a?(Hash)
504
- req.env[TRADED_TOKEN_ENV] = value.key?("code") ? code_token(value["code"]) : string_or_nil(value["token"])
509
+ req.env[TRADED_TOKEN_ENV] = value.key?("code") ? code_token(req, value["code"]) : string_or_nil(value["token"])
505
510
  end
506
511
  end
507
512
 
508
513
  def string_or_nil(value) = value.is_a?(String) ? value : nil
509
514
 
510
- def code_token(code)
511
- kind, token = @link_codes.redeem(code)
515
+ # A bound grant's code is checked before it is used up: presented by
516
+ # another identity, or not through the proxy, it stays (the one it was
517
+ # sent to can still open it) and the trade is refused token_bound.
518
+ def code_token(req, code)
519
+ kind, token = @link_codes.redeem(code) do |found_kind, found_token|
520
+ found_kind == :owner || code_identity_ok?(req, found_token)
521
+ end
512
522
  kind == :owner ? current_token : token
513
523
  end
514
524
 
525
+ def code_identity_ok?(req, token)
526
+ grant = @grants.lookup(token)
527
+ # No grant to check it against (deleted, left out of a newer store,
528
+ # a store that doesn't read): kept, the trade refused.
529
+ return false if grant.nil?
530
+ return true unless grant.identity
531
+
532
+ proxied = proxied?(req)
533
+ state, identity = proxied ? proxy_identity(req) : [:none, nil]
534
+ identity = nil unless state == :ok
535
+ return true if bound_ok?(grant, identity, proxied)
536
+
537
+ refuse_bound(req, grant, identity)
538
+ false
539
+ end
540
+
515
541
  TRADED_TOKEN_ENV = "samagotchi.traded_token"
516
542
 
517
543
  def fragment_trade?(req) = req.post? && req.path_info == TOKEN_TRADE_PATH
@@ -566,7 +592,7 @@ module Samagotchi
566
592
  # no longer works is cleared too, so the browser it was left in (the
567
593
  # owner's, who opened a participant link) is the owner's again.
568
594
  def refused_token(req)
569
- response = grant_ended_response(req) || unauthorized(req)
595
+ response = grant_bound_response(req) || grant_ended_response(req) || unauthorized(req)
570
596
  return response if token_required?(req) || !Grants.token?(req.cookies[token_cookie_name(req)])
571
597
 
572
598
  status, headers, body = response
@@ -837,18 +863,27 @@ module Samagotchi
837
863
  # workers it starts run that one, not this chi web's version;
838
864
  # session_commands: the routes chi --remote needs
839
865
  # (SessionAccessRoutes::FEATURE); remote_attach: the ones a remote
840
- # attached TUI needs (ATTACH_FEATURE). lan: its LAN address, nil when it
841
- # listens on loopback only; public_url: web.public_url's origin, nil
842
- # without one. A participant gets app, version and features only
866
+ # attached TUI needs (ATTACH_FEATURE); bound_grants: an invite takes an
867
+ # identity (BOUND_FEATURE). lan: its LAN address, nil when it listens
868
+ # on loopback only; proxy: {identity_header} behind an auth proxy
869
+ # (web.public_url or a tunnel; the header nil when none is trusted),
870
+ # nil without one: the Invite dialog's email field and chi share's
871
+ # warning. A participant gets app, version and features only
843
872
  # (#participant_info).
844
873
  def handle_info(req)
845
874
  return participant_info unless principal(req).owner?
846
875
 
847
876
  json_response(200, { app: "chi-web", version: Samagotchi::VERSION, installed: @hub&.installed, pid: Process.pid,
848
- cwd: Dir.pwd, features: INFO_FEATURES, lan: @lan && @lan[:ip] })
877
+ cwd: Dir.pwd, start_dir: start_dir, features: INFO_FEATURES, lan: @lan && @lan[:ip],
878
+ proxy: @public_url && { identity_header: @public_url.identity_header } })
849
879
  end
850
880
 
851
- INFO_FEATURES = ["dir", "newest_workers", SessionAccessRoutes::FEATURE, SessionAccessRoutes::ATTACH_FEATURE].freeze
881
+ # POST …/participants takes an identity (a grant bound to it): a chi
882
+ # web without it would ignore one and issue an unbound grant, so chi
883
+ # --remote share --identity asks for this first.
884
+ BOUND_FEATURE = "bound_grants"
885
+ INFO_FEATURES = ["dir", "newest_workers", SessionAccessRoutes::FEATURE, SessionAccessRoutes::ATTACH_FEATURE,
886
+ BOUND_FEATURE].freeze
852
887
 
853
888
  # The models a new session can start on, spelled as chi spells them
854
889
  # elsewhere: bare for the default host (what default.model holds), and
@@ -1049,6 +1084,10 @@ module Samagotchi
1049
1084
  return not_live(id) unless client
1050
1085
 
1051
1086
  reply = request.call(client)
1087
+ # Not the route's own 403 (a parent's option refused): the worker
1088
+ # asked for a secret this chi web didn't send.
1089
+ return error_response(502, BridgeSecret::ERROR, BridgeSecret::REFUSED) if reply.secret_refused?
1090
+
1052
1091
  mapped = yield(reply)
1053
1092
  return mapped if mapped
1054
1093
 
@@ -1095,8 +1134,8 @@ module Samagotchi
1095
1134
  # The session's Bridge, waiting briefly for one a resume just spawned.
1096
1135
  # @return [BridgeClient, nil]
1097
1136
  def live_bridge_client(id)
1098
- port = bridge_sidecar_port(id) || await_bridge_port(id, timeout: [@bridge_wait_timeout.to_f, 5.0].min)
1099
- port && BridgeClient.new(session_id: id, port: port, host: DEFAULT_HOST)
1137
+ sidecar = bridge_sidecar(id) || await_bridge_sidecar(id, timeout: [@bridge_wait_timeout.to_f, 5.0].min)
1138
+ bridge_client_for(id, sidecar)
1100
1139
  end
1101
1140
 
1102
1141
  # The process holding the session (an OwnerLock::Owner), or nil. A worker always runs a Bridge; a TUI (plain `chi`) doesn't share.
@@ -1319,14 +1358,16 @@ module Samagotchi
1319
1358
  # Single live transport: proxy to the session's Bridge. When no live
1320
1359
  # worker exists there is nothing to stream — history is available via
1321
1360
  # GET /api/sessions/:id/output.
1322
- bridge_port = bridge_sidecar_port(id)
1361
+ sidecar = bridge_sidecar(id)
1323
1362
  # A resume just spawned the worker: its bridge may still be binding.
1324
1363
  # Wait briefly for it so the first SSE connect lands on a live bridge
1325
1364
  # instead of an instantly-closed empty 200 / 503 during startup.
1326
- if bridge_port.nil? && @bridge_wait_timeout.to_i.positive?
1327
- bridge_port = await_bridge_port(id, timeout: [@bridge_wait_timeout.to_f, 2.0].min)
1365
+ if sidecar.nil? && @bridge_wait_timeout.to_i.positive?
1366
+ sidecar = await_bridge_sidecar(id, timeout: [@bridge_wait_timeout.to_f, 2.0].min)
1328
1367
  end
1329
- return error_response(503, "not_live", "no live bridge for session #{id}") unless bridge_port
1368
+ return error_response(503, "not_live", "no live bridge for session #{id}") unless sidecar
1369
+
1370
+ client = bridge_client_for(id, sidecar)
1330
1371
 
1331
1372
  headers = {
1332
1373
  "Content-Type" => "text/event-stream",
@@ -1341,19 +1382,19 @@ module Samagotchi
1341
1382
  # handler supports hijacking, stream frames straight to the socket
1342
1383
  # instead of returning an enumerable.
1343
1384
  if req.env["rack.hijack?"]
1344
- hijack = sse_hijack(id, req, bridge_port)
1385
+ hijack = sse_hijack(id, req, client)
1345
1386
  return [200, headers.merge("rack.hijack" => hijack), []]
1346
1387
  end
1347
1388
 
1348
- [200, headers, proxy_stream_body(id, req, bridge_port)]
1389
+ [200, headers, proxy_stream_body(id, req, client)]
1349
1390
  end
1350
1391
 
1351
1392
  # The one ProxyStreamBody both of #handle_stream's transports send:
1352
1393
  # the session's Bridge stream, through the request principal's frame
1353
1394
  # filter (#stream_filter).
1354
- def proxy_stream_body(id, req, port)
1395
+ def proxy_stream_body(id, req, client)
1355
1396
  query = participant_view?(req) ? participant_stream_query(req) : stream_query(req)
1356
- ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: query, headers: req.env,
1397
+ ProxyStreamBody.new(client: client, query: query, headers: req.env,
1357
1398
  server_running: stream_running(principal(req)), filter: stream_filter(req, id))
1358
1399
  end
1359
1400
 
@@ -1424,45 +1465,47 @@ module Samagotchi
1424
1465
  # frames directly to the client socket until it disconnects. The handler
1425
1466
  # (WEBrick via rackup) has already sent the status line + headers, so
1426
1467
  # only body bytes go to the socket here.
1427
- def sse_hijack(id, req, port)
1468
+ def sse_hijack(id, req, client)
1428
1469
  # Made before the status line goes out: a principal with no filter
1429
1470
  # is refused (#stream_filter), never streamed.
1430
- body = proxy_stream_body(id, req, port)
1471
+ body = proxy_stream_body(id, req, client)
1431
1472
  lambda do |io|
1432
1473
  body.each { |chunk| io.write(chunk) }
1433
1474
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
1434
1475
  nil # client went away — end the stream quietly
1435
1476
  rescue StandardError => e
1436
- Log.warn(:web, "proxy_failed", sid: id, port: port, error: e.class.name, msg: e.message)
1477
+ Log.warn(:web, "proxy_failed", sid: id, port: client.port, error: e.class.name, msg: e.message)
1437
1478
  nil
1438
1479
  end
1439
1480
  end
1440
1481
 
1441
1482
  # Bounded wait for a freshly-spawned worker's bridge sidecar so the
1442
1483
  # response hands the client a live transport without client-side polling.
1443
- # Returns the bridge port, or nil when it is not up by the deadline (the
1484
+ # Returns the bridge's sidecar (WorkerSidecar), or nil when it is not up by the deadline (the
1444
1485
  # session still exists; EventSource auto-reconnect covers late binders).
1445
- def await_bridge_port(session_id, timeout: @bridge_wait_timeout)
1486
+ def await_bridge_sidecar(session_id, timeout: @bridge_wait_timeout)
1446
1487
  return nil if timeout.nil? || timeout <= 0
1447
1488
 
1448
- BridgeClient.poll(timeout, interval: BridgeClient::SPAWN_POLL_INTERVAL) { bridge_sidecar_port(session_id) }
1489
+ BridgeClient.poll(timeout, interval: BridgeClient::SPAWN_POLL_INTERVAL) { bridge_sidecar(session_id) }
1449
1490
  rescue StandardError
1450
1491
  nil
1451
1492
  end
1452
1493
 
1453
- # Port of the session's live Bridge (stale sidecars are removed), or nil.
1454
- def bridge_sidecar_port(session_id)
1494
+ # The session's live Bridge's sidecar (stale ones are removed), or nil:
1495
+ # read again for every request, so a respawned worker's port and secret
1496
+ # are the ones used.
1497
+ # @return [WorkerSidecar, nil]
1498
+ def bridge_sidecar(session_id)
1455
1499
  dir = @session_class.session_dir(session_id, state_dir: default_state_dir)
1456
- BridgeClient.sidecar_port(dir, host: DEFAULT_HOST)
1500
+ BridgeClient.live_sidecar(dir, host: DEFAULT_HOST)
1457
1501
  rescue StandardError
1458
1502
  nil
1459
1503
  end
1460
1504
 
1461
1505
  # @return [BridgeClient, nil] a client for the session's live Bridge
1462
- def bridge_client(session_id)
1463
- port = bridge_sidecar_port(session_id)
1464
- port && BridgeClient.new(session_id: session_id, port: port, host: DEFAULT_HOST)
1465
- end
1506
+ def bridge_client(session_id) = bridge_client_for(session_id, bridge_sidecar(session_id))
1507
+
1508
+ def bridge_client_for(session_id, sidecar) = BridgeClient.for_sidecar(session_id, sidecar, host: DEFAULT_HOST)
1466
1509
 
1467
1510
  # GET a read path of the session's live Bridge (+path+: "state",
1468
1511
  # "snapshot", "stats", ...).
@@ -1582,6 +1625,9 @@ module Samagotchi
1582
1625
  @state_dir || Session.default_state_dir
1583
1626
  end
1584
1627
 
1628
+ # The folder chi web starts in (StartDir), the cwd when none was given.
1629
+ def start_dir = @start_dir || Dir.pwd
1630
+
1585
1631
  # The sessions folder as the page shows it: ~ for the home folder.
1586
1632
  def sessions_dir_label
1587
1633
  home_label(File.expand_path(default_state_dir))
@@ -1590,34 +1636,61 @@ module Samagotchi
1590
1636
  def home_label(dir) = Paths.tilde(dir)
1591
1637
 
1592
1638
  # <body> data for the page: where sessions are stored, where an
1593
- # all-view new chat starts (the server's cwd), and, when ?dir names a
1639
+ # all-view new chat starts (the start folder), and, when ?dir names a
1594
1640
  # folder in a repo, that project's name and root. A bad dir adds
1595
1641
  # nothing here: the list call answers 400 and the page says so.
1642
+ # Without ?dir (and without ?scope=all) the page is the start folder's
1643
+ # project view when that folder is in a repo: data-scope-dir names it
1644
+ # for the page (the URL stays bare, so a token's 303, a link code's
1645
+ # #code= and the app's start_url "/" all land there). ?dir= beats
1646
+ # ?scope=all.
1596
1647
  def index_data_attributes(req)
1597
1648
  # version: the chi that served the page; the events snapshot names
1598
1649
  # the one serving now (an upgraded chi web after a reconnect).
1599
- attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd), "version" => Samagotchi::VERSION }
1650
+ attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(start_dir), "version" => Samagotchi::VERSION }
1600
1651
  attrs["view"] = view_name(req.params["view"])
1601
1652
  # Only when on: the page reads its presence (the turn view ignores it).
1602
1653
  attrs["working-set"] = "1" if working_set?(req.params["working_set"])
1603
- # Always there: an empty one means no presets, not the default.
1604
- attrs["annotate-presets"] = @annotate_presets
1654
+ attrs.merge!(annotate_presets_data)
1605
1655
  dir, = scope_dir(req.params["dir"])
1656
+ unless dir
1657
+ dir = default_scope_dir(req)
1658
+ attrs["scope-dir"] = dir if dir
1659
+ end
1606
1660
  root = dir && ProjectScope.root_for(dir)
1607
1661
  if root
1608
1662
  attrs["project-name"] = File.basename(root)
1609
1663
  attrs["project-dir"] = home_label(root)
1610
1664
  elsif dir.nil?
1611
1665
  # The all view's way back: the project view it came from
1612
- # (?from=), else the project chi web runs in.
1666
+ # (?from=), else the project chi web starts in.
1613
1667
  back, = scope_dir(req.params["from"])
1614
- back ||= Dir.pwd
1668
+ back ||= start_dir
1615
1669
  if (back_root = ProjectScope.root_for(back))
1616
1670
  attrs["back-name"] = File.basename(back_root)
1617
1671
  attrs["back-dir"] = back
1618
1672
  end
1619
1673
  end
1620
- attrs.map { |key, value| %(data-#{key}="#{Rack::Utils.escape_html(value)}") }.join(" ")
1674
+ body_data(attrs)
1675
+ end
1676
+
1677
+ # +attrs+ as <body> data attributes, each value escaped.
1678
+ def body_data(attrs) = attrs.map { |key, value| %(data-#{key}="#{Rack::Utils.escape_html(value)}") }.join(" ")
1679
+
1680
+ # The Annotate presets (web.annotate_presets) for a page's <body>, the
1681
+ # owner's and the participant page's. Always there: an empty one means
1682
+ # no presets, not the default.
1683
+ def annotate_presets_data = { "annotate-presets" => @annotate_presets }
1684
+
1685
+ # The folder a bare page (no ?dir, no ?scope=all) is scoped to: the
1686
+ # start folder when it is still a folder in a repo, else nil (every
1687
+ # session).
1688
+ def default_scope_dir(req)
1689
+ return nil unless req.params["dir"].to_s.strip.empty?
1690
+ return nil if req.params["scope"] == "all"
1691
+
1692
+ dir = File.expand_path(start_dir)
1693
+ File.directory?(dir) && ProjectScope.root_for(dir) ? dir : nil
1621
1694
  end
1622
1695
 
1623
1696
  VIEWS = Config::BY_KEY["web.view"].enum_values
@@ -1750,8 +1823,9 @@ module Samagotchi
1750
1823
  # is kept (the env carries the token cookie, never held or inspected)
1751
1824
  # @param filter [#call, nil] chunk -> String (nil or "": nothing sent);
1752
1825
  # it sees the Bridge's reads as they come, not whole frames
1753
- def initialize(host:, port:, session_id:, query:, headers:, server_running: -> { true }, filter: nil)
1754
- @client = BridgeClient.new(session_id: session_id, port: port, host: host)
1826
+ # @param client [BridgeClient] the session's Bridge (App#bridge_client_for)
1827
+ def initialize(client:, query:, headers:, server_running: -> { true }, filter: nil)
1828
+ @client = client
1755
1829
  @query = query
1756
1830
  @last_event_id = headers["HTTP_LAST_EVENT_ID"]
1757
1831
  @server_running = server_running