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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/bin/chi +6 -1
  4. data/docs/architecture.md +35 -5
  5. data/docs/cli.md +33 -8
  6. data/docs/configuration.md +74 -20
  7. data/docs/internals/participants.md +111 -12
  8. data/docs/internals/prompt-caching.md +7 -6
  9. data/docs/internals/remote-sessions.md +6 -1
  10. data/docs/internals/web-push.md +4 -2
  11. data/docs/plugins.md +24 -8
  12. data/docs/sessions.md +4 -3
  13. data/docs/testing.md +13 -2
  14. data/lib/samagotchi/answer_command.rb +2 -0
  15. data/lib/samagotchi/bridge.rb +56 -13
  16. data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
  17. data/lib/samagotchi/bridge_client.rb +58 -19
  18. data/lib/samagotchi/bridge_secret.rb +35 -0
  19. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/config.rb +3 -0
  26. data/lib/samagotchi/engine.rb +1 -0
  27. data/lib/samagotchi/kernel_loop.rb +5 -0
  28. data/lib/samagotchi/participant_messages.rb +5 -0
  29. data/lib/samagotchi/participants.rb +16 -6
  30. data/lib/samagotchi/plugin/api.rb +6 -3
  31. data/lib/samagotchi/review_command.rb +3 -1
  32. data/lib/samagotchi/run_options.rb +3 -1
  33. data/lib/samagotchi/session_access/contract.rb +4 -1
  34. data/lib/samagotchi/session_access/local.rb +15 -4
  35. data/lib/samagotchi/session_access/remote.rb +43 -7
  36. data/lib/samagotchi/session_access.rb +11 -1
  37. data/lib/samagotchi/session_manager.rb +1 -1
  38. data/lib/samagotchi/sessions_command.rb +1 -2
  39. data/lib/samagotchi/share_command.rb +29 -8
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
  41. data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  43. data/lib/samagotchi/tool_declarations.rb +1 -1
  44. data/lib/samagotchi/tool_runner.rb +37 -1
  45. data/lib/samagotchi/toolkit.rb +50 -26
  46. data/lib/samagotchi/toolkit_binding.rb +32 -0
  47. data/lib/samagotchi/toolkit_command.rb +23 -0
  48. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  49. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  50. data/lib/samagotchi/tools/registry.rb +20 -6
  51. data/lib/samagotchi/tools/tool_index.rb +103 -20
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +117 -43
  54. data/lib/samagotchi/web/grants.rb +127 -16
  55. data/lib/samagotchi/web/link_codes.rb +24 -15
  56. data/lib/samagotchi/web/participant_access.rb +69 -13
  57. data/lib/samagotchi/web/participant_exposure.rb +44 -0
  58. data/lib/samagotchi/web/participant_review.rb +71 -9
  59. data/lib/samagotchi/web/participants_admin.rb +12 -6
  60. data/lib/samagotchi/web/proxy_gate.rb +18 -3
  61. data/lib/samagotchi/web/public/annotate_bar.css +18 -0
  62. data/lib/samagotchi/web/public/annotate_bar.js +10 -3
  63. data/lib/samagotchi/web/public/app.js +66 -155
  64. data/lib/samagotchi/web/public/composer.css +73 -0
  65. data/lib/samagotchi/web/public/composer_grip.js +21 -15
  66. data/lib/samagotchi/web/public/composer_mount.js +196 -0
  67. data/lib/samagotchi/web/public/forms.css +12 -0
  68. data/lib/samagotchi/web/public/index.html +14 -88
  69. data/lib/samagotchi/web/public/level_chip.js +2 -2
  70. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  71. data/lib/samagotchi/web/public/notifications.js +58 -8
  72. data/lib/samagotchi/web/public/participant.html +46 -10
  73. data/lib/samagotchi/web/public/participant.js +101 -18
  74. data/lib/samagotchi/web/public/participant_annotate.js +75 -0
  75. data/lib/samagotchi/web/public/participant_model.js +53 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +37 -13
  77. data/lib/samagotchi/web/public/question_card.js +15 -1
  78. data/lib/samagotchi/web/public/question_cards.js +11 -2
  79. data/lib/samagotchi/web/public/review_history.js +60 -0
  80. data/lib/samagotchi/web/public/scope.js +12 -8
  81. data/lib/samagotchi/web/public/stage_frame.js +9 -4
  82. data/lib/samagotchi/web/public/stage_view.js +4 -4
  83. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  84. data/lib/samagotchi/web/push/notifier.rb +17 -3
  85. data/lib/samagotchi/web/running.rb +7 -4
  86. data/lib/samagotchi/web/server.rb +34 -11
  87. data/lib/samagotchi/web/session_create.rb +7 -7
  88. data/lib/samagotchi/web/start_dir.rb +26 -0
  89. data/lib/samagotchi/web/tunnel.rb +10 -1
  90. data/lib/samagotchi/worker_sidecar.rb +22 -11
  91. metadata +10 -1
@@ -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. There is NO auth: binding 0.0.0.0 exposes remote
51
- # turn-execution (RCE). Documented, not fixed.
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
- @proxied_log_mutex = Mutex.new
141
- @proxied_logged_at = nil
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
- note_proxied_refused(request[:path])
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
- PROXIED_LOG_INTERVAL = 60.0
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
- # At most once a minute: a scan through a forward would fill the log.
560
- def note_proxied_refused(path)
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 = @proxied_log_mutex.synchronize do
563
- (@proxied_logged_at.nil? || now - @proxied_logged_at >= PROXIED_LOG_INTERVAL).tap { |fresh| @proxied_logged_at = now if fresh }
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, "proxied_refused", path: path) if due
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, or does
17
- # not serve the session (404), it yields one synthetic
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
- # Port of the live Bridge advertised in +session_dir+'s sidecar, or nil.
72
- # A sidecar whose port refuses a quick connect is stale (its worker died)
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 [Integer, nil]
76
- def self.sidecar_port(session_dir, host: HOST)
77
- WorkerSidecar.live_port(session_dir, unlink: true, host: host)
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
- port = sidecar_port(session_dir, host: host)
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
- port = poll(timeout, interval: SPAWN_POLL_INTERVAL) { sidecar_port(session_dir, host: host) }
96
- port && new(session_id: session_id, port: port, host: host)
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
- def worker_key = @port
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\nConnection: close\r\n\r\n")
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\nContent-Type: application/json\r\nContent-Length: #{json_body.bytesize}\r\nConnection: close\r\n\r\n#{json_body}")
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
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:bd850e8091098e249ac5b9b04a95a4d8a0dd8501cf9d58639a47d4f3bce1d757
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 from $sock)"
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,10 +1,10 @@
1
1
  ---
2
2
  name: dev
3
- version: 0.7.2
3
+ version: 0.7.3
4
4
  scope: system
5
5
  description: "Developer tools"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.107.0"
7
+ requires_chi: ">= 0.121.0"
8
8
  includes:
9
9
  - known-names
10
10
  - mcp
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.7.1
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:67b1ed9337236deb366459aaaed4e49f0d3122a0b38f3ff9328babcd332c0182
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.67.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
- head = "**#{server.name}**: #{state_text(server)}"
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")}"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.119.0
3
+ version: 0.121.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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),
@@ -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, state_dir: Session.default_state_dir)
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::Corrupt
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
- # its base's, "" none; at most Toolkit::PROMPT_MAX characters). A
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, source: @bundle)
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