samagotchi 0.153.0 → 0.154.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 (205) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +2 -3
  4. data/bin/chi +23 -24
  5. data/docs/architecture.md +30 -33
  6. data/docs/broadcast.md +5 -5
  7. data/docs/bundles.md +9 -12
  8. data/docs/cli.md +80 -96
  9. data/docs/configuration.md +27 -26
  10. data/docs/context.md +3 -3
  11. data/docs/desktop.md +1 -1
  12. data/docs/guardrails.md +8 -10
  13. data/docs/hooks.md +9 -9
  14. data/docs/internals/attached-context.md +2 -2
  15. data/docs/internals/background-tasks.md +3 -4
  16. data/docs/internals/block-renderers.md +4 -0
  17. data/docs/internals/bridge.md +3 -3
  18. data/docs/internals/context-telemetry.md +1 -1
  19. data/docs/internals/engine.md +33 -7
  20. data/docs/internals/objectives.md +6 -5
  21. data/docs/internals/participants.md +7 -7
  22. data/docs/internals/prompt-caching.md +1 -1
  23. data/docs/internals/remote-sessions.md +7 -7
  24. data/docs/internals/web-push.md +1 -1
  25. data/docs/memory.md +2 -2
  26. data/docs/plugins.md +23 -28
  27. data/docs/sessions.md +24 -24
  28. data/docs/sub-agent.md +2 -2
  29. data/docs/system-one.md +1 -1
  30. data/docs/testing.md +16 -6
  31. data/lib/samagotchi/answer_text.rb +34 -0
  32. data/lib/samagotchi/bridge.rb +43 -32
  33. data/lib/samagotchi/bridge_client.rb +10 -9
  34. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  35. data/lib/samagotchi/broadcast/triage.rb +1 -1
  36. data/lib/samagotchi/broadcast_command.rb +5 -5
  37. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  38. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  39. data/lib/samagotchi/bundle_command.rb +2 -2
  40. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  41. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  42. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  44. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  45. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  46. data/lib/samagotchi/child_move.rb +3 -3
  47. data/lib/samagotchi/commands/registry.rb +6 -6
  48. data/lib/samagotchi/composer_route.rb +4 -5
  49. data/lib/samagotchi/config.rb +14 -12
  50. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  51. data/lib/samagotchi/context_candidates.rb +4 -3
  52. data/lib/samagotchi/continue_offer.rb +1 -1
  53. data/lib/samagotchi/debug_log.rb +1 -1
  54. data/lib/samagotchi/degrade.rb +75 -0
  55. data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
  56. data/lib/samagotchi/engine/cards.rb +87 -0
  57. data/lib/samagotchi/engine/context_judge.rb +138 -0
  58. data/lib/samagotchi/engine/events.rb +131 -0
  59. data/lib/samagotchi/engine/guardrails.rb +87 -0
  60. data/lib/samagotchi/engine/hooks.rb +119 -0
  61. data/lib/samagotchi/engine/messages.rb +124 -0
  62. data/lib/samagotchi/engine/model_settings.rb +127 -0
  63. data/lib/samagotchi/engine/models.rb +261 -0
  64. data/lib/samagotchi/engine/objectives.rb +116 -0
  65. data/lib/samagotchi/engine/participants.rb +86 -0
  66. data/lib/samagotchi/engine/plugins.rb +208 -0
  67. data/lib/samagotchi/engine/questions.rb +98 -0
  68. data/lib/samagotchi/engine/recap.rb +130 -0
  69. data/lib/samagotchi/engine/session.rb +131 -0
  70. data/lib/samagotchi/engine/side_requests.rb +135 -0
  71. data/lib/samagotchi/engine/steering.rb +134 -0
  72. data/lib/samagotchi/engine/toolkit.rb +269 -0
  73. data/lib/samagotchi/engine/turn_end.rb +180 -0
  74. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  75. data/lib/samagotchi/engine/verify.rb +153 -0
  76. data/lib/samagotchi/engine.rb +62 -2646
  77. data/lib/samagotchi/grants.rb +575 -0
  78. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  79. data/lib/samagotchi/guardrails/context.rb +2 -3
  80. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  81. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  82. data/lib/samagotchi/hook_routes.rb +3 -3
  83. data/lib/samagotchi/idle_recap.rb +4 -5
  84. data/lib/samagotchi/idle_reminders.rb +3 -4
  85. data/lib/samagotchi/iteration_limit.rb +2 -2
  86. data/lib/samagotchi/kernel_loop.rb +10 -11
  87. data/lib/samagotchi/launch_mode.rb +13 -32
  88. data/lib/samagotchi/level_setting_command.rb +3 -2
  89. data/lib/samagotchi/llm_context_command.rb +3 -2
  90. data/lib/samagotchi/log.rb +7 -7
  91. data/lib/samagotchi/log_line.rb +1 -1
  92. data/lib/samagotchi/log_path.rb +1 -1
  93. data/lib/samagotchi/note_command.rb +1 -1
  94. data/lib/samagotchi/note_delivery.rb +18 -8
  95. data/lib/samagotchi/objectives.rb +24 -3
  96. data/lib/samagotchi/objectives_check.rb +8 -27
  97. data/lib/samagotchi/objectives_command.rb +4 -69
  98. data/lib/samagotchi/objectives_edit.rb +87 -0
  99. data/lib/samagotchi/output_formatter.rb +7 -1
  100. data/lib/samagotchi/owner_lock.rb +7 -1
  101. data/lib/samagotchi/parent_answers_command.rb +5 -4
  102. data/lib/samagotchi/participant_exposure.rb +42 -0
  103. data/lib/samagotchi/participant_mode.rb +1 -1
  104. data/lib/samagotchi/participant_name.rb +1 -1
  105. data/lib/samagotchi/participants.rb +17 -17
  106. data/lib/samagotchi/participants_watch.rb +1 -1
  107. data/lib/samagotchi/pending_input_queue.rb +2 -2
  108. data/lib/samagotchi/plugin/api.rb +3 -2
  109. data/lib/samagotchi/plugin/context.rb +21 -7
  110. data/lib/samagotchi/plugin/peer.rb +2 -1
  111. data/lib/samagotchi/plugin/sessions.rb +6 -4
  112. data/lib/samagotchi/plugin_tasks.rb +1 -2
  113. data/lib/samagotchi/private_files.rb +29 -0
  114. data/lib/samagotchi/project_env.rb +2 -2
  115. data/lib/samagotchi/question_desk.rb +8 -7
  116. data/lib/samagotchi/recap_command.rb +2 -1
  117. data/lib/samagotchi/recap_setup.rb +1 -1
  118. data/lib/samagotchi/recap_store.rb +1 -1
  119. data/lib/samagotchi/redact.rb +86 -0
  120. data/lib/samagotchi/reminder_queue.rb +2 -2
  121. data/lib/samagotchi/remote_attach_command.rb +1 -1
  122. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  123. data/lib/samagotchi/review_start.rb +1 -1
  124. data/lib/samagotchi/run_options.rb +6 -6
  125. data/lib/samagotchi/self_report.rb +42 -39
  126. data/lib/samagotchi/send_command/busy.rb +1 -1
  127. data/lib/samagotchi/send_command.rb +4 -1
  128. data/lib/samagotchi/session.rb +11 -2
  129. data/lib/samagotchi/session_access/contract.rb +3 -3
  130. data/lib/samagotchi/session_access/local.rb +9 -9
  131. data/lib/samagotchi/session_access/remote.rb +17 -7
  132. data/lib/samagotchi/session_access.rb +4 -4
  133. data/lib/samagotchi/session_archive_command.rb +2 -2
  134. data/lib/samagotchi/session_chain.rb +8 -6
  135. data/lib/samagotchi/session_commands.rb +45 -48
  136. data/lib/samagotchi/session_continue.rb +1 -1
  137. data/lib/samagotchi/session_delete_command.rb +4 -4
  138. data/lib/samagotchi/session_manager.rb +59 -36
  139. data/lib/samagotchi/session_metrics.rb +2 -2
  140. data/lib/samagotchi/session_model_settings.rb +3 -2
  141. data/lib/samagotchi/session_participants.rb +1 -1
  142. data/lib/samagotchi/session_retention.rb +3 -3
  143. data/lib/samagotchi/sessions_command.rb +4 -4
  144. data/lib/samagotchi/share_command.rb +10 -10
  145. data/lib/samagotchi/steer.rb +1 -1
  146. data/lib/samagotchi/system_prompt.rb +102 -109
  147. data/lib/samagotchi/task_watch.rb +1 -1
  148. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  149. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  150. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  151. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  152. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  153. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
  154. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  155. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  156. data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
  157. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  158. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  159. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  160. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  161. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  162. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  163. data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
  164. data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
  165. data/lib/samagotchi/terminal_ui.rb +142 -1387
  166. data/lib/samagotchi/title_command.rb +2 -2
  167. data/lib/samagotchi/tool_runner.rb +14 -16
  168. data/lib/samagotchi/toolkit_command.rb +2 -1
  169. data/lib/samagotchi/tools/delegate.rb +3 -1
  170. data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
  171. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  172. data/lib/samagotchi/tools/objectives_tool.rb +2 -2
  173. data/lib/samagotchi/tools/send_note.rb +2 -1
  174. data/lib/samagotchi/turn_endings.rb +2 -2
  175. data/lib/samagotchi/turn_flow.rb +4 -5
  176. data/lib/samagotchi/update_hint.rb +2 -2
  177. data/lib/samagotchi/version.rb +1 -1
  178. data/lib/samagotchi/web/app.rb +39 -50
  179. data/lib/samagotchi/web/participant_access.rb +2 -2
  180. data/lib/samagotchi/web/participant_review.rb +5 -5
  181. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  182. data/lib/samagotchi/web/participant_view.rb +2 -82
  183. data/lib/samagotchi/web/participants_admin.rb +1 -1
  184. data/lib/samagotchi/web/public/block_host.js +35 -8
  185. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  186. data/lib/samagotchi/web/public/chains.js +2 -2
  187. data/lib/samagotchi/web/public/claim_link.js +4 -4
  188. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  189. data/lib/samagotchi/web/public/format.js +3 -3
  190. data/lib/samagotchi/web/push/store.rb +1 -1
  191. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  192. data/lib/samagotchi/web/session_control.rb +12 -8
  193. data/lib/samagotchi/web/session_create.rb +1 -1
  194. data/lib/samagotchi/web/session_hub.rb +2 -2
  195. data/lib/samagotchi/web/session_show.rb +2 -2
  196. data/lib/samagotchi/web/session_summary.rb +1 -1
  197. data/lib/samagotchi/worker.rb +100 -9
  198. data/lib/samagotchi/worker_link.rb +3 -3
  199. data/lib/samagotchi/working_set_command.rb +3 -2
  200. metadata +29 -6
  201. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  202. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  203. data/lib/samagotchi/web/grants.rb +0 -575
  204. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  205. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -0,0 +1,575 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "openssl"
7
+ require_relative "steer"
8
+ require_relative "participant_mode"
9
+ require "securerandom"
10
+ require "time"
11
+ require_relative "atomic_file"
12
+ require_relative "config"
13
+ require_relative "log"
14
+ require_relative "participant_name"
15
+ require_relative "session"
16
+ require_relative "redacted_inspect"
17
+ require_relative "private_files"
18
+ require_relative "participant_exposure"
19
+
20
+ module Samagotchi
21
+ # Participants' access to one session each (the shared-sessions plan,
22
+ # security model §1): a grant binds one random token to one session, one
23
+ # participant and one role, until it expires or is revoked. Only the
24
+ # token's SHA-256 digest is kept, in
25
+ # $XDG_STATE_HOME/samagotchi/web-grants/grants.json (0600, in its own 0700
26
+ # folder): the token is shown once, when it is issued, and a leaked file
27
+ # grants nothing. The owner's Web::Token is a different credential and
28
+ # never touches this file. Participant domain, no HTTP: core files (Participants,
29
+ # the Worker, chi share) use it without loading chi web. See
30
+ # docs/internals/participants.md.
31
+ #
32
+ # One object per process (chi web, a chi share run): writes take a lock
33
+ # file next to the store, so the two never lose each other's grants; the
34
+ # reads follow the file's mtime, as Web::Token::Source does.
35
+ class Grants
36
+ include RedactedInspect
37
+
38
+ DIR = "web-grants"
39
+ FILE = "grants.json"
40
+ LOCK = "grants.lock"
41
+ # What a participant token starts with: the gate looks up only these,
42
+ # and a leaked one is recognisable (an owner token never has it).
43
+ PREFIX = "chp_"
44
+ # 32 random bytes, as Web::Token's.
45
+ BYTES = 32
46
+ DAY = 86_400
47
+ # D8: a grant lasts a week unless chi share --expires says otherwise,
48
+ # and never more than MAX_TTL.
49
+ DEFAULT_TTL = 7 * DAY
50
+ MAX_TTL = 30 * DAY
51
+ # Ended grants (revoked, expired) are kept this long, so their token
52
+ # answers token_revoked / token_expired rather than a bare 401.
53
+ KEEP_ENDED = 30 * DAY
54
+ # last_used_at is written at most this often per grant.
55
+ TOUCH_INTERVAL = 60
56
+ KINDS = %w[human agent].freeze
57
+ # The only role in v1: a reviewer reads and (P3) comments.
58
+ ROLES = %w[reviewer].freeze
59
+ # The participant mode a grant was issued for (ParticipantMode): "pair"
60
+ # only when the owner was shown that a participant's turns act
61
+ # (ReviewStart::PAIR_WARNING); none for every other grant, and every
62
+ # grant from before it, whose turns stay review turns.
63
+ PAIR_MODE = ParticipantMode::PAIR.to_s
64
+ NAME_MAX = ParticipantName::MAX
65
+ ID_RE = /\Ap_[0-9a-f]{8}\z/
66
+ # D7: what a participant's turns (and the side calls they cause) may
67
+ # cost the owner, in dollars, unless chi share --cap says otherwise
68
+ # (config web.participants.cap); at most MAX_CAP.
69
+ DEFAULT_CAP = 2.0
70
+ MAX_CAP = 1000.0
71
+ # How many of a participant's turns may wait behind their running one
72
+ # (config web.participants.queue); at most MAX_QUEUE.
73
+ DEFAULT_QUEUE = 3
74
+ MAX_QUEUE = 20
75
+ # chi share --expires and config web.participants.expires: Nh or Nd.
76
+ EXPIRES_RE = /\A(\d+)([hd])\z/
77
+
78
+ # The store's folder or file can be read by others (chi web refuses to
79
+ # start), or a grant can't be made from what was given.
80
+ class Insecure < StandardError; end
81
+ # Not ArgumentError: the session commands read that as "no such
82
+ # session".
83
+ class Invalid < StandardError; end
84
+
85
+ # An identity to bind a grant to that isn't one email address.
86
+ class BadIdentity < Invalid
87
+ def initialize(msg = "a link is bound to one email address (name@domain: one @, no spaces or commas, ASCII " \
88
+ "only)") = super
89
+ end
90
+
91
+ # An identity asked of a chi web that can't check one: no auth proxy
92
+ # naming the signed-in user (Web::ProxyGate), so the link would never open.
93
+ class Unbindable < Invalid
94
+ def initialize(msg = "a link can be bound to an email only on a chi web behind an auth proxy that names the " \
95
+ "signed-in user (web.public_url with web.proxy.identity_header, or chi web --tunnel " \
96
+ "tailscale): bound here it would never open") = super
97
+ end
98
+
99
+ # The store's version: 1, and BOUND_VERSION once a grant is bound to
100
+ # an identity, so a chi that knows only 1 and checks (from this one
101
+ # on) doesn't rewrite it. MAX_VERSION is the newest this chi reads
102
+ # and writes; a newer store it reads but never writes (Newer).
103
+ VERSION = 1
104
+ BOUND_VERSION = 2
105
+ MAX_VERSION = 2
106
+
107
+ # A store chi won't write over (Corrupt, Newer): the owner's invite,
108
+ # cap and revoke are refused with its message.
109
+ class Unwritable < StandardError; end
110
+
111
+ # The store is there but doesn't read (not JSON, a grant without its
112
+ # digest or expiry): its grants find nothing (fail closed), and
113
+ # nothing writes over it, which would lose every other grant.
114
+ class Corrupt < Unwritable
115
+ def initialize(path, reason)
116
+ super("#{path} can't be read (#{reason}): chi won't write over it; fix it, or move it aside to end " \
117
+ "every participant's link")
118
+ end
119
+ end
120
+
121
+ # The store was written by a newer chi (its version above
122
+ # MAX_VERSION): its grants still work here (read as this chi knows
123
+ # them), but nothing writes over it, which would drop what that chi
124
+ # added to them.
125
+ class Newer < Unwritable
126
+ def initialize(path, version)
127
+ super("#{path} was written by a newer chi (grants store version #{version}; this chi knows up to " \
128
+ "#{MAX_VERSION}): chi won't write over it, which would drop what that chi added; update chi " \
129
+ "(chi update)")
130
+ end
131
+ end
132
+
133
+ # One participant's access to one session. +id+ (p_<8 hex>) names the
134
+ # participant too: a turn they send is authored "p:<id>" (P3).
135
+ # +revoked_by+ why it was revoked: "owner", "archive", "delete",
136
+ # "gone" (the session was pruned).
137
+ # +cap+ (dollars) and +queue+ (turns waiting) as issued; nil in a
138
+ # grant from before them, which takes the config's (#spend_cap,
139
+ # #queue_limit).
140
+ # +exposure+: what the participant learns of the review's usage
141
+ # (ParticipantExposure; "own" for every grant today, and for one from
142
+ # before it). Kept as stored: a level this chi doesn't know reads as
143
+ # the narrowest, and is written back as it was.
144
+ # +extra+: the stored keys this chi doesn't know (a newer chi's,
145
+ # such as a field added to a grant without a version bump), written
146
+ # back as they were, so a rewrite here never drops them.
147
+ # +mode+: PAIR_MODE when the owner invited them to act (Grants#issue's
148
+ # mode:), nil otherwise. Stored only when set; a chi that doesn't know
149
+ # it keeps it in +extra+ and runs their turns as review turns.
150
+ Grant = Data.define(:id, :session_id, :digest, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
151
+ :revoked_by, :last_used_at, :cap, :queue, :identity, :exposure, :mode, :extra) do
152
+ # identity: the proxy identity (an email, lower case) the grant is
153
+ # bound to, nil for any (Web::ProxyGate; issued from shared-sessions P6).
154
+ def initialize(cap: nil, queue: nil, identity: nil, exposure: nil, mode: nil, extra: {}, **)
155
+ super(cap: cap, queue: queue, identity: identity, extra: extra, mode: mode,
156
+ exposure: exposure.nil? ? ParticipantExposure::DEFAULT : exposure, **)
157
+ end
158
+
159
+ def self.from_h(hash)
160
+ known = members - [:extra]
161
+ extra = hash.reject { |key, _| known.include?(key.to_s.to_sym) }
162
+ new(**known.to_h { |key| [key, hash[key.to_s]] }, extra: extra)
163
+ end
164
+
165
+ # The dollars this participant may cost the owner.
166
+ def spend_cap = cap.is_a?(Numeric) ? cap.to_f : Grants.default_cap
167
+
168
+ # How many of their turns may wait behind their running one.
169
+ def queue_limit = queue.is_a?(Integer) ? queue : Grants.default_queue
170
+
171
+ # The author a participant's turn carries (Principal#author).
172
+ def author = Steer.author_for(id)
173
+
174
+ # :active, :revoked or :expired at +now+ (a Time).
175
+ def state(now)
176
+ return :revoked if revoked_at
177
+ return :expired if Time.iso8601(expires_at) <= now
178
+
179
+ :active
180
+ end
181
+
182
+ def active?(now) = state(now) == :active
183
+
184
+ # When it stopped working (revoked or expired), nil while active.
185
+ def ended_at(now)
186
+ return Time.iso8601(revoked_at) if revoked_at
187
+
188
+ expiry = Time.iso8601(expires_at)
189
+ expiry <= now ? expiry : nil
190
+ end
191
+
192
+ # Never the digest: what chi share ls and the owner's routes show.
193
+ def public_h(now)
194
+ { id: id, session_id: session_id, kind: kind, name: name, role: role, created_at: created_at,
195
+ expires_at: expires_at, revoked_at: revoked_at, revoked_by: revoked_by, last_used_at: last_used_at,
196
+ state: state(now).to_s, cap: spend_cap, queue: queue_limit, identity: identity }
197
+ end
198
+
199
+ # A grant bound to no identity, with the default exposure, is stored
200
+ # as before either existed; the keys this chi doesn't know come
201
+ # after, as they were.
202
+ #
203
+ # A non-default exposure needs no version bump (identity's
204
+ # BOUND_VERSION): a chi that ignores it shows the participant the
205
+ # narrowest level, so it fails closed, and one that knows Grant#extra
206
+ # keeps it on a rewrite. A bump would only make that chi refuse the
207
+ # owner's invite, cap and revoke (Newer) for no safety gain.
208
+ def to_store
209
+ hash = to_h.except(:extra).transform_keys(&:to_s)
210
+ hash.delete("identity") if identity.nil?
211
+ hash.delete("mode") if mode.nil?
212
+ hash.delete("exposure") if exposure == ParticipantExposure::DEFAULT
213
+ hash.merge(extra.reject { |key, _| hash.key?(key) })
214
+ end
215
+
216
+ def inspect = "#<Grant #{id} #{session_id[0, 8]} #{name.inspect}>"
217
+ alias_method :to_s, :inspect
218
+ end
219
+
220
+ # A new grant and its token: the only time the token exists outside
221
+ # the participant's hands.
222
+ Issued = Data.define(:grant, :token) do
223
+ def inspect = "#<Issued #{grant.id}>"
224
+ alias_method :to_s, :inspect
225
+ end
226
+
227
+ # $XDG_STATE_HOME/samagotchi/web-grants/grants.json, next to web-token;
228
+ # +state_dir+ is the sessions dir (Session.default_state_dir).
229
+ def self.path(state_dir: nil, env: ENV)
230
+ File.join(File.dirname(state_dir || Session.default_state_dir(env: env)), DIR, FILE)
231
+ end
232
+
233
+ def self.digest(token) = Digest::SHA256.hexdigest(token.to_s)
234
+
235
+ # Seconds for +words+ (Nh or Nd, 1 hour to MAX_TTL), nil for none,
236
+ # false for words that aren't that.
237
+ def self.parse_ttl(words)
238
+ return nil if words.nil?
239
+
240
+ match = EXPIRES_RE.match(words.to_s.strip) or return false
241
+ seconds = match[1].to_i * (match[2] == "h" ? 3600 : DAY)
242
+ seconds.between?(3600, MAX_TTL) ? seconds : false
243
+ end
244
+
245
+ # A new grant's lifetime: config web.participants.expires, else
246
+ # DEFAULT_TTL (a value that isn't Nh/Nd in range too).
247
+ def self.default_ttl
248
+ parse_ttl(Config.get("web.participants.expires")) || DEFAULT_TTL
249
+ end
250
+
251
+ # A grant's cap without its own: config web.participants.cap, else
252
+ # DEFAULT_CAP (a value out of range too).
253
+ def self.default_cap
254
+ value = Config.get("web.participants.cap")
255
+ cap?(value) ? value.to_f : DEFAULT_CAP
256
+ end
257
+
258
+ # A grant's queue limit without its own: config
259
+ # web.participants.queue, else DEFAULT_QUEUE.
260
+ def self.default_queue
261
+ value = Config.get("web.participants.queue")
262
+ value.is_a?(Integer) && value.between?(0, MAX_QUEUE) ? value : DEFAULT_QUEUE
263
+ end
264
+
265
+ def self.cap?(value) = value.is_a?(Numeric) && value.to_f.finite? && value.to_f.between?(0, MAX_CAP)
266
+
267
+ def self.token?(value) = value.is_a?(String) && value.start_with?(PREFIX)
268
+
269
+ # What a proxy identity header carries: an email, or a login such as
270
+ # Tailscale's alex@github (one @, a domain without a dot).
271
+ IDENTITY_RE = /\A[^@,<>"\\]+@[^@,<>"\\]+\z/
272
+ # No space or control character anywhere (a header can't carry one,
273
+ # and a name with one could mislead in a listing).
274
+ IDENTITY_BAD_RE = /\p{Space}|\p{Cc}/
275
+ IDENTITY_MAX = 254
276
+
277
+ # +identity+ as the gate compares it (Web::ProxyGate#proxy_identity: strip,
278
+ # lower case).
279
+ # @raise [BadIdentity] not one email address
280
+ def self.clean_identity(identity)
281
+ # ASCII only, as Web::ProxyGate#proxy_identity: its lower case is the same
282
+ # whatever the string's encoding.
283
+ clean = identity.is_a?(String) && identity.ascii_only? ? identity.strip.downcase : ""
284
+ raise BadIdentity unless clean.length <= IDENTITY_MAX && IDENTITY_RE.match?(clean) && !IDENTITY_BAD_RE.match?(clean)
285
+
286
+ clean
287
+ end
288
+
289
+ # One line, at most NAME_MAX characters, no brackets (a name can't
290
+ # forge the "[Review contribution from …]" header; ParticipantName).
291
+ # @raise [Invalid] nothing left
292
+ def self.clean_name(name)
293
+ clean = ParticipantName.clean(name)
294
+ raise Invalid, "a participant needs a name" if clean.empty?
295
+
296
+ clean
297
+ end
298
+
299
+ # Refuses a store others could read: the grants folder must be 0700
300
+ # and the file 0600, both this user's (PrivateFiles).
301
+ # @raise [Insecure]
302
+ def self.check_permissions!(path)
303
+ PrivateFiles.check!(File.dirname(path), [path], holds: "the participants' grants", error: Insecure)
304
+ end
305
+
306
+ attr_reader :path
307
+
308
+ # @param clock [#call] the Time now (specs inject one)
309
+ def initialize(path = Grants.path, clock: -> { Time.now })
310
+ @path = path
311
+ @clock = clock
312
+ @mutex = Mutex.new
313
+ @stamp = nil
314
+ @grants = []
315
+ @corrupt = nil
316
+ @touched = {}
317
+ end
318
+
319
+ def now = @clock.call
320
+
321
+ # A new grant for +session_id+, written before the token is handed out.
322
+ # @param ttl [Integer, nil] seconds (nil: Grants.default_ttl; at most MAX_TTL)
323
+ # @param cap [Numeric, nil] dollars (nil: Grants.default_cap)
324
+ # @param identity [String, nil] the proxy identity (an email) the grant
325
+ # is bound to (Grants.clean_identity), nil for any
326
+ # @param on_write [#call, nil] see #update
327
+ # @return [Issued]
328
+ # @raise [Invalid]
329
+ def issue(session_id:, name:, kind: "human", role: "reviewer", ttl: nil, cap: nil, identity: nil, mode: nil,
330
+ on_write: nil)
331
+ Session.check_id!(session_id)
332
+ ttl ||= Grants.default_ttl
333
+ cap = Grants.default_cap if cap.nil?
334
+ raise Invalid, "kind is human or agent, not #{kind}" unless KINDS.include?(kind)
335
+ raise Invalid, "role is reviewer, not #{role}" unless ROLES.include?(role)
336
+ raise Invalid, "a grant lasts 1 hour to 30 days" unless ttl.is_a?(Integer) && ttl.between?(3600, MAX_TTL)
337
+ raise Invalid, CAP_RANGE unless Grants.cap?(cap)
338
+ raise Invalid, "mode is #{PAIR_MODE} or none, not #{mode}" unless mode.nil? || mode == PAIR_MODE
339
+
340
+ clean = Grants.clean_name(name)
341
+ identity = Grants.clean_identity(identity) unless identity.nil?
342
+ token = "#{PREFIX}#{SecureRandom.urlsafe_base64(BYTES)}"
343
+ issued = now
344
+ update(on_write) do |grants|
345
+ id = fresh_id(grants)
346
+ grant = Grant.new(id: id, session_id: session_id, digest: Grants.digest(token), kind: kind, name: clean,
347
+ role: role, created_at: stamp(issued), expires_at: stamp(issued + ttl), revoked_at: nil,
348
+ revoked_by: nil, last_used_at: nil, cap: cap.to_f, queue: Grants.default_queue,
349
+ identity: identity, mode: mode)
350
+ [grants + [grant], Issued.new(grant: grant, token: token)]
351
+ end
352
+ end
353
+
354
+ # The grant +token+ was issued for, in any state (an ended one too, so
355
+ # the gate can say why it's refused); nil for anything else.
356
+ # @return [Grant, nil]
357
+ def lookup(token)
358
+ return nil unless Grants.token?(token)
359
+
360
+ digest = Grants.digest(token)
361
+ # Every grant is compared, whatever matches first.
362
+ current.select { |grant| OpenSSL.secure_compare(grant.digest, digest) }.first
363
+ end
364
+
365
+ # @return [Grant, nil] the grant with this id
366
+ def find(id) = current.find { |grant| grant.id == id }
367
+
368
+ # Whether grant +id+ still works (a participant stream's running check).
369
+ def active?(id) = !!find(id)&.active?(now)
370
+
371
+ # @param session_id [String, nil] one session's, or every grant
372
+ # @return [Array<Grant>]
373
+ # @raise [Corrupt] (the owner's view: an empty list would hide it)
374
+ def list(session_id: nil)
375
+ current!.select { |grant| session_id.nil? || grant.session_id == session_id }
376
+ end
377
+
378
+ # Revokes the active grants of +session_id+ (just +id+ when given), or
379
+ # every session's when +session_id+ is nil.
380
+ # @param by [String] why: "owner", "archive", "delete", "gone"
381
+ # @param on_write [#call, nil] see #update
382
+ # @return [Array<Grant>] the grants it revoked (already ended ones are
383
+ # left as they were)
384
+ def revoke(session_id: nil, id: nil, by: "owner", on_write: nil)
385
+ at = now
386
+ matches = lambda do |grant|
387
+ grant.active?(at) && (session_id.nil? || grant.session_id == session_id) && (id.nil? || grant.id == id)
388
+ end
389
+ # Nothing to revoke (an archive of a session nobody was invited to):
390
+ # no write, no store made.
391
+ return [] unless current!.any?(&matches)
392
+
393
+ update(on_write) do |grants|
394
+ revoked = []
395
+ grants = grants.map do |grant|
396
+ next grant unless matches.call(grant)
397
+
398
+ grant.with(revoked_at: stamp(at), revoked_by: by).tap { |ended| revoked << ended }
399
+ end
400
+ [grants, revoked]
401
+ end
402
+ end
403
+
404
+ CAP_RANGE = "a cap is $0 to $#{MAX_CAP.to_i}".freeze
405
+
406
+ # Sets the active grant +id+'s cap to +cap+ dollars.
407
+ # @return [Grant, nil] the changed grant; nil when +id+ isn't an
408
+ # active grant (of +session_id+, when given)
409
+ # @param on_write [#call, nil] see #update
410
+ # @raise [Invalid] a cap out of range
411
+ def set_cap(id, cap, session_id: nil, on_write: nil)
412
+ raise Invalid, CAP_RANGE unless Grants.cap?(cap)
413
+
414
+ at = now
415
+ update(on_write) do |grants|
416
+ changed = nil
417
+ grants = grants.map do |grant|
418
+ next grant unless grant.id == id && grant.active?(at) && (session_id.nil? || grant.session_id == session_id)
419
+
420
+ changed = grant.with(cap: cap.to_f)
421
+ end
422
+ [grants, changed]
423
+ end
424
+ end
425
+
426
+ # Records a use of grant +id+, at most once per TOUCH_INTERVAL; not
427
+ # while the store doesn't read (it found no grant then anyway).
428
+ def touch(id)
429
+ at = now
430
+ @mutex.synchronize do
431
+ last = @touched[id]
432
+ return if last && at - last < TOUCH_INTERVAL
433
+
434
+ @touched[id] = at
435
+ end
436
+ update do |grants|
437
+ [grants.map { |grant| grant.id == id ? grant.with(last_used_at: stamp(at)) : grant }, nil]
438
+ end
439
+ rescue Unwritable
440
+ nil
441
+ end
442
+
443
+ private
444
+
445
+ # The grants on disk, re-read when the file changed (one stat a call);
446
+ # none while it doesn't read (logged once per change of the file).
447
+ def current
448
+ stamp = begin
449
+ stat = File.stat(@path)
450
+ [stat.mtime, stat.size, stat.ino]
451
+ rescue Errno::ENOENT
452
+ nil
453
+ end
454
+ @mutex.synchronize do
455
+ unless stamp == @stamp
456
+ reload(stamp)
457
+ @stamp = stamp
458
+ end
459
+ @grants
460
+ end
461
+ end
462
+
463
+ # #current, raising Corrupt for a store that doesn't read.
464
+ def current!
465
+ grants = current
466
+ corrupt = @mutex.synchronize { @corrupt }
467
+ raise corrupt if corrupt
468
+
469
+ grants
470
+ end
471
+
472
+ # In a newer chi's store (Newer) a grant with fields this chi doesn't
473
+ # know is left out: such a field may restrict it (a binding moved to a
474
+ # new key), so it fails closed here. Its link works on that chi again.
475
+ def reload(stamp)
476
+ grants, version = stamp ? read : [[], VERSION]
477
+ grants = grants.select { |grant| grant.extra.empty? } if version > MAX_VERSION
478
+ @grants = grants
479
+ @corrupt = nil
480
+ rescue Corrupt => e
481
+ @grants = []
482
+ @corrupt = e
483
+ Log.warn(:web, "grants_unreadable", echo: "[chi web] #{e.message}", path: @path)
484
+ end
485
+
486
+ # [grants, the store's version] (none yet: [[], VERSION]).
487
+ # @raise [Corrupt] the file is there but isn't a grants store
488
+ def read
489
+ data = begin
490
+ JSON.parse(File.read(@path))
491
+ rescue Errno::ENOENT
492
+ return [[], VERSION]
493
+ rescue JSON::ParserError
494
+ raise Corrupt.new(@path, "not JSON")
495
+ end
496
+ rows = data.is_a?(Hash) ? data["grants"] : nil
497
+ raise Corrupt.new(@path, "no grants list") unless rows.is_a?(Array)
498
+
499
+ version = data.fetch("version", VERSION)
500
+ raise Corrupt.new(@path, "a bad version") unless version.is_a?(Integer) && version >= VERSION
501
+
502
+ [rows.each_with_index.map { |hash, index| grant_from(hash, index) }, version]
503
+ end
504
+
505
+ # A stored grant the gate can compare and date, else Corrupt.
506
+ def grant_from(hash, index)
507
+ raise Corrupt.new(@path, "grant #{index + 1} isn't an object") unless hash.is_a?(Hash)
508
+
509
+ grant = Grant.from_h(hash)
510
+ problem = if !grant.id.is_a?(String) || !grant.session_id.is_a?(String) then "no id or session"
511
+ elsif !grant.digest.is_a?(String) || grant.digest.empty? then "no digest"
512
+ elsif !time?(grant.expires_at) then "no expiry"
513
+ elsif !grant.revoked_at.nil? && !time?(grant.revoked_at) then "a bad revoked_at"
514
+ elsif !identity?(grant.identity) then "a bad identity"
515
+ end
516
+ raise Corrupt.new(@path, "grant #{index + 1} has #{problem}") if problem
517
+
518
+ grant
519
+ end
520
+
521
+ # nil (unbound), or an identity as #issue stores it: anything else
522
+ # (false, "", a list) could read as unbound.
523
+ def identity?(value)
524
+ value.nil? || (value.is_a?(String) && Grants.clean_identity(value) == value)
525
+ rescue BadIdentity
526
+ false
527
+ end
528
+
529
+ def time?(value)
530
+ value.is_a?(String) && Time.iso8601(value)
531
+ rescue ArgumentError
532
+ false
533
+ end
534
+
535
+ # Read, change and write the store under its lock (another process
536
+ # may write it too). The block takes the grants and returns
537
+ # [new grants, result]. Ended grants older than KEEP_ENDED go.
538
+ # +on_write+ is called with (the grants written, result) once the store
539
+ # is written, still under the lock: Participants writes the sidecars
540
+ # there, so two writers can't finish out of order and lose a revoke in
541
+ # one.
542
+ def update(on_write = nil)
543
+ dir = File.dirname(@path)
544
+ FileUtils.mkdir_p(dir, mode: 0o700)
545
+ File.chmod(0o700, dir)
546
+ File.open(File.join(dir, LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
547
+ lock.flock(File::LOCK_EX)
548
+ # Never over a store that didn't read (Corrupt): the next write
549
+ # would keep only this one's change. Nor over a newer chi's
550
+ # (Newer): this chi would drop what it doesn't know.
551
+ stored, version = read
552
+ raise Newer.new(@path, version) if version > MAX_VERSION
553
+
554
+ grants, result = yield(stored)
555
+ at = now
556
+ kept = grants.reject { |grant| (ended = grant.ended_at(at)) && at - ended > KEEP_ENDED }
557
+ version = [version, kept.any?(&:identity) ? BOUND_VERSION : VERSION].max
558
+ body = JSON.pretty_generate({ "version" => version, "grants" => kept.map(&:to_store) })
559
+ AtomicFile.write(@path, "#{body}\n", perm: 0o600)
560
+ @mutex.synchronize { @stamp = nil }
561
+ on_write&.call(kept, result)
562
+ result
563
+ end
564
+ end
565
+
566
+ def fresh_id(grants)
567
+ loop do
568
+ id = "p_#{SecureRandom.hex(4)}"
569
+ return id unless grants.any? { |grant| grant.id == id }
570
+ end
571
+ end
572
+
573
+ def stamp(time) = time.utc.iso8601
574
+ end
575
+ end
@@ -77,7 +77,7 @@ module Samagotchi
77
77
  @origin = TurnOrigin::NONE
78
78
  end
79
79
 
80
- # Who can answer an approval: :repl, :worker or :non_interactive (the
80
+ # Who can answer an approval: :worker or :non_interactive (the
81
81
  # default, so a bare Engine denies instead of waiting for nobody). Set
82
82
  # by the host (TerminalUI, Worker).
83
83
  def interface
@@ -293,7 +293,7 @@ module Samagotchi
293
293
  end
294
294
 
295
295
  # Ask the user to approve a call the gate voted `ask` on, through the
296
- # question flow (REPL sync handler, attached TUI, web). Settles the
296
+ # question flow (the attached TUI, the web). Settles the
297
297
  # verdict: allow with the picked scope, or deny with a note for the
298
298
  # model. A --non-interactive run has no one to ask and denies at once.
299
299
  # @param verdict [Guardrails::Verdict]
@@ -8,10 +8,9 @@ module Samagotchi
8
8
  # session, the host (interface) and who queued the turn (origin). Repo
9
9
  # root and branch come from git, asked once per directory per turn.
10
10
  class Context
11
- # :repl — the plain REPL answers on the turn thread;
12
- # :worker — a shared-session worker (attached TUI / web answer);
11
+ # :worker — a session worker (the attached TUI or the web answers);
13
12
  # :non_interactive — nobody can answer (-p --non-interactive, specs).
14
- INTERFACES = %i[repl worker non_interactive].freeze
13
+ INTERFACES = %i[worker non_interactive].freeze
15
14
 
16
15
  attr_reader :cwd, :session_id, :interface, :origin
17
16
 
@@ -52,8 +52,8 @@ module Samagotchi
52
52
 
53
53
  # Whether an answer typed into this process is a parent agent's (so
54
54
  # ParentPolicy applies to it): stdin isn't a terminal (a pipe, a file,
55
- # /dev/null), or an agent marker is set. A person at chi --attach or
56
- # the REPL has a terminal and no marker.
55
+ # /dev/null), or an agent marker is set. A person at the attached TUI
56
+ # has a terminal and no marker.
57
57
  def parent_process?(env: ENV, stdin: $stdin)
58
58
  return true if AGENT_MARKERS.any? { |key| !env.fetch(key, "").to_s.strip.empty? }
59
59
 
@@ -191,8 +191,8 @@ module Samagotchi
191
191
  # tell the user. Never how to allow it (the web, chi --attach): that
192
192
  # is the user's.
193
193
  # @param reason [Symbol] what #refusal returned
194
- # @param typed [Boolean] the answer was typed at a ? prompt (the
195
- # REPL), not given to chi answer
194
+ # @param typed [Boolean] the answer was typed at a terminal's ?
195
+ # prompt, not given to chi answer
196
196
  def message(reason, pending:, strategy:, typed: false)
197
197
  deny = deny_label(pending)
198
198
  case reason.to_sym
@@ -66,7 +66,7 @@ module Samagotchi
66
66
  end
67
67
 
68
68
  # One line to the user (:hook_notice). During a turn it is a turn
69
- # event: the turn's sink (the REPL) and the observers (bridge, log).
69
+ # event: the turn's sink (its on_event:) and the observers (bridge, log).
70
70
  # Outside one (a plugin's command at the prompt) it is announced with
71
71
  # between_turns: true, which every UI shows as cards are shown. While the
72
72
  # plugins load it is held; on an anytime command's thread or an init task
@@ -121,8 +121,8 @@ module Samagotchi
121
121
  lines.empty? ? nil : lines.join("; ")
122
122
  end
123
123
 
124
- # A question through the question flow (REPL sync handler, attached TUI,
125
- # web), single-select, kind "hook". A --non-interactive run has no one
124
+ # A question through the question flow (the one-shot's sync handler,
125
+ # attached TUI, web), single-select, kind "hook". A --non-interactive run has no one
126
126
  # to ask: nil at once. Anything but an answer (a sync handler's text, no
127
127
  # answer, cancelled) is nil too.
128
128
  # @param question [HookQuestion]
@@ -21,8 +21,7 @@ module Samagotchi
21
21
  # Owns the inactivity clock bookkeeping shared across UIs: it reads the
22
22
  # Engine's `last_activity_at` / `activity_seq` / `turn_running?` seam (the
23
23
  # single source of truth) rather than tracking its own timeline, so the
24
- # clock behaves identically for the interactive REPL and any Engine-backed
25
- # worker.
24
+ # clock behaves identically for any Engine-backed worker.
26
25
  #
27
26
  # When the session has been idle for `@inactivity` seconds, no turn is
28
27
  # running, and there are >= `@min_user_turns` user turns, the job
@@ -285,7 +284,7 @@ module Samagotchi
285
284
  attr_reader :generation, :inactivity, :min_user_turns, :sentences, :timeout
286
285
 
287
286
  # @param callable [#call] true while a continue offer waits for an
288
- # answer (the Worker's or the REPL's TurnFlow); read at each attempt
287
+ # answer (the Worker's TurnFlow); read at each attempt
289
288
  attr_writer :awaiting_continue
290
289
 
291
290
  # @return [Hash, nil] the last recap written: {text:, title:, covered:,
@@ -345,7 +344,7 @@ module Samagotchi
345
344
 
346
345
  @mutex = Monitor.new
347
346
  # Serializes the attempt state machine (#tick on the scheduler thread,
348
- # #request_now on a Bridge or REPL thread, #write_now). Never held by
347
+ # #request_now on a Bridge thread, #write_now). Never held by
349
348
  # #state, so a snapshot reading it doesn't wait on an emit.
350
349
  @run_mutex = Monitor.new
351
350
  @generation = 0
@@ -389,7 +388,7 @@ module Samagotchi
389
388
  end
390
389
 
391
390
  # Write a recap now and wait for it, bounded by the timeout: the worker
392
- # (or the REPL) leaving. Skips the inactivity window and the once-per-
391
+ # leaving. Skips the inactivity window and the once-per-
393
392
  # window latch, not the rules on what to recap (minimum user turns,
394
393
  # something new). Takes over an attempt already in flight. Call it with
395
394
  # the idle scheduler stopped.