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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -24
- data/docs/cli.md +86 -12
- data/docs/guardrails.md +2 -1
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +4 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +153 -27
- data/docs/plugins.md +7 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/bridge.rb +7 -3
- data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +13 -16
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +16 -6
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +8 -1
- data/lib/samagotchi/launch_mode.rb +4 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/run_options.rb +14 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +8 -4
- data/lib/samagotchi/session_access/contract.rb +42 -4
- data/lib/samagotchi/session_access/dispatch.rb +29 -10
- data/lib/samagotchi/session_access/local.rb +52 -1
- data/lib/samagotchi/session_access/remote.rb +112 -16
- data/lib/samagotchi/session_access/web_bridge_client.rb +56 -1
- data/lib/samagotchi/session_access.rb +85 -3
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +21 -5
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
- data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +83 -20
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
- data/lib/samagotchi/terminal_ui.rb +4 -1
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -74
- data/lib/samagotchi/web/grants.rb +302 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +120 -4
- data/lib/samagotchi/worker.rb +26 -5
- 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
|