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
|
@@ -110,7 +110,9 @@ module Samagotchi
|
|
|
110
110
|
@stdout.puts("#{id} #{what}#{subjects} · idle")
|
|
111
111
|
@stdout.puts("warning: #{created.model_warning}") if created.model_warning
|
|
112
112
|
@stdout.puts("open it: #{@access.invocation.open_it(id[0, 8])}")
|
|
113
|
-
|
|
113
|
+
# Behind a proxy that names the user, a link can be bound to them.
|
|
114
|
+
identity = " --identity EMAIL" if @access.identity_header
|
|
115
|
+
@stdout.puts("invite someone: #{@access.invocation.command("share #{id[0, 8]} --name NAME#{identity}")}")
|
|
114
116
|
end
|
|
115
117
|
|
|
116
118
|
def refused(message)
|
|
@@ -14,7 +14,7 @@ module Samagotchi
|
|
|
14
14
|
module RunOptions
|
|
15
15
|
# The flags an underscored spelling is refused for (--recap_base_url),
|
|
16
16
|
# with the config registry's.
|
|
17
|
-
KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token --tunnel].freeze
|
|
17
|
+
KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token --tunnel --start-dir].freeze
|
|
18
18
|
|
|
19
19
|
module_function
|
|
20
20
|
|
|
@@ -223,6 +223,8 @@ module Samagotchi
|
|
|
223
223
|
opts.on("--tunnel PROVIDER", %w[tailscale],
|
|
224
224
|
"`chi web`: reach it over https from your tailnet through Tailscale Serve (never Funnel), for this run; " \
|
|
225
225
|
"turned off on exit") { |v| options[:tunnel] = v }
|
|
226
|
+
opts.on("--start-dir DIR", "`chi web`: the folder it starts in (web.start_dir; default: this one): its page opens on that " \
|
|
227
|
+
"folder's project, and a new session from the all view starts there") { |v| options[:start_dir] = v }
|
|
226
228
|
opts.on("--scope VALUE", %w[project all],
|
|
227
229
|
"Which sessions `chi web` and `chi sessions list` show: project (the default: this folder's git project; " \
|
|
228
230
|
"every session outside a repo) or all") { |v| options[:scope] = v }
|
|
@@ -123,6 +123,9 @@ module Samagotchi
|
|
|
123
123
|
"share --agent" => SUPPORTED,
|
|
124
124
|
"share --expires" => SUPPORTED,
|
|
125
125
|
"share --cap" => SUPPORTED,
|
|
126
|
+
# A chi web without bound grants (Remote#invite checks its
|
|
127
|
+
# bound_grants feature) is refused, never sent the identity.
|
|
128
|
+
"share --identity" => SUPPORTED,
|
|
126
129
|
"share cap" => SUPPORTED,
|
|
127
130
|
"share ls" => SUPPORTED,
|
|
128
131
|
"share ls --format" => SUPPORTED,
|
|
@@ -150,7 +153,7 @@ module Samagotchi
|
|
|
150
153
|
--llm-context-budget --toolkit --tool --no-tool --set --parent-answers --no-interrupt
|
|
151
154
|
--no-default-input --dir --version -h --help],
|
|
152
155
|
LOCAL_ONLY => %w[--no-shared --non-interactive -v --verbose --append-system-prompt --memory --mute --profile
|
|
153
|
-
--port --open --new-token --tunnel --scope --web-markdown --no-web-markdown]
|
|
156
|
+
--port --open --new-token --tunnel --start-dir --scope --web-markdown --no-web-markdown]
|
|
154
157
|
}.freeze
|
|
155
158
|
# A short run flag's name in a refusal.
|
|
156
159
|
RUN_WORDS = { "-v" => "--verbose" }.freeze
|
|
@@ -370,12 +370,16 @@ module Samagotchi
|
|
|
370
370
|
# and port when one runs on the LAN (the port it recorded, else
|
|
371
371
|
# web.port), else 127.0.0.1 (+lan+ false: the link opens on this
|
|
372
372
|
# machine only).
|
|
373
|
+
# A grant bound to +identity+ needs that public_url with an identity
|
|
374
|
+
# header (Participants.invite).
|
|
373
375
|
# @return [Invitation]
|
|
374
376
|
# @raise [ArgumentError] no such session
|
|
375
|
-
# @raise [BadRequest] a bad name, kind or
|
|
376
|
-
|
|
377
|
-
|
|
377
|
+
# @raise [BadRequest] a bad name, kind, ttl or identity, an archived
|
|
378
|
+
# session, an identity with no proxy to check it
|
|
379
|
+
def invite(id, name:, kind:, ttl: nil, cap: nil, identity: nil)
|
|
378
380
|
public_url = configured_public_url
|
|
381
|
+
issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, cap: cap,
|
|
382
|
+
identity: identity, public_url: public_url, state_dir: state_dir!)
|
|
379
383
|
web = public_url ? nil : local_web
|
|
380
384
|
link = if public_url then public_url.link(link_code(issued), "s=#{id}")
|
|
381
385
|
else "http://#{web.ip || "127.0.0.1"}:#{web.port}/?token=#{issued.token}#s=#{id}"
|
|
@@ -387,6 +391,13 @@ module Samagotchi
|
|
|
387
391
|
raise BadRequest.new(e.message, code: "invalid_participant")
|
|
388
392
|
end
|
|
389
393
|
|
|
394
|
+
# The header this machine's chi web names the signed-in user in (a
|
|
395
|
+
# link can be bound to an email: chi share --identity), nil when it
|
|
396
|
+
# has no such proxy: config's web.public_url with
|
|
397
|
+
# web.proxy.identity_header, or a running chi web --tunnel's.
|
|
398
|
+
# @return [String, nil]
|
|
399
|
+
def identity_header = configured_public_url&.identity_header
|
|
400
|
+
|
|
390
401
|
# @return [Array<ParticipantView>] the ones it revoked
|
|
391
402
|
def revoke_participants(id = nil, participant: nil)
|
|
392
403
|
Participants.revoke(grants, session_id: id, id: participant, state_dir: state_dir!)
|
|
@@ -440,7 +451,7 @@ module Samagotchi
|
|
|
440
451
|
configured = Config.get("web.public_url")
|
|
441
452
|
return Web::Running.live_public_url(path: Web::Running.path(state_dir: state_dir!)) if configured.to_s.strip.empty?
|
|
442
453
|
|
|
443
|
-
Web::PublicUrl.from_config(url: configured)
|
|
454
|
+
Web::PublicUrl.from_config(url: configured, identity_header: Config.get("web.proxy.identity_header"))
|
|
444
455
|
rescue Web::PublicUrl::Invalid
|
|
445
456
|
nil
|
|
446
457
|
end
|
|
@@ -122,7 +122,7 @@ module Samagotchi
|
|
|
122
122
|
end
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
-
ACCESS_ERRORS = [TokenRevoked, TokenExpired, NotPermitted, TooManyTurns, CapReached].freeze
|
|
125
|
+
ACCESS_ERRORS = [TokenRevoked, TokenExpired, TokenBound, NotPermitted, TooManyTurns, CapReached].freeze
|
|
126
126
|
|
|
127
127
|
# --- ids and sessions
|
|
128
128
|
|
|
@@ -252,15 +252,37 @@ module Samagotchi
|
|
|
252
252
|
end
|
|
253
253
|
|
|
254
254
|
# @param ttl [Integer, nil] seconds; nil: the grant's default
|
|
255
|
+
# @param identity [String, nil] bind the grant to this email: only a
|
|
256
|
+
# chi web with bound grants (BOUND_FEATURE) is asked; an older one
|
|
257
|
+
# would ignore it and issue a grant anyone could open
|
|
255
258
|
# @return [Invitation]
|
|
256
|
-
# @raise [BadRequest] a bad name, kind or
|
|
257
|
-
|
|
258
|
-
|
|
259
|
+
# @raise [BadRequest] a bad name, kind, ttl or identity, an archived
|
|
260
|
+
# session, an identity that chi web has no proxy to check
|
|
261
|
+
# @raise [RemoteTooOld] an identity, and a chi web without bound grants
|
|
262
|
+
def invite(id, name:, kind:, ttl: nil, cap: nil, identity: nil)
|
|
263
|
+
require_feature!(BOUND_FEATURE) unless identity.nil?
|
|
264
|
+
body = { name: name, kind: kind, ttl: ttl, cap: cap, identity: identity }.compact
|
|
265
|
+
reply = request(:post, "#{session_path(id)}/participants", body: body)
|
|
259
266
|
raise_refusal(reply) unless reply.status == 201
|
|
260
267
|
|
|
261
268
|
json = reply.json
|
|
262
|
-
|
|
263
|
-
|
|
269
|
+
participant = ParticipantView.from_h(json.fetch("participant"))
|
|
270
|
+
check_bound!(id, participant, identity)
|
|
271
|
+
Invitation.new(participant: participant, token: json.fetch("token"), link: json.fetch("link"),
|
|
272
|
+
lan: json["lan"] == true, warning: json["warning"])
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Web::App::BOUND_FEATURE: POST …/participants takes an identity.
|
|
276
|
+
BOUND_FEATURE = "bound_grants"
|
|
277
|
+
|
|
278
|
+
# The header that chi web names the signed-in user in (GET
|
|
279
|
+
# /api/info's proxy), nil when it has no such proxy, or is a chi
|
|
280
|
+
# web from before it said.
|
|
281
|
+
# @return [String, nil]
|
|
282
|
+
def identity_header
|
|
283
|
+
check_server!
|
|
284
|
+
header = @proxy.is_a?(Hash) ? @proxy["identity_header"] : nil
|
|
285
|
+
header.is_a?(String) && !header.empty? ? header : nil
|
|
264
286
|
end
|
|
265
287
|
|
|
266
288
|
# Revokes one participant (+participant+), a session's, or with no
|
|
@@ -602,6 +624,7 @@ module Samagotchi
|
|
|
602
624
|
if reply.json.is_a?(Hash)
|
|
603
625
|
@version = reply.json["version"]
|
|
604
626
|
@features = Array(reply.json["features"])
|
|
627
|
+
@proxy = reply.json["proxy"]
|
|
605
628
|
end
|
|
606
629
|
problem = Remote.info_problem(@entry, reply)
|
|
607
630
|
raise problem if problem
|
|
@@ -611,6 +634,18 @@ module Samagotchi
|
|
|
611
634
|
|
|
612
635
|
def too_old(version) = Remote.too_old_message(@entry, version)
|
|
613
636
|
|
|
637
|
+
# A grant asked bound to +identity+ came back bound to it, or is
|
|
638
|
+
# revoked at once (its link never printed) and refused: belt and
|
|
639
|
+
# braces behind BOUND_FEATURE.
|
|
640
|
+
# @raise [RemoteFailed]
|
|
641
|
+
def check_bound!(id, participant, identity)
|
|
642
|
+
return if identity.nil? || participant.identity == identity.to_s.strip.downcase
|
|
643
|
+
|
|
644
|
+
revoke_participants(id, participant: participant.id)
|
|
645
|
+
raise RemoteFailed, "#{@entry.label}: its chi web issued #{participant.id} unbound, so chi revoked it: " \
|
|
646
|
+
"update chi there (chi update)"
|
|
647
|
+
end
|
|
648
|
+
|
|
614
649
|
def strategy_or_hard(json) = strategy_of(json) || Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
|
|
615
650
|
|
|
616
651
|
# chi web's 404 for a path no route takes (#serve_static's), not a
|
|
@@ -730,7 +765,7 @@ module Samagotchi
|
|
|
730
765
|
# chi web's answer/dismiss reply as the worker's Bridge would have
|
|
731
766
|
# given it: 503 not_live is no worker (nil), 504 the Bridge's 408, 501
|
|
732
767
|
# not_supported its 404 for a route it lacks, 409 question_not_pending
|
|
733
|
-
# its 409; the rest as it came.
|
|
768
|
+
# its 409, 502 bridge_secret its 403 (BridgeSecret); the rest as it came.
|
|
734
769
|
def bridge_reply(reply)
|
|
735
770
|
json = reply.json.is_a?(Hash) ? reply.json : {}
|
|
736
771
|
case [reply.status, json["error"]]
|
|
@@ -738,6 +773,7 @@ module Samagotchi
|
|
|
738
773
|
in [404, "not_found"] then nil
|
|
739
774
|
in [504, _] then BridgeClient::Response.new(status: 408, body: reply.body)
|
|
740
775
|
in [501, "not_supported"] then BridgeClient::Response.new(status: 404, body: JSON.generate(json.merge("error" => "not_found")))
|
|
776
|
+
in [502, BridgeSecret::ERROR] then BridgeClient::Response.new(status: 403, body: reply.body)
|
|
741
777
|
else BridgeClient::Response.new(status: reply.status, body: reply.body)
|
|
742
778
|
end
|
|
743
779
|
end
|
|
@@ -51,6 +51,13 @@ module Samagotchi
|
|
|
51
51
|
def initialize(label) = super("#{label}: #{ParticipantMessages::TOKEN_EXPIRED}")
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# 401 token_bound: a link bound to one account, used without that
|
|
55
|
+
# account's sign-in (chi --remote doesn't go through the proxy).
|
|
56
|
+
class TokenBound < AuthRefused
|
|
57
|
+
CODE = "token_bound"
|
|
58
|
+
def initialize(label) = super("#{label}: #{ParticipantMessages::TOKEN_BOUND}")
|
|
59
|
+
end
|
|
60
|
+
|
|
54
61
|
# 403 not_permitted: a participant's token on a route only the session's
|
|
55
62
|
# owner may use.
|
|
56
63
|
class NotPermitted < RemoteFailed
|
|
@@ -94,8 +101,11 @@ module Samagotchi
|
|
|
94
101
|
# (Web::Grants::Grant#public_h): never its token or digest. +state+
|
|
95
102
|
# "active", "revoked" or "expired".
|
|
96
103
|
# +cap+ dollars and +queue+ turns (nil from a chi web before them).
|
|
104
|
+
# +identity+ the email the grant is bound to, nil for any.
|
|
97
105
|
ParticipantView = Data.define(:id, :session_id, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
|
|
98
|
-
:revoked_by, :last_used_at, :state, :cap, :queue) do
|
|
106
|
+
:revoked_by, :last_used_at, :state, :cap, :queue, :identity) do
|
|
107
|
+
def initialize(identity: nil, **) = super
|
|
108
|
+
|
|
99
109
|
def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key] || hash[key.to_s]] })
|
|
100
110
|
end
|
|
101
111
|
|
|
@@ -689,7 +689,7 @@ module Samagotchi
|
|
|
689
689
|
raise RestartRefused.new(session_id, :unsupported, version: sidecar.version)
|
|
690
690
|
end
|
|
691
691
|
|
|
692
|
-
reply = BridgeClient.
|
|
692
|
+
reply = BridgeClient.for_sidecar(session_id, sidecar).request_exit(client_id: client_id, restart: true)
|
|
693
693
|
raise RestartRefused.new(session_id, reply.json&.fetch("reason", nil) || :failed) if reply.status == 409
|
|
694
694
|
raise RestartRefused.new(session_id, :failed) unless reply.status == 200
|
|
695
695
|
|
|
@@ -371,8 +371,7 @@ module Samagotchi
|
|
|
371
371
|
# @return [Array(Hash, nil, Boolean, String, nil)]
|
|
372
372
|
def stats_snapshot_for(session)
|
|
373
373
|
dir = Samagotchi::Session.session_dir(session.id)
|
|
374
|
-
if (
|
|
375
|
-
client = Samagotchi::BridgeClient.new(session_id: session.id, port: port, read_timeout: STATS_TIMEOUT)
|
|
374
|
+
if (client = Samagotchi::BridgeClient.discover(session.id, session_dir: dir, read_timeout: STATS_TIMEOUT))
|
|
376
375
|
body = client.get_json("stats")&.dig("metrics")
|
|
377
376
|
if body.is_a?(Hash)
|
|
378
377
|
return [TerminalUI::EventRenderer.deep_symbolize_keys(body), true, nil]
|
|
@@ -22,13 +22,16 @@ module Samagotchi
|
|
|
22
22
|
include CLI::Command
|
|
23
23
|
|
|
24
24
|
USAGE = <<~TEXT
|
|
25
|
-
Usage: chi share ID --name NAME [--agent] [--expires 3d] [--cap 2]
|
|
25
|
+
Usage: chi share ID --name NAME [--identity EMAIL] [--agent] [--expires 3d] [--cap 2]
|
|
26
26
|
chi share ls [ID] [--format text|json]
|
|
27
27
|
chi share cap ID PARTICIPANT DOLLARS
|
|
28
28
|
chi share revoke ID [PARTICIPANT] | --all
|
|
29
29
|
ID --name NAME invite NAME to review session ID: prints the link they open (chi web on
|
|
30
30
|
the LAN, web.host: lan) and its QR code at a terminal. The link is shown
|
|
31
31
|
once; chi keeps only a digest of its token.
|
|
32
|
+
--identity EMAIL bind the link to that account: it opens only through chi web's auth proxy
|
|
33
|
+
signed in as EMAIL (web.public_url with web.proxy.identity_header, or
|
|
34
|
+
chi web --tunnel tailscale); a forwarded link opens for nobody else
|
|
32
35
|
--agent the participant is an agent (the same link, used as a Bearer token)
|
|
33
36
|
--expires N Nh or Nd, 1h to 30d (default 7d, web.participants.expires)
|
|
34
37
|
--cap DOLLARS what their turns may cost you, $0 to $1000 (default $2, web.participants.cap)
|
|
@@ -50,6 +53,7 @@ module Samagotchi
|
|
|
50
53
|
# The invite's flags (chi share ID …), and ls's and revoke's.
|
|
51
54
|
FLAGS = CLI::Flags.new(help: HELP_WORDS) do |f|
|
|
52
55
|
f.value "--name"
|
|
56
|
+
f.value "--identity"
|
|
53
57
|
f.switch "--agent"
|
|
54
58
|
f.value "--expires"
|
|
55
59
|
f.value "--cap"
|
|
@@ -91,9 +95,9 @@ module Samagotchi
|
|
|
91
95
|
return parsed if parsed.is_a?(Integer)
|
|
92
96
|
|
|
93
97
|
send(:"run_#{@sub || "invite"}", parsed.options, parsed.args)
|
|
94
|
-
rescue SessionAccess::BadRequest => e
|
|
98
|
+
rescue SessionAccess::BadRequest, Web::Grants::BadIdentity => e
|
|
95
99
|
usage_error(e.message)
|
|
96
|
-
rescue Web::Grants::
|
|
100
|
+
rescue Web::Grants::Unwritable => e
|
|
97
101
|
error_line("#{command_name}: #{e.message}")
|
|
98
102
|
CLI::Exit::FAILED
|
|
99
103
|
rescue ArgumentError => e
|
|
@@ -116,9 +120,13 @@ module Samagotchi
|
|
|
116
120
|
cap = options[:cap] && dollars(options[:cap])
|
|
117
121
|
return usage_error(CAP_USAGE) if cap == false
|
|
118
122
|
|
|
123
|
+
identity = options[:identity] && Web::Grants.clean_identity(options[:identity])
|
|
124
|
+
|
|
119
125
|
id = @access.resolve(args.first)
|
|
120
|
-
invitation = @access.invite(id, name: options[:name], kind: options[:agent] ? "agent" : "human", ttl: ttl, cap: cap
|
|
126
|
+
invitation = @access.invite(id, name: options[:name], kind: options[:agent] ? "agent" : "human", ttl: ttl, cap: cap,
|
|
127
|
+
identity: identity)
|
|
121
128
|
print_invitation(id, invitation)
|
|
129
|
+
warn_unbound(invitation.participant)
|
|
122
130
|
@changes = [[:joined, invitation.participant.id]]
|
|
123
131
|
0
|
|
124
132
|
end
|
|
@@ -186,10 +194,21 @@ module Samagotchi
|
|
|
186
194
|
0
|
|
187
195
|
end
|
|
188
196
|
|
|
197
|
+
# Decision B: an unbound link behind a proxy that could bind it opens
|
|
198
|
+
# for anyone the proxy lets in who holds it.
|
|
199
|
+
def warn_unbound(row)
|
|
200
|
+
return unless row.identity.nil? && (header = @access.identity_header)
|
|
201
|
+
|
|
202
|
+
error_line("#{command_name}: warning: #{format(UNBOUND_WARNING, header: header)}")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
UNBOUND_WARNING = "this link isn't bound to an account: anyone the proxy lets in who gets it can open it " \
|
|
206
|
+
"(--identity EMAIL binds it to the email %<header>s carries)"
|
|
207
|
+
|
|
189
208
|
def print_invitation(id, invitation)
|
|
190
209
|
row = invitation.participant
|
|
191
210
|
@stdout.puts("#{row.id} #{row.name} (#{row.kind}, #{row.role}) · session #{id[0, 8]} · expires " \
|
|
192
|
-
"#{local(row.expires_at, "%Y-%m-%d %H:%M")} · cap #{money(row.cap)}")
|
|
211
|
+
"#{local(row.expires_at, "%Y-%m-%d %H:%M")} · cap #{money(row.cap)}#{" · bound to #{row.identity}" if row.identity}")
|
|
193
212
|
@stdout.puts(invitation.link)
|
|
194
213
|
@stdout.puts("(shown once: chi keeps only its digest; chi share revoke #{id[0, 8]} #{row.id} ends it)")
|
|
195
214
|
if invitation.warning
|
|
@@ -201,12 +220,14 @@ module Samagotchi
|
|
|
201
220
|
@stdout.puts(Web::QR.lines(invitation.link)) if @tty
|
|
202
221
|
end
|
|
203
222
|
|
|
204
|
-
COLUMNS = ["SESSION", "PARTICIPANT", "NAME", "KIND", "ROLE", "CREATED", "EXPIRES", "LAST USED", "CAP",
|
|
223
|
+
COLUMNS = ["SESSION", "PARTICIPANT", "NAME", "IDENTITY", "KIND", "ROLE", "CREATED", "EXPIRES", "LAST USED", "CAP",
|
|
224
|
+
"STATE"].freeze
|
|
205
225
|
|
|
206
226
|
def print_table(rows)
|
|
207
227
|
cells = rows.map do |row|
|
|
208
|
-
[row.session_id[0, 8], row.id, row.name, row.
|
|
209
|
-
row.last_used_at ? local(row.last_used_at) : "-", money(row.cap),
|
|
228
|
+
[row.session_id[0, 8], row.id, row.name, row.identity ? "#{row.identity} · bound" : "-", row.kind, row.role,
|
|
229
|
+
local(row.created_at), local(row.expires_at), row.last_used_at ? local(row.last_used_at) : "-", money(row.cap),
|
|
230
|
+
row.state]
|
|
210
231
|
end
|
|
211
232
|
widths = COLUMNS.each_index.map { |i| ([COLUMNS[i]] + cells.map { |c| c[i] }).map(&:length).max }
|
|
212
233
|
[COLUMNS, *cells].each do |line|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../client_id"
|
|
4
|
+
require_relative "../bridge_secret"
|
|
4
5
|
require_relative "../composer_route"
|
|
5
6
|
require_relative "event_renderer"
|
|
6
7
|
require_relative "formatting"
|
|
@@ -450,6 +451,8 @@ module Samagotchi
|
|
|
450
451
|
# The stream ended for good: why, and what brings the session back.
|
|
451
452
|
def closed_line(reason)
|
|
452
453
|
id = @client.session_id
|
|
454
|
+
# The worker asked for a secret this terminal's chi doesn't send.
|
|
455
|
+
return "Lost the session's worker: #{BridgeSecret::REFUSED}" if reason == BridgeSecret::ERROR
|
|
453
456
|
return remote_closed_line(reason, id) if via.remote?
|
|
454
457
|
|
|
455
458
|
# Here a stopped session has always read as unreachable.
|
|
@@ -55,12 +55,27 @@ module Samagotchi
|
|
|
55
55
|
id = @client.session_id
|
|
56
56
|
return [:gone, "stopped"] if Session.stopped_marker?(id, state_dir: @state_dir)
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
# Kept: the stream moves to this very client (EventStream), and
|
|
59
|
+
# #switched takes it, port and secret, rather than reading again.
|
|
60
|
+
@found = BridgeClient.discover(id, session_dir: session_dir, host: @client.host)
|
|
61
|
+
[@found, nil]
|
|
59
62
|
end
|
|
60
63
|
|
|
61
|
-
# A new worker's Bridge
|
|
64
|
+
# A new worker's Bridge: the client #live_worker found for the stream
|
|
65
|
+
# (the event names its port), with that worker's secret. One the event
|
|
66
|
+
# doesn't name (another worker since) is read from the sidecar for that
|
|
67
|
+
# port, none when the sidecar names another one by now: that worker is
|
|
68
|
+
# gone too, and the stream finds the next.
|
|
62
69
|
def switched(event)
|
|
63
|
-
|
|
70
|
+
port = AttachLink.event_port(event)
|
|
71
|
+
found = @found
|
|
72
|
+
@client = if found&.port == port
|
|
73
|
+
found
|
|
74
|
+
else
|
|
75
|
+
sidecar = WorkerSidecar.read(session_dir)
|
|
76
|
+
sidecar = WorkerSidecar.new(port: port) unless sidecar&.port == port
|
|
77
|
+
BridgeClient.for_sidecar(@client.session_id, sidecar, host: @client.host)
|
|
78
|
+
end
|
|
64
79
|
end
|
|
65
80
|
|
|
66
81
|
# No sidecar to read: true, its Bridge answers (a P0-P2 worker refuses
|
|
@@ -15,6 +15,12 @@ module Samagotchi
|
|
|
15
15
|
# A numbered option shows as "1) Apple", a word as "yes: continue".
|
|
16
16
|
Option = Struct.new(:key, :label) do
|
|
17
17
|
def text = key.match?(/\A\d+\z/) ? "#{key}) #{label}" : "#{key}: #{label}"
|
|
18
|
+
|
|
19
|
+
# The model's pick (a label ending in "(Recommended)"): painted bold,
|
|
20
|
+
# its label kept as is so a typed label still matches.
|
|
21
|
+
def recommended? = label.to_s.rstrip.end_with?("(Recommended)")
|
|
22
|
+
|
|
23
|
+
def code = recommended? ? "1;92" : 92
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
CONTINUE_QUESTION = "The turn ran out of iterations. Continue it?"
|
|
@@ -83,7 +89,7 @@ module Samagotchi
|
|
|
83
89
|
question_rows = question_rows.first(1) unless details
|
|
84
90
|
rows.concat(question_rows.map { |row| @paint.call(row, @question_code) })
|
|
85
91
|
rows.concat(@details.map { |row| @paint.call(row, @question_code) }) if details
|
|
86
|
-
rows.concat(folded ? folded_options(width) : @options.map { |o| @paint.call(" #{o.text}",
|
|
92
|
+
rows.concat(folded ? folded_options(width) : @options.map { |o| @paint.call(" #{o.text}", o.code) })
|
|
87
93
|
rows << @paint.call(" [#{@hint}]", 90) if hint && @hint
|
|
88
94
|
rows
|
|
89
95
|
end
|
|
@@ -91,19 +97,24 @@ module Samagotchi
|
|
|
91
97
|
# The options packed into rows of +width+: " 1) Allow once · 2) Deny".
|
|
92
98
|
def folded_options(width)
|
|
93
99
|
rows = []
|
|
94
|
-
line =
|
|
100
|
+
line = []
|
|
95
101
|
@options.each do |option|
|
|
96
|
-
|
|
97
|
-
candidate = line.empty? ? " #{item}" : "#{line} · #{item}"
|
|
98
|
-
if !line.empty? && display_width(candidate) > width
|
|
102
|
+
if !line.empty? && display_width(" #{(line + [option]).map(&:text).join(" · ")}") > width
|
|
99
103
|
rows << line
|
|
100
|
-
line =
|
|
104
|
+
line = [option]
|
|
101
105
|
else
|
|
102
|
-
line
|
|
106
|
+
line << option
|
|
103
107
|
end
|
|
104
108
|
end
|
|
105
109
|
rows << line unless line.empty?
|
|
106
|
-
rows.map { |row|
|
|
110
|
+
rows.map { |row| paint_folded(row) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# One folded row; a recommended option in it is painted on its own.
|
|
114
|
+
def paint_folded(options)
|
|
115
|
+
return @paint.call(" #{options.map(&:text).join(" · ")}", 92) if options.none?(&:recommended?)
|
|
116
|
+
|
|
117
|
+
@paint.call(" ", 92) + options.map { |o| @paint.call(o.text, o.code) }.join(@paint.call(" · ", 92))
|
|
107
118
|
end
|
|
108
119
|
|
|
109
120
|
# Word wrap at +width+ columns, the first row after +first+, the others
|
|
@@ -412,7 +412,7 @@ module Samagotchi
|
|
|
412
412
|
},
|
|
413
413
|
{
|
|
414
414
|
name: "ask_user_question",
|
|
415
|
-
description: "Ask the user a structured qualification question. Supports single/multi selection plus optional freeform/Other input. Prefer this over plain numbered lists when you need a clear choice. The harness renders it natively and returns {selected, freeform}.",
|
|
415
|
+
description: "Ask the user a structured qualification question. Supports single/multi selection plus optional freeform/Other input. Prefer this over plain numbered lists when you need a clear choice. The harness renders it natively and returns {selected, freeform}. If you recommend one option, put it first and end its label with ' (Recommended)'.",
|
|
416
416
|
parameters: {
|
|
417
417
|
type: "object",
|
|
418
418
|
properties: {
|
|
@@ -77,7 +77,16 @@ module Samagotchi
|
|
|
77
77
|
# hook, no approval card for a call that can't run (KernelLoop#dispatch
|
|
78
78
|
# answers it with the not-loaded error).
|
|
79
79
|
original = call.dup
|
|
80
|
-
|
|
80
|
+
refusal = unreached_group_refusal(call)
|
|
81
|
+
verdict = if refusal
|
|
82
|
+
Guardrails::Verdict.new(call: call).deny!(refusal.message,
|
|
83
|
+
rule: Tools::ToolIndex::ToolkitGroupRefusal::RULE,
|
|
84
|
+
source: "core", decided_by: "core")
|
|
85
|
+
elsif left_out?(call)
|
|
86
|
+
Guardrails::Verdict.new(call: call)
|
|
87
|
+
else
|
|
88
|
+
evaluate(call, iteration, params)
|
|
89
|
+
end
|
|
81
90
|
call = verdict.call
|
|
82
91
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
83
92
|
label = plugin_label(call[:name])
|
|
@@ -378,6 +387,27 @@ module Samagotchi
|
|
|
378
387
|
!registry.left_out(call[:name], layers: layers).nil?
|
|
379
388
|
end
|
|
380
389
|
|
|
390
|
+
def unreached_group_refusal(call)
|
|
391
|
+
return if participant_turn?
|
|
392
|
+
|
|
393
|
+
registry = tools
|
|
394
|
+
index = registry&.index if registry.respond_to?(:index)
|
|
395
|
+
return unless index.respond_to?(:unreached_tool)
|
|
396
|
+
|
|
397
|
+
name = if call[:name] == index.call_name
|
|
398
|
+
args = call[:args].is_a?(Hash) ? call[:args] : {}
|
|
399
|
+
args["tool"] || args[:tool]
|
|
400
|
+
else
|
|
401
|
+
call[:name]
|
|
402
|
+
end
|
|
403
|
+
tool = index.unreached_tool(name)
|
|
404
|
+
tool && Tools::ToolIndex::ToolkitGroupRefusal.for(tool, registry)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def participant_turn?
|
|
408
|
+
@kernel.respond_to?(:participant_turn?) && @kernel.participant_turn?
|
|
409
|
+
end
|
|
410
|
+
|
|
381
411
|
# Whether the turn's host loads tools (TurnSettings#loads_tools).
|
|
382
412
|
def loads_tools? = @kernel.respond_to?(:turn_settings) && @kernel.turn_settings&.loads_tools == true
|
|
383
413
|
|
|
@@ -431,6 +461,12 @@ module Samagotchi
|
|
|
431
461
|
# A legacy veto keeps its old text; a verdict's deny tells the model
|
|
432
462
|
# who decided and not to route around it.
|
|
433
463
|
def denied(call, verdict)
|
|
464
|
+
if verdict.rule == Tools::ToolIndex::ToolkitGroupRefusal::RULE
|
|
465
|
+
output = "[#{call[:name]}] #{verdict.reason}"
|
|
466
|
+
activity = ToolActivity.tool_activity_event(call[:name], call, output, registry: tools)
|
|
467
|
+
return { output: output, activity: activity.merge(status: "blocked", guardrail: verdict.to_activity) }
|
|
468
|
+
end
|
|
469
|
+
|
|
434
470
|
output = if verdict.legacy?
|
|
435
471
|
reason = verdict.reason.to_s.strip
|
|
436
472
|
"[#{call[:name]}] Error: blocked by guardrail: #{reason.empty? ? "blocked by hook" : reason}"
|