samagotchi 0.108.0 → 0.110.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +7 -2
  5. data/docs/cli.md +40 -1
  6. data/docs/configuration.md +177 -2
  7. data/docs/internals/engine.md +19 -4
  8. data/docs/internals/participants.md +28 -8
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/internals/remote-sessions.md +6 -2
  11. data/docs/plugins.md +1 -1
  12. data/docs/sessions.md +3 -1
  13. data/docs/sub-agent.md +1 -1
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  15. data/lib/samagotchi/bridge.rb +25 -0
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +4 -0
  18. data/lib/samagotchi/context_note.rb +7 -3
  19. data/lib/samagotchi/engine.rb +20 -9
  20. data/lib/samagotchi/idle_recap.rb +1 -1
  21. data/lib/samagotchi/idle_reminders.rb +1 -1
  22. data/lib/samagotchi/kernel_loop.rb +12 -7
  23. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  24. data/lib/samagotchi/message_time.rb +104 -0
  25. data/lib/samagotchi/proxy_trace.rb +45 -0
  26. data/lib/samagotchi/reminder_queue.rb +20 -3
  27. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  28. data/lib/samagotchi/run_options.rb +4 -1
  29. data/lib/samagotchi/session_access/contract.rb +1 -1
  30. data/lib/samagotchi/session_access/local.rb +52 -10
  31. data/lib/samagotchi/session_inbox.rb +7 -3
  32. data/lib/samagotchi/steer.rb +5 -3
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  34. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  35. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  36. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  37. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  38. data/lib/samagotchi/terminal_ui.rb +40 -5
  39. data/lib/samagotchi/tool_activity.rb +11 -0
  40. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +246 -32
  43. data/lib/samagotchi/web/grants.rb +6 -3
  44. data/lib/samagotchi/web/info_probe.rb +5 -1
  45. data/lib/samagotchi/web/link_codes.rb +146 -0
  46. data/lib/samagotchi/web/participant_access.rb +9 -2
  47. data/lib/samagotchi/web/participant_review.rb +1 -1
  48. data/lib/samagotchi/web/participant_view.rb +12 -5
  49. data/lib/samagotchi/web/participants_admin.rb +13 -6
  50. data/lib/samagotchi/web/proxy_gate.rb +174 -0
  51. data/lib/samagotchi/web/public/app.js +35 -3
  52. data/lib/samagotchi/web/public/auth_lost.js +76 -0
  53. data/lib/samagotchi/web/public/bubbles.js +29 -7
  54. data/lib/samagotchi/web/public/data.js +29 -5
  55. data/lib/samagotchi/web/public/format.js +72 -6
  56. data/lib/samagotchi/web/public/index.html +2 -0
  57. data/lib/samagotchi/web/public/participant.html +2 -0
  58. data/lib/samagotchi/web/public/participant.js +47 -10
  59. data/lib/samagotchi/web/public/participant_model.js +25 -2
  60. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  61. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  62. data/lib/samagotchi/web/public/token_fragment.js +57 -0
  63. data/lib/samagotchi/web/public/tokens.css +2 -2
  64. data/lib/samagotchi/web/public/turn_events.js +3 -1
  65. data/lib/samagotchi/web/public/turn_html.js +15 -8
  66. data/lib/samagotchi/web/public/turn_view.js +8 -3
  67. data/lib/samagotchi/web/public_url.rb +87 -0
  68. data/lib/samagotchi/web/running.rb +39 -4
  69. data/lib/samagotchi/web/server.rb +138 -22
  70. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  71. data/lib/samagotchi/web/tunnel.rb +133 -0
  72. data/lib/samagotchi/worker_sidecar.rb +4 -3
  73. metadata +12 -1
@@ -38,6 +38,7 @@ require_relative "../version"
38
38
  require_relative "../config"
39
39
  require_relative "../output_formatter"
40
40
  require_relative "../image_store"
41
+ require_relative "../message_time"
41
42
  require_relative "../context_note"
42
43
  require_relative "../context_sources"
43
44
  require_relative "../context_providers"
@@ -59,6 +60,7 @@ require_relative "principal"
59
60
  require_relative "participant_access"
60
61
  require_relative "participants_admin"
61
62
  require_relative "participant_review"
63
+ require_relative "proxy_gate"
62
64
  require_relative "../log"
63
65
  require_relative "../paths"
64
66
  require_relative "../redacted_inspect"
@@ -79,6 +81,7 @@ module Samagotchi
79
81
  include ParticipantAccess
80
82
  include ParticipantsAdmin
81
83
  include ParticipantReview
84
+ include ProxyGate
82
85
 
83
86
  DEFAULT_HOST = BridgeClient::HOST
84
87
  BRIDGE_WAIT_TIMEOUT = 10.0
@@ -141,6 +144,10 @@ module Samagotchi
141
144
  # loopback only, no token
142
145
  # @param grants [Grants, nil] the participants' grants (default: the
143
146
  # store next to +state_dir+, Grants.path)
147
+ # @param public_url [PublicUrl, nil] behind an auth proxy
148
+ # (web.public_url, ProxyGate): every request needs a token
149
+ # @param public_token [String, Token::Source, nil] the access token
150
+ # with +public_url+ (the LAN one when both are set)
144
151
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
145
152
  bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
146
153
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
@@ -148,7 +155,7 @@ module Samagotchi
148
155
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
149
156
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
150
157
  working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL,
151
- grants: nil)
158
+ grants: nil, public_url: nil, public_token: nil)
152
159
  @manager = manager || SessionManager
153
160
  @registry = registry
154
161
  @registry_given = !registry.nil?
@@ -173,17 +180,36 @@ module Samagotchi
173
180
  @refused_logged = {}
174
181
  @wait_poll_interval = wait_poll_interval
175
182
  init_participant_access(grants, state_dir)
183
+ init_proxy_gate(public_url, public_token)
176
184
  end
177
185
 
178
186
  def call(env)
179
187
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
180
- response = route(env)
188
+ response = unframed(route(env))
181
189
  log_request(env, response, started)
182
190
  response
183
191
  end
184
192
 
185
193
  private
186
194
 
195
+ # No page of chi web's may be framed by another (clickjacking): every
196
+ # HTML answer says so. The page's full CSP is a later change.
197
+ FRAME_HEADERS = { "X-Frame-Options" => "DENY", "Content-Security-Policy" => "frame-ancestors 'none'" }.freeze
198
+ FRAME_DESTS = %w[iframe frame embed object].freeze
199
+
200
+ def unframed(response)
201
+ status, headers, body = response
202
+ return response unless headers["Content-Type"].to_s.start_with?("text/html")
203
+
204
+ [status, headers.merge(FRAME_HEADERS), body]
205
+ end
206
+
207
+ # A frame/embed/object load from anything but the page's own origin.
208
+ def framed?(env)
209
+ FRAME_DESTS.include?(env["HTTP_SEC_FETCH_DEST"].to_s.downcase) &&
210
+ env["HTTP_SEC_FETCH_SITE"].to_s.downcase != "same-origin"
211
+ end
212
+
187
213
  # The routes, first match wins: a handler takes the request and the
188
214
  # path's captures (the session id, an image's name). The fourth column
189
215
  # is who may use the route (ROUTE_PERMISSIONS, checked against the
@@ -200,6 +226,7 @@ module Samagotchi
200
226
  ["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :participant],
201
227
  ["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
202
228
  ["GET", %r{\A/api/info\z}, :handle_info, :participant],
229
+ ["POST", %r{\A/api/token\z}, :handle_token, :participant],
203
230
  ["GET", %r{\A/api/models\z}, :handle_models, :owner],
204
231
  ["GET", %r{\A/api/history\z}, :handle_history, :owner],
205
232
  ["GET", %r{\A/api/events\z}, :handle_events, :owner],
@@ -251,6 +278,10 @@ module Samagotchi
251
278
  req = Rack::Request.new(env)
252
279
  return forbidden unless local_host_header?(env)
253
280
  return cross_origin if cross_site?(env)
281
+ return error_response(403, "framed", "chi web's pages can't be framed by another page") if framed?(env)
282
+ # The fragment trade's script, which the 401 page loads: no secret in it.
283
+ return serve_static(req) if req.get? && GATE_FREE_PATHS.include?(req.path_info)
284
+ return probe_info if probe_info?(req)
254
285
 
255
286
  principal, denied = token_gate(req)
256
287
  return denied if denied
@@ -328,8 +359,11 @@ module Samagotchi
328
359
  # A viewer on this machine, whose editor can open the session's
329
360
  # paths: a loopback peer, or this Mac on its own LAN address (the LAN
330
361
  # URL or its QR code opened here). The phone isn't. An SSH tunnel or
331
- # a local reverse proxy looks like loopback too (a known gap).
362
+ # a local reverse proxy looks like loopback too (a known gap), unless
363
+ # it leaves a trace and web.public_url is set (#proxied?).
332
364
  def local_viewer?(req)
365
+ return false if proxied?(req)
366
+
333
367
  peer = req.get_header("REMOTE_ADDR").to_s
334
368
  LOOPBACK_PEERS.include?(peer) || (!@lan.nil? && peer == @lan[:ip])
335
369
  end
@@ -342,10 +376,12 @@ module Samagotchi
342
376
  # check stops DNS-rebinding pages. It reads the raw Host header:
343
377
  # Rack's req.host trusts X-Forwarded-Host, which such a page could
344
378
  # set. A request with no Host (HTTP/1.0) is answered only for a
345
- # loopback peer.
379
+ # loopback peer. With web.public_url its host (with its port, exactly)
380
+ # is answered too.
346
381
  def local_host_header?(env)
347
382
  host = env["HTTP_HOST"].to_s
348
383
  return LOOPBACK_PEERS.include?(env["REMOTE_ADDR"].to_s) if host.empty?
384
+ return true if @public_url&.host?(host)
349
385
 
350
386
  name = host.downcase.sub(/:\d*\z/, "")
351
387
  LOOPBACK_NAMES.include?(name) || (!@lan.nil? && name == @lan[:ip])
@@ -359,10 +395,17 @@ module Samagotchi
359
395
  # the cross-site check already refuses what Strict would add.
360
396
  TOKEN_COOKIE_ATTRIBUTES = "HttpOnly; SameSite=Lax; Path=/; Max-Age=34560000"
361
397
  REFUSED_LOG_INTERVAL = 60.0
398
+ REFUSED_LOG_KEYS = 256
362
399
 
363
400
  # Where #route keeps the request's Principal for its handlers.
364
401
  PRINCIPAL_ENV = "samagotchi.principal"
365
402
  PAGE_PATHS = ["/", "/index.html"].freeze
403
+ # POST {code} or {token}: the page trades a link's one-time code (its
404
+ # #code= fragment, token_fragment.js) or a pasted token (the 401 page's
405
+ # form) for the cookie.
406
+ TOKEN_TRADE_PATH = "/api/token"
407
+ TRADE_BODY_LIMIT = 4096
408
+ GATE_FREE_PATHS = ["/token_fragment.js"].freeze
366
409
 
367
410
  # The request's Principal, as #route resolved it.
368
411
  def principal(req) = req.env.fetch(PRINCIPAL_ENV)
@@ -378,9 +421,16 @@ module Samagotchi
378
421
  def token_gate(req)
379
422
  principal = principal_for(req)
380
423
  return [nil, refused_token(req)] if principal.nil?
424
+
381
425
  # The cookie gets the token that was presented: the owner's, or the
382
426
  # participant's grant's.
383
- return [nil, token_redirect(req, req.env[GRANT_TOKEN_ENV] || current_token)] if token_trade?(req)
427
+ if token_trade?(req)
428
+ token = req.env[GRANT_TOKEN_ENV] || current_token
429
+ return [nil, token_redirect(req, token)] unless fragment_trade?(req)
430
+ return [nil, form_trade_redirect(req, token)] if form_post?(req)
431
+
432
+ return [nil, token_cookie_response(req, token)]
433
+ end
384
434
 
385
435
  [principal, nil]
386
436
  end
@@ -394,9 +444,12 @@ module Samagotchi
394
444
  # machine's loopback), else nil. An ended or unknown grant's token is
395
445
  # nil anywhere: it never falls back to the owner.
396
446
  # @return [Principal, nil]
447
+ # With web.public_url (ProxyGate) every request needs a token, and a
448
+ # proxied one is #proxied_principal's.
397
449
  def principal_for(req)
398
450
  given = presented_tokens(req)
399
- return Principal::OWNER if @lan && given.any? { |value| token_match?(value, current_token) }
451
+ return proxied_principal(req, given) if proxied?(req)
452
+ return Principal::OWNER if (@lan || @public_url) && owner_token?(given)
400
453
 
401
454
  grant_tokens = given.select { |value| Grants.token?(value) }
402
455
  return Principal::OWNER if grant_tokens.empty? && !token_required?(req)
@@ -404,19 +457,93 @@ module Samagotchi
404
457
  participant_principal(req, grant_tokens)
405
458
  end
406
459
 
460
+ # With web.public_url a request from this machine that didn't come
461
+ # through the proxy (curl, chi's own callers) presents the Bearer
462
+ # header only: no cookie, and no trade that would set one. A cookie for
463
+ # 127.0.0.1 goes to every server on any of its ports. A LAN peer
464
+ # (web.host lan) keeps the LAN's token link and cookie.
407
465
  def presented_tokens(req)
408
- token_trade?(req) ? [req.params["token"]] : [bearer_token(req), req.cookies[TOKEN_COOKIE]]
466
+ return [traded_token(req)] if token_trade?(req)
467
+ return [bearer_token(req)] if direct_public?(req)
468
+
469
+ [bearer_token(req), req.cookies[token_cookie_name(req)]]
470
+ end
471
+
472
+ def direct_public?(req)
473
+ !@public_url.nil? && LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s) && !proxied?(req)
474
+ end
475
+
476
+ # The token a trade presents: the page's ?token=, or POST /api/token's
477
+ # body's (read once): its token, or the token a one-time link code
478
+ # stands for (LinkCodes#redeem: used up here, whatever the gate says).
479
+ def traded_token(req)
480
+ return req.params["token"] unless fragment_trade?(req)
481
+
482
+ req.env.fetch(TRADED_TOKEN_ENV) do
483
+ raw = request_body(req, TRADE_BODY_LIMIT).to_s
484
+ value = if raw.bytesize >= TRADE_BODY_LIMIT then nil
485
+ elsif form_post?(req) then Rack::Utils.parse_query(raw)
486
+ else parse_json(raw)
487
+ end
488
+ value = {} unless value.is_a?(Hash)
489
+ req.env[TRADED_TOKEN_ENV] = value.key?("code") ? code_token(value["code"]) : string_or_nil(value["token"])
490
+ end
491
+ end
492
+
493
+ def string_or_nil(value) = value.is_a?(String) ? value : nil
494
+
495
+ def code_token(code)
496
+ kind, token = @link_codes.redeem(code)
497
+ kind == :owner ? current_token : token
498
+ end
499
+
500
+ TRADED_TOKEN_ENV = "samagotchi.traded_token"
501
+
502
+ def fragment_trade?(req) = req.post? && req.path_info == TOKEN_TRADE_PATH
503
+
504
+ # The 401 page's paste box, posted as a form (no script needed).
505
+ def form_post?(req) = req.media_type == "application/x-www-form-urlencoded"
506
+
507
+ def owner_token?(given) = given.any? { |value| token_match?(value, current_token) }
508
+
509
+ # The cookie a request's token rides in, its name and attributes per
510
+ # request: a proxied request's is ProxyGate::SECURE_COOKIE, Secure (the
511
+ # public URL is https). A browser may hold both.
512
+ def token_cookie_name(req) = proxied?(req) ? SECURE_COOKIE : TOKEN_COOKIE
513
+
514
+ def token_cookie_attributes(req)
515
+ proxied?(req) ? "Secure; #{TOKEN_COOKIE_ATTRIBUTES}" : TOKEN_COOKIE_ATTRIBUTES
516
+ end
517
+
518
+ def token_cookie(req, value)
519
+ "#{token_cookie_name(req)}=#{value}; #{token_cookie_attributes(req)}"
409
520
  end
410
521
 
411
- def token_required?(req) = @lan && !LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s)
522
+ def cleared_token_cookie(req)
523
+ "#{token_cookie_name(req)}=; #{token_cookie_attributes(req).sub(/Max-Age=\d+/, "Max-Age=0")}"
524
+ end
525
+
526
+ def token_required?(req)
527
+ return true if @public_url
528
+
529
+ @lan && !LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s)
530
+ end
412
531
 
413
532
  # The page opened with ?token= (a gated request, or a grant's token
414
533
  # anywhere): its token is traded for the cookie, whatever else the
415
534
  # request carries.
535
+ # POST /api/token is the same trade for a token the page found in its
536
+ # URL's fragment.
416
537
  def token_trade?(req)
417
- return false unless req.get? && PAGE_PATHS.include?(req.path_info) && req.params.key?("token")
538
+ return false if direct_public?(req)
418
539
 
419
- token_required?(req) || Grants.token?(req.params["token"])
540
+ # Through the proxy no GET trade (a ?token= rides the sign-in
541
+ # redirect, and a crafted link would swap the owner's cookie): a
542
+ # link's #code= and the 401 page's POST do it.
543
+ page = req.get? && PAGE_PATHS.include?(req.path_info) && req.params.key?("token") && !(@public_url && proxied?(req))
544
+ return false unless page || fragment_trade?(req)
545
+
546
+ token_required?(req) || Grants.token?(traded_token(req))
420
547
  end
421
548
 
422
549
  # The 401 for a request #principal_for refused: an ended grant's, else
@@ -425,17 +552,16 @@ module Samagotchi
425
552
  # owner's, who opened a participant link) is the owner's again.
426
553
  def refused_token(req)
427
554
  response = grant_ended_response(req) || unauthorized(req)
428
- return response if token_required?(req) || !Grants.token?(req.cookies[TOKEN_COOKIE])
555
+ return response if token_required?(req) || !Grants.token?(req.cookies[token_cookie_name(req)])
429
556
 
430
557
  status, headers, body = response
431
- [status, headers.merge("Set-Cookie" => "#{TOKEN_COOKIE}=; #{TOKEN_COOKIE_ATTRIBUTES.sub(/Max-Age=\d+/, "Max-Age=0")}"),
432
- body]
558
+ [status, headers.merge("Set-Cookie" => cleared_token_cookie(req)), body]
433
559
  end
434
560
 
435
561
  def bearer_token(req) = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer +(\S+)\z/, 1]
436
562
 
437
563
  def current_token
438
- token = @lan[:token]
564
+ token = @lan ? @lan[:token] : @public_token
439
565
  token.respond_to?(:current) ? token.current : token
440
566
  end
441
567
 
@@ -446,32 +572,87 @@ module Samagotchi
446
572
  end
447
573
 
448
574
  # The same page (and its other parameters, as they were written), the
449
- # token traded for the cookie.
575
+ # token traded for the cookie. Proxied, the Location is absolute from
576
+ # web.public_url: WEBrick would make a relative one absolute from the
577
+ # first X-Forwarded-Host/-Proto, which a client sets through
578
+ # oauth2-proxy (an open redirect).
450
579
  def token_redirect(req, token)
451
580
  query = req.query_string.split("&").reject { |pair| pair.split("=", 2).first == "token" }.join("&")
452
581
  location = query.empty? ? req.path_info : "#{req.path_info}?#{query}"
453
- [303, { "Location" => location, "Set-Cookie" => "#{TOKEN_COOKIE}=#{token}; #{TOKEN_COOKIE_ATTRIBUTES}",
582
+ location = "#{@public_url.origin}#{location}" if proxied?(req)
583
+ [303, { "Location" => location, "Set-Cookie" => token_cookie(req, token),
454
584
  "Cache-Control" => "no-store", "Content-Length" => "0" }, []]
455
585
  end
456
586
 
587
+ # The paste box's trade: the cookie and a 303 to the page (absolute
588
+ # from web.public_url when proxied, as #token_redirect).
589
+ def form_trade_redirect(req, token)
590
+ location = proxied?(req) ? "#{@public_url.origin}/" : "/"
591
+ [303, { "Location" => location, "Set-Cookie" => token_cookie(req, token), "Cache-Control" => "no-store",
592
+ "Content-Length" => "0" }, []]
593
+ end
594
+
595
+ # The fragment trade's answer: the cookie, no body.
596
+ def token_cookie_response(req, token)
597
+ [204, { "Set-Cookie" => token_cookie(req, token), "Cache-Control" => "no-store" }, []]
598
+ end
599
+
600
+ # POST /api/token past the gate without a trade (nothing to trade here:
601
+ # no token is asked of this request): nothing to do.
602
+ def handle_token(_req) = [204, { "Cache-Control" => "no-store" }, []]
603
+
457
604
  def unauthorized(req)
458
- log_refused(req.get_header("REMOTE_ADDR").to_s, req.path_info)
459
- return error_response(401, "unauthorized", "chi web on the LAN needs its access token") if req.path_info.start_with?("/api/")
605
+ reason = req.env[REFUSED_ENV]
606
+ log_refused(refused_peer(req), req.path_info, reason)
607
+ return error_response(401, "unauthorized", unauthorized_detail(req, reason)) if req.path_info.start_with?("/api/")
460
608
 
461
- body = UNAUTHORIZED_PAGE
609
+ body = @public_url ? PUBLIC_UNAUTHORIZED_PAGE : UNAUTHORIZED_PAGE
462
610
  [401, { "Content-Type" => "text/html; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store" },
463
611
  [body]]
464
612
  end
465
613
 
614
+ def unauthorized_detail(req, reason)
615
+ return "chi web behind the proxy: #{reason || "needs its access token"}" if proxied?(req)
616
+ return "chi web with web.public_url needs its access token, on this machine too" if @public_url
617
+
618
+ "chi web on the LAN needs its access token"
619
+ end
620
+
621
+ # Who a refusal is logged as: the peer, or behind a proxy (every peer
622
+ # 127.0.0.1) the client: its identity, else the X-Forwarded-For hop the
623
+ # proxy appended (the last one).
624
+ def refused_peer(req)
625
+ return req.get_header("REMOTE_ADDR").to_s unless proxied?(req)
626
+
627
+ identity = @public_url.identity_env && req.env[@public_url.identity_env]
628
+ hop = req.get_header("HTTP_X_FORWARDED_FOR").to_s.split(",").last.to_s.strip
629
+ "proxy:#{[identity.to_s.strip, hop].find { |v| !v.empty? } || "?"}"[0, 200]
630
+ end
631
+
466
632
  # At most once a minute per peer: a phone with an old link retries.
467
- def log_refused(peer, path)
633
+ # At most REFUSED_LOG_KEYS peers a minute (rotating headers can't
634
+ # fill the log).
635
+ def log_refused(peer, path, reason = nil)
468
636
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
469
637
  due = @refused_mutex.synchronize do
470
638
  @refused_logged.delete_if { |_, at| now - at >= REFUSED_LOG_INTERVAL } if @refused_logged.size > 64
639
+ next false if @refused_logged.size >= REFUSED_LOG_KEYS && !@refused_logged.key?(peer)
640
+
471
641
  last = @refused_logged[peer]
472
642
  (last.nil? || now - last >= REFUSED_LOG_INTERVAL).tap { |fresh| @refused_logged[peer] = now if fresh }
473
643
  end
474
- Log.warn(:web, "unauthorized", peer: peer, path: path) if due
644
+ Log.warn(:web, "unauthorized", peer: peer, path: path, **(reason ? { reason: reason } : {})) if due
645
+ end
646
+
647
+ # Behind a proxy (web.public_url) the paste box posts the token in the
648
+ # body (POST /api/token): a GET would put it in the URL, the proxy's
649
+ # access log and the history. On this machine the page opens through
650
+ # the proxy too.
651
+ def self.public_unauthorized_page(page)
652
+ page.sub('<form method="get" action="/">', '<form method="post" action="/api/token">')
653
+ .sub("Open the link chi web printed in its terminal (LAN, or Proxied behind an\nauth proxy), or scan its QR code.",
654
+ "Open the Proxied link chi web printed in its terminal (it works once; chi web again prints a new " \
655
+ "one), or paste the token from its web-token file.")
475
656
  end
476
657
 
477
658
  # What a phone sees without the token. The field is for a home-screen
@@ -489,14 +670,26 @@ module Samagotchi
489
670
  </style></head>
490
671
  <body>
491
672
  <h1>chi web</h1>
492
- <p>This page needs chi web's access token. Open the LAN link chi web printed in its terminal, or scan its QR code.</p>
673
+ <p>This page needs chi web's access token. Open the link chi web printed in its terminal (LAN, or Proxied behind an
674
+ auth proxy), or scan its QR code.</p>
675
+ <p id="trade" hidden></p>
493
676
  <form method="get" action="/">
494
677
  <label for="token">Or paste the token:</label>
495
678
  <input id="token" name="token" autocomplete="off" autocapitalize="off" spellcheck="false">
496
679
  <button type="submit">Open</button>
497
680
  </form>
681
+ <script type="module">
682
+ import { tradeFragmentCode } from "/token_fragment.js";
683
+ tradeFragmentCode({ onRefused: (words) => {
684
+ const line = document.getElementById("trade");
685
+ line.textContent = words;
686
+ line.hidden = false;
687
+ } });
688
+ </script>
498
689
  </body></html>
499
690
  HTML
691
+ PUBLIC_UNAUTHORIZED_PAGE = public_unauthorized_page(UNAUTHORIZED_PAGE).freeze
692
+ private_class_method :public_unauthorized_page
500
693
 
501
694
  # Another website's page in the desktop browser can send "simple"
502
695
  # requests here (a text/plain POST needs no preflight) and would start
@@ -510,18 +703,30 @@ module Samagotchi
510
703
  def cross_site?(env)
511
704
  foreign = %w[cross-site same-site].include?(env["HTTP_SEC_FETCH_SITE"].to_s.downcase)
512
705
  origin = env["HTTP_ORIGIN"]
513
- foreign ||= !origin.nil? && origin.downcase != "http://#{env["HTTP_HOST"].to_s.downcase}"
706
+ foreign ||= !origin.nil? && !same_origin?(env, origin.downcase)
514
707
  return false unless foreign
515
708
 
516
709
  !%w[GET HEAD].include?(env["REQUEST_METHOD"]) || env["PATH_INFO"].to_s.start_with?("/api/")
517
710
  end
518
711
 
712
+ # The page's own origin: through the proxy only web.public_url's
713
+ # (https), else http://<Host> (or the public origin).
714
+ def same_origin?(env, origin)
715
+ return origin == @public_url.origin if @public_url && proxied?(Rack::Request.new(env))
716
+
717
+ origin == "http://#{env["HTTP_HOST"].to_s.downcase}" || public_origin?(origin)
718
+ end
719
+
720
+ # The page served through web.public_url (Origin https://<public host>).
721
+ def public_origin?(origin) = !@public_url.nil? && origin.downcase == @public_url.origin
722
+
519
723
  def cross_origin
520
724
  error_response(403, "cross_origin", "requests from other websites are refused")
521
725
  end
522
726
 
523
727
  def forbidden
524
- names = @lan ? "127.0.0.1, [::1], localhost and #{@lan[:ip]}" : "127.0.0.1, [::1] and localhost"
728
+ names = ["127.0.0.1", "[::1]", "localhost", @lan && @lan[:ip], @public_url&.host].compact
729
+ names = "#{names[0..-2].join(", ")} and #{names.last}"
525
730
  error_response(403, "forbidden", "only the host names #{names} are answered")
526
731
  end
527
732
 
@@ -576,8 +781,9 @@ module Samagotchi
576
781
  # session_commands: the routes chi --remote needs
577
782
  # (SessionAccessRoutes::FEATURE); remote_attach: the ones a remote
578
783
  # attached TUI needs (ATTACH_FEATURE). lan: its LAN address, nil when it
579
- # listens on loopback only. A participant gets app, version and
580
- # features only (#participant_info).
784
+ # listens on loopback only; public_url: web.public_url's origin, nil
785
+ # without one. A participant gets app, version and features only
786
+ # (#participant_info).
581
787
  def handle_info(req)
582
788
  return participant_info unless principal(req).owner?
583
789
 
@@ -2478,11 +2684,12 @@ module Samagotchi
2478
2684
  # a live worker's (it counts against its Engine's messages), else
2479
2685
  # recap.json against the session file. A stopped session gets its
2480
2686
  # recap without a worker being woken.
2481
- # @return [Hash, nil] {text:, turns_since:}
2687
+ # @return [Hash, nil] {text:, turns_since:, created_at:} (created_at:
2688
+ # when it was written, UTC ISO; absent from an older recap)
2482
2689
  def saved_recap_for(id, session, turn_snapshot)
2483
2690
  if turn_snapshot
2484
2691
  saved = turn_snapshot["saved_recap"]
2485
- return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i }
2692
+ return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i, created_at: saved["created_at"] }.compact
2486
2693
  end
2487
2694
 
2488
2695
  saved = RecapStore.read(@session_class.session_dir(id, state_dir: default_state_dir))
@@ -2490,7 +2697,7 @@ module Samagotchi
2490
2697
 
2491
2698
  messages = Array(session.messages)
2492
2699
  since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_prompt?(m) }
2493
- { text: saved[:text], turns_since: since }
2700
+ { text: saved[:text], turns_since: since, created_at: saved[:created_at] }.compact
2494
2701
  rescue StandardError
2495
2702
  nil
2496
2703
  end
@@ -2527,7 +2734,7 @@ module Samagotchi
2527
2734
  role = (m[:role] || m["role"]).to_s
2528
2735
  content = (m[:content] || m["content"]).to_s
2529
2736
  if Samagotchi::ContextNote.note?(m)
2530
- note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
2737
+ note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m), **time_of(m) }
2531
2738
  # A context wake turn's note starts that turn (Steer.turn_prompt?):
2532
2739
  # the page opens the turn with it and pairs it with its record.
2533
2740
  if Samagotchi::Steer.turn_prompt?(m)
@@ -2540,7 +2747,7 @@ module Samagotchi
2540
2747
  # A plugin's steer: a row of the step that answered it, which the
2541
2748
  # page finds by `step`.
2542
2749
  if Samagotchi::Steer.steer?(m)
2543
- filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index) }
2750
+ filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index), **time_of(m) }
2544
2751
  next
2545
2752
  end
2546
2753
  # A turn that ended with no answer (TurnNote.empty's marker): its
@@ -2563,7 +2770,7 @@ module Samagotchi
2563
2770
  norm_role = "user" if norm_role == "user"
2564
2771
  next unless %w[user assistant].include?(norm_role)
2565
2772
 
2566
- message = { role: norm_role, content: stripped }
2773
+ message = { role: norm_role, content: stripped, **time_of(m) }
2567
2774
  # Merged into the running turn (Steer::INPUT_KIND): part of it,
2568
2775
  # read by step +step+; a wake turn's report starts its turn
2569
2776
  # (Steer.turn_prompt?). Its sender (a delegate report, chi send):
@@ -2603,6 +2810,13 @@ module Samagotchi
2603
2810
  []
2604
2811
  end
2605
2812
 
2813
+ # The entry's wall time (MessageTime: the page's hover and gap
2814
+ # dividers) as {at:}, or nothing for an entry saved without one.
2815
+ def time_of(message)
2816
+ at = Samagotchi::MessageTime.of(message)
2817
+ at.is_a?(String) && !at.empty? ? { at: at } : {}
2818
+ end
2819
+
2606
2820
  # A no-answer turn's marker as display items: each empty step that
2607
2821
  # has something to show (its thinking) as a text-less assistant step
2608
2822
  # (only with +parts+, as other text-less steps), then
@@ -91,8 +91,10 @@ module Samagotchi
91
91
  # grant from before them, which takes the config's (#spend_cap,
92
92
  # #queue_limit).
93
93
  Grant = Data.define(:id, :session_id, :digest, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
94
- :revoked_by, :last_used_at, :cap, :queue) do
95
- def initialize(cap: nil, queue: nil, **) = super
94
+ :revoked_by, :last_used_at, :cap, :queue, :identity) do
95
+ # identity: the proxy identity (an email, lower case) the grant is
96
+ # bound to, nil for any (ProxyGate; issued from shared-sessions P6).
97
+ def initialize(cap: nil, queue: nil, identity: nil, **) = super
96
98
 
97
99
  def self.from_h(hash)
98
100
  new(**members.to_h { |key| [key, hash[key.to_s]] })
@@ -132,7 +134,8 @@ module Samagotchi
132
134
  state: state(now).to_s, cap: spend_cap, queue: queue_limit }
133
135
  end
134
136
 
135
- def to_store = to_h.transform_keys(&:to_s)
137
+ # A grant bound to no identity is stored as before identity existed.
138
+ def to_store = to_h.transform_keys(&:to_s).tap { |hash| hash.delete("identity") if identity.nil? }
136
139
 
137
140
  def inspect = "#<Grant #{id} #{session_id[0, 8]} #{name.inspect}>"
138
141
  alias_method :to_s, :inspect
@@ -6,7 +6,11 @@ require "net/http"
6
6
  module Samagotchi
7
7
  module Web
8
8
  # What answers GET /api/info on host:port: the one probe for a running
9
- # chi web (chi web's own port check, chi update, chi self).
9
+ # chi web (chi web's own port check, chi share's port, chi update, chi
10
+ # self). It never sends a token: whatever listens on the port may not be
11
+ # chi web (another user's process on a shared host). A chi web behind a
12
+ # proxy answers it without one (ProxyGate#probe_info). Nothing it
13
+ # answers is trusted for a link.
10
14
  module InfoProbe
11
15
  module_function
12
16