agent-cli-runtime 0.1.1 → 0.2.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.
@@ -0,0 +1,280 @@
1
+ require "json"
2
+
3
+ module AgentCliRuntime
4
+ module OpenCode
5
+ module Probe
6
+ REQUIRED_RUN_FLAGS = %w[
7
+ --model --variant --format --dir --pure --auto
8
+ ].freeze
9
+ SAFE_ENVIRONMENT_KEYS = %w[
10
+ HOME LANG LC_ALL LOGNAME PATH SHELL SSL_CERT_DIR SSL_CERT_FILE
11
+ TMPDIR USER
12
+ ].freeze
13
+ ANSI_PATTERN = /\e\[[0-?]*[ -\/]*[@-~]/
14
+ private_constant :SAFE_ENVIRONMENT_KEYS, :ANSI_PATTERN
15
+
16
+ module_function
17
+
18
+ def call(request, env: ENV)
19
+ call!(request, env:)
20
+ rescue Error => e
21
+ profile = Profiles.resolve(request.profile)
22
+ executable = profile.bin(env:)
23
+ RouteProbeResult.new(
24
+ provider: profile.name,
25
+ ready: false,
26
+ installed: profile.binary_installed?(env:),
27
+ executable: executable,
28
+ version: nil,
29
+ minimum_version: profile.min_version,
30
+ auth_configuration: AuthConfiguration.new(status: :not_checked),
31
+ route: request.route,
32
+ route_available: false,
33
+ available_variants: [],
34
+ capability_evidence: [
35
+ CapabilityEvidence.new(
36
+ capability: error_capability(e), supported: false,
37
+ provider: profile.name,
38
+ launcher_identity: profile.launcher_identity,
39
+ diagnostic: Redactor.diagnostic(e)
40
+ )
41
+ ],
42
+ diagnostic: Redactor.diagnostic(e)
43
+ )
44
+ end
45
+
46
+ def call!(request, env: ENV)
47
+ unless request.is_a?(ProbeRequest)
48
+ raise ArgumentError,
49
+ "request must be an AgentCliRuntime::ProbeRequest"
50
+ end
51
+
52
+ profile = Profiles.resolve(request.profile)
53
+ unless profile.name == :opencode
54
+ raise ArgumentError,
55
+ "route-aware ProbeRequest currently requires profile :opencode"
56
+ end
57
+ installed = profile.binary_installed?(env:)
58
+ unless installed
59
+ raise BinaryUnavailable,
60
+ "opencode binary not runnable: #{profile.bin(env:)}"
61
+ end
62
+
63
+ child_env = child_environment(profile, request, env:)
64
+ version = profile.check_version!(env: child_env)
65
+ evidence = [
66
+ evidence(profile, :installation),
67
+ evidence(profile, :version, [ version ])
68
+ ]
69
+
70
+ run_help = capture!(profile, child_env, "run", "--help")
71
+ missing = REQUIRED_RUN_FLAGS.reject { |flag| advertised?(run_help, flag) }
72
+ unless missing.empty?
73
+ raise UnsupportedCapability,
74
+ "OpenCode run is missing required capability #{missing.join(', ')}"
75
+ end
76
+ evidence.concat(REQUIRED_RUN_FLAGS.map do |flag|
77
+ evidence(profile, capability_for(flag), [ flag ])
78
+ end)
79
+
80
+ export_help = capture!(profile, child_env, "export", "--help")
81
+ unless advertised?(export_help, "--sanitize")
82
+ raise UnsupportedCapability,
83
+ "OpenCode export is missing required --sanitize capability"
84
+ end
85
+ evidence << evidence(profile, :sanitized_export, [ "--sanitize" ])
86
+
87
+ auth_output = capture!(profile, child_env, "auth", "list")
88
+ configured_key = request.credential_environment_keys.find do |key|
89
+ !env[key].to_s.empty?
90
+ end
91
+ auth_from_inventory =
92
+ normalized_output(auth_output).match?(
93
+ /(?:\A|[^A-Za-z0-9_.-])#{Regexp.escape(request.route.provider)}(?:\z|[^A-Za-z0-9_.-])/
94
+ )
95
+ unless configured_key || request.credential_file_staged ||
96
+ auth_from_inventory
97
+ raise AuthenticationError,
98
+ "OpenCode authentication source is missing for requested provider"
99
+ end
100
+ auth = AuthConfiguration.new(
101
+ status: :configured,
102
+ source:
103
+ configured_key ? "selected environment" :
104
+ (request.credential_file_staged ? "staged auth file" :
105
+ "local auth inventory")
106
+ )
107
+ evidence << evidence(profile, :auth_configuration)
108
+
109
+ models_output = capture!(
110
+ profile, child_env, "models", request.route.provider, "--verbose"
111
+ )
112
+ variants = variants_for(models_output, request.route.to_s)
113
+ if variants.nil?
114
+ raise RouteUnavailable,
115
+ "requested OpenCode route is unavailable in the local model inventory"
116
+ end
117
+ if request.variant && !variants.include?(request.variant)
118
+ raise RouteUnavailable,
119
+ "requested OpenCode variant is unavailable for the exact route"
120
+ end
121
+ evidence << evidence(profile, :model_route, [ request.route.to_s ])
122
+ evidence << evidence(profile, :model_variant, [ request.variant ]) if
123
+ request.variant
124
+
125
+ RouteProbeResult.new(
126
+ provider: profile.name,
127
+ ready: true,
128
+ installed: true,
129
+ executable: profile.bin(env: child_env),
130
+ version: version,
131
+ minimum_version: profile.min_version,
132
+ auth_configuration: auth,
133
+ route: request.route,
134
+ route_available: true,
135
+ available_variants: variants,
136
+ capability_evidence: evidence,
137
+ diagnostic: nil
138
+ )
139
+ rescue UnknownProvider
140
+ raise
141
+ rescue Error
142
+ raise
143
+ rescue StandardError => e
144
+ raise ProbeError, Redactor.diagnostic(e)
145
+ end
146
+
147
+ def child_environment(profile, request, env:)
148
+ selected = (ENV.keys | env.keys).to_h { |key| [ key, nil ] }
149
+ env.each do |key, value|
150
+ if SAFE_ENVIRONMENT_KEYS.include?(key) || key.start_with?("LC_") ||
151
+ key.start_with?("MISE_")
152
+ selected[key] = value.to_s
153
+ end
154
+ end
155
+ %w[
156
+ BUNDLE_BIN_PATH BUNDLE_GEMFILE GEM_HOME GEM_PATH RUBYLIB RUBYOPT
157
+ ].each { |key| selected[key] = nil }
158
+ profile.env_bin_override_keys.each do |key|
159
+ selected[key] = env[key].to_s unless env[key].to_s.empty?
160
+ end
161
+ request.credential_environment_keys.each do |key|
162
+ selected[key] = env[key].to_s unless env[key].to_s.empty?
163
+ end
164
+ selected.merge(request.environment).freeze
165
+ end
166
+ private_class_method :child_environment
167
+
168
+ def capture!(profile, environment, *arguments)
169
+ out, err, status = profile.capture_local(*arguments, env: environment)
170
+ unless status.success?
171
+ diagnostic = normalized_output("#{err}\n#{out}")
172
+ raise ConfigurationError,
173
+ diagnostic.empty? ?
174
+ "OpenCode local inspection command failed" : diagnostic
175
+ end
176
+ "#{out}\n#{err}"
177
+ rescue Errno::ENOENT, Errno::EACCES, Timeout::Error => e
178
+ raise BinaryUnavailable,
179
+ "OpenCode local inspection command could not run (#{e.class.name.split('::').last})"
180
+ end
181
+ private_class_method :capture!
182
+
183
+ def advertised?(help, flag)
184
+ help.match?(/(?:\A|[\s,])#{Regexp.escape(flag)}(?:[\s,=\[]|$)/)
185
+ end
186
+ private_class_method :advertised?
187
+
188
+ def variants_for(output, route)
189
+ text = normalized_output(output)
190
+ lines = text.lines
191
+ index = lines.index { |line| line.strip == route }
192
+ return nil unless index
193
+
194
+ suffix = lines.drop(index + 1).join
195
+ object = first_json_object(suffix)
196
+ return [] unless object
197
+
198
+ parsed = JSON.parse(object)
199
+ variants = parsed["variants"]
200
+ variants.is_a?(Hash) ? variants.keys.sort.freeze : [].freeze
201
+ rescue JSON::ParserError
202
+ raise ConfigurationError,
203
+ "OpenCode local model inventory is malformed"
204
+ end
205
+ private_class_method :variants_for
206
+
207
+ def first_json_object(text)
208
+ start = text.index("{")
209
+ return nil unless start
210
+
211
+ depth = 0
212
+ quoted = false
213
+ escaped = false
214
+ text.each_char.with_index do |character, index|
215
+ next if index < start
216
+
217
+ if quoted
218
+ if escaped
219
+ escaped = false
220
+ elsif character == "\\"
221
+ escaped = true
222
+ elsif character == '"'
223
+ quoted = false
224
+ end
225
+ next
226
+ end
227
+ if character == '"'
228
+ quoted = true
229
+ elsif character == "{"
230
+ depth += 1
231
+ elsif character == "}"
232
+ depth -= 1
233
+ return text[start..index] if depth.zero?
234
+ end
235
+ end
236
+ nil
237
+ end
238
+ private_class_method :first_json_object
239
+
240
+ def normalized_output(value)
241
+ value.to_s.gsub(ANSI_PATTERN, "").strip
242
+ end
243
+ private_class_method :normalized_output
244
+
245
+ def evidence(profile, capability, arguments = [])
246
+ CapabilityEvidence.new(
247
+ capability: capability,
248
+ supported: true,
249
+ provider: profile.name,
250
+ launcher_identity: profile.launcher_identity,
251
+ arguments: arguments.compact
252
+ )
253
+ end
254
+ private_class_method :evidence
255
+
256
+ def capability_for(flag)
257
+ {
258
+ "--model" => :model,
259
+ "--variant" => :model_variant,
260
+ "--format" => :json_events,
261
+ "--dir" => :working_directory,
262
+ "--pure" => :pure,
263
+ "--auto" => :permission_enforcement
264
+ }.fetch(flag)
265
+ end
266
+ private_class_method :capability_for
267
+
268
+ def error_capability(error)
269
+ case error
270
+ when AuthenticationError then :auth_configuration
271
+ when RouteUnavailable then :model_route
272
+ when UnsupportedCapability then :cli_capability
273
+ when BinaryUnavailable then :installation
274
+ else :probe
275
+ end
276
+ end
277
+ private_class_method :error_capability
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,402 @@
1
+ require "json"
2
+
3
+ module AgentCliRuntime
4
+ module OpenCode
5
+ module ResultParser
6
+ MAX_RUN_BYTES = 4 * 1024 * 1024
7
+ MAX_EXPORT_BYTES = 4 * 1024 * 1024
8
+ MAX_FINAL_MESSAGE_BYTES = 1024 * 1024
9
+ MAX_EVENTS = 10_000
10
+ MAX_UNKNOWN_EVENTS = 16
11
+ TERMINAL_REASONS = %w[stop length content-filter].freeze
12
+ KNOWN_EVENT_TYPES = %w[
13
+ step_start step_finish text reasoning tool_use error
14
+ ].freeze
15
+ EVENT_PART_TYPES = {
16
+ "step_start" => "step-start",
17
+ "step_finish" => "step-finish",
18
+ "text" => "text",
19
+ "reasoning" => "reasoning",
20
+ "tool_use" => "tool"
21
+ }.freeze
22
+ AUTH_PATTERN = /auth|credential|api[ _-]?key|unauthorized|forbidden/i
23
+ CONFIGURATION_PATTERN =
24
+ /\b(?:Config(?:uration)?Error|UnknownProvider|UnknownModel|ModelNotFound|RouteUnavailable|VariantUnavailable)\b|\b(?:invalid|unknown|unsupported) (?:configuration|provider|model|route|variant)\b|\b(?:provider|model|route|variant)(?: [^\n]+)? (?:not found|unavailable)\b/i
25
+ private_constant :KNOWN_EVENT_TYPES, :EVENT_PART_TYPES,
26
+ :AUTH_PATTERN, :CONFIGURATION_PATTERN
27
+
28
+ module_function
29
+
30
+ def parse_run(stdout)
31
+ bounded_input!(stdout, MAX_RUN_BYTES, "OpenCode run output")
32
+ session_id = nil
33
+ terminal = nil
34
+ texts = []
35
+ unknown = []
36
+ error_seen = false
37
+ event_count = 0
38
+
39
+ stdout.each_line.with_index(1) do |line, line_number|
40
+ next if line.strip.empty?
41
+
42
+ event_count += 1
43
+ malformed!("OpenCode run output contains too many events") if
44
+ event_count > MAX_EVENTS
45
+ event = parse_json_line(line, line_number)
46
+ type = required_string(event, "type", "event type")
47
+ unless KNOWN_EVENT_TYPES.include?(type)
48
+ additive_session = validate_additive_session!(event, session_id)
49
+ session_id ||= additive_session
50
+ if unknown.length < MAX_UNKNOWN_EVENTS
51
+ unknown << Redactor.diagnostic(
52
+ "unknown OpenCode event #{type}", bytes: 128
53
+ )
54
+ end
55
+ next
56
+ end
57
+
58
+ event_session = required_string(event, "sessionID", "event sessionID")
59
+ session_id ||= event_session
60
+ malformed!("OpenCode run sessionID changed within one capture") unless
61
+ session_id == event_session
62
+
63
+ if type == "error"
64
+ validate_error!(event)
65
+ error_seen = true
66
+ next
67
+ end
68
+
69
+ part = required_hash(event, "part", "event part")
70
+ validate_part!(part, type, session_id)
71
+ message_id = required_string(part, "messageID", "part messageID")
72
+ case type
73
+ when "text"
74
+ text = part["text"]
75
+ malformed!("OpenCode text part must contain text") unless
76
+ text.is_a?(String)
77
+ texts << [ message_id, text ]
78
+ when "step_finish"
79
+ terminal = terminal_part(part, message_id)
80
+ end
81
+ end
82
+
83
+ malformed!("OpenCode run emitted an error on a zero exit") if error_seen
84
+ malformed!("OpenCode run has no recognized terminal step") unless terminal
85
+ unless TERMINAL_REASONS.include?(terminal.fetch(:reason))
86
+ malformed!("OpenCode terminal step has an unrecognized finish reason")
87
+ end
88
+ message = texts.filter_map do |message_id, text|
89
+ text if message_id == terminal.fetch(:message_id)
90
+ end.join
91
+ malformed!("OpenCode terminal assistant message is empty") if message.empty?
92
+
93
+ final_message_truncated = message.bytesize > MAX_FINAL_MESSAGE_BYTES
94
+ ParsedRun.new(
95
+ session_id: session_id,
96
+ terminal_message_id: terminal.fetch(:message_id),
97
+ terminal_reason: terminal.fetch(:reason),
98
+ final_message: bounded_string(message, MAX_FINAL_MESSAGE_BYTES),
99
+ final_message_truncated: final_message_truncated,
100
+ preliminary_usage: terminal.fetch(:usage),
101
+ unknown_events: unknown.compact
102
+ )
103
+ rescue MalformedOutput
104
+ raise
105
+ rescue StandardError => e
106
+ raise MalformedOutput, Redactor.diagnostic(e)
107
+ end
108
+
109
+ def normalize(captured, requested_route:, profile:)
110
+ unless captured.is_a?(CapturedResult)
111
+ raise ArgumentError, "captured must be an AgentCliRuntime::CapturedResult"
112
+ end
113
+ route = requested_route.is_a?(Route) ?
114
+ requested_route : Route.parse(requested_route)
115
+ termination = captured.termination
116
+ return failure_outcome(
117
+ profile, route, termination, :timed_out, "OpenCode run timed out"
118
+ ) if termination.timed_out
119
+ return failure_outcome(
120
+ profile, route, termination, :cancelled, "OpenCode run was cancelled"
121
+ ) if termination.cancelled
122
+ unless termination.success?
123
+ kind, diagnostic = classify_failure(captured)
124
+ return failure_outcome(
125
+ profile, route, termination, kind, diagnostic
126
+ )
127
+ end
128
+
129
+ parsed = parse_run(captured.stdout)
130
+ inspection = parse_inspection(
131
+ captured.inspection_output,
132
+ session_id: parsed.session_id,
133
+ message_id: parsed.terminal_message_id
134
+ )
135
+ actual = inspection.fetch(:route)
136
+ NormalizedOutcome.new(
137
+ provider: profile.name,
138
+ launcher_identity: profile.launcher_identity,
139
+ kind: :completed,
140
+ termination: termination,
141
+ final_message: parsed.final_message,
142
+ final_message_truncated: parsed.final_message_truncated,
143
+ identity: RouteIdentity.new(requested: route, actual: actual),
144
+ usage: inspection.fetch(:usage),
145
+ diagnostic: nil,
146
+ unknown_events: parsed.unknown_events,
147
+ session_id: parsed.session_id,
148
+ message_id: parsed.terminal_message_id
149
+ )
150
+ rescue MalformedOutput => e
151
+ malformed_outcome(profile, requested_route, captured, e)
152
+ end
153
+
154
+ def parse_inspection(output, session_id:, message_id:)
155
+ if output.nil?
156
+ malformed!("OpenCode sanitized export evidence is required")
157
+ end
158
+ bounded_input!(output, MAX_EXPORT_BYTES, "OpenCode sanitized export")
159
+ export = JSON.parse(output)
160
+ malformed!("OpenCode sanitized export must be an object") unless
161
+ export.is_a?(Hash)
162
+ info = required_hash(export, "info", "export info")
163
+ unless required_string(info, "id", "export session id") == session_id
164
+ malformed!("OpenCode sanitized export session does not match the run")
165
+ end
166
+ messages = export["messages"]
167
+ malformed!("OpenCode sanitized export messages must be an array") unless
168
+ messages.is_a?(Array)
169
+ matches = messages.filter_map do |message|
170
+ next unless message.is_a?(Hash) && message["info"].is_a?(Hash)
171
+
172
+ record = message.fetch("info")
173
+ next unless record["id"] == message_id
174
+
175
+ record
176
+ end
177
+ unless matches.one?
178
+ malformed!("OpenCode sanitized export must contain one terminal assistant record")
179
+ end
180
+ assistant = matches.fetch(0)
181
+ unless assistant["role"] == "assistant" &&
182
+ assistant["sessionID"] == session_id
183
+ malformed!("OpenCode sanitized export terminal record is not correlated")
184
+ end
185
+ unless TERMINAL_REASONS.include?(assistant["finish"])
186
+ malformed!("OpenCode sanitized export terminal record is incomplete")
187
+ end
188
+ provider = required_string(
189
+ assistant, "providerID", "assistant providerID"
190
+ )
191
+ model = required_string(assistant, "modelID", "assistant modelID")
192
+ tokens = assistant["tokens"]
193
+ unless tokens.nil? || tokens.is_a?(Hash)
194
+ malformed!("OpenCode assistant tokens must be an object")
195
+ end
196
+ tokens ||= {}
197
+ cache = tokens["cache"]
198
+ unless cache.nil? || cache.is_a?(Hash)
199
+ malformed!("OpenCode assistant cache tokens must be an object")
200
+ end
201
+ cache ||= {}
202
+
203
+ {
204
+ route: Route.new(provider:, model:),
205
+ usage: NormalizedUsage.new(
206
+ input: numeric(tokens, "input", integer: true),
207
+ output: numeric(tokens, "output", integer: true),
208
+ cache_read: numeric(cache, "read", integer: true),
209
+ cache_write: numeric(cache, "write", integer: true),
210
+ reasoning: numeric(tokens, "reasoning", integer: true),
211
+ cost: numeric(assistant, "cost", integer: false)
212
+ )
213
+ }.freeze
214
+ rescue JSON::ParserError => e
215
+ raise MalformedOutput,
216
+ Redactor.diagnostic("OpenCode sanitized export is malformed: #{e.message}")
217
+ rescue ArgumentError => e
218
+ raise MalformedOutput, Redactor.diagnostic(e)
219
+ end
220
+
221
+ def terminal_part(part, message_id)
222
+ reason = required_string(part, "reason", "terminal reason")
223
+ tokens = required_hash(part, "tokens", "terminal tokens")
224
+ cache = required_hash(tokens, "cache", "terminal cache tokens")
225
+ {
226
+ message_id: message_id,
227
+ reason: reason,
228
+ usage: NormalizedUsage.new(
229
+ input: required_numeric(tokens, "input", integer: true),
230
+ output: required_numeric(tokens, "output", integer: true),
231
+ cache_read: required_numeric(cache, "read", integer: true),
232
+ cache_write: required_numeric(cache, "write", integer: true),
233
+ reasoning: required_numeric(tokens, "reasoning", integer: true),
234
+ cost: required_numeric(part, "cost", integer: false)
235
+ )
236
+ }.freeze
237
+ rescue ArgumentError => e
238
+ malformed!(e.message)
239
+ end
240
+ private_class_method :terminal_part
241
+
242
+ def validate_part!(part, event_type, session_id)
243
+ expected = EVENT_PART_TYPES.fetch(event_type)
244
+ unless part["type"] == expected && part["sessionID"] == session_id
245
+ malformed!("OpenCode #{event_type} part is not correlated")
246
+ end
247
+ required_string(part, "id", "part id")
248
+ end
249
+ private_class_method :validate_part!
250
+
251
+ def validate_error!(event)
252
+ error = required_hash(event, "error", "error event payload")
253
+ required_string(error, "name", "error name")
254
+ data = required_hash(error, "data", "error data")
255
+ required_string(data, "message", "error message")
256
+ end
257
+ private_class_method :validate_error!
258
+
259
+ def validate_additive_session!(event, session_id)
260
+ value = event["sessionID"]
261
+ return nil if value.nil?
262
+ unless value.is_a?(String) && !value.empty? && !value.include?("\0")
263
+ malformed!("OpenCode additive event sessionID must be a non-empty string")
264
+ end
265
+ return value if session_id.nil? || value == session_id
266
+
267
+ malformed!("OpenCode additive event session does not match the run")
268
+ end
269
+ private_class_method :validate_additive_session!
270
+
271
+ def classify_failure(captured)
272
+ details = error_details(captured.stdout)
273
+ diagnostic = Redactor.diagnostic(
274
+ [ *details, captured.stderr ].reject(&:empty?).join("\n")
275
+ ) || "OpenCode CLI exited without diagnostic evidence"
276
+ corpus = [ *details, captured.stderr ].join(" ")
277
+ kind =
278
+ if corpus.match?(AUTH_PATTERN)
279
+ :authentication_failure
280
+ elsif corpus.match?(CONFIGURATION_PATTERN)
281
+ :configuration_failure
282
+ else
283
+ :cli_failure
284
+ end
285
+ [ kind, diagnostic ]
286
+ end
287
+ private_class_method :classify_failure
288
+
289
+ def error_details(stdout)
290
+ return [] if stdout.bytesize > MAX_RUN_BYTES
291
+
292
+ stdout.each_line.filter_map do |line|
293
+ event = JSON.parse(line)
294
+ next unless event.is_a?(Hash) && event["type"] == "error"
295
+
296
+ error = event["error"]
297
+ next unless error.is_a?(Hash)
298
+
299
+ name = error["name"].to_s
300
+ data = error["data"]
301
+ message = data.is_a?(Hash) ? data["message"].to_s : ""
302
+ [ name, message ].reject(&:empty?).join(": ")
303
+ rescue JSON::ParserError
304
+ nil
305
+ end
306
+ end
307
+ private_class_method :error_details
308
+
309
+ def failure_outcome(profile, route, termination, kind, diagnostic)
310
+ NormalizedOutcome.new(
311
+ provider: profile.name,
312
+ launcher_identity: profile.launcher_identity,
313
+ kind: kind,
314
+ termination: termination,
315
+ identity: RouteIdentity.new(requested: route),
316
+ diagnostic: Redactor.diagnostic(diagnostic)
317
+ )
318
+ end
319
+ private_class_method :failure_outcome
320
+
321
+ def malformed_outcome(profile, requested_route, captured, error)
322
+ route = requested_route.is_a?(Route) ?
323
+ requested_route : Route.parse(requested_route)
324
+ NormalizedOutcome.new(
325
+ provider: profile.name,
326
+ launcher_identity: profile.launcher_identity,
327
+ kind: :malformed_output,
328
+ termination: captured.termination,
329
+ identity: RouteIdentity.new(requested: route),
330
+ diagnostic: Redactor.diagnostic(error)
331
+ )
332
+ end
333
+ private_class_method :malformed_outcome
334
+
335
+ def parse_json_line(line, line_number)
336
+ value = JSON.parse(line)
337
+ malformed!("OpenCode event on line #{line_number} must be an object") unless
338
+ value.is_a?(Hash)
339
+ value
340
+ rescue JSON::ParserError
341
+ malformed!("OpenCode run output has malformed JSON on line #{line_number}")
342
+ end
343
+ private_class_method :parse_json_line
344
+
345
+ def required_hash(hash, key, label)
346
+ value = hash[key]
347
+ malformed!("OpenCode #{label} must be an object") unless value.is_a?(Hash)
348
+ value
349
+ end
350
+ private_class_method :required_hash
351
+
352
+ def required_string(hash, key, label)
353
+ value = hash[key]
354
+ unless value.is_a?(String) && !value.empty? && !value.include?("\0")
355
+ malformed!("OpenCode #{label} must be a non-empty string")
356
+ end
357
+ value
358
+ end
359
+ private_class_method :required_string
360
+
361
+ def required_numeric(hash, key, integer:)
362
+ malformed!("OpenCode #{key} is required") unless hash.key?(key)
363
+ numeric(hash, key, integer:)
364
+ end
365
+ private_class_method :required_numeric
366
+
367
+ def numeric(hash, key, integer:)
368
+ return nil unless hash.key?(key)
369
+
370
+ value = hash[key]
371
+ valid = integer ? value.is_a?(Integer) : value.is_a?(Numeric)
372
+ valid &&= value.finite? if value.respond_to?(:finite?)
373
+ unless valid && value >= 0
374
+ malformed!("OpenCode #{key} must be a non-negative number")
375
+ end
376
+ value
377
+ end
378
+ private_class_method :numeric
379
+
380
+ def bounded_input!(value, bytes, label)
381
+ unless value.is_a?(String) && value.bytesize <= bytes
382
+ malformed!("#{label} exceeds the bounded input size")
383
+ end
384
+ end
385
+ private_class_method :bounded_input!
386
+
387
+ def bounded_string(value, bytes)
388
+ return value if value.bytesize <= bytes
389
+
390
+ value.byteslice(0, bytes).to_s
391
+ .force_encoding(Encoding::UTF_8)
392
+ .scrub("?")
393
+ end
394
+ private_class_method :bounded_string
395
+
396
+ def malformed!(message)
397
+ raise MalformedOutput, Redactor.diagnostic(message)
398
+ end
399
+ private_class_method :malformed!
400
+ end
401
+ end
402
+ end