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