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
|
@@ -19,7 +19,8 @@ module Samagotchi
|
|
|
19
19
|
module Web
|
|
20
20
|
# What a participant (a Web::Grants token) may do in chi web, mixed into
|
|
21
21
|
# App: the gate's lookup of their grant, the refusals that are theirs
|
|
22
|
-
# (token_revoked, token_expired with its ended page,
|
|
22
|
+
# (token_revoked, token_expired with its ended page, token_bound for a
|
|
23
|
+
# link bound to another account, another session's
|
|
23
24
|
# 404, not_permitted by role), the trimmed answers of their own routes
|
|
24
25
|
# (info, the session, resolve, a wait on their turn), their turns, and
|
|
25
26
|
# the running check that ends their streams when the grant does. What
|
|
@@ -30,6 +31,11 @@ module Samagotchi
|
|
|
30
31
|
# A participant's refused routes are audited at most this often per
|
|
31
32
|
# route (a page retrying would fill the log).
|
|
32
33
|
REFUSED_AUDIT_INTERVAL = 60.0
|
|
34
|
+
# At most this many refusals a minute are remembered per reason, so
|
|
35
|
+
# audited: rotating paths or headers can't grow the map or the audit
|
|
36
|
+
# file without bound (as App::REFUSED_LOG_KEYS for the log), nor one
|
|
37
|
+
# reason's flood silence another's.
|
|
38
|
+
REFUSED_AUDIT_KEYS = 256
|
|
33
39
|
|
|
34
40
|
ENDED = {
|
|
35
41
|
revoked: ["token_revoked", ParticipantMessages::TOKEN_REVOKED],
|
|
@@ -65,8 +71,8 @@ module Samagotchi
|
|
|
65
71
|
def participant_principal(req, tokens, identity: nil, proxied: false)
|
|
66
72
|
tokens.each do |token|
|
|
67
73
|
grant = @grants.lookup(token) or next
|
|
68
|
-
|
|
69
|
-
req
|
|
74
|
+
unless bound_ok?(grant, identity, proxied)
|
|
75
|
+
refuse_bound(req, grant, identity)
|
|
70
76
|
next
|
|
71
77
|
end
|
|
72
78
|
grant = settle_grant(grant)
|
|
@@ -82,6 +88,36 @@ module Samagotchi
|
|
|
82
88
|
|
|
83
89
|
GRANT_TOKEN_ENV = "samagotchi.grant_token"
|
|
84
90
|
ENDED_GRANT_ENV = "samagotchi.ended_grant"
|
|
91
|
+
BOUND_GRANT_ENV = "samagotchi.bound_grant"
|
|
92
|
+
|
|
93
|
+
# Whether +grant+ may be used by this request: an unbound one anywhere,
|
|
94
|
+
# a bound one only proxied and only for its identity (the one check,
|
|
95
|
+
# for the gate and for a link code's trade).
|
|
96
|
+
def bound_ok?(grant, identity, proxied)
|
|
97
|
+
grant.identity.nil? || (proxied && !identity.nil? && identity == grant.identity)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A bound grant presented by another identity, or not through the
|
|
101
|
+
# proxy: #grant_bound_response answers it (token_bound).
|
|
102
|
+
def refuse_bound(req, grant, identity)
|
|
103
|
+
req.env[ProxyGate::REFUSED_ENV] = "grant #{grant.id} is bound to another identity than #{identity || "(none)"}"
|
|
104
|
+
req.env[BOUND_GRANT_ENV] ||= [grant, identity]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# 401 token_bound for a bound grant's token (or link code) presented
|
|
108
|
+
# without its identity (the page's HTML says the same words), nil when
|
|
109
|
+
# none was: logged as a refusal and audited in the grant's session.
|
|
110
|
+
def grant_bound_response(req)
|
|
111
|
+
grant, identity = req.env[BOUND_GRANT_ENV]
|
|
112
|
+
return nil unless grant
|
|
113
|
+
|
|
114
|
+
log_refused(refused_peer(req), req.path_info, "grant #{grant.id} is bound to another identity than #{identity || "(none)"}")
|
|
115
|
+
audit_refused(req, grant, reason: "token_bound", per_route: false, identity: (identity || "(none)")[0, 200])
|
|
116
|
+
detail = ParticipantMessages::TOKEN_BOUND
|
|
117
|
+
return error_response(401, "token_bound", detail) if req.path_info.start_with?("/api/")
|
|
118
|
+
|
|
119
|
+
html_response(401, BOUND_PAGE.sub(ENDED_SENTENCE, "#{detail[0].upcase}#{detail[1..]}."))
|
|
120
|
+
end
|
|
85
121
|
|
|
86
122
|
def settle_grant(grant)
|
|
87
123
|
return grant unless grant.active?(@grants.now)
|
|
@@ -93,6 +129,9 @@ module Samagotchi
|
|
|
93
129
|
|
|
94
130
|
Participants.revoke(@grants, session_id: grant.session_id, id: grant.id, by: by, state_dir: default_state_dir)
|
|
95
131
|
@grants.find(grant.id) || grant
|
|
132
|
+
rescue Grants::Unwritable
|
|
133
|
+
# A store this chi can't write (a newer chi's): refused all the same.
|
|
134
|
+
grant.with(revoked_at: @grants.now.utc.iso8601, revoked_by: by)
|
|
96
135
|
end
|
|
97
136
|
|
|
98
137
|
# 401 token_revoked / token_expired for an ended grant's token (the
|
|
@@ -115,16 +154,24 @@ module Samagotchi
|
|
|
115
154
|
|
|
116
155
|
# A participant's request, past the gate: its use is recorded (the
|
|
117
156
|
# grant's last_used_at, at most once a minute) and a peer not seen
|
|
118
|
-
# since chi web started goes into the audit trail.
|
|
157
|
+
# since chi web started goes into the audit trail. Behind a proxy
|
|
158
|
+
# every peer is 127.0.0.1: there the peer is the client's identity
|
|
159
|
+
# (else the proxy's X-Forwarded-For hop, #refused_peer), and the line
|
|
160
|
+
# names the identity.
|
|
119
161
|
def participant_seen(req, principal)
|
|
120
162
|
grant = principal.grant
|
|
121
163
|
@grants.touch(grant.id)
|
|
122
|
-
peer = req
|
|
164
|
+
peer = refused_peer(req)
|
|
123
165
|
fresh = @participant_mutex.synchronize { @participant_peers[grant.id].add?(peer) }
|
|
124
166
|
return unless fresh
|
|
125
167
|
|
|
126
|
-
|
|
127
|
-
|
|
168
|
+
fields = { ip: req.get_header("REMOTE_ADDR").to_s, user_agent: req.get_header("HTTP_USER_AGENT").to_s[0, 200] }
|
|
169
|
+
if proxied?(req)
|
|
170
|
+
state, identity = proxy_identity(req)
|
|
171
|
+
fields[:identity] = identity if state == :ok
|
|
172
|
+
fields[:peer] = peer
|
|
173
|
+
end
|
|
174
|
+
Participants.log(grant.session_id, :peer, state_dir: default_state_dir, participant: grant.id, **fields)
|
|
128
175
|
end
|
|
129
176
|
|
|
130
177
|
# A participant asking for a session that isn't their grant's: 404 as
|
|
@@ -146,19 +193,27 @@ module Samagotchi
|
|
|
146
193
|
error_response(403, "not_permitted", NOT_PERMITTED)
|
|
147
194
|
end
|
|
148
195
|
|
|
149
|
-
|
|
150
|
-
|
|
196
|
+
# +reason+ and +fields+ go into the line (a bound refusal names the
|
|
197
|
+
# identity that presented it). Once a minute per route for
|
|
198
|
+
# not_permitted; once a minute per grant and identity for a bound
|
|
199
|
+
# refusal, whatever the path (+per_route+ false); at most
|
|
200
|
+
# REFUSED_AUDIT_KEYS keys a minute.
|
|
201
|
+
def audit_refused(req, grant, reason: "not_permitted", per_route: true, **fields)
|
|
202
|
+
key = per_route ? [grant.id, req.request_method, req.path_info, reason, fields] : [grant.id, reason, fields]
|
|
151
203
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
152
204
|
due = @participant_mutex.synchronize do
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
205
|
+
seen = (@participant_refused[reason] ||= {})
|
|
206
|
+
seen.delete_if { |_, at| now - at >= REFUSED_AUDIT_INTERVAL } if seen.size > 64
|
|
207
|
+
next false if seen.size >= REFUSED_AUDIT_KEYS && !seen.key?(key)
|
|
208
|
+
|
|
209
|
+
last = seen[key]
|
|
210
|
+
(last.nil? || now - last >= REFUSED_AUDIT_INTERVAL).tap { |fresh| seen[key] = now if fresh }
|
|
156
211
|
end
|
|
157
212
|
return unless due
|
|
158
213
|
|
|
159
214
|
Participants.log(grant.session_id, :refused, state_dir: default_state_dir, participant: grant.id,
|
|
160
215
|
method: req.request_method, path: req.path_info[0, 200],
|
|
161
|
-
reason:
|
|
216
|
+
reason: reason, **fields)
|
|
162
217
|
end
|
|
163
218
|
|
|
164
219
|
# GET /api/info for a participant: what SessionAccess::Remote's
|
|
@@ -405,6 +460,7 @@ module Samagotchi
|
|
|
405
460
|
<p>{sentence}</p>
|
|
406
461
|
</body></html>
|
|
407
462
|
HTML
|
|
463
|
+
BOUND_PAGE = ENDED_PAGE.sub("<title>chi: review link ended</title>", "<title>chi: review link for another account</title>")
|
|
408
464
|
end
|
|
409
465
|
end
|
|
410
466
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Web
|
|
5
|
+
# What a participant may learn of their review's usage, by their grant's
|
|
6
|
+
# exposure level (Grants::Grant#exposure; the review-composer plan's D3):
|
|
7
|
+
# the policy that maps a level to the usage fields the review route
|
|
8
|
+
# gives them (ParticipantReview#review_usage). One level today:
|
|
9
|
+
#
|
|
10
|
+
# - +own+ (every grant's): their own spend and cap, their own queued
|
|
11
|
+
# turns and queue limit, their link's expiry, the session's model.
|
|
12
|
+
# Never another participant's or the owner's usage, nor the session's.
|
|
13
|
+
#
|
|
14
|
+
# A later level (a tier that also shows the session's spend, say) adds
|
|
15
|
+
# its row to LEVELS and its fields' sources in ParticipantReview: one
|
|
16
|
+
# place each. A level this chi doesn't know (written by a newer chi, or
|
|
17
|
+
# a hand-edited store) reads as DEFAULT, the narrowest: an unknown level
|
|
18
|
+
# never shows more. See docs/internals/participants.md (Exposure).
|
|
19
|
+
module ParticipantExposure
|
|
20
|
+
OWN = "own"
|
|
21
|
+
DEFAULT = OWN
|
|
22
|
+
# level => the usage fields it shows, in the order the page reads them.
|
|
23
|
+
LEVELS = {
|
|
24
|
+
OWN => %i[spent cap queued queue_limit expires_at model].freeze
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# +value+ as a level this chi knows, else DEFAULT.
|
|
28
|
+
def self.level(value) = LEVELS.key?(value) ? value : DEFAULT
|
|
29
|
+
|
|
30
|
+
# @return [Array<Symbol>] the fields +value+'s level shows
|
|
31
|
+
def self.fields(value) = LEVELS.fetch(level(value))
|
|
32
|
+
|
|
33
|
+
# The usage +value+'s level shows, each field read from +sources+
|
|
34
|
+
# (field => a callable): only the fields shown are computed, and a
|
|
35
|
+
# field a level names with no source raises (KeyError), never shows
|
|
36
|
+
# nothing silently.
|
|
37
|
+
# @param sources [Hash{Symbol => #call}]
|
|
38
|
+
# @return [Hash{Symbol => Object}]
|
|
39
|
+
def self.usage(value, sources)
|
|
40
|
+
fields(value).to_h { |field| [field, sources.fetch(field).call] }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "participant_view"
|
|
4
4
|
require_relative "participant_stream_filter"
|
|
5
|
+
require_relative "participant_exposure"
|
|
5
6
|
require_relative "../session_metrics"
|
|
6
7
|
require_relative "../review_start"
|
|
7
8
|
require_relative "../guardrails/review_paths"
|
|
@@ -58,15 +59,18 @@ module Samagotchi
|
|
|
58
59
|
automatic = automatic_turn_ids(id, session, shown, current, state)
|
|
59
60
|
waiting = !!(current&.dig("waiting_for_owner") || turn_snapshot&.dig("continue_offer") ||
|
|
60
61
|
(turn_snapshot && !turn_snapshot["current_turn"] && turn_snapshot["pending_question"]))
|
|
62
|
+
queued = turn_snapshot && snapshot_list(turn_snapshot, "queued")
|
|
63
|
+
status = displayed_status(session, state)
|
|
64
|
+
status = Session::STATUS_IDLE if status == Session::STATUS_RUNNING && current.nil?
|
|
61
65
|
json_response(200, {
|
|
62
|
-
viewer: review_viewer(req, id),
|
|
63
|
-
session: { id: session.id, title: participant_title(session), status:
|
|
66
|
+
viewer: review_viewer(req, id, session: session, state: state, queued: queued, viewer: viewer),
|
|
67
|
+
session: { id: session.id, title: participant_title(session), status: status },
|
|
64
68
|
subjects: review_subjects(session, viewer),
|
|
65
69
|
participants: participant_names(id).map { |entry| entry.slice(:id, :name, :kind) },
|
|
66
70
|
messages: with_reply_html(ParticipantView.messages(shown, viewer, automatic: automatic)),
|
|
67
71
|
current_turn: current,
|
|
68
72
|
waiting_for_owner: waiting,
|
|
69
|
-
queued: review_queue(
|
|
73
|
+
queued: review_queue(queued || [], viewer),
|
|
70
74
|
last_event_id: turn_snapshot && turn_snapshot["event_id"]
|
|
71
75
|
})
|
|
72
76
|
end
|
|
@@ -140,15 +144,71 @@ module Samagotchi
|
|
|
140
144
|
def delegate_child?(session) = !session.parent_id.to_s.empty?
|
|
141
145
|
|
|
142
146
|
# Who is reading: a participant (their id, name, whether they may
|
|
143
|
-
# comment: not once their cap is reached
|
|
144
|
-
#
|
|
145
|
-
|
|
147
|
+
# comment: not once their cap is reached, and their +usage+ as their
|
|
148
|
+
# grant's exposure shows it), or the owner looking through the
|
|
149
|
+
# participant view (no composer, no usage: the owner has /stats).
|
|
150
|
+
# +state+ the live worker's session state (nil without one), +queued+
|
|
151
|
+
# its queue.
|
|
152
|
+
def review_viewer(req, id, session:, state:, queued:, viewer:)
|
|
146
153
|
return { kind: "owner", can_comment: false } if principal(req).owner?
|
|
147
154
|
|
|
148
155
|
grant = principal(req).grant
|
|
149
156
|
capped = !participant_cap_refusal(grant).nil?
|
|
150
157
|
{ kind: "participant", id: grant.id, name: grant.name, participant_kind: grant.kind, can_comment: !capped,
|
|
151
|
-
cap_reached: capped, session_id: id
|
|
158
|
+
cap_reached: capped, session_id: id,
|
|
159
|
+
usage: review_usage(grant, session: session, state: state, queued: queued, viewer: viewer) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# A participant's usage, the fields their grant's exposure level
|
|
163
|
+
# shows (ParticipantExposure), each computed only when shown. Every
|
|
164
|
+
# source reads the grant's own author: never another participant's or
|
|
165
|
+
# the owner's.
|
|
166
|
+
def review_usage(grant, session:, state:, queued:, viewer:)
|
|
167
|
+
usage = ParticipantExposure.usage(grant.exposure, {
|
|
168
|
+
spent: -> { participant_spent(grant, state) },
|
|
169
|
+
cap: -> { grant.spend_cap },
|
|
170
|
+
queued: -> { queued&.count { |entry| entry.is_a?(Hash) && viewer.own?(entry["author"]) } },
|
|
171
|
+
queue_limit: -> { grant.queue_limit },
|
|
172
|
+
expires_at: -> { grant.expires_at },
|
|
173
|
+
model: -> { participant_model(session, state, viewer) }
|
|
174
|
+
})
|
|
175
|
+
usage.delete(:queued) unless queued
|
|
176
|
+
usage
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# What the participant has cost so far, in dollars: the saved
|
|
180
|
+
# analytics' count (#participant_cap_refusal's), or the live worker's
|
|
181
|
+
# when it is more (SessionMetrics#spend: its saved turns, the running
|
|
182
|
+
# turn's finished generations and the side requests; a running turn
|
|
183
|
+
# is saved only at its end). The worker's count already holds the
|
|
184
|
+
# saved one, so the two are never added.
|
|
185
|
+
def participant_spent(grant, state)
|
|
186
|
+
dir = Session.session_dir(grant.session_id, state_dir: default_state_dir)
|
|
187
|
+
saved = SessionMetrics.saved_spend(dir, grant.author)
|
|
188
|
+
live = live_spend(state, grant.author)
|
|
189
|
+
[saved, live].max.round(4)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# +author+'s cost in the live worker's metrics (spend_by_author), 0.0
|
|
193
|
+
# without one.
|
|
194
|
+
def live_spend(state, author)
|
|
195
|
+
cost = state.is_a?(Hash) ? state.dig("metrics", "spend_by_author", author, "cost") : nil
|
|
196
|
+
cost.is_a?(Numeric) && cost.to_f.finite? ? cost.to_f : 0.0
|
|
197
|
+
rescue TypeError
|
|
198
|
+
0.0
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The model the session runs on, as its host is sent it (the alias
|
|
202
|
+
# applied, no host name): the live worker's, else the saved one's;
|
|
203
|
+
# nil when it doesn't resolve.
|
|
204
|
+
def participant_model(session, state, viewer)
|
|
205
|
+
ref = (state.is_a?(Hash) && state["model_name"]) || session.model_name
|
|
206
|
+
return nil if ref.to_s.empty?
|
|
207
|
+
|
|
208
|
+
name = host_registry.bare_name(ref.to_s)
|
|
209
|
+
name.to_s.empty? ? nil : ParticipantView::Redact.text(name.to_s, viewer)
|
|
210
|
+
rescue StandardError
|
|
211
|
+
nil
|
|
152
212
|
end
|
|
153
213
|
|
|
154
214
|
# The queue as a participant sees it: their own queued turns (the
|
|
@@ -185,8 +245,10 @@ module Samagotchi
|
|
|
185
245
|
grant = principal(req).grant
|
|
186
246
|
attrs.merge!("viewer" => "participant", "session" => grant.session_id, "name" => grant.name)
|
|
187
247
|
end
|
|
188
|
-
|
|
189
|
-
|
|
248
|
+
# The owner's participant view has no composer; a participant's
|
|
249
|
+
# selection quotes with the same presets as the owner's page.
|
|
250
|
+
attrs.merge!(annotate_presets_data) unless principal(req).owner?
|
|
251
|
+
html_response(200, File.read(path).sub("<body>", "<body #{body_data(attrs)}>"))
|
|
190
252
|
end
|
|
191
253
|
|
|
192
254
|
# A filtered stream's frame filter (App#stream_filter), for the
|
|
@@ -22,14 +22,17 @@ module Samagotchi
|
|
|
22
22
|
# grants (one session's, or every one), in every state, never a token
|
|
23
23
|
# or digest. One session's also carries +warning+: the share warning
|
|
24
24
|
# for a session that isn't a review session (ReviewStart.share_warning,
|
|
25
|
-
# null for one), which the panel shows before its Invite
|
|
25
|
+
# null for one), which the panel shows before its Invite; and
|
|
26
|
+
# +identity_header+: the header this chi web's auth proxy names the
|
|
27
|
+
# signed-in user in (null without one), for the Invite's email field.
|
|
26
28
|
def handle_participants_list(_req, id = nil)
|
|
27
29
|
now = @grants.now
|
|
28
30
|
grants = @grants.list(session_id: id).map { |grant| grant.public_h(now) }
|
|
29
31
|
return json_response(200, { participants: grants }) unless id
|
|
30
32
|
|
|
31
33
|
warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
|
|
32
|
-
json_response(200, { participants: with_spend(id, grants), warning: warning
|
|
34
|
+
json_response(200, { participants: with_spend(id, grants), warning: warning,
|
|
35
|
+
identity_header: @public_url&.identity_header })
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
# One session's grants with what each spent and how many turns they
|
|
@@ -44,9 +47,11 @@ module Samagotchi
|
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
# POST /api/sessions/:id/participants {name, kind?, ttl? (seconds),
|
|
47
|
-
# cap? (dollars)
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
+
# cap? (dollars), identity? (an email: the grant is bound to it, only
|
|
51
|
+
# behind an auth proxy with an identity header)}: a new grant. The
|
|
52
|
+
# answer carries the token and the link once (chi keeps only its
|
|
53
|
+
# digest), and +warning+ for a session that isn't a review session
|
|
54
|
+
# (ReviewStart.share_warning; null for one).
|
|
50
55
|
def handle_participants_invite(req, id)
|
|
51
56
|
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
52
57
|
ttl = body["ttl"]
|
|
@@ -56,7 +61,8 @@ module Samagotchi
|
|
|
56
61
|
return error_response(400, "invalid_participant", Grants::CAP_RANGE) unless cap.nil? || Grants.cap?(cap)
|
|
57
62
|
|
|
58
63
|
issued = Participants.invite(@grants, session_id: id, name: body["name"], kind: (body["kind"] || "human").to_s,
|
|
59
|
-
ttl: ttl, cap: cap,
|
|
64
|
+
ttl: ttl, cap: cap, identity: body["identity"], public_url: @public_url,
|
|
65
|
+
state_dir: default_state_dir)
|
|
60
66
|
link = participant_link(req, id, issued.token, expires_at: issued.grant.expires_at)
|
|
61
67
|
warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
|
|
62
68
|
json_response(201, { participant: issued.grant.public_h(@grants.now), token: issued.token,
|
|
@@ -11,9 +11,12 @@ module Samagotchi
|
|
|
11
11
|
# chi web behind an auth proxy (web.public_url), mixed into App. With a
|
|
12
12
|
# PublicUrl every request needs a token (this machine's too: a proxy
|
|
13
13
|
# with no trace and a rewritten Host looks local). A request is
|
|
14
|
-
# *proxied* when it
|
|
15
|
-
#
|
|
16
|
-
#
|
|
14
|
+
# *proxied* when it comes from a loopback peer (the proxy runs on this
|
|
15
|
+
# machine: the pod's Caddy/oauth2-proxy, Tailscale Serve) and carries a
|
|
16
|
+
# proxy trace (ProxyTrace: X-Forwarded-*, Forwarded, X-Real-IP, a known
|
|
17
|
+
# identity header…), the configured identity header, or the public Host.
|
|
18
|
+
# A LAN peer's traces are its own words, never the proxy's: it is a LAN
|
|
19
|
+
# request (the LAN token rules; a bound grant refused). A proxied request:
|
|
17
20
|
# - is the owner with the owner token, and, when web.proxy.identity_header
|
|
18
21
|
# is set, only if that header is one clean value equal to
|
|
19
22
|
# web.proxy.owner (D-a: identity AND token);
|
|
@@ -45,6 +48,13 @@ module Samagotchi
|
|
|
45
48
|
# and another port does.
|
|
46
49
|
SECURE_COOKIE = "__Host-chi_token"
|
|
47
50
|
|
|
51
|
+
# 127.0.0.0/8, ::1 and their IPv4-mapped form: the only peers whose
|
|
52
|
+
# proxy traces and identity header are trusted (the proxy is local).
|
|
53
|
+
def self.loopback_peer?(addr)
|
|
54
|
+
addr = addr.to_s
|
|
55
|
+
addr.start_with?("127.", "::ffff:127.") || addr == "::1"
|
|
56
|
+
end
|
|
57
|
+
|
|
48
58
|
private
|
|
49
59
|
|
|
50
60
|
def init_proxy_gate(public_url, token)
|
|
@@ -59,6 +69,8 @@ module Samagotchi
|
|
|
59
69
|
return false unless @public_url
|
|
60
70
|
|
|
61
71
|
env = req.env
|
|
72
|
+
return false unless ProxyGate.loopback_peer?(env["REMOTE_ADDR"])
|
|
73
|
+
|
|
62
74
|
return true if @public_url.host?(env["HTTP_HOST"])
|
|
63
75
|
return true if @public_url.identity_env && env.key?(@public_url.identity_env)
|
|
64
76
|
|
|
@@ -86,6 +98,9 @@ module Samagotchi
|
|
|
86
98
|
value = value.strip
|
|
87
99
|
return [:bad, "identity header empty"] if value.empty?
|
|
88
100
|
return [:bad, "identity header repeated or with a comma"] if value.include?(",")
|
|
101
|
+
# Grants.clean_identity takes ASCII only: a non-ASCII header could
|
|
102
|
+
# never match a grant or the owner, so it is refused as such.
|
|
103
|
+
return [:bad, "identity header not ASCII"] unless value.ascii_only?
|
|
89
104
|
|
|
90
105
|
[:ok, value.downcase]
|
|
91
106
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* The selection bubble (annotate_bar.js): Annotate + the presets (web.annotate_presets), small pills with a gap (they
|
|
2
|
+
wrap on a narrow page). Both pages link it: the owner's (app.js) and the participant page (participant.js). Colours
|
|
3
|
+
are tokens.css's. */
|
|
4
|
+
#annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
|
|
5
|
+
#annotateBar[hidden]{display:none}
|
|
6
|
+
#annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px var(--shadow-strong)}
|
|
7
|
+
/* A bundle's pills (chi.annotation) after a thin separator; beyond 3, "⋯" opens the rest in a list under it. */
|
|
8
|
+
#annotateBar .annotate-actions{display:flex;flex-wrap:wrap;gap:4px;align-items:center;position:relative}
|
|
9
|
+
#annotateBar .annotate-actions[hidden]{display:none}
|
|
10
|
+
#annotateBar .annotate-sep{width:1px;align-self:stretch;margin:3px 2px;background:var(--border-strong)}
|
|
11
|
+
#annotateBar .annotate-action,#annotateBar .annotate-more{background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
12
|
+
#annotateBar .annotate-action:disabled{opacity:.5;cursor:not-allowed}
|
|
13
|
+
#annotateBar .annotate-action[data-sends]::after{content:"↗";margin-left:4px;opacity:.75}
|
|
14
|
+
#annotateBar .annotate-more-list{position:absolute;top:calc(100% + 4px);right:0;display:flex;flex-direction:column;align-items:flex-end;gap:4px}
|
|
15
|
+
#annotateBar.docked{right:8px;justify-content:center}
|
|
16
|
+
@media (pointer:coarse){#annotateBar button{font-size:14px;padding:8px 14px}}
|
|
17
|
+
#annotateBar .annotate-more-list[hidden]{display:none}
|
|
18
|
+
#annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
@@ -39,12 +39,19 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
|
|
|
39
39
|
// @param dock () => the element the bar docks above on a touch screen
|
|
40
40
|
// (the composer's dock); without one, or when it isn't laid out, the
|
|
41
41
|
// bar docks at the bottom of the window
|
|
42
|
+
// @param source (node) => where a selection starting at +node+ came from,
|
|
43
|
+
// {kind, root, …}, or null where the page isn't annotatable
|
|
44
|
+
// (annotations.js annotationSource by default; the participant page
|
|
45
|
+
// passes its own, participant_annotate.js)
|
|
46
|
+
// @param quote (text, source) => the quote block for the selected +text+
|
|
47
|
+
// (annotations.js quoteBlock by default), or null for no text
|
|
42
48
|
// @param doc, win the page's document and window
|
|
43
49
|
// @return {schedule(), hide(), el}; schedule() re-reads the selection on the
|
|
44
50
|
// next frame (call it on scroll)
|
|
45
51
|
export function createAnnotateBar({
|
|
46
52
|
presets, canAnnotate, roots, isLive, onQuote, actions = () => [], running = () => false, onAction = () => {},
|
|
47
|
-
dock = () => null,
|
|
53
|
+
dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock,
|
|
54
|
+
doc = globalThis.document, win = globalThis.window,
|
|
48
55
|
}) {
|
|
49
56
|
const bar = doc.createElement("div");
|
|
50
57
|
bar.id = "annotateBar";
|
|
@@ -131,7 +138,7 @@ export function createAnnotateBar({
|
|
|
131
138
|
if (!canAnnotate() || !sel || sel.isCollapsed || !sel.rangeCount) return hide();
|
|
132
139
|
const range = sel.getRangeAt(0);
|
|
133
140
|
if (!roots().some((root) => root.contains(range.startContainer))) return hide();
|
|
134
|
-
const source =
|
|
141
|
+
const source = sourceOf(range.startContainer);
|
|
135
142
|
// A running turn's narration box shows complete sentences and its thinking
|
|
136
143
|
// is appended by delta; a selection in either is refused while the step is
|
|
137
144
|
// live — the quote lands once it closes (then on the full text).
|
|
@@ -140,7 +147,7 @@ export function createAnnotateBar({
|
|
|
140
147
|
const clipped = range.cloneRange();
|
|
141
148
|
if (!source.root.contains(range.endContainer)) clipped.setEnd(source.root, source.root.childNodes.length);
|
|
142
149
|
const raw = clipped.toString();
|
|
143
|
-
const block =
|
|
150
|
+
const block = quote(raw, source);
|
|
144
151
|
if (!block) return hide();
|
|
145
152
|
pendingQuote = block;
|
|
146
153
|
pendingFields = selectionFields(raw, source);
|