samagotchi 0.119.0 → 0.121.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -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 +74 -20
  7. data/docs/internals/participants.md +111 -12
  8. data/docs/internals/prompt-caching.md +7 -6
  9. data/docs/internals/remote-sessions.md +6 -1
  10. data/docs/internals/web-push.md +4 -2
  11. data/docs/plugins.md +24 -8
  12. data/docs/sessions.md +4 -3
  13. data/docs/testing.md +13 -2
  14. data/lib/samagotchi/answer_command.rb +2 -0
  15. data/lib/samagotchi/bridge.rb +56 -13
  16. data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
  17. data/lib/samagotchi/bridge_client.rb +58 -19
  18. data/lib/samagotchi/bridge_secret.rb +35 -0
  19. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/config.rb +3 -0
  26. data/lib/samagotchi/engine.rb +1 -0
  27. data/lib/samagotchi/kernel_loop.rb +5 -0
  28. data/lib/samagotchi/participant_messages.rb +5 -0
  29. data/lib/samagotchi/participants.rb +16 -6
  30. data/lib/samagotchi/plugin/api.rb +6 -3
  31. data/lib/samagotchi/review_command.rb +3 -1
  32. data/lib/samagotchi/run_options.rb +3 -1
  33. data/lib/samagotchi/session_access/contract.rb +4 -1
  34. data/lib/samagotchi/session_access/local.rb +15 -4
  35. data/lib/samagotchi/session_access/remote.rb +43 -7
  36. data/lib/samagotchi/session_access.rb +11 -1
  37. data/lib/samagotchi/session_manager.rb +1 -1
  38. data/lib/samagotchi/sessions_command.rb +1 -2
  39. data/lib/samagotchi/share_command.rb +29 -8
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
  41. data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  43. data/lib/samagotchi/tool_declarations.rb +1 -1
  44. data/lib/samagotchi/tool_runner.rb +37 -1
  45. data/lib/samagotchi/toolkit.rb +50 -26
  46. data/lib/samagotchi/toolkit_binding.rb +32 -0
  47. data/lib/samagotchi/toolkit_command.rb +23 -0
  48. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  49. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  50. data/lib/samagotchi/tools/registry.rb +20 -6
  51. data/lib/samagotchi/tools/tool_index.rb +103 -20
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +117 -43
  54. data/lib/samagotchi/web/grants.rb +127 -16
  55. data/lib/samagotchi/web/link_codes.rb +24 -15
  56. data/lib/samagotchi/web/participant_access.rb +69 -13
  57. data/lib/samagotchi/web/participant_exposure.rb +44 -0
  58. data/lib/samagotchi/web/participant_review.rb +71 -9
  59. data/lib/samagotchi/web/participants_admin.rb +12 -6
  60. data/lib/samagotchi/web/proxy_gate.rb +18 -3
  61. data/lib/samagotchi/web/public/annotate_bar.css +18 -0
  62. data/lib/samagotchi/web/public/annotate_bar.js +10 -3
  63. data/lib/samagotchi/web/public/app.js +66 -155
  64. data/lib/samagotchi/web/public/composer.css +73 -0
  65. data/lib/samagotchi/web/public/composer_grip.js +21 -15
  66. data/lib/samagotchi/web/public/composer_mount.js +196 -0
  67. data/lib/samagotchi/web/public/forms.css +12 -0
  68. data/lib/samagotchi/web/public/index.html +14 -88
  69. data/lib/samagotchi/web/public/level_chip.js +2 -2
  70. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  71. data/lib/samagotchi/web/public/notifications.js +58 -8
  72. data/lib/samagotchi/web/public/participant.html +46 -10
  73. data/lib/samagotchi/web/public/participant.js +101 -18
  74. data/lib/samagotchi/web/public/participant_annotate.js +75 -0
  75. data/lib/samagotchi/web/public/participant_model.js +53 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +37 -13
  77. data/lib/samagotchi/web/public/question_card.js +15 -1
  78. data/lib/samagotchi/web/public/question_cards.js +11 -2
  79. data/lib/samagotchi/web/public/review_history.js +60 -0
  80. data/lib/samagotchi/web/public/scope.js +12 -8
  81. data/lib/samagotchi/web/public/stage_frame.js +9 -4
  82. data/lib/samagotchi/web/public/stage_view.js +4 -4
  83. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  84. data/lib/samagotchi/web/push/notifier.rb +17 -3
  85. data/lib/samagotchi/web/running.rb +7 -4
  86. data/lib/samagotchi/web/server.rb +34 -11
  87. data/lib/samagotchi/web/session_create.rb +7 -7
  88. data/lib/samagotchi/web/start_dir.rb +26 -0
  89. data/lib/samagotchi/web/tunnel.rb +10 -1
  90. data/lib/samagotchi/worker_sidecar.rb +22 -11
  91. metadata +10 -1
@@ -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
@@ -14,6 +14,7 @@ require_relative "../participant_name"
14
14
  require_relative "../session"
15
15
  require_relative "../redacted_inspect"
16
16
  require_relative "private_files"
17
+ require_relative "participant_exposure"
17
18
 
18
19
  module Samagotchi
19
20
  module Web
@@ -74,16 +75,54 @@ module Samagotchi
74
75
  # session".
75
76
  class Invalid < StandardError; end
76
77
 
78
+ # An identity to bind a grant to that isn't one email address.
79
+ class BadIdentity < Invalid
80
+ def initialize(msg = "a link is bound to one email address (name@domain: one @, no spaces or commas, ASCII " \
81
+ "only)") = super
82
+ end
83
+
84
+ # An identity asked of a chi web that can't check one: no auth proxy
85
+ # naming the signed-in user (ProxyGate), so the link would never open.
86
+ class Unbindable < Invalid
87
+ def initialize(msg = "a link can be bound to an email only on a chi web behind an auth proxy that names the " \
88
+ "signed-in user (web.public_url with web.proxy.identity_header, or chi web --tunnel " \
89
+ "tailscale): bound here it would never open") = super
90
+ end
91
+
92
+ # The store's version: 1, and BOUND_VERSION once a grant is bound to
93
+ # an identity, so a chi that knows only 1 and checks (from this one
94
+ # on) doesn't rewrite it. MAX_VERSION is the newest this chi reads
95
+ # and writes; a newer store it reads but never writes (Newer).
96
+ VERSION = 1
97
+ BOUND_VERSION = 2
98
+ MAX_VERSION = 2
99
+
100
+ # A store chi won't write over (Corrupt, Newer): the owner's invite,
101
+ # cap and revoke are refused with its message.
102
+ class Unwritable < StandardError; end
103
+
77
104
  # The store is there but doesn't read (not JSON, a grant without its
78
105
  # digest or expiry): its grants find nothing (fail closed), and
79
106
  # nothing writes over it, which would lose every other grant.
80
- class Corrupt < StandardError
107
+ class Corrupt < Unwritable
81
108
  def initialize(path, reason)
82
109
  super("#{path} can't be read (#{reason}): chi won't write over it; fix it, or move it aside to end " \
83
110
  "every participant's link")
84
111
  end
85
112
  end
86
113
 
114
+ # The store was written by a newer chi (its version above
115
+ # MAX_VERSION): its grants still work here (read as this chi knows
116
+ # them), but nothing writes over it, which would drop what that chi
117
+ # added to them.
118
+ class Newer < Unwritable
119
+ def initialize(path, version)
120
+ super("#{path} was written by a newer chi (grants store version #{version}; this chi knows up to " \
121
+ "#{MAX_VERSION}): chi won't write over it, which would drop what that chi added; update chi " \
122
+ "(chi update)")
123
+ end
124
+ end
125
+
87
126
  # One participant's access to one session. +id+ (p_<8 hex>) names the
88
127
  # participant too: a turn they send is authored "p:<id>" (P3).
89
128
  # +revoked_by+ why it was revoked: "owner", "archive", "delete",
@@ -91,14 +130,26 @@ module Samagotchi
91
130
  # +cap+ (dollars) and +queue+ (turns waiting) as issued; nil in a
92
131
  # grant from before them, which takes the config's (#spend_cap,
93
132
  # #queue_limit).
133
+ # +exposure+: what the participant learns of the review's usage
134
+ # (ParticipantExposure; "own" for every grant today, and for one from
135
+ # before it). Kept as stored: a level this chi doesn't know reads as
136
+ # the narrowest, and is written back as it was.
137
+ # +extra+: the stored keys this chi doesn't know (a newer chi's,
138
+ # such as a field added to a grant without a version bump), written
139
+ # back as they were, so a rewrite here never drops them.
94
140
  Grant = Data.define(:id, :session_id, :digest, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
95
- :revoked_by, :last_used_at, :cap, :queue, :identity) do
141
+ :revoked_by, :last_used_at, :cap, :queue, :identity, :exposure, :extra) do
96
142
  # identity: the proxy identity (an email, lower case) the grant is
97
143
  # bound to, nil for any (ProxyGate; issued from shared-sessions P6).
98
- def initialize(cap: nil, queue: nil, identity: nil, **) = super
144
+ def initialize(cap: nil, queue: nil, identity: nil, exposure: nil, extra: {}, **)
145
+ super(cap: cap, queue: queue, identity: identity, extra: extra,
146
+ exposure: exposure.nil? ? ParticipantExposure::DEFAULT : exposure, **)
147
+ end
99
148
 
100
149
  def self.from_h(hash)
101
- new(**members.to_h { |key| [key, hash[key.to_s]] })
150
+ known = members - [:extra]
151
+ extra = hash.reject { |key, _| known.include?(key.to_s.to_sym) }
152
+ new(**known.to_h { |key| [key, hash[key.to_s]] }, extra: extra)
102
153
  end
103
154
 
104
155
  # The dollars this participant may cost the owner.
@@ -132,11 +183,24 @@ module Samagotchi
132
183
  def public_h(now)
133
184
  { id: id, session_id: session_id, kind: kind, name: name, role: role, created_at: created_at,
134
185
  expires_at: expires_at, revoked_at: revoked_at, revoked_by: revoked_by, last_used_at: last_used_at,
135
- state: state(now).to_s, cap: spend_cap, queue: queue_limit }
186
+ state: state(now).to_s, cap: spend_cap, queue: queue_limit, identity: identity }
136
187
  end
137
188
 
138
- # A grant bound to no identity is stored as before identity existed.
139
- def to_store = to_h.transform_keys(&:to_s).tap { |hash| hash.delete("identity") if identity.nil? }
189
+ # A grant bound to no identity, with the default exposure, is stored
190
+ # as before either existed; the keys this chi doesn't know come
191
+ # after, as they were.
192
+ #
193
+ # A non-default exposure needs no version bump (identity's
194
+ # BOUND_VERSION): a chi that ignores it shows the participant the
195
+ # narrowest level, so it fails closed, and one that knows Grant#extra
196
+ # keeps it on a rewrite. A bump would only make that chi refuse the
197
+ # owner's invite, cap and revoke (Newer) for no safety gain.
198
+ def to_store
199
+ hash = to_h.except(:extra).transform_keys(&:to_s)
200
+ hash.delete("identity") if identity.nil?
201
+ hash.delete("exposure") if exposure == ParticipantExposure::DEFAULT
202
+ hash.merge(extra.reject { |key, _| hash.key?(key) })
203
+ end
140
204
 
141
205
  def inspect = "#<Grant #{id} #{session_id[0, 8]} #{name.inspect}>"
142
206
  alias_method :to_s, :inspect
@@ -191,6 +255,26 @@ module Samagotchi
191
255
 
192
256
  def self.token?(value) = value.is_a?(String) && value.start_with?(PREFIX)
193
257
 
258
+ # What a proxy identity header carries: an email, or a login such as
259
+ # Tailscale's alex@github (one @, a domain without a dot).
260
+ IDENTITY_RE = /\A[^@,<>"\\]+@[^@,<>"\\]+\z/
261
+ # No space or control character anywhere (a header can't carry one,
262
+ # and a name with one could mislead in a listing).
263
+ IDENTITY_BAD_RE = /\p{Space}|\p{Cc}/
264
+ IDENTITY_MAX = 254
265
+
266
+ # +identity+ as the gate compares it (ProxyGate#proxy_identity: strip,
267
+ # lower case).
268
+ # @raise [BadIdentity] not one email address
269
+ def self.clean_identity(identity)
270
+ # ASCII only, as ProxyGate#proxy_identity: its lower case is the same
271
+ # whatever the string's encoding.
272
+ clean = identity.is_a?(String) && identity.ascii_only? ? identity.strip.downcase : ""
273
+ raise BadIdentity unless clean.length <= IDENTITY_MAX && IDENTITY_RE.match?(clean) && !IDENTITY_BAD_RE.match?(clean)
274
+
275
+ clean
276
+ end
277
+
194
278
  # One line, at most NAME_MAX characters, no brackets (a name can't
195
279
  # forge the "[Review contribution from …]" header; ParticipantName).
196
280
  # @raise [Invalid] nothing left
@@ -226,10 +310,12 @@ module Samagotchi
226
310
  # A new grant for +session_id+, written before the token is handed out.
227
311
  # @param ttl [Integer, nil] seconds (nil: Grants.default_ttl; at most MAX_TTL)
228
312
  # @param cap [Numeric, nil] dollars (nil: Grants.default_cap)
313
+ # @param identity [String, nil] the proxy identity (an email) the grant
314
+ # is bound to (Grants.clean_identity), nil for any
229
315
  # @param on_write [#call, nil] see #update
230
316
  # @return [Issued]
231
317
  # @raise [Invalid]
232
- def issue(session_id:, name:, kind: "human", role: "reviewer", ttl: nil, cap: nil, on_write: nil)
318
+ def issue(session_id:, name:, kind: "human", role: "reviewer", ttl: nil, cap: nil, identity: nil, on_write: nil)
233
319
  Session.check_id!(session_id)
234
320
  ttl ||= Grants.default_ttl
235
321
  cap = Grants.default_cap if cap.nil?
@@ -239,13 +325,15 @@ module Samagotchi
239
325
  raise Invalid, CAP_RANGE unless Grants.cap?(cap)
240
326
 
241
327
  clean = Grants.clean_name(name)
328
+ identity = Grants.clean_identity(identity) unless identity.nil?
242
329
  token = "#{PREFIX}#{SecureRandom.urlsafe_base64(BYTES)}"
243
330
  issued = now
244
331
  update(on_write) do |grants|
245
332
  id = fresh_id(grants)
246
333
  grant = Grant.new(id: id, session_id: session_id, digest: Grants.digest(token), kind: kind, name: clean,
247
334
  role: role, created_at: stamp(issued), expires_at: stamp(issued + ttl), revoked_at: nil,
248
- revoked_by: nil, last_used_at: nil, cap: cap.to_f, queue: Grants.default_queue)
335
+ revoked_by: nil, last_used_at: nil, cap: cap.to_f, queue: Grants.default_queue,
336
+ identity: identity)
249
337
  [grants + [grant], Issued.new(grant: grant, token: token)]
250
338
  end
251
339
  end
@@ -335,7 +423,7 @@ module Samagotchi
335
423
  update do |grants|
336
424
  [grants.map { |grant| grant.id == id ? grant.with(last_used_at: stamp(at)) : grant }, nil]
337
425
  end
338
- rescue Corrupt
426
+ rescue Unwritable
339
427
  nil
340
428
  end
341
429
 
@@ -368,8 +456,13 @@ module Samagotchi
368
456
  grants
369
457
  end
370
458
 
459
+ # In a newer chi's store (Newer) a grant with fields this chi doesn't
460
+ # know is left out: such a field may restrict it (a binding moved to a
461
+ # new key), so it fails closed here. Its link works on that chi again.
371
462
  def reload(stamp)
372
- @grants = stamp ? read : []
463
+ grants, version = stamp ? read : [[], VERSION]
464
+ grants = grants.select { |grant| grant.extra.empty? } if version > MAX_VERSION
465
+ @grants = grants
373
466
  @corrupt = nil
374
467
  rescue Corrupt => e
375
468
  @grants = []
@@ -377,19 +470,23 @@ module Samagotchi
377
470
  Log.warn(:web, "grants_unreadable", echo: "[chi web] #{e.message}", path: @path)
378
471
  end
379
472
 
473
+ # [grants, the store's version] (none yet: [[], VERSION]).
380
474
  # @raise [Corrupt] the file is there but isn't a grants store
381
475
  def read
382
476
  data = begin
383
477
  JSON.parse(File.read(@path))
384
478
  rescue Errno::ENOENT
385
- return []
479
+ return [[], VERSION]
386
480
  rescue JSON::ParserError
387
481
  raise Corrupt.new(@path, "not JSON")
388
482
  end
389
483
  rows = data.is_a?(Hash) ? data["grants"] : nil
390
484
  raise Corrupt.new(@path, "no grants list") unless rows.is_a?(Array)
391
485
 
392
- rows.each_with_index.map { |hash, index| grant_from(hash, index) }
486
+ version = data.fetch("version", VERSION)
487
+ raise Corrupt.new(@path, "a bad version") unless version.is_a?(Integer) && version >= VERSION
488
+
489
+ [rows.each_with_index.map { |hash, index| grant_from(hash, index) }, version]
393
490
  end
394
491
 
395
492
  # A stored grant the gate can compare and date, else Corrupt.
@@ -401,12 +498,21 @@ module Samagotchi
401
498
  elsif !grant.digest.is_a?(String) || grant.digest.empty? then "no digest"
402
499
  elsif !time?(grant.expires_at) then "no expiry"
403
500
  elsif !grant.revoked_at.nil? && !time?(grant.revoked_at) then "a bad revoked_at"
501
+ elsif !identity?(grant.identity) then "a bad identity"
404
502
  end
405
503
  raise Corrupt.new(@path, "grant #{index + 1} has #{problem}") if problem
406
504
 
407
505
  grant
408
506
  end
409
507
 
508
+ # nil (unbound), or an identity as #issue stores it: anything else
509
+ # (false, "", a list) could read as unbound.
510
+ def identity?(value)
511
+ value.nil? || (value.is_a?(String) && Grants.clean_identity(value) == value)
512
+ rescue BadIdentity
513
+ false
514
+ end
515
+
410
516
  def time?(value)
411
517
  value.is_a?(String) && Time.iso8601(value)
412
518
  rescue ArgumentError
@@ -427,11 +533,16 @@ module Samagotchi
427
533
  File.open(File.join(dir, LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
428
534
  lock.flock(File::LOCK_EX)
429
535
  # Never over a store that didn't read (Corrupt): the next write
430
- # would keep only this one's change.
431
- grants, result = yield(read)
536
+ # would keep only this one's change. Nor over a newer chi's
537
+ # (Newer): this chi would drop what it doesn't know.
538
+ stored, version = read
539
+ raise Newer.new(@path, version) if version > MAX_VERSION
540
+
541
+ grants, result = yield(stored)
432
542
  at = now
433
543
  kept = grants.reject { |grant| (ended = grant.ended_at(at)) && at - ended > KEEP_ENDED }
434
- body = JSON.pretty_generate({ "version" => 1, "grants" => kept.map(&:to_store) })
544
+ version = [version, kept.any?(&:identity) ? BOUND_VERSION : VERSION].max
545
+ body = JSON.pretty_generate({ "version" => version, "grants" => kept.map(&:to_store) })
435
546
  AtomicFile.write(@path, "#{body}\n", perm: 0o600)
436
547
  @mutex.synchronize { @stamp = nil }
437
548
  on_write&.call(kept, result)
@@ -56,25 +56,38 @@ module Samagotchi
56
56
  end
57
57
 
58
58
  # The code's [kind, token] (token nil for the owner's), once: the code
59
- # is gone after. nil for an unknown, used or expired code.
59
+ # is gone after. nil for an unknown, used or expired code. With a
60
+ # block, it is asked (kind, token) first, under the lock: false keeps
61
+ # the code for another try and gives nil (a bound grant's code opened
62
+ # by another identity: decision C).
60
63
  def redeem(code)
61
64
  return nil unless LinkCodes.code?(code)
62
65
 
63
66
  wanted = digest(code)
64
- found = nil
67
+ result = nil
65
68
  update do |entries|
66
- entries.reject do |entry|
67
- next false unless same_digest?(entry["digest"].to_s, wanted)
69
+ entry = entries.find { |candidate| same_digest?(candidate["digest"].to_s, wanted) }
70
+ next entries unless entry
68
71
 
69
- found = entry
70
- true
71
- end
72
+ opened = open_entry(code, entry)
73
+ next entries if opened && block_given? && !yield(*opened)
74
+
75
+ result = opened
76
+ entries.reject { |candidate| candidate.equal?(entry) }
72
77
  end
73
- return nil unless found
74
- return nil if Time.iso8601(found["expires_at"]) <= @clock.call
78
+ result
79
+ end
80
+
81
+ private
82
+
83
+ def digest(code) = Digest::SHA256.hexdigest(code)
75
84
 
76
- kind = found["kind"] == "grant" ? :grant : :owner
77
- token = found["sealed"] && unseal(code, found["sealed"])
85
+ # [kind, token] for a live entry whose token unseals, else nil.
86
+ def open_entry(code, entry)
87
+ return nil if Time.iso8601(entry["expires_at"].to_s) <= @clock.call
88
+
89
+ kind = entry["kind"] == "grant" ? :grant : :owner
90
+ token = entry["sealed"] && unseal(code, entry["sealed"])
78
91
  return nil if kind == :grant && token.nil?
79
92
 
80
93
  [kind, token]
@@ -82,10 +95,6 @@ module Samagotchi
82
95
  nil
83
96
  end
84
97
 
85
- private
86
-
87
- def digest(code) = Digest::SHA256.hexdigest(code)
88
-
89
98
  def same_digest?(left, right) = left.bytesize == right.bytesize && OpenSSL.fixed_length_secure_compare(left, right)
90
99
 
91
100
  def key(code) = OpenSSL::KDF.hkdf(code, salt: "chi-link-code", info: "seal", length: 32, hash: "SHA256")