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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -1
- data/bin/chi +6 -1
- data/docs/architecture.md +35 -5
- data/docs/cli.md +33 -8
- data/docs/configuration.md +74 -20
- data/docs/internals/participants.md +111 -12
- data/docs/internals/prompt-caching.md +7 -6
- data/docs/internals/remote-sessions.md +6 -1
- data/docs/internals/web-push.md +4 -2
- data/docs/plugins.md +24 -8
- data/docs/sessions.md +4 -3
- data/docs/testing.md +13 -2
- data/lib/samagotchi/answer_command.rb +2 -0
- data/lib/samagotchi/bridge.rb +56 -13
- data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
- data/lib/samagotchi/bridge_client.rb +58 -19
- data/lib/samagotchi/bridge_secret.rb +35 -0
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +5 -0
- data/lib/samagotchi/participant_messages.rb +5 -0
- data/lib/samagotchi/participants.rb +16 -6
- data/lib/samagotchi/plugin/api.rb +6 -3
- data/lib/samagotchi/review_command.rb +3 -1
- data/lib/samagotchi/run_options.rb +3 -1
- data/lib/samagotchi/session_access/contract.rb +4 -1
- data/lib/samagotchi/session_access/local.rb +15 -4
- data/lib/samagotchi/session_access/remote.rb +43 -7
- data/lib/samagotchi/session_access.rb +11 -1
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/sessions_command.rb +1 -2
- data/lib/samagotchi/share_command.rb +29 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
- data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
- data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/toolkit.rb +50 -26
- data/lib/samagotchi/toolkit_binding.rb +32 -0
- data/lib/samagotchi/toolkit_command.rb +23 -0
- data/lib/samagotchi/tools/deferred_tools.rb +10 -2
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/registry.rb +20 -6
- data/lib/samagotchi/tools/tool_index.rb +103 -20
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -43
- data/lib/samagotchi/web/grants.rb +127 -16
- data/lib/samagotchi/web/link_codes.rb +24 -15
- data/lib/samagotchi/web/participant_access.rb +69 -13
- data/lib/samagotchi/web/participant_exposure.rb +44 -0
- data/lib/samagotchi/web/participant_review.rb +71 -9
- data/lib/samagotchi/web/participants_admin.rb +12 -6
- data/lib/samagotchi/web/proxy_gate.rb +18 -3
- data/lib/samagotchi/web/public/annotate_bar.css +18 -0
- data/lib/samagotchi/web/public/annotate_bar.js +10 -3
- data/lib/samagotchi/web/public/app.js +66 -155
- data/lib/samagotchi/web/public/composer.css +73 -0
- data/lib/samagotchi/web/public/composer_grip.js +21 -15
- data/lib/samagotchi/web/public/composer_mount.js +196 -0
- data/lib/samagotchi/web/public/forms.css +12 -0
- data/lib/samagotchi/web/public/index.html +14 -88
- data/lib/samagotchi/web/public/level_chip.js +2 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
- data/lib/samagotchi/web/public/notifications.js +58 -8
- data/lib/samagotchi/web/public/participant.html +46 -10
- data/lib/samagotchi/web/public/participant.js +101 -18
- data/lib/samagotchi/web/public/participant_annotate.js +75 -0
- data/lib/samagotchi/web/public/participant_model.js +53 -1
- data/lib/samagotchi/web/public/participants_panel.js +37 -13
- data/lib/samagotchi/web/public/question_card.js +15 -1
- data/lib/samagotchi/web/public/question_cards.js +11 -2
- data/lib/samagotchi/web/public/review_history.js +60 -0
- data/lib/samagotchi/web/public/scope.js +12 -8
- data/lib/samagotchi/web/public/stage_frame.js +9 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -4
- data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
- data/lib/samagotchi/web/push/notifier.rb +17 -3
- data/lib/samagotchi/web/running.rb +7 -4
- data/lib/samagotchi/web/server.rb +34 -11
- data/lib/samagotchi/web/session_create.rb +7 -7
- data/lib/samagotchi/web/start_dir.rb +26 -0
- data/lib/samagotchi/web/tunnel.rb +10 -1
- data/lib/samagotchi/worker_sidecar.rb +22 -11
- metadata +10 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
-
|
|
511
|
-
|
|
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)
|
|
841
|
-
#
|
|
842
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
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
|
|
1327
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
1395
|
+
def proxy_stream_body(id, req, client)
|
|
1355
1396
|
query = participant_view?(req) ? participant_stream_query(req) : stream_query(req)
|
|
1356
|
-
ProxyStreamBody.new(
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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) {
|
|
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
|
-
#
|
|
1454
|
-
|
|
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.
|
|
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
|
-
|
|
1464
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
1666
|
+
# (?from=), else the project chi web starts in.
|
|
1613
1667
|
back, = scope_dir(req.params["from"])
|
|
1614
|
-
back ||=
|
|
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
|
|
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
|
-
|
|
1754
|
-
|
|
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 <
|
|
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, **)
|
|
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
|
-
|
|
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
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
+
result = nil
|
|
65
68
|
update do |entries|
|
|
66
|
-
entries.
|
|
67
|
-
|
|
69
|
+
entry = entries.find { |candidate| same_digest?(candidate["digest"].to_s, wanted) }
|
|
70
|
+
next entries unless entry
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
78
|
+
result
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def digest(code) = Digest::SHA256.hexdigest(code)
|
|
75
84
|
|
|
76
|
-
|
|
77
|
-
|
|
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")
|