samagotchi 0.153.0 → 0.154.1
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 +42 -1
- data/README.md +2 -3
- data/bin/chi +23 -24
- data/docs/architecture.md +30 -33
- data/docs/broadcast.md +5 -5
- data/docs/bundles.md +9 -12
- data/docs/cli.md +82 -96
- data/docs/configuration.md +27 -26
- data/docs/context.md +3 -3
- data/docs/desktop.md +1 -1
- data/docs/guardrails.md +8 -10
- data/docs/hooks.md +9 -9
- data/docs/internals/attached-context.md +2 -2
- data/docs/internals/background-tasks.md +3 -4
- data/docs/internals/block-renderers.md +4 -0
- data/docs/internals/bridge.md +21 -8
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +33 -7
- data/docs/internals/objectives.md +6 -5
- data/docs/internals/participants.md +7 -7
- data/docs/internals/prompt-caching.md +4 -2
- data/docs/internals/remote-sessions.md +7 -7
- data/docs/internals/web-frontend.md +2 -2
- data/docs/internals/web-push.md +1 -1
- data/docs/memory.md +2 -2
- data/docs/plugins.md +23 -28
- data/docs/sessions.md +24 -24
- data/docs/sub-agent.md +2 -2
- data/docs/system-one.md +1 -1
- data/docs/testing.md +31 -6
- data/lib/samagotchi/answer_text.rb +33 -0
- data/lib/samagotchi/bridge/card_places.rb +56 -7
- data/lib/samagotchi/bridge/card_store.rb +24 -9
- data/lib/samagotchi/bridge.rb +52 -32
- data/lib/samagotchi/bridge_client.rb +19 -14
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/broadcast/triage.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +5 -5
- data/lib/samagotchi/bundle_command/try/command.rb +1 -1
- data/lib/samagotchi/bundle_command/try/run.rb +1 -1
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
- data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
- data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
- data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
- data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/child_move.rb +3 -3
- data/lib/samagotchi/commands/registry.rb +6 -6
- data/lib/samagotchi/composer_route.rb +4 -5
- data/lib/samagotchi/config.rb +14 -12
- data/lib/samagotchi/context_candidate_choice.rb +1 -1
- data/lib/samagotchi/context_candidates.rb +4 -3
- data/lib/samagotchi/continue_offer.rb +1 -1
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/degrade.rb +75 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +3 -3
- data/lib/samagotchi/engine/cards.rb +87 -0
- data/lib/samagotchi/engine/context_judge.rb +138 -0
- data/lib/samagotchi/engine/events.rb +131 -0
- data/lib/samagotchi/engine/guardrails.rb +87 -0
- data/lib/samagotchi/engine/hooks.rb +119 -0
- data/lib/samagotchi/engine/messages.rb +124 -0
- data/lib/samagotchi/engine/model_settings.rb +127 -0
- data/lib/samagotchi/engine/models.rb +261 -0
- data/lib/samagotchi/engine/objectives.rb +116 -0
- data/lib/samagotchi/engine/participants.rb +86 -0
- data/lib/samagotchi/engine/plugins.rb +208 -0
- data/lib/samagotchi/engine/questions.rb +98 -0
- data/lib/samagotchi/engine/recap.rb +130 -0
- data/lib/samagotchi/engine/session.rb +131 -0
- data/lib/samagotchi/engine/side_requests.rb +135 -0
- data/lib/samagotchi/engine/steering.rb +134 -0
- data/lib/samagotchi/engine/toolkit.rb +269 -0
- data/lib/samagotchi/engine/turn_end.rb +180 -0
- data/lib/samagotchi/engine/turn_helpers.rb +160 -0
- data/lib/samagotchi/engine/verify.rb +153 -0
- data/lib/samagotchi/engine.rb +62 -2646
- data/lib/samagotchi/engine_snapshots.rb +9 -8
- data/lib/samagotchi/grants.rb +575 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -2
- data/lib/samagotchi/guardrails/context.rb +2 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
- data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
- data/lib/samagotchi/hook_routes.rb +3 -3
- data/lib/samagotchi/idle_recap.rb +7 -6
- data/lib/samagotchi/idle_reminders.rb +3 -4
- data/lib/samagotchi/iteration_limit.rb +2 -2
- data/lib/samagotchi/kernel_loop.rb +13 -13
- data/lib/samagotchi/launch_mode.rb +13 -32
- data/lib/samagotchi/level_setting_command.rb +3 -2
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- data/lib/samagotchi/llm_context_command.rb +3 -2
- data/lib/samagotchi/log.rb +7 -7
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/log_path.rb +1 -1
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +18 -8
- data/lib/samagotchi/objectives.rb +24 -3
- data/lib/samagotchi/objectives_check.rb +8 -27
- data/lib/samagotchi/objectives_command.rb +4 -69
- data/lib/samagotchi/objectives_edit.rb +87 -0
- data/lib/samagotchi/output_formatter.rb +7 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/parent_answers_command.rb +5 -4
- data/lib/samagotchi/participant_exposure.rb +42 -0
- data/lib/samagotchi/participant_mode.rb +1 -1
- data/lib/samagotchi/participant_name.rb +1 -1
- data/lib/samagotchi/participants.rb +17 -17
- data/lib/samagotchi/participants_watch.rb +1 -1
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/plugin/context.rb +21 -7
- data/lib/samagotchi/plugin/peer.rb +2 -1
- data/lib/samagotchi/plugin/sessions.rb +6 -4
- data/lib/samagotchi/plugin_tasks.rb +1 -2
- data/lib/samagotchi/private_files.rb +29 -0
- data/lib/samagotchi/project_env.rb +2 -2
- data/lib/samagotchi/question_desk.rb +8 -7
- data/lib/samagotchi/recap_command.rb +2 -1
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/recap_store.rb +1 -1
- data/lib/samagotchi/redact.rb +86 -0
- data/lib/samagotchi/reminder_queue.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +2 -2
- data/lib/samagotchi/review_start.rb +1 -1
- data/lib/samagotchi/run_options.rb +6 -6
- data/lib/samagotchi/self_report.rb +42 -39
- data/lib/samagotchi/send_command/busy.rb +1 -1
- data/lib/samagotchi/send_command.rb +4 -1
- data/lib/samagotchi/session.rb +11 -2
- data/lib/samagotchi/session_access/contract.rb +3 -3
- data/lib/samagotchi/session_access/local.rb +9 -9
- data/lib/samagotchi/session_access/remote.rb +17 -7
- data/lib/samagotchi/session_access.rb +4 -4
- data/lib/samagotchi/session_archive_command.rb +2 -2
- data/lib/samagotchi/session_chain.rb +8 -6
- data/lib/samagotchi/session_commands.rb +45 -48
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +4 -4
- data/lib/samagotchi/session_manager.rb +67 -49
- data/lib/samagotchi/session_metrics.rb +2 -2
- data/lib/samagotchi/session_model_settings.rb +3 -2
- data/lib/samagotchi/session_participants.rb +1 -1
- data/lib/samagotchi/session_retention.rb +27 -7
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +10 -10
- data/lib/samagotchi/steer.rb +13 -2
- data/lib/samagotchi/stream_event.rb +22 -0
- data/lib/samagotchi/system_prompt.rb +154 -145
- data/lib/samagotchi/task_watch.rb +1 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -11
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
- data/lib/samagotchi/terminal_ui/input_support.rb +17 -22
- data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
- data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
- data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
- data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
- data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
- data/lib/samagotchi/terminal_ui/reline_seam.rb +2 -12
- data/lib/samagotchi/terminal_ui/status_row.rb +6 -14
- data/lib/samagotchi/terminal_ui.rb +142 -1387
- data/lib/samagotchi/title_command.rb +2 -2
- data/lib/samagotchi/tool_runner.rb +15 -16
- data/lib/samagotchi/toolkit_command.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +3 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/objectives_tool.rb +2 -2
- data/lib/samagotchi/tools/send_note.rb +2 -1
- data/lib/samagotchi/turn_endings.rb +2 -2
- data/lib/samagotchi/turn_flow.rb +11 -7
- data/lib/samagotchi/update_hint.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +42 -51
- data/lib/samagotchi/web/message_display.rb +7 -0
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_review.rb +5 -5
- data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
- data/lib/samagotchi/web/participant_view.rb +2 -82
- data/lib/samagotchi/web/participants_admin.rb +1 -1
- data/lib/samagotchi/web/public/block_host.js +35 -8
- data/lib/samagotchi/web/public/block_renderers.css +9 -5
- data/lib/samagotchi/web/public/card_stamps.js +27 -0
- data/lib/samagotchi/web/public/cards_place.js +5 -3
- data/lib/samagotchi/web/public/chains.js +2 -2
- data/lib/samagotchi/web/public/claim_link.js +4 -4
- data/lib/samagotchi/web/public/composer_mode.js +1 -1
- data/lib/samagotchi/web/public/format.js +4 -3
- data/lib/samagotchi/web/public/turn_html.js +8 -4
- data/lib/samagotchi/web/push/store.rb +1 -1
- data/lib/samagotchi/web/session_access_routes.rb +13 -4
- data/lib/samagotchi/web/session_control.rb +12 -8
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +2 -2
- data/lib/samagotchi/web/session_show.rb +6 -2
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +103 -9
- data/lib/samagotchi/worker_link.rb +3 -3
- data/lib/samagotchi/working_set_command.rb +3 -2
- data/lib/samagotchi.rb +0 -1
- metadata +31 -7
- data/lib/samagotchi/pending_input_queue.rb +0 -55
- data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
- data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
- data/lib/samagotchi/web/grants.rb +0 -575
- data/lib/samagotchi/web/participant_exposure.rb +0 -44
- data/lib/samagotchi/web/private_files.rb +0 -31
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "degrade"
|
|
3
4
|
require_relative "session_setting"
|
|
4
5
|
require_relative "token_usage"
|
|
5
6
|
|
|
@@ -171,16 +172,16 @@ module Samagotchi
|
|
|
171
172
|
# doesn't count. Without a model (EffectiveModel), no probe. A remote
|
|
172
173
|
# chat host has no /props: nil until a turn. The name asked is never
|
|
173
174
|
# resolved (HostRegistry#bare_name), so it holds on a broken resolve.
|
|
174
|
-
def served_model_for(snapshot, model: nil)
|
|
175
|
-
asked = bare_model_name(model ? model.name : effective_model_name)
|
|
176
|
-
return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
|
|
177
|
-
return [nil, nil] unless model
|
|
178
|
-
|
|
179
|
-
model.served_from_props(asked)
|
|
180
175
|
# Only bare_model_name and the snapshot lookup are left to fail here:
|
|
181
176
|
# the probe's failure is EffectiveModel#served_from_props's.
|
|
182
|
-
|
|
183
|
-
[nil, nil]
|
|
177
|
+
def served_model_for(snapshot, model: nil)
|
|
178
|
+
Degrade.call(:model, "served_model_unreadable", fallback: [nil, nil]) do
|
|
179
|
+
asked = bare_model_name(model ? model.name : effective_model_name)
|
|
180
|
+
return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
|
|
181
|
+
return [nil, nil] unless model
|
|
182
|
+
|
|
183
|
+
model.served_from_props(asked)
|
|
184
|
+
end
|
|
184
185
|
end
|
|
185
186
|
|
|
186
187
|
# The next turn's kit prompt text for /stats: its kit and its estimated
|
|
@@ -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: :
|
|
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 (
|
|
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
|
-
# :
|
|
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[
|
|
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
|
|
56
|
-
#
|
|
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 ?
|
|
195
|
-
#
|
|
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 (
|
|
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 (
|
|
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]
|