agents_control 0.1.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +202 -0
  3. data/README.md +282 -0
  4. data/exe/agents_control +6 -0
  5. data/lib/agents_control/agents/base.rb +52 -0
  6. data/lib/agents_control/agents/claude_code.rb +243 -0
  7. data/lib/agents_control/anchors/scheduler.rb +161 -0
  8. data/lib/agents_control/channels/base.rb +27 -0
  9. data/lib/agents_control/channels/telegram/api.rb +179 -0
  10. data/lib/agents_control/channels/telegram/bot.rb +146 -0
  11. data/lib/agents_control/channels/telegram/channel.rb +251 -0
  12. data/lib/agents_control/channels/telegram/chunker.rb +64 -0
  13. data/lib/agents_control/channels/telegram/keyboards.rb +129 -0
  14. data/lib/agents_control/channels/telegram/markdown.rb +59 -0
  15. data/lib/agents_control/channels/telegram/router.rb +482 -0
  16. data/lib/agents_control/channels/telegram/settings_menu.rb +112 -0
  17. data/lib/agents_control/cli.rb +347 -0
  18. data/lib/agents_control/config.rb +178 -0
  19. data/lib/agents_control/console.rb +320 -0
  20. data/lib/agents_control/daemon.rb +251 -0
  21. data/lib/agents_control/dispatcher.rb +163 -0
  22. data/lib/agents_control/doctor.rb +234 -0
  23. data/lib/agents_control/event.rb +111 -0
  24. data/lib/agents_control/executor.rb +83 -0
  25. data/lib/agents_control/hooks/server.rb +197 -0
  26. data/lib/agents_control/keyboard.rb +90 -0
  27. data/lib/agents_control/menu.rb +100 -0
  28. data/lib/agents_control/pending.rb +70 -0
  29. data/lib/agents_control/process_probe.rb +136 -0
  30. data/lib/agents_control/prompt.rb +227 -0
  31. data/lib/agents_control/rate_limit_watcher.rb +202 -0
  32. data/lib/agents_control/registry.rb +115 -0
  33. data/lib/agents_control/reply.rb +41 -0
  34. data/lib/agents_control/screen_watcher.rb +158 -0
  35. data/lib/agents_control/secrets.rb +256 -0
  36. data/lib/agents_control/service.rb +165 -0
  37. data/lib/agents_control/session.rb +66 -0
  38. data/lib/agents_control/store.rb +132 -0
  39. data/lib/agents_control/terminals/base.rb +76 -0
  40. data/lib/agents_control/terminals/iterm2.rb +167 -0
  41. data/lib/agents_control/terminals/null.rb +27 -0
  42. data/lib/agents_control/terminals/tmux.rb +106 -0
  43. data/lib/agents_control/transcript.rb +123 -0
  44. data/lib/agents_control/version.rb +5 -0
  45. data/lib/agents_control/which.rb +59 -0
  46. data/lib/agents_control.rb +52 -0
  47. metadata +102 -0
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module AgentsControl
7
+ module Agents
8
+ # Claude Code.
9
+ #
10
+ # `{"decision":"block","reason":"..."}` returned to a Stop hook comes
11
+ # back to the agent as user input, and the agent continues working
12
+ # with that text — this is what the whole idea of answering from
13
+ # Telegram rests on.
14
+ class ClaudeCode < Base
15
+ # Claude Code's config directory honors CLAUDE_CONFIG_DIR — just
16
+ # like the agent itself. This matters both for anyone keeping
17
+ # settings outside the home directory, and for tests: without this
18
+ # override a test run would edit the user's real file.
19
+ def self.settings_path
20
+ base = ENV["CLAUDE_CONFIG_DIR"] || File.expand_path("~/.claude")
21
+
22
+ File.join(base, "settings.json")
23
+ end
24
+
25
+ # The events we care about. Others arrive too but are ignored:
26
+ # subscribing to everything is extra overhead on every agent call.
27
+ EVENTS = %w[SessionStart SessionEnd PermissionRequest Stop Notification StopFailure].freeze
28
+
29
+ class << self
30
+ def key = :claude_code
31
+
32
+ def binaries = %w[claude]
33
+
34
+ def handles?(payload)
35
+ payload.is_a?(Hash) && payload.key?("hook_event_name")
36
+ end
37
+ end
38
+
39
+ def initialize(settings_path: self.class.settings_path)
40
+ @settings_path = settings_path
41
+ end
42
+
43
+ def capabilities = %i[push blocking_reply]
44
+
45
+ def to_event(payload)
46
+ case payload["hook_event_name"]
47
+ when "SessionStart" then build(payload, :started)
48
+ when "SessionEnd" then build(payload, :ended)
49
+ when "PermissionRequest" then permission_event(payload)
50
+ when "Stop" then stop_event(payload)
51
+ when "Notification" then notification_event(payload)
52
+ when "StopFailure" then build(payload, :error, text: payload["error"])
53
+ end
54
+ end
55
+
56
+ # Reply → the hook response body. The shape depends on the event:
57
+ # granting permission and continuing a dialog are structured
58
+ # differently in the API.
59
+ def to_response(event, reply)
60
+ case event.raw["hook_event_name"]
61
+ when "PermissionRequest" then permission_response(event, reply)
62
+ when "Stop" then stop_response(reply)
63
+ else {}
64
+ end
65
+ end
66
+
67
+ # ── installation ──────────────────────────────────────────────────
68
+
69
+ # Merge, not overwrite: settings.json holds other settings we don't
70
+ # own, and they must not be clobbered. The timeout is set
71
+ # explicitly: Claude Code waits longer than the documented 600
72
+ # seconds, and how long to wait is our call.
73
+ def install!(url, secret: nil, timeout: 660)
74
+ settings = read_settings
75
+ settings["hooks"] ||= {}
76
+
77
+ EVENTS.each do |name|
78
+ settings["hooks"][name] = merge_hook(settings["hooks"][name],
79
+ hook_entry(url, name, secret, timeout))
80
+ end
81
+
82
+ write_settings(settings)
83
+ end
84
+
85
+ def uninstall!
86
+ settings = read_settings
87
+ hooks = settings["hooks"] || {}
88
+
89
+ EVENTS.each do |name|
90
+ next unless hooks[name]
91
+
92
+ hooks[name] = hooks[name].filter_map { |group| without_ours(group) }
93
+ hooks.delete(name) if hooks[name].empty?
94
+ end
95
+
96
+ # If the key wasn't there before us, it shouldn't be there after
97
+ # us either: cleaning up after ourselves means not leaving an
98
+ # empty shell behind.
99
+ settings.delete("hooks") if hooks.empty?
100
+
101
+ write_settings(settings)
102
+ end
103
+
104
+ def installed?
105
+ hooks = read_settings["hooks"] || {}
106
+
107
+ EVENTS.all? { |name| Array(hooks[name]).any? { |group| ours?(group) } }
108
+ end
109
+
110
+ private
111
+
112
+ def build(payload, kind, text: nil, **extra)
113
+ Event.new(
114
+ kind: kind,
115
+ agent: key,
116
+ session_id: payload["session_id"],
117
+ cwd: payload["cwd"],
118
+ transcript_path: payload["transcript_path"],
119
+ text: text,
120
+ raw: payload,
121
+ **extra
122
+ )
123
+ end
124
+
125
+ def permission_event(payload)
126
+ build(payload, :needs_permission,
127
+ tool_name: payload["tool_name"],
128
+ tool_input: payload["tool_input"])
129
+ end
130
+
131
+ # Stop fires at the end of every turn, not only when the agent is
132
+ # actually asking something. The payload alone can't tell them
133
+ # apart — whether to bother the human is a decision the Dispatcher
134
+ # makes based on settings.
135
+ #
136
+ # stop_hook_active means we're already holding this turn blocked.
137
+ # Answering with another block would produce an infinite loop.
138
+ def stop_event(payload)
139
+ return nil if payload["stop_hook_active"]
140
+
141
+ build(payload, :needs_input, text: payload["last_assistant_message"])
142
+ end
143
+
144
+ def notification_event(payload)
145
+ kind = payload["notificationType"] == "agent_completed" ? :finished : :progress
146
+
147
+ build(payload, kind, text: payload["message"])
148
+ end
149
+
150
+ def permission_response(event, reply)
151
+ behavior = reply.permits? ? "allow" : "deny"
152
+ decision = { "behavior" => behavior }
153
+
154
+ # "Allow and don't ask again" — the rule is remembered by the
155
+ # agent itself, we don't need to store it afterward.
156
+ decision["storeRule"] = { "matcher" => event.tool_name } if reply.allow? && reply.remember
157
+
158
+ {
159
+ "hookSpecificOutput" => {
160
+ "hookEventName" => "PermissionRequest",
161
+ "decision" => decision
162
+ }
163
+ }
164
+ end
165
+
166
+ # An empty reply means "let the agent stop". A block with text
167
+ # feeds that text back to the agent as input.
168
+ def stop_response(reply)
169
+ return {} unless reply.text? && !reply.text.to_s.empty?
170
+
171
+ { "decision" => "block", "reason" => reply.text }
172
+ end
173
+
174
+ # ── settings.json handling ─────────────────────────────────────────
175
+
176
+ # The secret is written as a literal value, not via $VAR:
177
+ # environment substitution pulls variables from the agent's own
178
+ # process, which the user launches by hand — our environment isn't
179
+ # there.
180
+ #
181
+ # The settings file is already readable only by its owner, and the
182
+ # secret protects against another process on the local port, not
183
+ # against another user on the same account.
184
+ def hook_entry(url, name, secret, timeout)
185
+ hook = {
186
+ "type" => "http",
187
+ "url" => "#{url}/#{name}",
188
+ "timeout" => timeout,
189
+ "statusMessage" => "agents_control: waiting for a reply in Telegram",
190
+ # This marker is how our own entries are found on uninstall,
191
+ # without touching anyone else's.
192
+ "_agents_control" => true
193
+ }
194
+ hook["headers"] = { "Authorization" => "Bearer #{secret}" } if secret
195
+
196
+ { "hooks" => [hook] }
197
+ end
198
+
199
+ def merge_hook(existing, entry)
200
+ groups = Array(existing).reject { |group| ours?(group) }
201
+
202
+ groups + [entry]
203
+ end
204
+
205
+ def ours?(group)
206
+ Array(group["hooks"]).any? { |hook| hook["_agents_control"] }
207
+ end
208
+
209
+ def without_ours(group)
210
+ remaining = Array(group["hooks"]).reject { |hook| hook["_agents_control"] }
211
+
212
+ remaining.empty? ? nil : group.merge("hooks" => remaining)
213
+ end
214
+
215
+ def read_settings
216
+ return {} unless File.exist?(@settings_path)
217
+
218
+ parsed = JSON.parse(File.read(@settings_path))
219
+ parsed.is_a?(Hash) ? parsed : {}
220
+ rescue JSON::ParserError
221
+ # A broken file that isn't ours to fix, but we definitely have
222
+ # no right to overwrite its contents either.
223
+ raise Error, "could not parse #{@settings_path}: file is corrupted"
224
+ end
225
+
226
+ # Via a temporary file: an interrupted write must not leave the
227
+ # user with no Claude Code settings at all. Chmod'd before the
228
+ # rename: this file carries our hook secret in plaintext once
229
+ # install! runs, and it must never sit world-readable, even briefly.
230
+ def write_settings(settings)
231
+ FileUtils.mkdir_p(File.dirname(@settings_path))
232
+ temporary = "#{@settings_path}.agents_control.tmp"
233
+
234
+ File.write(temporary, JSON.pretty_generate(settings))
235
+ File.chmod(0o600, temporary)
236
+ File.rename(temporary, @settings_path)
237
+ true
238
+ ensure
239
+ FileUtils.rm_f(temporary) if temporary && File.exist?(temporary)
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module AgentsControl
6
+ module Anchors
7
+ # Placing rate-limit windows on a schedule.
8
+ #
9
+ # A five-hour window starts at the minute of the first message and
10
+ # expires exactly three hundred minutes later. This tool doesn't add
11
+ # a single extra token — it moves window boundaries to where they're
12
+ # convenient. The difference is between "the window reset at 2:37pm,
13
+ # mid-work" and "windows at exactly 7am, noon, 5pm."
14
+ #
15
+ # Pinging has to use a cheap model. The five-hour window is shared
16
+ # across the account, but weekly limits are tracked per model family:
17
+ # an anchor on opus would spend the scarcest bucket for an effect
18
+ # haiku gives for free.
19
+ class Scheduler
20
+ DAYS = %w[sun mon tue wed thu fri sat].freeze
21
+
22
+ # How late it's still worth catching up on a missed slot. If the
23
+ # laptop slept and woke an hour later, an anchor is already
24
+ # pointless — the window will start somewhere other than planned regardless.
25
+ GRACE = 300
26
+
27
+ # How often to wake up and check the clock.
28
+ TICK = 30
29
+
30
+ def initialize(config:, store:, executor: Executor.new, clock: -> { Time.now }, logger: nil)
31
+ @config = config
32
+ @store = store
33
+ @executor = executor
34
+ @clock = clock
35
+ @logger = logger
36
+ @running = false
37
+ end
38
+
39
+ # The thread always starts; whether it's enabled is checked on
40
+ # every tick — so a setting changed from the menu takes effect
41
+ # without restarting the daemon.
42
+ def start
43
+ @running = true
44
+ @thread = Thread.new do
45
+ tick while @running
46
+ end
47
+
48
+ self
49
+ end
50
+
51
+ def stop
52
+ @running = false
53
+ @thread&.kill
54
+ end
55
+
56
+ # One pass: fire if the time has come.
57
+ def tick(now = @clock.call)
58
+ due_slot(now)&.then { |slot| fire(slot, now) } if enabled?
59
+ rescue StandardError => e
60
+ log("failure: #{e.class}: #{e.message}")
61
+ ensure
62
+ sleep(TICK) if @running
63
+ end
64
+
65
+ # The next firing time — for doctor and /status.
66
+ def next_run_at(from = @clock.call)
67
+ (0..7).each do |offset|
68
+ date = from.to_date + offset
69
+ next unless enabled_day?(date)
70
+
71
+ slot = slots_on(date).find { |time| time > from }
72
+ return slot if slot
73
+ end
74
+
75
+ nil
76
+ end
77
+
78
+ # Whether a window is currently active. Known because the daemon
79
+ # sees every agent event: any of them means a human was just working.
80
+ def window_active?(now = @clock.call)
81
+ last = @store.get(ACTIVITY_KEY)
82
+ return false unless last
83
+
84
+ now.to_i - last.to_i < WINDOW
85
+ end
86
+
87
+ ACTIVITY_KEY = "agent:last_activity"
88
+ WINDOW = 5 * 3600
89
+
90
+ private
91
+
92
+ def enabled? = @config.get("anchors.enabled", false)
93
+
94
+ def schedule = Array(@config.get("anchors.schedule", []))
95
+
96
+ def enabled_day?(date)
97
+ days = Array(@config.get("anchors.days", DAYS)).map(&:to_s)
98
+
99
+ days.include?(DAYS[date.wday])
100
+ end
101
+
102
+ def slots_on(date)
103
+ schedule.filter_map do |entry|
104
+ hour, minute = entry.to_s.split(":").map(&:to_i)
105
+ next if hour.nil?
106
+
107
+ Time.new(date.year, date.month, date.day, hour, minute, 0)
108
+ end.sort
109
+ end
110
+
111
+ def due_slot(now)
112
+ return nil unless enabled_day?(now.to_date)
113
+
114
+ slots_on(now.to_date).find do |slot|
115
+ now >= slot && now - slot < GRACE && !fired?(slot)
116
+ end
117
+ end
118
+
119
+ def fired?(slot) = !@store.get(slot_key(slot)).nil?
120
+
121
+ def slot_key(slot) = "anchor:#{slot.strftime('%Y-%m-%d %H:%M')}"
122
+
123
+ def fire(slot, now)
124
+ # The marker is set before the call, not after: if the ping
125
+ # hangs or the process crashes, retrying it half a minute later
126
+ # is pointless.
127
+ @store.put(now.to_i, ttl: 86_400 * 2, key: slot_key(slot))
128
+
129
+ if @config.get("anchors.skip_if_window_active", true) && window_active?(now)
130
+ return log("skipping #{slot.strftime('%H:%M')}: window is already open")
131
+ end
132
+
133
+ ping(slot)
134
+ end
135
+
136
+ def ping(slot)
137
+ binary = Which.find("claude")
138
+ return log("couldn't find claude — anchor skipped") unless binary
139
+
140
+ model = @config.get("anchors.model", "haiku")
141
+ result = @executor.run(binary, "-p", "ok", "--model", model,
142
+ "--max-turns", "1", timeout: 120)
143
+
144
+ if result.success?
145
+ log("anchor #{slot.strftime('%H:%M')} on #{model}: window opened")
146
+ else
147
+ log("anchor #{slot.strftime('%H:%M')} failed: #{result.stderr.to_s[0, 120]}")
148
+ end
149
+ end
150
+
151
+ # log() catches any write failure itself: an exception raised
152
+ # inside a rescue isn't caught by that same rescue, and would kill
153
+ # the thread for good.
154
+ def log(message)
155
+ @logger&.puts("[anchors] #{message}")
156
+ rescue StandardError
157
+ nil
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ module Channels
5
+ # The notification channel contract.
6
+ #
7
+ # There's only one implementation right now — Telegram. The interface
8
+ # is still broken out because the core must not know about buttons
9
+ # and chats: it operates on an event and a reply, and how that's
10
+ # shown to a human is the channel's concern.
11
+ #
12
+ # A second implementation (ntfy, Slack, whatever) is deliberately not
13
+ # invented ahead of time: the seam is marked out, but guessing its
14
+ # shape before a real need shows up is exactly the kind of
15
+ # complication this project avoids.
16
+ class Base
17
+ # Notify, expecting nothing back.
18
+ def notify(_event) = raise(NotImplementedError)
19
+
20
+ # Ask a question and return a Reply, or nil once the timeout expires.
21
+ def ask(_event, timeout:) = raise(NotImplementedError)
22
+
23
+ # Whether the channel is ready to work: has a token, has someone to talk to.
24
+ def ready? = raise(NotImplementedError)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "timeout"
6
+ require "json"
7
+ require "uri"
8
+
9
+ module AgentsControl
10
+ module Channels
11
+ module Telegram
12
+ # A thin Bot API client built on the stdlib.
13
+ #
14
+ # Deliberately gem-free: `net/http` covers everything needed, and
15
+ # an extra dependency in a tool installed with a single command
16
+ # costs more than the lines it would save.
17
+ #
18
+ # The token is part of the URL, so any trace of a request —
19
+ # exception message, log, debug output — must go through redact.
20
+ # Otherwise the secret leaks somewhere nobody meant to put it.
21
+ class Api
22
+ HOST = "api.telegram.org"
23
+
24
+ # Telegram requires answering a button press within 10 seconds,
25
+ # or it stays stuck in the interface. Regular calls are kept
26
+ # noticeably shorter than that.
27
+ CALL_TIMEOUT = 8
28
+
29
+ Error = Class.new(AgentsControl::Error)
30
+
31
+ # Another process is already reading updates with this same
32
+ # token. This doesn't resolve itself: one instance per token is required.
33
+ Conflict = Class.new(Error)
34
+
35
+ # Too many requests. The response carries retry_after — how long to wait.
36
+ class TooManyRequests < Error
37
+ attr_reader :retry_after
38
+
39
+ def initialize(message, retry_after)
40
+ super(message)
41
+ @retry_after = retry_after
42
+ end
43
+ end
44
+
45
+ # The network dropped. Unlike other errors, this is an expected
46
+ # state for a laptop that got closed and carried off; it's fixed
47
+ # by retrying, not by stopping.
48
+ Unavailable = Class.new(Error)
49
+
50
+ def initialize(token, http: nil)
51
+ @token = token
52
+ @http = http || Http.new
53
+ end
54
+
55
+ def get_me = call("getMe")
56
+
57
+ # timeout here means long polling: the connection stays open
58
+ # until an update shows up. The network timeout has to be
59
+ # noticeably longer, or the client would drop the connection
60
+ # right as the server was about to answer.
61
+ def get_updates(offset: nil, timeout: 30)
62
+ call(
63
+ "getUpdates",
64
+ { offset: offset, timeout: timeout, allowed_updates: %w[message callback_query] },
65
+ read_timeout: timeout + 15
66
+ )
67
+ end
68
+
69
+ def send_message(chat_id:, text:, reply_markup: nil, parse_mode: nil)
70
+ call("sendMessage", {
71
+ chat_id: chat_id, text: text, parse_mode: parse_mode,
72
+ reply_markup: reply_markup && JSON.generate(reply_markup)
73
+ })
74
+ end
75
+
76
+ def edit_message_text(chat_id:, message_id:, text:, reply_markup: nil)
77
+ call("editMessageText", {
78
+ chat_id: chat_id, message_id: message_id, text: text,
79
+ reply_markup: reply_markup && JSON.generate(reply_markup)
80
+ })
81
+ end
82
+
83
+ # The command menu in the Telegram UI: a button next to the
84
+ # input field instead of hunting through the chat for a list.
85
+ def set_my_commands(commands)
86
+ payload = commands.map { |name, description| { command: name, description: description } }
87
+
88
+ call("setMyCommands", { commands: JSON.generate(payload) })
89
+ end
90
+
91
+ def answer_callback_query(id, text: nil, show_alert: false)
92
+ call("answerCallbackQuery", { callback_query_id: id, text: text, show_alert: show_alert })
93
+ end
94
+
95
+ # Strip the token out of arbitrary text before logging it.
96
+ #
97
+ # Matches both the literal value and its `inspect`-escaped form
98
+ # (control characters like a tab become the two characters
99
+ # `\t`): `URI::InvalidURIError`'s message is built with
100
+ # `inspect`, so a plain literal match alone would miss the token
101
+ # whenever it contains anything `inspect` escapes, and let it
102
+ # straight through into a log.
103
+ #
104
+ # An empty token is guarded separately: `gsub` against an empty
105
+ # pattern matches between every character, turning any message
106
+ # into unreadable noise instead of leaving it untouched.
107
+ def redact(text)
108
+ return text.to_s if @token.to_s.empty?
109
+
110
+ text.to_s.gsub(Regexp.union(@token.to_s, escaped_token), "<token>")
111
+ end
112
+
113
+ private
114
+
115
+ def escaped_token = @token.to_s.inspect[1..-2]
116
+
117
+ def call(method, params = {}, read_timeout: CALL_TIMEOUT)
118
+ response = @http.post(url_for(method), compact(params), read_timeout: read_timeout)
119
+ parse(response, method)
120
+ rescue Http::NetworkError => e
121
+ raise Unavailable, redact(e.message)
122
+ rescue URI::InvalidURIError
123
+ # Don't even try to redact this one: the token sits inside a
124
+ # URI, escaped, and a redaction pass that turned out wrong
125
+ # would be worse than a message with none of the URI in it at all.
126
+ raise Error, "#{method}: token is not valid for a URL (unexpected characters)"
127
+ end
128
+
129
+ def url_for(method) = URI("https://#{HOST}/bot#{@token}/#{method}")
130
+
131
+ # Drop nil fields: Telegram treats them as values that were actually passed.
132
+ def compact(params) = params.compact
133
+
134
+ def parse(response, method)
135
+ body = JSON.parse(response.body)
136
+ return body["result"] if body["ok"]
137
+
138
+ raise_api_error(body, method)
139
+ rescue JSON::ParserError
140
+ raise Error, "#{method}: unrecognized response (HTTP #{response.code})"
141
+ end
142
+
143
+ def raise_api_error(body, method)
144
+ description = redact(body["description"].to_s)
145
+ retry_after = body.dig("parameters", "retry_after")
146
+
147
+ case body["error_code"]
148
+ when 409 then raise Conflict, "#{method}: another process is already reading updates"
149
+ when 429 then raise TooManyRequests.new("#{method}: too many requests", retry_after.to_i)
150
+ else raise Error, "#{method}: #{description}"
151
+ end
152
+ end
153
+ end
154
+
155
+ # Transport is split out separately so tests never hit the network.
156
+ class Http
157
+ NetworkError = Class.new(StandardError)
158
+
159
+ def post(uri, params, read_timeout:)
160
+ request = Net::HTTP::Post.new(uri)
161
+ request.set_form_data(params)
162
+
163
+ Net::HTTP.start(uri.host, uri.port,
164
+ use_ssl: true, open_timeout: 10, read_timeout: read_timeout) do |http|
165
+ http.request(request)
166
+ end
167
+ rescue *NETWORK_ERRORS => e
168
+ raise NetworkError, e.message
169
+ end
170
+
171
+ NETWORK_ERRORS = [
172
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
173
+ Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EPIPE,
174
+ SocketError, Timeout::Error, OpenSSL::SSL::SSLError, IOError
175
+ ].freeze
176
+ end
177
+ end
178
+ end
179
+ end