samagotchi 0.103.0 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -24
  5. data/docs/cli.md +86 -12
  6. data/docs/guardrails.md +2 -1
  7. data/docs/internals/context-telemetry.md +6 -2
  8. data/docs/internals/engine.md +4 -1
  9. data/docs/internals/participants.md +137 -0
  10. data/docs/internals/remote-sessions.md +153 -27
  11. data/docs/plugins.md +7 -1
  12. data/docs/sessions.md +4 -2
  13. data/docs/sub-agent.md +8 -0
  14. data/docs/testing.md +68 -4
  15. data/lib/samagotchi/bridge.rb +7 -3
  16. data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
  17. data/lib/samagotchi/bridge_client.rb +4 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_invocation.rb +13 -16
  20. data/lib/samagotchi/context_status.rb +15 -2
  21. data/lib/samagotchi/engine.rb +16 -6
  22. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_scheduler.rb +34 -9
  25. data/lib/samagotchi/kernel_loop.rb +8 -1
  26. data/lib/samagotchi/launch_mode.rb +4 -0
  27. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  28. data/lib/samagotchi/owner_lock.rb +65 -5
  29. data/lib/samagotchi/participants.rb +122 -0
  30. data/lib/samagotchi/remote_attach_command.rb +119 -0
  31. data/lib/samagotchi/run_options.rb +14 -2
  32. data/lib/samagotchi/send_command.rb +1 -1
  33. data/lib/samagotchi/session.rb +8 -4
  34. data/lib/samagotchi/session_access/contract.rb +42 -4
  35. data/lib/samagotchi/session_access/dispatch.rb +29 -10
  36. data/lib/samagotchi/session_access/local.rb +52 -1
  37. data/lib/samagotchi/session_access/remote.rb +112 -16
  38. data/lib/samagotchi/session_access/web_bridge_client.rb +56 -1
  39. data/lib/samagotchi/session_access.rb +85 -3
  40. data/lib/samagotchi/session_continue.rb +1 -1
  41. data/lib/samagotchi/session_inbox.rb +18 -9
  42. data/lib/samagotchi/session_manager.rb +21 -5
  43. data/lib/samagotchi/sessions_command.rb +4 -4
  44. data/lib/samagotchi/share_command.rb +174 -0
  45. data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
  46. data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +83 -20
  48. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  49. data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
  50. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  51. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  52. data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
  53. data/lib/samagotchi/terminal_ui.rb +4 -1
  54. data/lib/samagotchi/testing/waiting.rb +92 -9
  55. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +187 -74
  58. data/lib/samagotchi/web/grants.rb +302 -0
  59. data/lib/samagotchi/web/participant_access.rb +286 -0
  60. data/lib/samagotchi/web/principal.rb +29 -0
  61. data/lib/samagotchi/web/server.rb +3 -1
  62. data/lib/samagotchi/web/session_access_routes.rb +120 -4
  63. data/lib/samagotchi/worker.rb +26 -5
  64. metadata +10 -1
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "securerandom"
7
+ require "time"
8
+ require "rack/utils"
9
+ require_relative "../atomic_file"
10
+ require_relative "../session"
11
+ require_relative "../redacted_inspect"
12
+
13
+ module Samagotchi
14
+ module Web
15
+ # Participants' access to one session each (the shared-sessions plan,
16
+ # security model §1): a grant binds one random token to one session, one
17
+ # participant and one role, until it expires or is revoked. Only the
18
+ # token's SHA-256 digest is kept, in
19
+ # $XDG_STATE_HOME/samagotchi/web-grants/grants.json (0600, in its own 0700
20
+ # folder): the token is shown once, when it is issued, and a leaked file
21
+ # grants nothing. The owner's Web::Token is a different credential and
22
+ # never touches this file. See docs/internals/participants.md.
23
+ #
24
+ # One object per process (chi web, a chi share run): writes take a lock
25
+ # file next to the store, so the two never lose each other's grants; the
26
+ # reads follow the file's mtime, as Token::Source does.
27
+ class Grants
28
+ include RedactedInspect
29
+
30
+ DIR = "web-grants"
31
+ FILE = "grants.json"
32
+ LOCK = "grants.lock"
33
+ # What a participant token starts with: the gate looks up only these,
34
+ # and a leaked one is recognisable (an owner token never has it).
35
+ PREFIX = "chp_"
36
+ # 32 random bytes, as Web::Token's.
37
+ BYTES = 32
38
+ DAY = 86_400
39
+ # D8: a grant lasts a week unless chi share --expires says otherwise,
40
+ # and never more than MAX_TTL.
41
+ DEFAULT_TTL = 7 * DAY
42
+ MAX_TTL = 30 * DAY
43
+ # Ended grants (revoked, expired) are kept this long, so their token
44
+ # answers token_revoked / token_expired rather than a bare 401.
45
+ KEEP_ENDED = 30 * DAY
46
+ # last_used_at is written at most this often per grant.
47
+ TOUCH_INTERVAL = 60
48
+ KINDS = %w[human agent].freeze
49
+ # The only role in v1: a reviewer reads and (P3) comments.
50
+ ROLES = %w[reviewer].freeze
51
+ NAME_MAX = 64
52
+ ID_RE = /\Ap_[0-9a-f]{8}\z/
53
+
54
+ # The store's folder or file can be read by others (chi web refuses to
55
+ # start), or a grant can't be made from what was given.
56
+ class Insecure < StandardError; end
57
+ # Not ArgumentError: the session commands read that as "no such
58
+ # session".
59
+ class Invalid < StandardError; end
60
+
61
+ # One participant's access to one session. +id+ (p_<8 hex>) names the
62
+ # participant too: a turn they send is authored "p:<id>" (P3).
63
+ # +revoked_by+ why it was revoked: "owner", "archive", "delete",
64
+ # "gone" (the session was pruned).
65
+ Grant = Data.define(:id, :session_id, :digest, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
66
+ :revoked_by, :last_used_at) do
67
+ def self.from_h(hash)
68
+ new(**members.to_h { |key| [key, hash[key.to_s]] })
69
+ end
70
+
71
+ # The author a participant's turn carries (Principal#author).
72
+ def author = "p:#{id}"
73
+
74
+ # :active, :revoked or :expired at +now+ (a Time).
75
+ def state(now)
76
+ return :revoked if revoked_at
77
+ return :expired if Time.iso8601(expires_at) <= now
78
+
79
+ :active
80
+ end
81
+
82
+ def active?(now) = state(now) == :active
83
+
84
+ # When it stopped working (revoked or expired), nil while active.
85
+ def ended_at(now)
86
+ return Time.iso8601(revoked_at) if revoked_at
87
+
88
+ expiry = Time.iso8601(expires_at)
89
+ expiry <= now ? expiry : nil
90
+ end
91
+
92
+ # Never the digest: what chi share ls and the owner's routes show.
93
+ def public_h(now)
94
+ { id: id, session_id: session_id, kind: kind, name: name, role: role, created_at: created_at,
95
+ expires_at: expires_at, revoked_at: revoked_at, revoked_by: revoked_by, last_used_at: last_used_at,
96
+ state: state(now).to_s }
97
+ end
98
+
99
+ def to_store = to_h.transform_keys(&:to_s)
100
+
101
+ def inspect = "#<Grant #{id} #{session_id[0, 8]} #{name.inspect}>"
102
+ alias_method :to_s, :inspect
103
+ end
104
+
105
+ # A new grant and its token: the only time the token exists outside
106
+ # the participant's hands.
107
+ Issued = Data.define(:grant, :token) do
108
+ def inspect = "#<Issued #{grant.id}>"
109
+ alias_method :to_s, :inspect
110
+ end
111
+
112
+ # $XDG_STATE_HOME/samagotchi/web-grants/grants.json, next to web-token;
113
+ # +state_dir+ is the sessions dir (Session.default_state_dir).
114
+ def self.path(state_dir: nil, env: ENV)
115
+ File.join(File.dirname(state_dir || Session.default_state_dir(env: env)), DIR, FILE)
116
+ end
117
+
118
+ def self.digest(token) = Digest::SHA256.hexdigest(token.to_s)
119
+
120
+ def self.token?(value) = value.is_a?(String) && value.start_with?(PREFIX)
121
+
122
+ # One line, at most NAME_MAX characters, no brackets (a name can't
123
+ # forge P3's "[Review contribution from …]" header).
124
+ # @raise [Invalid] nothing left
125
+ def self.clean_name(name)
126
+ clean = name.to_s.gsub(/[[:cntrl:]\[\]]/, " ").squeeze(" ").strip[0, NAME_MAX].to_s.strip
127
+ raise Invalid, "a participant needs a name" if clean.empty?
128
+
129
+ clean
130
+ end
131
+
132
+ # Refuses a store others could read: the grants folder must be 0700
133
+ # and the file 0600, both this user's. Only these two: the state dir
134
+ # above them is often 0755, and that is fine. A folder not made yet
135
+ # passes (it is made 0700).
136
+ # @raise [Insecure]
137
+ def self.check_permissions!(path)
138
+ [File.dirname(path), path].each do |target|
139
+ stat = File.stat(target)
140
+ next if stat.mode.nobits?(0o077) && stat.uid == Process.uid
141
+
142
+ raise Insecure, "#{target} can be read by other users: chmod 700 #{File.dirname(path)} and 600 #{path} " \
143
+ "(it holds the participants' grants)"
144
+ rescue Errno::ENOENT
145
+ next
146
+ end
147
+ end
148
+
149
+ attr_reader :path
150
+
151
+ # @param clock [#call] the Time now (specs inject one)
152
+ def initialize(path = Grants.path, clock: -> { Time.now })
153
+ @path = path
154
+ @clock = clock
155
+ @mutex = Mutex.new
156
+ @stamp = nil
157
+ @grants = []
158
+ @touched = {}
159
+ end
160
+
161
+ def now = @clock.call
162
+
163
+ # A new grant for +session_id+, written before the token is handed out.
164
+ # @param ttl [Integer] seconds (DEFAULT_TTL; at most MAX_TTL)
165
+ # @return [Issued]
166
+ # @raise [Invalid]
167
+ def issue(session_id:, name:, kind: "human", role: "reviewer", ttl: DEFAULT_TTL)
168
+ Session.check_id!(session_id)
169
+ raise Invalid, "kind is human or agent, not #{kind}" unless KINDS.include?(kind)
170
+ raise Invalid, "role is reviewer, not #{role}" unless ROLES.include?(role)
171
+ raise Invalid, "a grant lasts 1 hour to 30 days" unless ttl.is_a?(Integer) && ttl.between?(3600, MAX_TTL)
172
+
173
+ clean = Grants.clean_name(name)
174
+ token = "#{PREFIX}#{SecureRandom.urlsafe_base64(BYTES)}"
175
+ issued = now
176
+ update do |grants|
177
+ id = fresh_id(grants)
178
+ grant = Grant.new(id: id, session_id: session_id, digest: Grants.digest(token), kind: kind, name: clean,
179
+ role: role, created_at: stamp(issued), expires_at: stamp(issued + ttl), revoked_at: nil,
180
+ revoked_by: nil, last_used_at: nil)
181
+ [grants + [grant], Issued.new(grant: grant, token: token)]
182
+ end
183
+ end
184
+
185
+ # The grant +token+ was issued for, in any state (an ended one too, so
186
+ # the gate can say why it's refused); nil for anything else.
187
+ # @return [Grant, nil]
188
+ def lookup(token)
189
+ return nil unless Grants.token?(token)
190
+
191
+ digest = Grants.digest(token)
192
+ # Every grant is compared, whatever matches first.
193
+ current.select { |grant| Rack::Utils.secure_compare(grant.digest, digest) }.first
194
+ end
195
+
196
+ # @return [Grant, nil] the grant with this id
197
+ def find(id) = current.find { |grant| grant.id == id }
198
+
199
+ # Whether grant +id+ still works (a participant stream's running check).
200
+ def active?(id) = !!find(id)&.active?(now)
201
+
202
+ # @param session_id [String, nil] one session's, or every grant
203
+ # @return [Array<Grant>]
204
+ def list(session_id: nil)
205
+ current.select { |grant| session_id.nil? || grant.session_id == session_id }
206
+ end
207
+
208
+ # Revokes the active grants of +session_id+ (just +id+ when given), or
209
+ # every session's when +session_id+ is nil.
210
+ # @param by [String] why: "owner", "archive", "delete", "gone"
211
+ # @return [Array<Grant>] the grants it revoked (already ended ones are
212
+ # left as they were)
213
+ def revoke(session_id: nil, id: nil, by: "owner")
214
+ at = now
215
+ matches = lambda do |grant|
216
+ grant.active?(at) && (session_id.nil? || grant.session_id == session_id) && (id.nil? || grant.id == id)
217
+ end
218
+ # Nothing to revoke (an archive of a session nobody was invited to):
219
+ # no write, no store made.
220
+ return [] unless current.any?(&matches)
221
+
222
+ update do |grants|
223
+ revoked = []
224
+ grants = grants.map do |grant|
225
+ next grant unless matches.call(grant)
226
+
227
+ grant.with(revoked_at: stamp(at), revoked_by: by).tap { |ended| revoked << ended }
228
+ end
229
+ [grants, revoked]
230
+ end
231
+ end
232
+
233
+ # Records a use of grant +id+, at most once per TOUCH_INTERVAL.
234
+ def touch(id)
235
+ at = now
236
+ @mutex.synchronize do
237
+ last = @touched[id]
238
+ return if last && at - last < TOUCH_INTERVAL
239
+
240
+ @touched[id] = at
241
+ end
242
+ update do |grants|
243
+ [grants.map { |grant| grant.id == id ? grant.with(last_used_at: stamp(at)) : grant }, nil]
244
+ end
245
+ end
246
+
247
+ private
248
+
249
+ # The grants on disk, re-read when the file changed (one stat a call).
250
+ def current
251
+ stamp = begin
252
+ stat = File.stat(@path)
253
+ [stat.mtime, stat.size, stat.ino]
254
+ rescue Errno::ENOENT
255
+ nil
256
+ end
257
+ @mutex.synchronize do
258
+ unless stamp == @stamp
259
+ @grants = stamp ? read : []
260
+ @stamp = stamp
261
+ end
262
+ @grants
263
+ end
264
+ end
265
+
266
+ def read
267
+ data = JSON.parse(File.read(@path))
268
+ Array(data.is_a?(Hash) ? data["grants"] : nil).filter_map { |hash| Grant.from_h(hash) if hash.is_a?(Hash) }
269
+ rescue Errno::ENOENT, JSON::ParserError, ArgumentError
270
+ []
271
+ end
272
+
273
+ # Read, change and write the store under its lock (another process
274
+ # may write it too). The block takes the grants and returns
275
+ # [new grants, result]. Ended grants older than KEEP_ENDED go.
276
+ def update
277
+ dir = File.dirname(@path)
278
+ FileUtils.mkdir_p(dir, mode: 0o700)
279
+ File.chmod(0o700, dir)
280
+ File.open(File.join(dir, LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
281
+ lock.flock(File::LOCK_EX)
282
+ grants, result = yield(read)
283
+ at = now
284
+ kept = grants.reject { |grant| (ended = grant.ended_at(at)) && at - ended > KEEP_ENDED }
285
+ body = JSON.pretty_generate({ "version" => 1, "grants" => kept.map(&:to_store) })
286
+ AtomicFile.write(@path, "#{body}\n", perm: 0o600)
287
+ @mutex.synchronize { @stamp = nil }
288
+ result
289
+ end
290
+ end
291
+
292
+ def fresh_id(grants)
293
+ loop do
294
+ id = "p_#{SecureRandom.hex(4)}"
295
+ return id unless grants.any? { |grant| grant.id == id }
296
+ end
297
+ end
298
+
299
+ def stamp(time) = time.utc.iso8601
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require_relative "grants"
5
+ require_relative "principal"
6
+ require_relative "../archive_store"
7
+ require_relative "../participants"
8
+ require_relative "../session"
9
+ require_relative "../log"
10
+
11
+ module Samagotchi
12
+ module Web
13
+ # What a participant (a Web::Grants token) may do in chi web, mixed into
14
+ # App: the gate's lookup of their grant, the refusals that are theirs
15
+ # (token_revoked, token_expired, another session's 404, not_permitted
16
+ # by role), the trimmed and filtered answers of the :participant routes,
17
+ # the minimal participant page, and the running check that ends their
18
+ # streams when the grant does. See docs/internals/participants.md.
19
+ module ParticipantAccess
20
+ # A participant's refused routes are audited at most this often per
21
+ # route (a page retrying would fill the log).
22
+ REFUSED_AUDIT_INTERVAL = 60.0
23
+
24
+ ENDED = {
25
+ revoked: ["token_revoked", "this review link was revoked by the session's owner"],
26
+ expired: ["token_expired", "this review link expired: ask the session's owner for a new one"]
27
+ }.freeze
28
+ NOT_PERMITTED = "a participant can't do that: only the session's owner can"
29
+
30
+ private
31
+
32
+ def init_participant_access(grants, state_dir)
33
+ @grants = grants || Grants.new(Grants.path(state_dir: state_dir))
34
+ @participant_mutex = Mutex.new
35
+ @participant_peers = Hash.new { |hash, key| hash[key] = Set.new }
36
+ @participant_refused = {}
37
+ @participant_expired = Set.new
38
+ end
39
+
40
+ # The participant principal for the first of +tokens+ that is a live
41
+ # grant's, or nil. An ended grant met on the way is kept in the
42
+ # request's env for #token_gate's answer (#grant_ended_response). A
43
+ # grant whose session was archived or is gone is revoked here (archive
44
+ # and delete revoke too; this catches retention's prune and a delete
45
+ # from another process).
46
+ # @return [Principal, nil]
47
+ def participant_principal(req, tokens)
48
+ tokens.each do |token|
49
+ grant = @grants.lookup(token) or next
50
+ grant = settle_grant(grant)
51
+ state = grant.state(@grants.now)
52
+ if state == :active
53
+ req.env[GRANT_TOKEN_ENV] = token
54
+ return Principal.new(kind: :participant, grant: grant)
55
+ end
56
+ req.env[ENDED_GRANT_ENV] ||= [grant, state]
57
+ end
58
+ nil
59
+ end
60
+
61
+ GRANT_TOKEN_ENV = "samagotchi.grant_token"
62
+ ENDED_GRANT_ENV = "samagotchi.ended_grant"
63
+
64
+ def settle_grant(grant)
65
+ return grant unless grant.active?(@grants.now)
66
+
67
+ by = if !File.exist?(Session.session_file(grant.session_id, state_dir: default_state_dir)) then "gone"
68
+ elsif ArchiveStore.archived?(Session.session_dir(grant.session_id, state_dir: default_state_dir)) then "archive"
69
+ end
70
+ return grant unless by
71
+
72
+ Participants.revoke(@grants, session_id: grant.session_id, id: grant.id, by: by, state_dir: default_state_dir)
73
+ @grants.find(grant.id) || grant
74
+ end
75
+
76
+ # 401 token_revoked / token_expired for an ended grant's token (the
77
+ # page's HTML says the same), nil when none was presented.
78
+ def grant_ended_response(req)
79
+ grant, state = req.env[ENDED_GRANT_ENV]
80
+ return nil unless grant
81
+
82
+ audit_expired(grant) if state == :expired
83
+ code, detail = ENDED.fetch(state)
84
+ return error_response(401, code, detail) if req.path_info.start_with?("/api/")
85
+
86
+ html_response(401, ENDED_PAGE)
87
+ end
88
+
89
+ def audit_expired(grant)
90
+ fresh = @participant_mutex.synchronize { @participant_expired.add?(grant.id) }
91
+ Participants.log(grant.session_id, :expired, state_dir: default_state_dir, participant: grant.id) if fresh
92
+ end
93
+
94
+ # A participant's request, past the gate: its use is recorded (the
95
+ # grant's last_used_at, at most once a minute) and a peer not seen
96
+ # since chi web started goes into the audit trail.
97
+ def participant_seen(req, principal)
98
+ grant = principal.grant
99
+ @grants.touch(grant.id)
100
+ peer = req.get_header("REMOTE_ADDR").to_s
101
+ fresh = @participant_mutex.synchronize { @participant_peers[grant.id].add?(peer) }
102
+ return unless fresh
103
+
104
+ Participants.log(grant.session_id, :peer, state_dir: default_state_dir, participant: grant.id, ip: peer,
105
+ user_agent: req.get_header("HTTP_USER_AGENT").to_s[0, 200])
106
+ end
107
+
108
+ # A participant asking for a session that isn't their grant's: 404 as
109
+ # for one that doesn't exist, so they can't learn which do.
110
+ def other_session?(principal, path, captures)
111
+ return false if principal.owner? || !path.start_with?("/api/sessions/") || captures.empty?
112
+
113
+ captures.first != principal.grant.session_id
114
+ end
115
+
116
+ def unknown_session(id)
117
+ error_response(404, "not_found", "Session not found: #{id.to_s[0, 80]}")
118
+ end
119
+
120
+ # 403 not_permitted, worded for the principal's role; a participant's
121
+ # refusal is audited.
122
+ def not_permitted_for(req, principal)
123
+ return not_permitted if principal.owner?
124
+
125
+ audit_refused(req, principal.grant)
126
+ error_response(403, "not_permitted", NOT_PERMITTED)
127
+ end
128
+
129
+ def audit_refused(req, grant)
130
+ key = [grant.id, req.request_method, req.path_info]
131
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
132
+ due = @participant_mutex.synchronize do
133
+ @participant_refused.delete_if { |_, at| now - at >= REFUSED_AUDIT_INTERVAL } if @participant_refused.size > 256
134
+ last = @participant_refused[key]
135
+ (last.nil? || now - last >= REFUSED_AUDIT_INTERVAL).tap { |fresh| @participant_refused[key] = now if fresh }
136
+ end
137
+ return unless due
138
+
139
+ Participants.log(grant.session_id, :refused, state_dir: default_state_dir, participant: grant.id,
140
+ method: req.request_method, path: req.path_info[0, 200],
141
+ reason: "not_permitted")
142
+ end
143
+
144
+ # GET /api/info for a participant: what SessionAccess::Remote's
145
+ # check needs (app, version, features), not this machine's pid, cwd
146
+ # or LAN address.
147
+ def participant_info
148
+ json_response(200, { app: "chi-web", version: Samagotchi::VERSION, features: App::INFO_FEATURES })
149
+ end
150
+
151
+ # GET /api/sessions/:id for a participant (their grant's session):
152
+ # its title, status and participants, nothing of the conversation.
153
+ def participant_show(id)
154
+ session = @session_class.load(id, state_dir: default_state_dir)
155
+ json_response(200, { session: { id: session.id, title: participant_title(session), status: session.status,
156
+ participants: participant_names(id) } })
157
+ end
158
+
159
+ # The session's own title, never Session#display_title's fallback (the
160
+ # first prompt's opening words: the conversation).
161
+ def participant_title(session)
162
+ title = session.title.to_s.strip
163
+ title.empty? ? UNTITLED : title
164
+ end
165
+
166
+ UNTITLED = "an untitled session"
167
+
168
+ def participant_names(id)
169
+ now = @grants.now
170
+ Participants.read(id, state_dir: default_state_dir).select { |entry| entry.active?(now) }
171
+ .map { |entry| { id: entry.id, name: entry.name, kind: entry.kind, role: entry.role } }
172
+ end
173
+
174
+ # The page a participant's link opens (P2: before comments exist).
175
+ # Never the owner's page, whose every route would refuse them.
176
+ def participant_page(principal)
177
+ grant = principal.grant
178
+ session = @session_class.load(grant.session_id, state_dir: default_state_dir)
179
+ name = CGI.escapeHTML(grant.name)
180
+ others = participant_names(grant.session_id).map { |entry| CGI.escapeHTML(entry[:name]) }
181
+ html_response(200, format(PARTICIPANT_PAGE, name: name, title: CGI.escapeHTML(participant_title(session)),
182
+ status: CGI.escapeHTML(session.status.to_s),
183
+ participants: others.join(", ")))
184
+ end
185
+
186
+ # Whether a principal's stream may go on (the proxy's running check,
187
+ # asked every BridgeClient::STREAM_POLL): while the server runs, and
188
+ # for a participant while their grant works, so a revoke or the expiry
189
+ # ends it within a poll (a chi share revoke in another process too:
190
+ # the grants file is re-read when it changes).
191
+ def stream_running(principal)
192
+ return @server_running if principal.owner?
193
+
194
+ id = principal.grant.id
195
+ -> { @server_running.call && @grants.active?(id) }
196
+ end
197
+
198
+ # ── The owner's routes ─────────────────────────────────────────────
199
+
200
+ # GET /api/sessions/:id/participants, GET /api/participants: the
201
+ # grants (one session's, or every one), in every state, never a token
202
+ # or digest.
203
+ def handle_participants_list(_req, id = nil)
204
+ now = @grants.now
205
+ json_response(200, { participants: @grants.list(session_id: id).map { |grant| grant.public_h(now) } })
206
+ end
207
+
208
+ # POST /api/sessions/:id/participants {name, kind?, ttl? (seconds)}:
209
+ # a new grant. The answer carries the token and the link once; chi
210
+ # keeps only its digest.
211
+ def handle_participants_invite(req, id)
212
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
213
+ ttl = body["ttl"]
214
+ return error_response(400, "invalid_participant", "ttl is a number of seconds") unless ttl.nil? || ttl.is_a?(Integer)
215
+
216
+ issued = Participants.invite(@grants, session_id: id, name: body["name"], kind: (body["kind"] || "human").to_s,
217
+ ttl: ttl, state_dir: default_state_dir)
218
+ json_response(201, { participant: issued.grant.public_h(@grants.now), token: issued.token,
219
+ link: participant_link(req, id, issued.token), lan: !@lan.nil? })
220
+ rescue Grants::Invalid => e
221
+ error_response(400, "invalid_participant", e.message)
222
+ end
223
+
224
+ # DELETE /api/sessions/:id/participants[?id=p_…], DELETE
225
+ # /api/participants: revokes one participant, a session's or every
226
+ # one's; their open streams end within a poll.
227
+ def handle_participants_revoke(req, id = nil)
228
+ grant_id = req.params["id"]
229
+ if grant_id && !Grants::ID_RE.match?(grant_id.to_s)
230
+ return error_response(400, "invalid_participant", "not a participant id: #{grant_id.to_s[0, 40]}")
231
+ end
232
+
233
+ revoked = Participants.revoke(@grants, session_id: id, id: grant_id, by: "owner", state_dir: default_state_dir)
234
+ now = @grants.now
235
+ json_response(200, { revoked: revoked.map { |grant| grant.public_h(now) } })
236
+ end
237
+
238
+ # The link a participant opens: this chi web's LAN address when it has
239
+ # one (the owner may be asking on loopback), else the Host asked; the
240
+ # token traded for the cookie, the session in the fragment.
241
+ def participant_link(req, id, token)
242
+ host = req.get_header("HTTP_HOST").to_s
243
+ port = host[/:(\d+)\z/, 1] || req.port.to_s
244
+ name = @lan ? @lan[:ip] : host.sub(/:\d*\z/, "")
245
+ "http://#{name}:#{port}/?token=#{token}#s=#{id}"
246
+ end
247
+
248
+ def html_response(status, body)
249
+ [status, { "Content-Type" => "text/html; charset=utf-8", "Content-Length" => body.bytesize.to_s,
250
+ "Cache-Control" => "no-store" }, [body]]
251
+ end
252
+
253
+ PAGE_STYLE = <<~CSS
254
+ :root { color-scheme: light dark; }
255
+ body { font: 16px/1.5 -apple-system, system-ui, sans-serif; max-width: 30rem; margin: 3rem auto; padding: 0 1rem; }
256
+ .meta { opacity: .75; }
257
+ CSS
258
+
259
+ PARTICIPANT_PAGE = <<~HTML
260
+ <!doctype html>
261
+ <html lang="en"><head><meta charset="utf-8">
262
+ <meta name="viewport" content="width=device-width, initial-scale=1">
263
+ <title>chi · %<name>s's review</title>
264
+ <style>#{PAGE_STYLE}</style></head>
265
+ <body>
266
+ <h1>%<name>s's review</h1>
267
+ <p class="meta">%<title>s · %<status>s</p>
268
+ <p>Comments open soon: the owner will see what you write here.</p>
269
+ <p class="meta">Participants: %<participants>s</p>
270
+ </body></html>
271
+ HTML
272
+
273
+ ENDED_PAGE = <<~HTML
274
+ <!doctype html>
275
+ <html lang="en"><head><meta charset="utf-8">
276
+ <meta name="viewport" content="width=device-width, initial-scale=1">
277
+ <title>chi: review link ended</title>
278
+ <style>#{PAGE_STYLE}</style></head>
279
+ <body>
280
+ <h1>chi</h1>
281
+ <p>This review link has expired or was revoked. Ask the session's owner for a new one.</p>
282
+ </body></html>
283
+ HTML
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Web
5
+ # Who a chi web request is (App#principal_for). Today there is one
6
+ # kind: the owner, a peer on this machine or a request with the
7
+ # Web::Token access token. The routes' permission column
8
+ # (App::ROUTES, :owner or :participant) is checked against it, and a
9
+ # turn's author comes from it, never from the request body. See
10
+ # docs/internals/remote-sessions.md (Trust model).
11
+ #
12
+ # +kind+ :owner; +grant+ what a non-owner principal was admitted by
13
+ # (nil for the owner).
14
+ Principal = Data.define(:kind, :grant) do
15
+ def owner? = kind == :owner
16
+
17
+ # Whether this principal may use a route with +permission+ (a ROUTES
18
+ # row's): the owner every route, anyone else only a :participant one.
19
+ def permits?(permission) = owner? || permission == :participant
20
+
21
+ # The author a turn this principal sends carries: nil for the owner,
22
+ # so an owner's turn, its input file and its session stay as they
23
+ # were; any other principal's names it through its grant.
24
+ def author = owner? ? nil : grant.author
25
+ end
26
+
27
+ Principal::OWNER = Principal.new(kind: :owner, grant: nil)
28
+ end
29
+ end
@@ -95,6 +95,8 @@ module Samagotchi
95
95
  puts lan_off_line(setting)
96
96
  return 0
97
97
  end
98
+ # The participants' grants: never served from a store others can read.
99
+ Grants.check_permissions!(Grants.path)
98
100
  if start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
99
101
  annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set)
100
102
  0
@@ -102,7 +104,7 @@ module Samagotchi
102
104
  1
103
105
  end
104
106
  end
105
- rescue Lan::Error => e
107
+ rescue Lan::Error, Grants::Insecure => e
106
108
  warn "Error: #{e.message}"
107
109
  1
108
110
  rescue Interrupt