mistri 0.5.0 → 0.6.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +469 -4
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +289 -385
  5. data/SECURITY.md +40 -0
  6. data/UPGRADING.md +640 -0
  7. data/assets/logo-animated.svg +30 -0
  8. data/assets/logo-dark.svg +14 -0
  9. data/assets/logo-light.svg +14 -0
  10. data/assets/logo.svg +14 -0
  11. data/assets/social-preview.png +0 -0
  12. data/docs/README.md +87 -0
  13. data/docs/context-and-workspaces.md +378 -0
  14. data/docs/mcp.md +366 -0
  15. data/docs/reliability.md +450 -0
  16. data/docs/sessions.md +295 -0
  17. data/docs/sub-agents.md +401 -0
  18. data/docs/tool-contracts.md +324 -0
  19. data/examples/approval.rb +36 -0
  20. data/examples/browser.rb +27 -0
  21. data/examples/page_editor.rb +31 -0
  22. data/examples/quickstart.rb +21 -0
  23. data/lib/generators/mistri/install/install_generator.rb +7 -3
  24. data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
  25. data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
  26. data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
  27. data/lib/mistri/agent.rb +575 -55
  28. data/lib/mistri/budget.rb +26 -1
  29. data/lib/mistri/child.rb +72 -16
  30. data/lib/mistri/compaction.rb +26 -10
  31. data/lib/mistri/compactor.rb +34 -11
  32. data/lib/mistri/console.rb +28 -7
  33. data/lib/mistri/content.rb +9 -3
  34. data/lib/mistri/dispatchers.rb +14 -12
  35. data/lib/mistri/errors.rb +83 -4
  36. data/lib/mistri/event.rb +24 -8
  37. data/lib/mistri/event_delivery.rb +60 -0
  38. data/lib/mistri/locks.rb +3 -3
  39. data/lib/mistri/mcp/client.rb +74 -19
  40. data/lib/mistri/mcp/egress.rb +216 -0
  41. data/lib/mistri/mcp/oauth.rb +476 -127
  42. data/lib/mistri/mcp/wires.rb +115 -23
  43. data/lib/mistri/mcp.rb +42 -8
  44. data/lib/mistri/message.rb +21 -11
  45. data/lib/mistri/models.rb +160 -22
  46. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  47. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  48. data/lib/mistri/providers/anthropic.rb +29 -6
  49. data/lib/mistri/providers/fake.rb +26 -10
  50. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  51. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  52. data/lib/mistri/providers/gemini.rb +31 -5
  53. data/lib/mistri/providers/openai/assembler.rb +337 -60
  54. data/lib/mistri/providers/openai/serializer.rb +13 -12
  55. data/lib/mistri/providers/openai.rb +29 -5
  56. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  57. data/lib/mistri/result.rb +1 -1
  58. data/lib/mistri/schema.rb +893 -75
  59. data/lib/mistri/session.rb +560 -48
  60. data/lib/mistri/sinks/coalesced.rb +17 -10
  61. data/lib/mistri/spawner.rb +111 -61
  62. data/lib/mistri/sse.rb +57 -14
  63. data/lib/mistri/stores/active_record.rb +1 -1
  64. data/lib/mistri/stores/memory.rb +21 -2
  65. data/lib/mistri/sub_agent/execution.rb +81 -0
  66. data/lib/mistri/sub_agent/runtime.rb +297 -0
  67. data/lib/mistri/sub_agent.rb +124 -87
  68. data/lib/mistri/task_output.rb +24 -6
  69. data/lib/mistri/tool.rb +93 -13
  70. data/lib/mistri/tool_arguments.rb +377 -0
  71. data/lib/mistri/tool_call.rb +43 -9
  72. data/lib/mistri/tool_context.rb +4 -2
  73. data/lib/mistri/tool_executor.rb +117 -26
  74. data/lib/mistri/tool_result.rb +15 -10
  75. data/lib/mistri/tools/edit_file.rb +62 -8
  76. data/lib/mistri/tools.rb +41 -4
  77. data/lib/mistri/transport.rb +149 -44
  78. data/lib/mistri/usage.rb +65 -13
  79. data/lib/mistri/version.rb +1 -1
  80. data/lib/mistri/workspace/active_record.rb +183 -3
  81. data/lib/mistri/workspace/directory.rb +28 -8
  82. data/lib/mistri/workspace/memory.rb +34 -9
  83. data/lib/mistri/workspace/single.rb +62 -5
  84. data/lib/mistri/workspace.rb +39 -0
  85. data/lib/mistri.rb +6 -1
  86. data/mistri.gemspec +34 -0
  87. metadata +31 -3
data/lib/mistri/event.rb CHANGED
@@ -11,15 +11,18 @@ module Mistri
11
11
  # message's content list.
12
12
  # origin names the sub-agent an event came from: nil for this agent's own
13
13
  # turns, and nesting joins names left to right ("researcher>writer").
14
- # duration is the tool's execution time in seconds on :tool_result
15
- # events; nil where nothing ran (denials, interruptions).
14
+ # duration is the measured tool execution time on :tool_result events; nil
15
+ # means no duration is available, not necessarily that no side effect ran.
16
+ # tool_error is explicit on :tool_result and does not change Event#error?,
17
+ # which means a terminal provider-turn failure.
16
18
  class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
17
19
  :reason, :message, :error_message, :partial, :origin,
18
20
  :duration, :attempt, :max_attempts, :delay,
19
- :agent, :session_id, :status)
21
+ :agent, :session_id, :status, :tool_error)
20
22
  # The stream types come from a provider mid-turn; the loop adds
21
- # :tool_result after it runs each tool, :approval_needed when a gated
22
- # call parks for a human, :compacting/:compaction around a context
23
+ # :tool_started when a resolved tool commits to execution, :tool_result
24
+ # after it finishes, :approval_needed when a gated call parks for a human,
25
+ # :compacting/:compaction around a context
23
26
  # compaction, and :retry (with attempt, max_attempts, delay) before it
24
27
  # waits out a transient failure, so one subscription sees the whole
25
28
  # exchange. :done and :error are loop-owned and terminal: only the
@@ -33,7 +36,7 @@ module Mistri
33
36
  thinking_start thinking_delta thinking_end
34
37
  toolcall_start toolcall_delta toolcall_end
35
38
  done error
36
- tool_result approval_needed
39
+ tool_started tool_result approval_needed
37
40
  compacting compaction
38
41
  retry
39
42
  subagent_report
@@ -42,9 +45,20 @@ module Mistri
42
45
  def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
43
46
  reason: nil, message: nil, error_message: nil, partial: nil, origin: nil,
44
47
  duration: nil, attempt: nil, max_attempts: nil, delay: nil,
45
- agent: nil, session_id: nil, status: nil)
48
+ agent: nil, session_id: nil, status: nil, tool_error: nil)
46
49
  raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
47
50
 
51
+ if type == :tool_result
52
+ unless [true, false].include?(tool_error)
53
+ raise ArgumentError, "tool_result events require a boolean tool_error"
54
+ end
55
+ elsif !tool_error.nil?
56
+ raise ArgumentError, "tool_error is only valid on tool_result events"
57
+ end
58
+ if type == :tool_result && message && message.tool_error != tool_error
59
+ raise ArgumentError, "event tool_error must match its message"
60
+ end
61
+
48
62
  super
49
63
  end
50
64
 
@@ -52,13 +66,15 @@ module Mistri
52
66
 
53
67
  def error? = type == :error
54
68
 
69
+ def tool_error? = tool_error == true
70
+
55
71
  def terminal? = done? || error?
56
72
 
57
73
  # Partials are ephemeral streaming state and stay out of serialization.
58
74
  def to_h
59
75
  { type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
60
76
  reason:, message: message&.to_h, error_message:, origin:, duration:,
61
- attempt:, max_attempts:, delay:, agent:, session_id:, status: }.compact
77
+ attempt:, max_attempts:, delay:, agent:, session_id:, status:, tool_error: }.compact
62
78
  end
63
79
  end
64
80
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Preserves subscriber exception identity across internal rescue boundaries.
5
+ module EventDelivery
6
+ # Tags a subscriber failure with the boundary that first observed it.
7
+ class Failure < StandardError
8
+ attr_reader :original, :boundary
9
+
10
+ def initialize(original, boundary)
11
+ @original = original
12
+ @boundary = boundary
13
+ super(original.message)
14
+ set_backtrace(original.backtrace)
15
+ end
16
+ end
17
+
18
+ # Wraps one subscriber and unwraps only failures that it created.
19
+ class Boundary
20
+ def initialize(subscriber, passthrough: [])
21
+ @subscriber = subscriber
22
+ @passthrough = passthrough.freeze
23
+ @callable = method(:call).to_proc
24
+ end
25
+
26
+ def to_proc = @callable
27
+
28
+ def call(event)
29
+ @subscriber.call(event)
30
+ rescue Failure
31
+ raise
32
+ rescue StandardError => e
33
+ raise if @passthrough.any? { |error_class| e.is_a?(error_class) }
34
+
35
+ raise Failure.new(e, self)
36
+ end
37
+
38
+ def unwrap(error)
39
+ error.boundary.equal?(self) ? error.original : error
40
+ end
41
+ end
42
+
43
+ module_function
44
+
45
+ def wrap(subscriber, passthrough: [])
46
+ Boundary.new(subscriber, passthrough: passthrough) if subscriber
47
+ end
48
+
49
+ def unwrap(error, boundary)
50
+ return error unless error.is_a?(Failure) && boundary
51
+
52
+ boundary.unwrap(error)
53
+ end
54
+
55
+ def original(error)
56
+ error.is_a?(Failure) ? error.original : error
57
+ end
58
+ end
59
+ private_constant :EventDelivery
60
+ end
data/lib/mistri/locks.rb CHANGED
@@ -6,15 +6,15 @@ module Mistri
6
6
  # Cross-process coordination for hosts that run loops in more than one
7
7
  # process: leases that say "this is alive right now" and flags that carry
8
8
  # one-bit requests (stop) between processes. Everything expires on its
9
- # own, so a crashed holder never wedges anything; a live holder renews on
10
- # a heartbeat, so only dead ones expire.
9
+ # own, so a crashed holder never wedges anything; a healthy holder renews
10
+ # on a heartbeat, while a stalled process can still outlive its lease.
11
11
  #
12
12
  # Configure once at boot:
13
13
  #
14
14
  # Mistri.locks = Mistri::Locks::Memory.new # single process
15
15
  # Mistri.locks = Mistri::Locks::RailsCache.new # requires opt-in file
16
16
  #
17
- # An adapter implements six methods: acquire(key, ttl:) -> bool,
17
+ # An adapter implements seven methods: acquire(key, ttl:) -> bool,
18
18
  # renew(key, ttl:), release(key), held?(key), set_flag(key, ttl:),
19
19
  # flag?(key), clear_flag(key). With no adapter configured, everything
20
20
  # lease-aware degrades gracefully: children read :running instead of
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
3
  module Mistri
6
4
  module MCP
7
5
  # A Model Context Protocol client: the initialize handshake, tools/list
@@ -20,37 +18,45 @@ module Mistri
20
18
  # re-resolving, so a host's refresh logic lives in one lambda. A session
21
19
  # the server expires (404 with a session attached) transparently
22
20
  # re-initializes, per spec.
21
+ # Remote URLs default to public HTTPS. allow_non_public: is consulted only
22
+ # for otherwise blocked addresses and plain HTTP remains loopback-only.
23
+ # max_record_bytes bounds one JSON body, SSE line, or stdio record without
24
+ # imposing a lifetime limit on an SSE stream.
23
25
  #
24
26
  # One client serializes its calls; parallel tool calls against one
25
27
  # server queue rather than interleave.
26
28
  class Client
27
29
  PROTOCOL_VERSION = "2025-11-25"
28
30
  SUPPORTED_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
29
- LOOPBACK = %w[localhost 127.0.0.1 ::1].freeze
30
-
31
31
  attr_reader :server_info
32
32
 
33
33
  def initialize(url: nil, command: nil, env: {}, token: nil, headers: {},
34
- client_name: "mistri", open_timeout: 15, read_timeout: 120)
34
+ client_name: "mistri", open_timeout: 15, read_timeout: 120,
35
+ allow_non_public: nil, max_tool_pages: 100, max_tools: 10_000,
36
+ max_record_bytes: DEFAULT_MAX_RECORD_BYTES)
35
37
  if [url, command].compact.length != 1
36
38
  raise ConfigurationError, "pass exactly one of url: or command:"
37
39
  end
38
40
 
39
- if url && token && URI(url).scheme == "http" && !LOOPBACK.include?(URI(url).host)
40
- raise ConfigurationError,
41
- "refusing to send a bearer token over plain HTTP to #{URI(url).host}"
42
- end
41
+ validate_limit(:max_tool_pages, max_tool_pages)
42
+ validate_limit(:max_tools, max_tools)
43
+ validate_limit(:max_record_bytes, max_record_bytes)
43
44
 
44
45
  @wire = if url
45
46
  Wires::Http.new(url: url, token: token, headers: headers,
46
- open_timeout: open_timeout, read_timeout: read_timeout)
47
+ open_timeout: open_timeout, read_timeout: read_timeout,
48
+ allow_non_public: allow_non_public,
49
+ max_record_bytes: max_record_bytes)
47
50
  else
48
- Wires::Stdio.new(command: command, env: env, read_timeout: read_timeout)
51
+ Wires::Stdio.new(command: command, env: env, read_timeout: read_timeout,
52
+ max_record_bytes: max_record_bytes)
49
53
  end
50
54
  @client_name = client_name
51
55
  @mutex = Mutex.new
52
56
  @serial = 0
53
57
  @connected = false
58
+ @max_tool_pages = max_tool_pages
59
+ @max_tools = max_tools
54
60
  end
55
61
 
56
62
  # The server's tools as it describes them: hashes with "name",
@@ -72,8 +78,7 @@ module Mistri
72
78
  end
73
79
 
74
80
  def close
75
- @wire.close
76
- @connected = false
81
+ @mutex.synchronize { reset_wire }
77
82
  nil
78
83
  end
79
84
 
@@ -96,6 +101,9 @@ module Mistri
96
101
  @server_info = result["serverInfo"]
97
102
  @wire.notify({ jsonrpc: "2.0", method: "notifications/initialized" })
98
103
  @connected = true
104
+ rescue Mistri::Error
105
+ reset_wire
106
+ raise
99
107
  end
100
108
 
101
109
  def request(method, params, reconnected: false, refreshed: false)
@@ -119,7 +127,9 @@ module Mistri
119
127
  payload = { jsonrpc: "2.0", id: id, method: method, params: params }
120
128
  result = nil
121
129
  responded = false
122
- @wire.call(payload) do |record|
130
+ replayable = method != "tools/call"
131
+ # A tool may commit its external side effect before the connection dies.
132
+ @wire.call(payload, replayable: replayable) do |record|
123
133
  next unless record.is_a?(Hash) && record["id"] == id
124
134
 
125
135
  responded = true
@@ -127,9 +137,28 @@ module Mistri
127
137
 
128
138
  result = record["result"]
129
139
  end
130
- raise Error, "the server sent no response to #{method}" unless responded
140
+ unless responded
141
+ detail = "the server sent no matching response to #{method}"
142
+ raise Error, detail if replayable
143
+
144
+ raise AmbiguousDeliveryError, "#{AmbiguousDeliveryError.default_message}: #{detail}"
145
+ end
131
146
 
132
147
  result
148
+ rescue ResponseTooLargeError, ResponseTooComplexError, WireError => e
149
+ reset_wire
150
+ raise if replayable
151
+ return result if responded
152
+
153
+ message = "the request was sent but its tool outcome could not be confirmed: " \
154
+ "#{e.message}; the operation may have completed; do not retry automatically; " \
155
+ "verify external state first"
156
+ raise AmbiguousDeliveryError, message
157
+ rescue AmbiguousDeliveryError
158
+ reset_wire
159
+ return result if responded
160
+
161
+ raise
133
162
  rescue ProviderError => e
134
163
  raise SessionExpired if e.status == 404 && @wire.session?
135
164
 
@@ -139,13 +168,39 @@ module Mistri
139
168
  def list_tools
140
169
  collected = []
141
170
  cursor = nil
142
- loop do
171
+ seen_cursors = {}
172
+ @max_tool_pages.times do
143
173
  result = request("tools/list", cursor ? { cursor: cursor } : {})
144
- collected.concat(Array(result["tools"]))
174
+ raise Error, "tools/list returned a malformed result" unless result.is_a?(Hash)
175
+
176
+ page = result["tools"]
177
+ raise Error, "tools/list returned a malformed tools collection" unless page.is_a?(Array)
178
+ if collected.length + page.length > @max_tools
179
+ raise Error, "tool discovery exceeded #{@max_tools} tools"
180
+ end
181
+
182
+ collected.concat(page)
145
183
  cursor = result["nextCursor"]
146
- break unless cursor
184
+ return collected if cursor.nil?
185
+ raise Error, "tools/list returned a malformed cursor" unless cursor.is_a?(String)
186
+ raise Error, "tools/list repeated a cursor" if seen_cursors[cursor]
187
+
188
+ seen_cursors[cursor] = true
147
189
  end
148
- collected
190
+ raise Error, "tool discovery exceeded #{@max_tool_pages} pages"
191
+ end
192
+
193
+ def validate_limit(name, value)
194
+ return if value.is_a?(Integer) && value.positive?
195
+
196
+ raise ConfigurationError, "#{name}: must be a positive integer"
197
+ end
198
+
199
+ def reset_wire
200
+ @wire.close
201
+ @wire.reset_session
202
+ @connected = false
203
+ @server_info = nil
149
204
  end
150
205
 
151
206
  def rpc_error(error)
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "socket"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ module Mistri
9
+ module MCP
10
+ # Resolves untrusted MCP URLs onto globally reachable addresses and pins
11
+ # that decision to the eventual connection. A host may narrowly approve a
12
+ # non-public address, but cannot bypass URL shape or plaintext rules.
13
+ module Egress
14
+ Target = Data.define(:uri, :address)
15
+ NAT64_WELL_KNOWN = IPAddr.new("64:ff9b::/96")
16
+ IPV6_GLOBAL_UNICAST = IPAddr.new("2000::/3")
17
+
18
+ # IANA IPv4/IPv6 Special-Purpose Address Registries, updated 2025-10-09.
19
+ # Broad non-global ranges contain a few globally reachable assignments.
20
+ # https://www.iana.org/assignments/iana-ipv4-special-registry/
21
+ # https://www.iana.org/assignments/iana-ipv6-special-registry/
22
+ # https://www.iana.org/assignments/ipv6-address-space/
23
+ GLOBAL_EXCEPTIONS = %w[
24
+ 192.0.0.9/32
25
+ 192.0.0.10/32
26
+ 192.31.196.0/24
27
+ 192.52.193.0/24
28
+ 192.175.48.0/24
29
+ 2001:1::1/128
30
+ 2001:1::2/128
31
+ 2001:1::3/128
32
+ 2001:3::/32
33
+ 2001:4:112::/48
34
+ 2001:20::/28
35
+ 2001:30::/28
36
+ 2620:4f:8000::/48
37
+ ].map { |range| IPAddr.new(range) }.freeze
38
+
39
+ NON_GLOBAL = %w[
40
+ 0.0.0.0/8
41
+ 10.0.0.0/8
42
+ 100.64.0.0/10
43
+ 127.0.0.0/8
44
+ 169.254.0.0/16
45
+ 172.16.0.0/12
46
+ 192.0.0.0/24
47
+ 192.0.2.0/24
48
+ 192.88.99.0/24
49
+ 192.168.0.0/16
50
+ 198.18.0.0/15
51
+ 198.51.100.0/24
52
+ 203.0.113.0/24
53
+ 224.0.0.0/4
54
+ 240.0.0.0/4
55
+ ::/96
56
+ 64:ff9b:1::/48
57
+ 100::/64
58
+ 100:0:0:1::/64
59
+ 2001::/23
60
+ 2001:db8::/32
61
+ 2002::/16
62
+ 3fff::/20
63
+ 5f00::/16
64
+ fc00::/7
65
+ fe80::/10
66
+ fec0::/10
67
+ ff00::/8
68
+ ].map { |range| IPAddr.new(range) }.freeze
69
+
70
+ module_function
71
+
72
+ def target(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil)
73
+ targets(url, allow_non_public:, label:, timeout:, lookup:).first
74
+ end
75
+
76
+ def targets(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil)
77
+ uri, address_resolver = resolver(url, allow_non_public:, label:, timeout:, lookup:)
78
+ address_resolver.call.map { |address| Target.new(uri:, address:) }
79
+ end
80
+
81
+ def resolver(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil)
82
+ validate_exception(allow_non_public)
83
+ uri = normalize(url, label)
84
+ if uri.scheme == "http" && allow_non_public.nil?
85
+ raise UnsafeURLError, "#{label} must use HTTPS"
86
+ end
87
+
88
+ snapshot = uri.to_s.freeze
89
+ resolve_addresses = lambda do
90
+ target = URI(snapshot)
91
+ addresses = resolve(target, label, timeout, lookup: lookup)
92
+ approved_addresses(target, addresses, allow_non_public:, label:)
93
+ .map(&:to_s).freeze
94
+ end
95
+ [URI(snapshot), resolve_addresses]
96
+ end
97
+
98
+ def approved_addresses(uri, addresses, allow_non_public: nil, label: "URL")
99
+ validate_exception(allow_non_public)
100
+ if addresses.empty?
101
+ raise UnsafeURLError,
102
+ "#{label} host #{uri.hostname.inspect} resolved to no addresses"
103
+ end
104
+ rejected = addresses.reject do |address|
105
+ policy_uri = URI(uri.to_s).freeze
106
+ policy_address = address.dup.freeze
107
+ globally_reachable?(address) || allow_non_public&.call(policy_uri, policy_address)
108
+ end
109
+ unless rejected.empty?
110
+ raise UnsafeURLError,
111
+ "#{label} host #{uri.hostname.inspect} resolves to a non-public address"
112
+ end
113
+ if uri.scheme == "http" && !addresses.all?(&:loopback?)
114
+ raise UnsafeURLError, "#{label} must use HTTPS"
115
+ end
116
+
117
+ addresses
118
+ end
119
+
120
+ def approved_address(uri, addresses, allow_non_public: nil, label: "URL")
121
+ approved_addresses(uri, addresses, allow_non_public:, label:).first
122
+ end
123
+
124
+ def normalize(url, label = "URL")
125
+ uri = parse(url, label)
126
+ uri.scheme = uri.scheme.downcase
127
+ uri.host = uri.host.downcase
128
+ uri.port = nil if uri.port == uri.default_port
129
+ uri.path = "" if uri.path == "/"
130
+ uri
131
+ rescue URI::Error, ArgumentError
132
+ raise UnsafeURLError, "#{label} must be an absolute HTTP(S) URL"
133
+ end
134
+
135
+ def redirect_uri(url)
136
+ value = url.to_s
137
+ uri = parse(value, "redirect_uri")
138
+ return value if uri.scheme == "https"
139
+
140
+ address = parse_address(uri.hostname)
141
+ return value if uri.hostname.casecmp?("localhost") || address&.loopback?
142
+
143
+ raise UnsafeURLError, "redirect_uri must use HTTPS or an explicit loopback address"
144
+ end
145
+
146
+ def origin(uri)
147
+ value = uri.dup
148
+ value.path = ""
149
+ value.query = nil
150
+ value.to_s
151
+ end
152
+
153
+ def display(uri)
154
+ value = uri.dup
155
+ value.query = nil
156
+ value.to_s
157
+ end
158
+
159
+ def globally_reachable?(address)
160
+ address = address.native if address.ipv4_mapped?
161
+ if NAT64_WELL_KNOWN.include?(address)
162
+ embedded = IPAddr.new(address.to_i & 0xffffffff, Socket::AF_INET)
163
+ return globally_reachable?(embedded)
164
+ end
165
+ return true if GLOBAL_EXCEPTIONS.any? { |range| range.include?(address) }
166
+ return false if address.ipv6? && !IPV6_GLOBAL_UNICAST.include?(address)
167
+
168
+ NON_GLOBAL.none? { |range| range.include?(address) }
169
+ end
170
+
171
+ def resolve(uri, label, timeout, lookup: nil)
172
+ lookup ||= Addrinfo.method(:getaddrinfo)
173
+ entries = lookup.call(uri.hostname, uri.port, nil, :STREAM, timeout: timeout)
174
+ found = entries.filter_map do |entry|
175
+ address = parse_address(entry.ip_address)
176
+ address&.ipv4_mapped? ? address.native : address
177
+ end.uniq
178
+ if found.empty?
179
+ raise UnsafeURLError,
180
+ "#{label} host #{uri.hostname.inspect} resolved to no addresses"
181
+ end
182
+
183
+ found
184
+ rescue SocketError, IOError, Timeout::Error
185
+ raise UnsafeURLError, "#{label} host #{uri.hostname.inspect} could not be resolved"
186
+ end
187
+
188
+ def parse_address(value)
189
+ IPAddr.new(value)
190
+ rescue IPAddr::Error
191
+ nil
192
+ end
193
+
194
+ def validate_exception(exception)
195
+ return if exception.nil? || exception.respond_to?(:call)
196
+
197
+ raise ConfigurationError, "allow_non_public: must be callable"
198
+ end
199
+
200
+ def parse(url, label)
201
+ uri = URI.parse(url.to_s)
202
+ unless uri.is_a?(URI::HTTP) && %w[http https].include?(uri.scheme) &&
203
+ !uri.hostname.to_s.empty?
204
+ raise UnsafeURLError, "#{label} must be an absolute HTTP(S) URL"
205
+ end
206
+ raise UnsafeURLError, "#{label} must not contain credentials" if uri.user || uri.password
207
+ raise UnsafeURLError, "#{label} must not contain a fragment" if uri.fragment
208
+ raise UnsafeURLError, "#{label} has an invalid port" unless (1..65_535).cover?(uri.port)
209
+
210
+ uri
211
+ rescue URI::Error, ArgumentError
212
+ raise UnsafeURLError, "#{label} must be an absolute HTTP(S) URL"
213
+ end
214
+ end
215
+ end
216
+ end