samagotchi 0.119.0 → 0.121.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -1
- data/bin/chi +6 -1
- data/docs/architecture.md +35 -5
- data/docs/cli.md +33 -8
- data/docs/configuration.md +74 -20
- data/docs/internals/participants.md +111 -12
- data/docs/internals/prompt-caching.md +7 -6
- data/docs/internals/remote-sessions.md +6 -1
- data/docs/internals/web-push.md +4 -2
- data/docs/plugins.md +24 -8
- data/docs/sessions.md +4 -3
- data/docs/testing.md +13 -2
- data/lib/samagotchi/answer_command.rb +2 -0
- data/lib/samagotchi/bridge.rb +56 -13
- data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
- data/lib/samagotchi/bridge_client.rb +58 -19
- data/lib/samagotchi/bridge_secret.rb +35 -0
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +5 -0
- data/lib/samagotchi/participant_messages.rb +5 -0
- data/lib/samagotchi/participants.rb +16 -6
- data/lib/samagotchi/plugin/api.rb +6 -3
- data/lib/samagotchi/review_command.rb +3 -1
- data/lib/samagotchi/run_options.rb +3 -1
- data/lib/samagotchi/session_access/contract.rb +4 -1
- data/lib/samagotchi/session_access/local.rb +15 -4
- data/lib/samagotchi/session_access/remote.rb +43 -7
- data/lib/samagotchi/session_access.rb +11 -1
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/sessions_command.rb +1 -2
- data/lib/samagotchi/share_command.rb +29 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
- data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
- data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/toolkit.rb +50 -26
- data/lib/samagotchi/toolkit_binding.rb +32 -0
- data/lib/samagotchi/toolkit_command.rb +23 -0
- data/lib/samagotchi/tools/deferred_tools.rb +10 -2
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/registry.rb +20 -6
- data/lib/samagotchi/tools/tool_index.rb +103 -20
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -43
- data/lib/samagotchi/web/grants.rb +127 -16
- data/lib/samagotchi/web/link_codes.rb +24 -15
- data/lib/samagotchi/web/participant_access.rb +69 -13
- data/lib/samagotchi/web/participant_exposure.rb +44 -0
- data/lib/samagotchi/web/participant_review.rb +71 -9
- data/lib/samagotchi/web/participants_admin.rb +12 -6
- data/lib/samagotchi/web/proxy_gate.rb +18 -3
- data/lib/samagotchi/web/public/annotate_bar.css +18 -0
- data/lib/samagotchi/web/public/annotate_bar.js +10 -3
- data/lib/samagotchi/web/public/app.js +66 -155
- data/lib/samagotchi/web/public/composer.css +73 -0
- data/lib/samagotchi/web/public/composer_grip.js +21 -15
- data/lib/samagotchi/web/public/composer_mount.js +196 -0
- data/lib/samagotchi/web/public/forms.css +12 -0
- data/lib/samagotchi/web/public/index.html +14 -88
- data/lib/samagotchi/web/public/level_chip.js +2 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
- data/lib/samagotchi/web/public/notifications.js +58 -8
- data/lib/samagotchi/web/public/participant.html +46 -10
- data/lib/samagotchi/web/public/participant.js +101 -18
- data/lib/samagotchi/web/public/participant_annotate.js +75 -0
- data/lib/samagotchi/web/public/participant_model.js +53 -1
- data/lib/samagotchi/web/public/participants_panel.js +37 -13
- data/lib/samagotchi/web/public/question_card.js +15 -1
- data/lib/samagotchi/web/public/question_cards.js +11 -2
- data/lib/samagotchi/web/public/review_history.js +60 -0
- data/lib/samagotchi/web/public/scope.js +12 -8
- data/lib/samagotchi/web/public/stage_frame.js +9 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -4
- data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
- data/lib/samagotchi/web/push/notifier.rb +17 -3
- data/lib/samagotchi/web/running.rb +7 -4
- data/lib/samagotchi/web/server.rb +34 -11
- data/lib/samagotchi/web/session_create.rb +7 -7
- data/lib/samagotchi/web/start_dir.rb +26 -0
- data/lib/samagotchi/web/tunnel.rb +10 -1
- data/lib/samagotchi/worker_sidecar.rb +22 -11
- metadata +10 -1
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -5,8 +5,10 @@ require "json"
|
|
|
5
5
|
require "uri"
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "securerandom"
|
|
8
|
+
require "openssl"
|
|
8
9
|
require "time"
|
|
9
10
|
require_relative "atomic_file"
|
|
11
|
+
require_relative "bridge_secret"
|
|
10
12
|
require_relative "delivery"
|
|
11
13
|
require_relative "session_inbox"
|
|
12
14
|
|
|
@@ -47,8 +49,10 @@ module Samagotchi
|
|
|
47
49
|
# `SessionInbox` file-IPC input path.
|
|
48
50
|
# * One capture observer fills a shared ring buffer; each connection gets a
|
|
49
51
|
# per-connection live queue (see SSEWriter).
|
|
50
|
-
# * Binds 127.0.0.1 only
|
|
51
|
-
#
|
|
52
|
+
# * Binds 127.0.0.1 only, and answers only a request that names its secret
|
|
53
|
+
# (#secret_ok?): a random one per Bridge, in the worker's 0600 bridge.json,
|
|
54
|
+
# which only this machine's user can read. Binding 0.0.0.0 would still put
|
|
55
|
+
# remote turn-execution (RCE) one leaked secret away: don't.
|
|
52
56
|
# * Optional at runtime: this module is never loaded unless explicitly
|
|
53
57
|
# engaged as a transport.
|
|
54
58
|
class Bridge
|
|
@@ -67,6 +71,12 @@ module Samagotchi
|
|
|
67
71
|
MAX_BODY_BYTES = 1_000_000
|
|
68
72
|
# A request's deadline (see #handle_post_turn) that isn't epoch seconds.
|
|
69
73
|
BAD_DEADLINE = [{ "Allow" => "POST" }, 400, { error: "bad_deadline", detail: "deadline must be epoch seconds" }].freeze
|
|
74
|
+
# The request header that names the Bridge's secret, lowercased as #read_request keeps it.
|
|
75
|
+
SECRET_HEADER = BridgeSecret::HEADER.downcase
|
|
76
|
+
# Bytes of randomness in a Bridge's secret (hex in bridge.json: twice as many characters).
|
|
77
|
+
SECRET_BYTES = 32
|
|
78
|
+
# What a request without this Bridge's secret, or with another one, is told (403 bridge_secret).
|
|
79
|
+
SECRET_REFUSED = BridgeSecret::REFUSED
|
|
70
80
|
|
|
71
81
|
# @param engine [Samagotchi::Engine] the owning engine (must already live
|
|
72
82
|
# in this process)
|
|
@@ -130,6 +140,9 @@ module Samagotchi
|
|
|
130
140
|
@pending_card = PendingCard.new(session_dir)
|
|
131
141
|
@heartbeat_interval = heartbeat_interval
|
|
132
142
|
@epoch = SecureRandom.hex(4)
|
|
143
|
+
# Only in bridge.json (0600) and this process: never logged, never in an
|
|
144
|
+
# event, a reply or the session's files.
|
|
145
|
+
@secret = SecureRandom.hex(SECRET_BYTES)
|
|
133
146
|
|
|
134
147
|
@capture_handle = nil
|
|
135
148
|
@server = nil
|
|
@@ -137,8 +150,8 @@ module Samagotchi
|
|
|
137
150
|
@connection_threads = []
|
|
138
151
|
@stopped = false
|
|
139
152
|
@mutex = Monitor.new
|
|
140
|
-
@
|
|
141
|
-
@
|
|
153
|
+
@refused_log_mutex = Mutex.new
|
|
154
|
+
@refused_logged_at = {}
|
|
142
155
|
@open_streams = 0
|
|
143
156
|
# Open streams by the client_id a stream named (`?client_id=`); web
|
|
144
157
|
# tabs, through the `chi web` proxy, name none.
|
|
@@ -146,6 +159,10 @@ module Samagotchi
|
|
|
146
159
|
@last_client_activity_at = monotonic_now
|
|
147
160
|
end
|
|
148
161
|
|
|
162
|
+
# The secret never shows in an inspect (a log line, an error's message).
|
|
163
|
+
def inspect = "#<#{self.class.name} session_id=#{@session_id.inspect} port=#{@port}>"
|
|
164
|
+
alias to_s inspect
|
|
165
|
+
|
|
149
166
|
# @return [Boolean] whether the server has been stopped.
|
|
150
167
|
def stopped?
|
|
151
168
|
@mutex.synchronize { @stopped }
|
|
@@ -442,7 +459,6 @@ module Samagotchi
|
|
|
442
459
|
request = read_request(io)
|
|
443
460
|
break if request.nil?
|
|
444
461
|
|
|
445
|
-
note_client_activity
|
|
446
462
|
started = monotonic_now
|
|
447
463
|
|
|
448
464
|
method = request[:method].to_s.upcase
|
|
@@ -455,22 +471,37 @@ module Samagotchi
|
|
|
455
471
|
if request[:too_large]
|
|
456
472
|
write_json(io, 413, { "Connection" => "close" }, { error: "too_large", detail: "request body over #{MAX_BODY_BYTES} bytes" })
|
|
457
473
|
break
|
|
474
|
+
# A refusal closes the connection, as its Connection: close says: a
|
|
475
|
+
# client that asked to keep it open (a stream's keep-alive) reads
|
|
476
|
+
# the refusal to its end at once rather than waiting.
|
|
458
477
|
elsif browser_request?(headers)
|
|
459
478
|
write_json(io, 403, nil, { error: "cross_origin", detail: "the bridge answers chi's own clients only" })
|
|
479
|
+
break
|
|
460
480
|
elsif proxied_request?(headers)
|
|
461
|
-
|
|
481
|
+
note_refused(:proxied, "proxied_refused", request[:path])
|
|
462
482
|
write_json(io, 403, nil, { error: "proxied", detail: "the bridge answers chi's own clients on this machine, " \
|
|
463
483
|
"never a request through a proxy" })
|
|
484
|
+
break
|
|
485
|
+
elsif !secret_ok?(headers)
|
|
486
|
+
note_refused(:secret, "secret_refused", request[:path])
|
|
487
|
+
write_json(io, 403, nil, { error: BridgeSecret::ERROR, detail: SECRET_REFUSED })
|
|
488
|
+
break
|
|
489
|
+
# From here on a client of this worker (it named the secret): only its
|
|
490
|
+
# requests keep the worker from its idle exit, never a refused one's
|
|
491
|
+
# (an older chi web's polls, a scan through a forward).
|
|
464
492
|
elsif route.nil?
|
|
493
|
+
note_client_activity
|
|
465
494
|
write_json(io, 404, { "Allow" => "GET, POST" },
|
|
466
495
|
{ error: "not_found", path: request[:path] })
|
|
467
496
|
elsif route.first == :stream
|
|
497
|
+
note_client_activity
|
|
468
498
|
cursor = reconnect_cursor(headers, request[:query])
|
|
469
499
|
Log.debug(:bridge, "stream", method: method, path: request[:path], client_id: stream_client_id(request[:query]))
|
|
470
500
|
serve_sse(io, route.last, last_event_id: cursor, snapshot: snapshot_requested?(request[:query]),
|
|
471
501
|
client_id: stream_client_id(request[:query]), raw: raw_requested?(request[:query]))
|
|
472
502
|
break # SSE owns the connection until the client disconnects.
|
|
473
503
|
else
|
|
504
|
+
note_client_activity
|
|
474
505
|
payload, status, body = dispatch(route.first, route.last, request[:body], request[:query])
|
|
475
506
|
write_json(io, status, payload, body)
|
|
476
507
|
end
|
|
@@ -554,15 +585,26 @@ module Samagotchi
|
|
|
554
585
|
# web's stream proxy forwards Last-Event-ID only (ProxyStreamBody).
|
|
555
586
|
def proxied_request?(headers) = headers.keys.any? { |name| ProxyTrace.header?(name) }
|
|
556
587
|
|
|
557
|
-
|
|
588
|
+
REFUSED_LOG_INTERVAL = 60.0
|
|
589
|
+
|
|
590
|
+
# Whether the request names this Bridge's secret (SECRET_HEADER), compared
|
|
591
|
+
# in constant time. Every route asks for it, the stream and the read-only
|
|
592
|
+
# ones too: what they show is the session itself.
|
|
593
|
+
def secret_ok?(headers)
|
|
594
|
+
given = headers[SECRET_HEADER]
|
|
595
|
+
given.is_a?(String) && OpenSSL.secure_compare(given, @secret)
|
|
596
|
+
end
|
|
558
597
|
|
|
559
|
-
#
|
|
560
|
-
|
|
598
|
+
# A refusal's log line, at most once a minute per +kind+: a scan through
|
|
599
|
+
# a forward, or an old chi web's every poll, would fill the log. The path
|
|
600
|
+
# only, never a header.
|
|
601
|
+
def note_refused(kind, event, path)
|
|
561
602
|
now = monotonic_now
|
|
562
|
-
due = @
|
|
563
|
-
|
|
603
|
+
due = @refused_log_mutex.synchronize do
|
|
604
|
+
last = @refused_logged_at[kind]
|
|
605
|
+
(last.nil? || now - last >= REFUSED_LOG_INTERVAL).tap { |fresh| @refused_logged_at[kind] = now if fresh }
|
|
564
606
|
end
|
|
565
|
-
Log.warn(:bridge,
|
|
607
|
+
Log.warn(:bridge, event, path: path) if due
|
|
566
608
|
end
|
|
567
609
|
|
|
568
610
|
def browser_request?(headers)
|
|
@@ -1455,9 +1497,10 @@ module Samagotchi
|
|
|
1455
1497
|
# version: the chi this worker runs (chi update and the web's badge
|
|
1456
1498
|
# report older ones); features: what a client may ask of it; epoch:
|
|
1457
1499
|
# this worker's, as its event ids carry it.
|
|
1500
|
+
# secret: what every request must name (#secret_ok?).
|
|
1458
1501
|
WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
|
|
1459
1502
|
version: Samagotchi::VERSION, input_format: @input_format, features: FEATURES,
|
|
1460
|
-
epoch: @epoch).write(session_dir)
|
|
1503
|
+
epoch: @epoch, secret: @secret).write(session_dir)
|
|
1461
1504
|
rescue StandardError => e
|
|
1462
1505
|
Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{WorkerSidecar::FILE}: #{e.class}: #{e.message}", error: e.class.name)
|
|
1463
1506
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "uri"
|
|
5
|
+
require_relative "../bridge_secret"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
class BridgeClient
|
|
@@ -13,8 +14,9 @@ module Samagotchi
|
|
|
13
14
|
# what was missed, or sends a `reset` frame carrying a fresh snapshot when
|
|
14
15
|
# it can't. Either snapshot kind means "re-render from here".
|
|
15
16
|
#
|
|
16
|
-
# When the Bridge stays unreachable through every reconnect delay,
|
|
17
|
-
# not serve the session (404), it
|
|
17
|
+
# When the Bridge stays unreachable through every reconnect delay, does
|
|
18
|
+
# not serve the session (404), or refuses it for want of its secret (403
|
|
19
|
+
# bridge_secret, after rediscovery found no new worker), it yields one synthetic
|
|
18
20
|
# `{"type" => "stream_closed", "reason" => ...}` and stops. #close stops
|
|
19
21
|
# it without that event.
|
|
20
22
|
#
|
|
@@ -120,6 +122,16 @@ module Samagotchi
|
|
|
120
122
|
break if closed?
|
|
121
123
|
return finish("unknown_session") if outcome == :unknown_session
|
|
122
124
|
|
|
125
|
+
# Refused for want of the secret: a new worker (a new sidecar, a
|
|
126
|
+
# new secret) is followed at once; else no retry would get in.
|
|
127
|
+
if outcome == :secret_refused
|
|
128
|
+
return finish(BridgeSecret::ERROR) unless rediscover_worker == :moved
|
|
129
|
+
|
|
130
|
+
failures = 0
|
|
131
|
+
@reason = nil
|
|
132
|
+
next
|
|
133
|
+
end
|
|
134
|
+
|
|
123
135
|
if outcome == :events
|
|
124
136
|
failures = 0
|
|
125
137
|
@reason = nil
|
|
@@ -146,7 +158,7 @@ module Samagotchi
|
|
|
146
158
|
|
|
147
159
|
# One connection: :events when it yielded any, else why it failed.
|
|
148
160
|
def read_connection
|
|
149
|
-
sock, status = @client.connect_stream(query: @query, last_event_id: @last_event_id, timeout: HEADER_TIMEOUT)
|
|
161
|
+
sock, status, headers = @client.connect_stream(query: @query, last_event_id: @last_event_id, timeout: HEADER_TIMEOUT)
|
|
150
162
|
return :unreachable unless sock
|
|
151
163
|
|
|
152
164
|
unless attach(sock)
|
|
@@ -154,6 +166,7 @@ module Samagotchi
|
|
|
154
166
|
return :closed
|
|
155
167
|
end
|
|
156
168
|
return :unknown_session if status == 404
|
|
169
|
+
return :secret_refused if status == 403 && BridgeSecret.refused?(status, refusal_body(sock, headers))
|
|
157
170
|
return :unreachable unless status == 200
|
|
158
171
|
|
|
159
172
|
# Back as soon as the stream answers: a replay may bring nothing
|
|
@@ -181,6 +194,24 @@ module Samagotchi
|
|
|
181
194
|
detach(sock)
|
|
182
195
|
end
|
|
183
196
|
|
|
197
|
+
# A refusal's JSON body: its Content-Length's worth, else up to the
|
|
198
|
+
# Bridge's close, within HEADER_TIMEOUT.
|
|
199
|
+
def refusal_body(sock, headers)
|
|
200
|
+
body = +""
|
|
201
|
+
length = headers.to_h["content-length"]&.to_i
|
|
202
|
+
limit = length&.between?(0, 16_384) ? length : 16_384
|
|
203
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HEADER_TIMEOUT
|
|
204
|
+
while body.bytesize < limit
|
|
205
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
206
|
+
break unless left.positive? && sock.wait_readable(left)
|
|
207
|
+
|
|
208
|
+
body << sock.readpartial(limit - body.bytesize)
|
|
209
|
+
end
|
|
210
|
+
body
|
|
211
|
+
rescue IOError, SystemCallError
|
|
212
|
+
body
|
|
213
|
+
end
|
|
214
|
+
|
|
184
215
|
# Publish the socket for #close; false when already closed.
|
|
185
216
|
def attach(sock)
|
|
186
217
|
@mutex.synchronize do
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "json"
|
|
4
5
|
require "socket"
|
|
5
6
|
|
|
6
7
|
require_relative "worker_sidecar"
|
|
8
|
+
require_relative "bridge_secret"
|
|
7
9
|
require_relative "delivery"
|
|
8
10
|
require_relative "chi_invocation"
|
|
9
11
|
require_relative "bridge_client/sse_parser"
|
|
@@ -22,6 +24,8 @@ module Samagotchi
|
|
|
22
24
|
HOST = "127.0.0.1"
|
|
23
25
|
# A reply's status line; its code is group 1.
|
|
24
26
|
STATUS_LINE = StreamRequest::STATUS_LINE
|
|
27
|
+
# The request header that carries the Bridge's secret (Bridge#secret_ok?).
|
|
28
|
+
SECRET_HEADER = BridgeSecret::HEADER
|
|
25
29
|
STREAM_CONNECT_ATTEMPTS = 3
|
|
26
30
|
# Seconds #stream waits for the Bridge's response headers, per attempt:
|
|
27
31
|
# a worker that took the connection and froze must not hold the web's
|
|
@@ -45,6 +49,9 @@ module Samagotchi
|
|
|
45
49
|
Response = Struct.new(:status, :body, keyword_init: true) do
|
|
46
50
|
def ok? = status == 200
|
|
47
51
|
|
|
52
|
+
# The Bridge refused it for want of its secret (BridgeSecret::REFUSED says what to do).
|
|
53
|
+
def secret_refused? = BridgeSecret.refused?(status, body)
|
|
54
|
+
|
|
48
55
|
# @return [Hash, nil] the body parsed as JSON, or nil when it is not
|
|
49
56
|
def json
|
|
50
57
|
JSON.parse(body.to_s)
|
|
@@ -68,19 +75,28 @@ module Samagotchi
|
|
|
68
75
|
"this session's worker runs an older chi and can't #{cant}; restart it: #{restart} (its turns still work)"
|
|
69
76
|
end
|
|
70
77
|
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
# and is removed.
|
|
78
|
+
# The live Bridge's sidecar in +session_dir+, or nil. A sidecar whose
|
|
79
|
+
# port refuses a quick connect is stale (its worker died) and is removed.
|
|
74
80
|
# @param session_dir [String]
|
|
75
|
-
# @return [
|
|
76
|
-
def self.
|
|
77
|
-
WorkerSidecar.
|
|
81
|
+
# @return [WorkerSidecar, nil]
|
|
82
|
+
def self.live_sidecar(session_dir, host: HOST)
|
|
83
|
+
WorkerSidecar.live(session_dir, unlink: true, host: host)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The one way a client is made for a worker: its port and its secret
|
|
87
|
+
# come from the same sidecar read, so a respawned worker (a new port, a
|
|
88
|
+
# new secret) is reached by a client made after it, never by hand.
|
|
89
|
+
# @param sidecar [WorkerSidecar, nil]
|
|
90
|
+
# @return [BridgeClient, nil] nil without a sidecar
|
|
91
|
+
def self.for_sidecar(session_id, sidecar, host: HOST, read_timeout: READ_TIMEOUT)
|
|
92
|
+
return nil unless sidecar
|
|
93
|
+
|
|
94
|
+
new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout)
|
|
78
95
|
end
|
|
79
96
|
|
|
80
97
|
# @return [BridgeClient, nil] a client for the live Bridge in +session_dir+
|
|
81
|
-
def self.discover(session_id, session_dir:, host: HOST)
|
|
82
|
-
|
|
83
|
-
port && new(session_id: session_id, port: port, host: host)
|
|
98
|
+
def self.discover(session_id, session_dir:, host: HOST, read_timeout: READ_TIMEOUT)
|
|
99
|
+
for_sidecar(session_id, live_sidecar(session_dir, host: host), host: host, read_timeout: read_timeout)
|
|
84
100
|
end
|
|
85
101
|
|
|
86
102
|
# How often a wait for a new worker's Bridge looks: a missing sidecar is
|
|
@@ -92,8 +108,8 @@ module Samagotchi
|
|
|
92
108
|
# @param timeout [Float] seconds
|
|
93
109
|
# @return [BridgeClient, nil] nil when no live sidecar appeared in time
|
|
94
110
|
def self.wait_for(session_id, session_dir:, timeout:, host: HOST)
|
|
95
|
-
|
|
96
|
-
|
|
111
|
+
sidecar = poll(timeout, interval: SPAWN_POLL_INTERVAL) { live_sidecar(session_dir, host: host) }
|
|
112
|
+
for_sidecar(session_id, sidecar, host: host)
|
|
97
113
|
end
|
|
98
114
|
|
|
99
115
|
# Call the block every +interval+ seconds until it returns a truthy value
|
|
@@ -113,17 +129,27 @@ module Samagotchi
|
|
|
113
129
|
attr_reader :session_id, :port, :host
|
|
114
130
|
|
|
115
131
|
# Which worker this client talks to, for EventStream: a rediscovered
|
|
116
|
-
# client with another key is a new worker. A Bridge's port
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
# client with another key is a new worker. A Bridge's port, and a
|
|
133
|
+
# fingerprint of its secret when it has one: a new worker that got the
|
|
134
|
+
# old one's port back has another secret, so it is still a new worker.
|
|
135
|
+
def worker_key = @secret ? "#{@port}-#{Digest::SHA256.hexdigest(@secret)[0, 12]}" : @port
|
|
136
|
+
|
|
137
|
+
# @param secret [String, nil] the Bridge's secret, sent in SECRET_HEADER
|
|
138
|
+
# with every request (nil: a worker from before secrets, which asks for
|
|
139
|
+
# none); from the worker's sidecar (.for_sidecar), never typed
|
|
119
140
|
# @param read_timeout [Numeric] see READ_TIMEOUT
|
|
120
|
-
def initialize(session_id:, port:, host: HOST, read_timeout: READ_TIMEOUT)
|
|
141
|
+
def initialize(session_id:, port:, secret:, host: HOST, read_timeout: READ_TIMEOUT)
|
|
121
142
|
@session_id = session_id
|
|
122
143
|
@port = port
|
|
144
|
+
@secret = secret
|
|
123
145
|
@host = host
|
|
124
146
|
@read_timeout = read_timeout
|
|
125
147
|
end
|
|
126
148
|
|
|
149
|
+
# The secret never shows in an inspect (a log line, an error's message).
|
|
150
|
+
def inspect = "#<#{self.class.name} session_id=#{@session_id.inspect} port=#{@port}>"
|
|
151
|
+
alias to_s inspect
|
|
152
|
+
|
|
127
153
|
# POST /session/:id/answer. 200 = recorded, 409 = another client answered
|
|
128
154
|
# first (or the question is gone), 400 = invalid selection, 408
|
|
129
155
|
# deadline_passed = read too late and dropped (see DEADLINE_SHARE), 403
|
|
@@ -289,7 +315,7 @@ module Samagotchi
|
|
|
289
315
|
# (nil when it isn't JSON); [nil, nil] when no reply came
|
|
290
316
|
def get(path)
|
|
291
317
|
sock = TCPSocket.new(@host, @port)
|
|
292
|
-
sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\
|
|
318
|
+
sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\n#{secret_line}Connection: close\r\n\r\n")
|
|
293
319
|
response = read_reply(sock, path)
|
|
294
320
|
sock.close rescue nil
|
|
295
321
|
# A 500 whose text has "200" in it is not a 200.
|
|
@@ -332,14 +358,22 @@ module Samagotchi
|
|
|
332
358
|
# the bridge socket briefly refuses). A few quick bounded retries
|
|
333
359
|
# avoid the silent empty-200 that EventSource would otherwise keep
|
|
334
360
|
# re-opening.
|
|
361
|
+
status = nil
|
|
335
362
|
STREAM_CONNECT_ATTEMPTS.times do |attempt|
|
|
336
|
-
sock, = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
|
|
363
|
+
sock, status = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
|
|
337
364
|
break if sock
|
|
338
365
|
|
|
339
366
|
sleep(0.15 * (attempt + 1))
|
|
340
367
|
end
|
|
341
368
|
return unless sock
|
|
342
369
|
|
|
370
|
+
# A refusal (a 403 such as bridge_secret) is no event stream: its JSON
|
|
371
|
+
# isn't passed on as one.
|
|
372
|
+
if status == 403
|
|
373
|
+
sock.close rescue nil
|
|
374
|
+
return
|
|
375
|
+
end
|
|
376
|
+
|
|
343
377
|
begin
|
|
344
378
|
loop do
|
|
345
379
|
if running
|
|
@@ -375,11 +409,16 @@ module Samagotchi
|
|
|
375
409
|
# nil when the Bridge can't be reached
|
|
376
410
|
def connect_stream(query: "", last_event_id: nil, timeout: nil)
|
|
377
411
|
StreamRequest.open(host: @host, port: @port, path: "/session/#{@session_id}/stream#{query}",
|
|
378
|
-
last_event_id: last_event_id, timeout: timeout)
|
|
412
|
+
last_event_id: last_event_id, headers: secret_headers, timeout: timeout)
|
|
379
413
|
end
|
|
380
414
|
|
|
381
415
|
private
|
|
382
416
|
|
|
417
|
+
# The request header naming the Bridge's secret (none without one).
|
|
418
|
+
def secret_headers = @secret ? { SECRET_HEADER => @secret } : {}
|
|
419
|
+
|
|
420
|
+
def secret_line = secret_headers.map { |name, value| "#{name}: #{value}\r\n" }.join
|
|
421
|
+
|
|
383
422
|
# When this client stops waiting for a reply, as epoch seconds (see
|
|
384
423
|
# DEADLINE_SHARE). Wall clock: the worker runs on this machine and reads
|
|
385
424
|
# the same one.
|
|
@@ -388,7 +427,7 @@ module Samagotchi
|
|
|
388
427
|
def post(path, payload, read_body:, read_timeout: @read_timeout)
|
|
389
428
|
sock = TCPSocket.new(@host, @port)
|
|
390
429
|
json_body = JSON.generate(payload)
|
|
391
|
-
sock.write("POST /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\
|
|
430
|
+
sock.write("POST /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\n#{secret_line}Content-Type: application/json\r\nContent-Length: #{json_body.bytesize}\r\nConnection: close\r\n\r\n#{json_body}")
|
|
392
431
|
reply = read_reply(sock, path, whole: read_body, timeout: read_timeout)
|
|
393
432
|
body = read_body ? reply.split("\r\n\r\n", 2)[1] : nil
|
|
394
433
|
Response.new(status: reply[STATUS_LINE, 1].to_i, body: body)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A session Bridge's secret, as both sides name it: the request header
|
|
7
|
+
# that carries it (Bridge#secret_ok?, BridgeClient), the error a Bridge
|
|
8
|
+
# answers without it (403), and the one message every client shows for
|
|
9
|
+
# that refusal.
|
|
10
|
+
module BridgeSecret
|
|
11
|
+
# The request header (Bridge's #read_request keeps names lowercased).
|
|
12
|
+
HEADER = "X-Chi-Bridge"
|
|
13
|
+
# The 403's error.
|
|
14
|
+
ERROR = "bridge_secret"
|
|
15
|
+
# What a request without this Bridge's secret, or with another one, is
|
|
16
|
+
# told, and what a client shows when it gets that: the asker comes from
|
|
17
|
+
# a chi that reads no secret, a chi web, an attached terminal or a
|
|
18
|
+
# session worker (a delegate's parent) started before chi was updated.
|
|
19
|
+
REFUSED = "the request didn't name this session worker's secret: a chi web, an attached terminal or a session " \
|
|
20
|
+
"worker started before chi was updated can't reach it; restart chi web, re-attach terminals " \
|
|
21
|
+
"(chi --attach ID) and restart the older session workers (chi sessions restart ID...)"
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Whether a Bridge reply (+status+, raw +body+) is this refusal.
|
|
26
|
+
def refused?(status, body)
|
|
27
|
+
return false unless status.to_i == 403
|
|
28
|
+
|
|
29
|
+
parsed = body.is_a?(Hash) ? body : JSON.parse(body.to_s)
|
|
30
|
+
parsed.is_a?(Hash) && parsed["error"] == ERROR
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: claude-peer
|
|
3
|
-
version: 0.4.
|
|
3
|
+
version: 0.4.4
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Pair this session with a Claude Code session you work with: the claude_send tool and /claude send it a part of the work (a plan step, a diff, a finding) to validate or as context, and Claude's answer comes back as the claude-outbox context; /claude install-skill installs Claude's chi-peer skill, which pairs"
|
|
6
6
|
trust_level: reviewed
|
|
@@ -12,7 +12,7 @@ plugin:
|
|
|
12
12
|
file: plugin.rb
|
|
13
13
|
sha256: sha256:50f427d3862ae9d0d15693b4d887c528162b96c0320cc663df89d59d830d4660
|
|
14
14
|
scripts:
|
|
15
|
-
pair: sha256:
|
|
15
|
+
pair: sha256:106d5b44020602fcd2d8065da30d10e1043e30c1b31ea82b4f27f7f79ada6592
|
|
16
16
|
chi-peer-SKILL.md: sha256:b9366f8f4aee9c54d209368ce421eab7b971c03c75efab0415d1988fd40f9e60
|
|
17
17
|
# The plugin needs Samagotchi::Plugin::Peer and chi.peer_source (chi 0.67.0).
|
|
18
18
|
# An agreeing answer's closing wake (the skills say an OK wakes chi once)
|
|
@@ -147,6 +147,6 @@ attach claude-outbox --why "Claude's answers to what you sent it (claude-peer)"
|
|
|
147
147
|
if [ -z "$inbox" ]; then
|
|
148
148
|
echo "paired with chi $id (socket $sock)"
|
|
149
149
|
else
|
|
150
|
-
echo "paired with chi $id (socket $inbox on chi's machine, forwarded
|
|
150
|
+
echo "paired with chi $id (socket $inbox on chi's machine, forwarded to this Claude's $sock)"
|
|
151
151
|
fi
|
|
152
152
|
$chi context ls "$id"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.7.
|
|
3
|
+
version: 0.7.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; their tools go into chi's tool index: the model searches them with find_tools and calls one with call_tool (<server>/<tool>); /mcp lists them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files: {}
|
|
8
8
|
plugin:
|
|
9
9
|
file: plugin.rb
|
|
10
|
-
sha256: sha256:
|
|
10
|
+
sha256: sha256:c4bb4cf99ad43168ea68c25283a153c5dea1e5be123743c8a4c1278c16b5ea90
|
|
11
11
|
# 0.67.0 is the release with chi.tool_source (the core tool index the bundle publishes into).
|
|
12
|
-
requires_chi: ">= 0.
|
|
12
|
+
requires_chi: ">= 0.121.0"
|
|
@@ -354,6 +354,7 @@ class Plugin
|
|
|
354
354
|
def register(chi)
|
|
355
355
|
@chi = chi
|
|
356
356
|
ctx = chi.ctx
|
|
357
|
+
@ctx = ctx
|
|
357
358
|
configs = @settings["servers"]
|
|
358
359
|
configs = {} unless configs.is_a?(Hash)
|
|
359
360
|
unless configs.empty?
|
|
@@ -952,7 +953,10 @@ class Plugin
|
|
|
952
953
|
return "No servers. Add them in config.yml under `bundles: mcp: servers:` (docs/plugins.md, The mcp bundle)." if @servers.empty?
|
|
953
954
|
|
|
954
955
|
servers = @servers.map do |server|
|
|
955
|
-
|
|
956
|
+
marker = if @source && !@source.declared_groups.include?(server.name)
|
|
957
|
+
" (not in toolkit #{@ctx.toolkit})"
|
|
958
|
+
end
|
|
959
|
+
head = "**#{server.name}**: #{state_text(server)}#{marker}"
|
|
956
960
|
# Sorted: a server lists its tools in its own (often grouped) order.
|
|
957
961
|
tools = server.tools.sort_by(&:name).map { |t| ["- `#{t.server}/#{t.name}`", mark(t)].compact.join(" ") }
|
|
958
962
|
tools.empty? ? head : "#{head}\n#{tools.join("\n")}"
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -326,6 +326,9 @@ module Samagotchi
|
|
|
326
326
|
Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli], lifecycle: :web_start),
|
|
327
327
|
# What a file ref (a tool row's file, a diff line) opens on this machine: a preset (vscode, vscode-insiders, cursor, zed, none) or a URL template with {path} and an optional {line} (Web::Editor).
|
|
328
328
|
Entry.new(key: "web.editor", yaml_path: %w[web editor], type: :string, default: "vscode", expose: %i[env config cli], lifecycle: :web_start),
|
|
329
|
+
# The folder chi web starts in (Web::StartDir): its bare page shows that folder's project, and a new session
|
|
330
|
+
# without a folder (the all view's) starts there. Unset: the folder chi web runs in. chi web --start-dir wins.
|
|
331
|
+
Entry.new(key: "web.start_dir", yaml_path: %w[web start_dir], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
|
|
329
332
|
# Participants (chi share): a new grant's lifetime (Nh or Nd, 1h to 30d), and what a grant without its own
|
|
330
333
|
# takes: its spending cap in dollars ($0 to $1000) and how many of its turns may wait (0 to 20). Read when used.
|
|
331
334
|
Entry.new(key: "web.participants.expires", yaml_path: %w[web participants expires], type: :string, default: "7d", expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -377,6 +377,7 @@ module Samagotchi
|
|
|
377
377
|
# A cut tool output is saved whole in the session's folder.
|
|
378
378
|
@kernel.session_dir = -> { @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir) }
|
|
379
379
|
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
380
|
+
@kernel.participant_turn = -> { Steer.participant_author?(turn_author) }
|
|
380
381
|
# The turn-end warm-up; the kernel asks it for a slot pin per request.
|
|
381
382
|
@prompt_warmup = PromptWarmup.new
|
|
382
383
|
@kernel.warmup = @prompt_warmup
|
|
@@ -176,6 +176,11 @@ module Samagotchi
|
|
|
176
176
|
# The Guardrails::Gate ToolRunner asks before each call; the Engine sets
|
|
177
177
|
# it (nil: ToolRunner's own, hooks only).
|
|
178
178
|
attr_accessor :guardrail_gate
|
|
179
|
+
# Whether the current turn belongs to a participant, so pre-gate
|
|
180
|
+
# refusals can defer to ReviewBoundary.
|
|
181
|
+
attr_accessor :participant_turn
|
|
182
|
+
|
|
183
|
+
def participant_turn? = @participant_turn&.call == true
|
|
179
184
|
# @return [LLM::TurnSettings] the turn's vision, sampling, thinking and
|
|
180
185
|
# model name, set by the Engine per turn (#run sets the model name
|
|
181
186
|
# too); the thinking level is its own field, since the native path
|
|
@@ -10,6 +10,11 @@ module Samagotchi
|
|
|
10
10
|
TOKEN_REVOKED = "this review link was revoked by the session's owner"
|
|
11
11
|
# 401 token_expired: the grant's time ran out.
|
|
12
12
|
TOKEN_EXPIRED = "this review link expired: ask the session's owner for a new one"
|
|
13
|
+
# 401 token_bound: a link bound to one account (chi share --identity)
|
|
14
|
+
# opened by another, or not through the auth proxy (the LAN, this
|
|
15
|
+
# machine). Never names either identity.
|
|
16
|
+
TOKEN_BOUND = "this review link is for another account: open it through the proxy, signed in as the person " \
|
|
17
|
+
"it was sent to"
|
|
13
18
|
# Either of those, where only that the link ended is known: the
|
|
14
19
|
# worker's cancel_reason "revoked" (participants.json reads them
|
|
15
20
|
# removed or past expires_at), as their chi send --wait reports it.
|
|
@@ -98,21 +98,29 @@ module Samagotchi
|
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
# Invites a participant to +session_id+: the grant (+grants+, a
|
|
101
|
-
# Web::Grants), the sidecar and the audit line.
|
|
101
|
+
# Web::Grants), the sidecar and the audit line. A grant bound to
|
|
102
|
+
# +identity+ needs +public_url+ (the chi web's Web::PublicUrl) with an
|
|
103
|
+
# identity header: only a request through that proxy can carry it.
|
|
102
104
|
# @return [Web::Grants::Issued]
|
|
103
105
|
# @raise [Web::Grants::Invalid] an archived session, or bad values
|
|
106
|
+
# (Unbindable: an identity without such a public_url)
|
|
104
107
|
# @raise [ArgumentError] no such session
|
|
105
|
-
def invite(grants, session_id:, name:, kind: "human", ttl: nil, cap: nil,
|
|
108
|
+
def invite(grants, session_id:, name:, kind: "human", ttl: nil, cap: nil, identity: nil, public_url: nil,
|
|
109
|
+
state_dir: Session.default_state_dir)
|
|
106
110
|
raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(Session.session_file(session_id, state_dir: state_dir))
|
|
111
|
+
|
|
112
|
+
identity = Web::Grants.clean_identity(identity) unless identity.nil?
|
|
113
|
+
raise Web::Grants::Unbindable if !identity.nil? && public_url&.identity_header.nil?
|
|
107
114
|
if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
|
|
108
115
|
raise Web::Grants::Invalid, "session #{session_id[0, 8]} is archived: unarchive it to invite anyone"
|
|
109
116
|
end
|
|
110
117
|
|
|
111
|
-
issued = grants.issue(session_id: session_id, name: name, kind: kind, ttl: ttl, cap: cap,
|
|
118
|
+
issued = grants.issue(session_id: session_id, name: name, kind: kind, ttl: ttl, cap: cap, identity: identity,
|
|
112
119
|
on_write: sidecar_writer(state_dir, [session_id]))
|
|
113
120
|
grant = issued.grant
|
|
114
121
|
log(session_id, :issued, state_dir: state_dir, participant: grant.id, name: grant.name, kind: grant.kind,
|
|
115
|
-
role: grant.role, expires_at: grant.expires_at, cap: grant.spend_cap
|
|
122
|
+
role: grant.role, expires_at: grant.expires_at, cap: grant.spend_cap,
|
|
123
|
+
**(grant.identity ? { identity: grant.identity } : {}))
|
|
116
124
|
issued
|
|
117
125
|
end
|
|
118
126
|
|
|
@@ -148,11 +156,13 @@ module Samagotchi
|
|
|
148
156
|
# session nobody was invited to.
|
|
149
157
|
# A grants store that doesn't read (Web::Grants::Corrupt) doesn't stop
|
|
150
158
|
# the archive or delete: its links already find no grant, and the
|
|
151
|
-
# store itself says so (Log.warn) until it's fixed.
|
|
159
|
+
# store itself says so (Log.warn) until it's fixed. Nor does one a newer
|
|
160
|
+
# chi wrote (Newer): chi web's gate refuses a grant whose session is
|
|
161
|
+
# archived or gone even when it can't write the revoke.
|
|
152
162
|
def end_sessions(session_ids, by:, state_dir: Session.default_state_dir)
|
|
153
163
|
grants = Web::Grants.new(Web::Grants.path(state_dir: state_dir))
|
|
154
164
|
session_ids.each { |sid| revoke(grants, session_id: sid, by: by, state_dir: state_dir) }
|
|
155
|
-
rescue Web::Grants::
|
|
165
|
+
rescue Web::Grants::Unwritable
|
|
156
166
|
nil
|
|
157
167
|
end
|
|
158
168
|
end
|
|
@@ -308,12 +308,14 @@ module Samagotchi
|
|
|
308
308
|
# toolkit.default, ctx.use_toolkit): +base+'s tools (a toolkit chi or
|
|
309
309
|
# a bundle has) with +add+ declared and +remove+ left out, and
|
|
310
310
|
# +prompt+: text the sessions on it get in their system prompt (nil:
|
|
311
|
-
#
|
|
311
|
+
# its base's, "" none; at most Toolkit::PROMPT_MAX characters);
|
|
312
|
+
# +groups+: MCP source group names/globs (nil inherits its base).
|
|
313
|
+
# A
|
|
312
314
|
# name chi (full, lean, child) or another bundle has is a load error,
|
|
313
315
|
# and so is a prompt that isn't text or is too long; a base that
|
|
314
316
|
# doesn't resolve leaves the sessions on it with every tool
|
|
315
317
|
# (Toolkit#resolve).
|
|
316
|
-
def toolkit(name, base:, add: [], remove: [], prompt: nil)
|
|
318
|
+
def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)
|
|
317
319
|
name = name.to_s
|
|
318
320
|
raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
|
|
319
321
|
|
|
@@ -327,7 +329,8 @@ module Samagotchi
|
|
|
327
329
|
end
|
|
328
330
|
raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
|
|
329
331
|
|
|
330
|
-
@toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt,
|
|
332
|
+
@toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt,
|
|
333
|
+
groups: groups, source: @bundle)
|
|
331
334
|
nil
|
|
332
335
|
end
|
|
333
336
|
|