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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +7 -2
- data/docs/cli.md +40 -1
- data/docs/configuration.md +177 -2
- data/docs/internals/engine.md +19 -4
- data/docs/internals/participants.md +28 -8
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/internals/remote-sessions.md +6 -2
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +3 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +25 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +20 -9
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_reminders.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +12 -7
- data/lib/samagotchi/llm/chat_loop.rb +9 -2
- data/lib/samagotchi/message_time.rb +104 -0
- data/lib/samagotchi/proxy_trace.rb +45 -0
- data/lib/samagotchi/reminder_queue.rb +20 -3
- data/lib/samagotchi/reply_wait/poll.rb +39 -1
- data/lib/samagotchi/run_options.rb +4 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +52 -10
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/steer.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
- data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
- data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
- data/lib/samagotchi/terminal_ui.rb +40 -5
- data/lib/samagotchi/tool_activity.rb +11 -0
- data/lib/samagotchi/turn_checkpoint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +246 -32
- data/lib/samagotchi/web/grants.rb +6 -3
- data/lib/samagotchi/web/info_probe.rb +5 -1
- data/lib/samagotchi/web/link_codes.rb +146 -0
- data/lib/samagotchi/web/participant_access.rb +9 -2
- data/lib/samagotchi/web/participant_review.rb +1 -1
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/participants_admin.rb +13 -6
- data/lib/samagotchi/web/proxy_gate.rb +174 -0
- data/lib/samagotchi/web/public/app.js +35 -3
- data/lib/samagotchi/web/public/auth_lost.js +76 -0
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/data.js +29 -5
- data/lib/samagotchi/web/public/format.js +72 -6
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/participant.html +2 -0
- data/lib/samagotchi/web/public/participant.js +47 -10
- data/lib/samagotchi/web/public/participant_model.js +25 -2
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/time_gaps.js +98 -0
- data/lib/samagotchi/web/public/token_fragment.js +57 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_html.js +15 -8
- data/lib/samagotchi/web/public/turn_view.js +8 -3
- data/lib/samagotchi/web/public_url.rb +87 -0
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +138 -22
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- data/lib/samagotchi/worker_sidecar.rb +4 -3
- metadata +12 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
538
|
+
return false if direct_public?(req)
|
|
418
539
|
|
|
419
|
-
|
|
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[
|
|
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" =>
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
580
|
-
# features only
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|