agent-cli-runtime 0.1.1 → 0.2.4

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,416 @@
1
+ require "json"
2
+
3
+ module AgentCliRuntime
4
+ module OpenCode
5
+ module ResultParser
6
+ MAX_RUN_BYTES = 4 * 1024 * 1024
7
+ # Sanitized exports contain the complete session and every tool result,
8
+ # so their bounded limit must accommodate long implementation sessions.
9
+ MAX_EXPORT_BYTES = 64 * 1024 * 1024
10
+ MAX_FINAL_MESSAGE_BYTES = 1024 * 1024
11
+ MAX_EVENTS = 10_000
12
+ MAX_UNKNOWN_EVENTS = 16
13
+ TERMINAL_REASONS = %w[stop length content-filter].freeze
14
+ KNOWN_EVENT_TYPES = %w[
15
+ step_start step_finish text reasoning tool_use error
16
+ ].freeze
17
+ EVENT_PART_TYPES = {
18
+ "step_start" => "step-start",
19
+ "step_finish" => "step-finish",
20
+ "text" => "text",
21
+ "reasoning" => "reasoning",
22
+ "tool_use" => "tool"
23
+ }.freeze
24
+ AUTH_PATTERN = /auth|credential|api[ _-]?key|unauthorized|forbidden/i
25
+ CONFIGURATION_PATTERN =
26
+ /\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
27
+ UPSTREAM_TIMEOUT_PATTERN =
28
+ /\b(?:upstream\s+)?(?:idle\s+)?timeout\b|\btimed out\b|error_type['"\s:=>]+timeout\b|\bcode['"\s:=>]+504\b/i
29
+ private_constant :KNOWN_EVENT_TYPES, :EVENT_PART_TYPES,
30
+ :AUTH_PATTERN, :CONFIGURATION_PATTERN,
31
+ :UPSTREAM_TIMEOUT_PATTERN
32
+
33
+ module_function
34
+
35
+ def parse_run(stdout)
36
+ bounded_input!(stdout, MAX_RUN_BYTES, "OpenCode run output")
37
+ session_id = nil
38
+ terminal = nil
39
+ texts = []
40
+ unknown = []
41
+ error_seen = false
42
+ event_count = 0
43
+
44
+ stdout.each_line.with_index(1) do |line, line_number|
45
+ next if line.strip.empty?
46
+
47
+ event_count += 1
48
+ malformed!("OpenCode run output contains too many events") if
49
+ event_count > MAX_EVENTS
50
+ event = parse_json_line(line, line_number)
51
+ type = required_string(event, "type", "event type")
52
+ unless KNOWN_EVENT_TYPES.include?(type)
53
+ additive_session = validate_additive_session!(event, session_id)
54
+ session_id ||= additive_session
55
+ if unknown.length < MAX_UNKNOWN_EVENTS
56
+ unknown << Redactor.diagnostic(
57
+ "unknown OpenCode event #{type}", bytes: 128
58
+ )
59
+ end
60
+ next
61
+ end
62
+
63
+ event_session = required_string(event, "sessionID", "event sessionID")
64
+ session_id ||= event_session
65
+ malformed!("OpenCode run sessionID changed within one capture") unless
66
+ session_id == event_session
67
+
68
+ if type == "error"
69
+ validate_error!(event)
70
+ error_seen = true
71
+ next
72
+ end
73
+
74
+ part = required_hash(event, "part", "event part")
75
+ validate_part!(part, type, session_id)
76
+ message_id = required_string(part, "messageID", "part messageID")
77
+ case type
78
+ when "text"
79
+ text = part["text"]
80
+ malformed!("OpenCode text part must contain text") unless
81
+ text.is_a?(String)
82
+ texts << [ message_id, text ]
83
+ when "step_finish"
84
+ terminal = terminal_part(part, message_id)
85
+ end
86
+ end
87
+
88
+ malformed!("OpenCode run emitted an error on a zero exit") if error_seen
89
+ malformed!("OpenCode run has no recognized terminal step") unless terminal
90
+ unless TERMINAL_REASONS.include?(terminal.fetch(:reason))
91
+ malformed!("OpenCode terminal step has an unrecognized finish reason")
92
+ end
93
+ message = texts.filter_map do |message_id, text|
94
+ text if message_id == terminal.fetch(:message_id)
95
+ end.join
96
+ final_message_truncated = message.bytesize > MAX_FINAL_MESSAGE_BYTES
97
+ ParsedRun.new(
98
+ session_id: session_id,
99
+ terminal_message_id: terminal.fetch(:message_id),
100
+ terminal_reason: terminal.fetch(:reason),
101
+ final_message: bounded_string(message, MAX_FINAL_MESSAGE_BYTES),
102
+ final_message_truncated: final_message_truncated,
103
+ preliminary_usage: terminal.fetch(:usage),
104
+ unknown_events: unknown.compact
105
+ )
106
+ rescue MalformedOutput
107
+ raise
108
+ rescue StandardError => e
109
+ raise MalformedOutput, Redactor.diagnostic(e)
110
+ end
111
+
112
+ def normalize(captured, requested_route:, profile:)
113
+ unless captured.is_a?(CapturedResult)
114
+ raise ArgumentError, "captured must be an AgentCliRuntime::CapturedResult"
115
+ end
116
+ route = requested_route.is_a?(Route) ?
117
+ requested_route : Route.parse(requested_route)
118
+ termination = captured.termination
119
+ return failure_outcome(
120
+ profile, route, termination, :timed_out, "OpenCode run timed out"
121
+ ) if termination.timed_out
122
+ return failure_outcome(
123
+ profile, route, termination, :cancelled, "OpenCode run was cancelled"
124
+ ) if termination.cancelled
125
+ unless termination.success?
126
+ kind, diagnostic = classify_failure(captured)
127
+ return failure_outcome(
128
+ profile, route, termination, kind, diagnostic
129
+ )
130
+ end
131
+
132
+ parsed = parse_run(captured.stdout)
133
+ inspection = parse_inspection(
134
+ captured.inspection_output,
135
+ session_id: parsed.session_id,
136
+ message_id: parsed.terminal_message_id
137
+ )
138
+ actual = inspection.fetch(:route)
139
+ NormalizedOutcome.new(
140
+ provider: profile.name,
141
+ launcher_identity: profile.launcher_identity,
142
+ kind: :completed,
143
+ termination: termination,
144
+ final_message: parsed.final_message,
145
+ final_message_truncated: parsed.final_message_truncated,
146
+ identity: RouteIdentity.new(requested: route, actual: actual),
147
+ usage: inspection.fetch(:usage),
148
+ diagnostic: nil,
149
+ unknown_events: parsed.unknown_events,
150
+ session_id: parsed.session_id,
151
+ message_id: parsed.terminal_message_id
152
+ )
153
+ rescue MalformedOutput => e
154
+ malformed_outcome(profile, requested_route, captured, e)
155
+ end
156
+
157
+ def parse_inspection(output, session_id:, message_id:)
158
+ if output.nil?
159
+ malformed!("OpenCode sanitized export evidence is required")
160
+ end
161
+ bounded_input!(output, MAX_EXPORT_BYTES, "OpenCode sanitized export")
162
+ export = JSON.parse(output)
163
+ malformed!("OpenCode sanitized export must be an object") unless
164
+ export.is_a?(Hash)
165
+ info = required_hash(export, "info", "export info")
166
+ unless required_string(info, "id", "export session id") == session_id
167
+ malformed!("OpenCode sanitized export session does not match the run")
168
+ end
169
+ messages = export["messages"]
170
+ malformed!("OpenCode sanitized export messages must be an array") unless
171
+ messages.is_a?(Array)
172
+ assistant = nil
173
+ messages.each do |message|
174
+ next unless message.is_a?(Hash) && message["info"].is_a?(Hash)
175
+
176
+ record = message.fetch("info")
177
+ next unless record["id"] == message_id
178
+
179
+ if assistant
180
+ malformed!("OpenCode sanitized export must contain one terminal assistant record")
181
+ end
182
+ assistant = record
183
+ end
184
+ unless assistant
185
+ malformed!("OpenCode sanitized export must contain one terminal assistant record")
186
+ end
187
+ unless assistant["role"] == "assistant" &&
188
+ assistant["sessionID"] == session_id
189
+ malformed!("OpenCode sanitized export terminal record is not correlated")
190
+ end
191
+ unless TERMINAL_REASONS.include?(assistant["finish"])
192
+ malformed!("OpenCode sanitized export terminal record is incomplete")
193
+ end
194
+ provider = required_string(
195
+ assistant, "providerID", "assistant providerID"
196
+ )
197
+ model = required_string(assistant, "modelID", "assistant modelID")
198
+ tokens = assistant["tokens"]
199
+ unless tokens.nil? || tokens.is_a?(Hash)
200
+ malformed!("OpenCode assistant tokens must be an object")
201
+ end
202
+ tokens ||= {}
203
+ cache = tokens["cache"]
204
+ unless cache.nil? || cache.is_a?(Hash)
205
+ malformed!("OpenCode assistant cache tokens must be an object")
206
+ end
207
+ cache ||= {}
208
+
209
+ {
210
+ route: Route.new(provider:, model:),
211
+ usage: NormalizedUsage.new(
212
+ input: numeric(tokens, "input", integer: true),
213
+ output: numeric(tokens, "output", integer: true),
214
+ cache_read: numeric(cache, "read", integer: true),
215
+ cache_write: numeric(cache, "write", integer: true),
216
+ reasoning: numeric(tokens, "reasoning", integer: true),
217
+ input_includes_cache_read: cache.key?("read") ? true : nil,
218
+ input_includes_cache_write: cache.key?("write") ? true : nil,
219
+ output_includes_reasoning: tokens.key?("reasoning") ? false : nil,
220
+ provider_reported_cost: numeric(assistant, "cost", integer: false)
221
+ )
222
+ }.freeze
223
+ rescue JSON::ParserError => e
224
+ raise MalformedOutput,
225
+ Redactor.diagnostic("OpenCode sanitized export is malformed: #{e.message}")
226
+ rescue ArgumentError => e
227
+ raise MalformedOutput, Redactor.diagnostic(e)
228
+ end
229
+
230
+ def terminal_part(part, message_id)
231
+ reason = required_string(part, "reason", "terminal reason")
232
+ tokens = required_hash(part, "tokens", "terminal tokens")
233
+ cache = required_hash(tokens, "cache", "terminal cache tokens")
234
+ {
235
+ message_id: message_id,
236
+ reason: reason,
237
+ usage: NormalizedUsage.new(
238
+ input: required_numeric(tokens, "input", integer: true),
239
+ output: required_numeric(tokens, "output", integer: true),
240
+ cache_read: required_numeric(cache, "read", integer: true),
241
+ cache_write: required_numeric(cache, "write", integer: true),
242
+ reasoning: required_numeric(tokens, "reasoning", integer: true),
243
+ input_includes_cache_read: true,
244
+ input_includes_cache_write: true,
245
+ output_includes_reasoning: false,
246
+ provider_reported_cost: required_numeric(part, "cost", integer: false)
247
+ )
248
+ }.freeze
249
+ rescue ArgumentError => e
250
+ malformed!(e.message)
251
+ end
252
+ private_class_method :terminal_part
253
+
254
+ def validate_part!(part, event_type, session_id)
255
+ expected = EVENT_PART_TYPES.fetch(event_type)
256
+ unless part["type"] == expected && part["sessionID"] == session_id
257
+ malformed!("OpenCode #{event_type} part is not correlated")
258
+ end
259
+ required_string(part, "id", "part id")
260
+ end
261
+ private_class_method :validate_part!
262
+
263
+ def validate_error!(event)
264
+ error = required_hash(event, "error", "error event payload")
265
+ required_string(error, "name", "error name")
266
+ data = required_hash(error, "data", "error data")
267
+ required_string(data, "message", "error message")
268
+ end
269
+ private_class_method :validate_error!
270
+
271
+ def validate_additive_session!(event, session_id)
272
+ value = event["sessionID"]
273
+ return nil if value.nil?
274
+ unless value.is_a?(String) && !value.empty? && !value.include?("\0")
275
+ malformed!("OpenCode additive event sessionID must be a non-empty string")
276
+ end
277
+ return value if session_id.nil? || value == session_id
278
+
279
+ malformed!("OpenCode additive event session does not match the run")
280
+ end
281
+ private_class_method :validate_additive_session!
282
+
283
+ def classify_failure(captured)
284
+ details = error_details(captured.stdout)
285
+ diagnostic = Redactor.diagnostic(
286
+ [ *details, captured.stderr ].reject(&:empty?).join("\n")
287
+ ) || "OpenCode CLI exited without diagnostic evidence"
288
+ corpus = [ *details, captured.stderr ].join(" ")
289
+ kind =
290
+ if corpus.match?(AUTH_PATTERN)
291
+ :authentication_failure
292
+ elsif corpus.match?(CONFIGURATION_PATTERN)
293
+ :configuration_failure
294
+ elsif corpus.match?(UPSTREAM_TIMEOUT_PATTERN)
295
+ :timed_out
296
+ else
297
+ :cli_failure
298
+ end
299
+ [ kind, diagnostic ]
300
+ end
301
+ private_class_method :classify_failure
302
+
303
+ def error_details(stdout)
304
+ return [] if stdout.bytesize > MAX_RUN_BYTES
305
+
306
+ stdout.each_line.filter_map do |line|
307
+ event = JSON.parse(line)
308
+ next unless event.is_a?(Hash) && event["type"] == "error"
309
+
310
+ error = event["error"]
311
+ next unless error.is_a?(Hash)
312
+
313
+ name = error["name"].to_s
314
+ data = error["data"]
315
+ message = data.is_a?(Hash) ? data["message"].to_s : ""
316
+ [ name, message ].reject(&:empty?).join(": ")
317
+ rescue JSON::ParserError
318
+ nil
319
+ end
320
+ end
321
+ private_class_method :error_details
322
+
323
+ def failure_outcome(profile, route, termination, kind, diagnostic)
324
+ NormalizedOutcome.new(
325
+ provider: profile.name,
326
+ launcher_identity: profile.launcher_identity,
327
+ kind: kind,
328
+ termination: termination,
329
+ identity: RouteIdentity.new(requested: route),
330
+ diagnostic: Redactor.diagnostic(diagnostic)
331
+ )
332
+ end
333
+ private_class_method :failure_outcome
334
+
335
+ def malformed_outcome(profile, requested_route, captured, error)
336
+ route = requested_route.is_a?(Route) ?
337
+ requested_route : Route.parse(requested_route)
338
+ NormalizedOutcome.new(
339
+ provider: profile.name,
340
+ launcher_identity: profile.launcher_identity,
341
+ kind: :malformed_output,
342
+ termination: captured.termination,
343
+ identity: RouteIdentity.new(requested: route),
344
+ diagnostic: Redactor.diagnostic(error)
345
+ )
346
+ end
347
+ private_class_method :malformed_outcome
348
+
349
+ def parse_json_line(line, line_number)
350
+ value = JSON.parse(line)
351
+ malformed!("OpenCode event on line #{line_number} must be an object") unless
352
+ value.is_a?(Hash)
353
+ value
354
+ rescue JSON::ParserError
355
+ malformed!("OpenCode run output has malformed JSON on line #{line_number}")
356
+ end
357
+ private_class_method :parse_json_line
358
+
359
+ def required_hash(hash, key, label)
360
+ value = hash[key]
361
+ malformed!("OpenCode #{label} must be an object") unless value.is_a?(Hash)
362
+ value
363
+ end
364
+ private_class_method :required_hash
365
+
366
+ def required_string(hash, key, label)
367
+ value = hash[key]
368
+ unless value.is_a?(String) && !value.empty? && !value.include?("\0")
369
+ malformed!("OpenCode #{label} must be a non-empty string")
370
+ end
371
+ value
372
+ end
373
+ private_class_method :required_string
374
+
375
+ def required_numeric(hash, key, integer:)
376
+ malformed!("OpenCode #{key} is required") unless hash.key?(key)
377
+ numeric(hash, key, integer:)
378
+ end
379
+ private_class_method :required_numeric
380
+
381
+ def numeric(hash, key, integer:)
382
+ return nil unless hash.key?(key)
383
+
384
+ value = hash[key]
385
+ valid = integer ? value.is_a?(Integer) : value.is_a?(Numeric)
386
+ valid &&= value.finite? if value.respond_to?(:finite?)
387
+ unless valid && value >= 0
388
+ malformed!("OpenCode #{key} must be a non-negative number")
389
+ end
390
+ value
391
+ end
392
+ private_class_method :numeric
393
+
394
+ def bounded_input!(value, bytes, label)
395
+ unless value.is_a?(String) && value.bytesize <= bytes
396
+ malformed!("#{label} exceeds the bounded input size")
397
+ end
398
+ end
399
+ private_class_method :bounded_input!
400
+
401
+ def bounded_string(value, bytes)
402
+ return value if value.bytesize <= bytes
403
+
404
+ value.byteslice(0, bytes).to_s
405
+ .force_encoding(Encoding::UTF_8)
406
+ .scrub("?")
407
+ end
408
+ private_class_method :bounded_string
409
+
410
+ def malformed!(message)
411
+ raise MalformedOutput, Redactor.diagnostic(message)
412
+ end
413
+ private_class_method :malformed!
414
+ end
415
+ end
416
+ end
@@ -4,10 +4,10 @@ require "rubygems/version"
4
4
 
5
5
  module AgentCliRuntime
6
6
  class Profile
7
- PROMPT_STYLES = %i[positional headless_flag_value stdin].freeze
7
+ PROMPT_STYLES = %i[positional headless_flag_value stdin piped_stdin].freeze
8
8
  WORKSPACE_WRITE_PERMISSION_MODE = "workspace-write".freeze
9
9
  READ_ONLY_PERMISSION_MODE = "read-only".freeze
10
- CAPTURE_TIMEOUT_SECONDS = 10
10
+ CAPTURE_TIMEOUT_SECONDS = 120
11
11
  CAPTURE_POLL_SECONDS = 0.01
12
12
  CAPTURE_TERM_GRACE_SECONDS = 0.2
13
13
  CAPTURE_REAP_GRACE_SECONDS = 0.2
@@ -27,7 +27,9 @@ module AgentCliRuntime
27
27
  :effort_argument_builder, :launcher_identity,
28
28
  :cli_capabilities, :declared_capability_support,
29
29
  :credential_environment_keys, :configuration_environment_key,
30
- :default_configuration_directory
30
+ :default_configuration_directory,
31
+ :permission_policy_required, :result_parser,
32
+ :version_check_timeout_sec
31
33
 
32
34
  def initialize(name:, bin_default:, headless_flag:, version_flag:,
33
35
  env_bin_override_keys: [], permission_skip_flag: nil,
@@ -36,10 +38,13 @@ module AgentCliRuntime
36
38
  output_format_flags: [], min_version: nil,
37
39
  prompt_style: :positional, model_argument_builder: nil,
38
40
  effort_argument_builder: nil, launcher_identity: nil,
39
- usage_extractor: nil, auth_configuration_probe: nil,
41
+ usage_extractor: nil, error_extractor: nil,
42
+ auth_configuration_probe: nil,
40
43
  cli_capabilities: {}, raw_cli_arguments_supported: false,
41
44
  credential_environment_keys: [], configuration_environment_key: nil,
42
- default_configuration_directory: nil)
45
+ default_configuration_directory: nil,
46
+ permission_policy_required: false, result_parser: nil,
47
+ version_check_timeout_sec: CAPTURE_TIMEOUT_SECONDS)
43
48
  normalized_prompt_style = prompt_style.to_sym
44
49
  unless PROMPT_STYLES.include?(normalized_prompt_style)
45
50
  raise ArgumentError,
@@ -66,6 +71,7 @@ module AgentCliRuntime
66
71
  @launcher_identity =
67
72
  immutable_string(launcher_identity || "agent-cli-runtime/v1:#{@name}")
68
73
  @usage_extractor = usage_extractor || ->(_event) { nil }
74
+ @error_extractor = error_extractor || ErrorExtractors::DEFAULT
69
75
  @auth_configuration_probe = auth_configuration_probe
70
76
  @cli_capabilities = normalize_cli_capabilities(cli_capabilities)
71
77
  @raw_cli_arguments_supported = raw_cli_arguments_supported == true
@@ -78,6 +84,12 @@ module AgentCliRuntime
78
84
  @default_configuration_directory = optional_relative_directory(
79
85
  default_configuration_directory
80
86
  )
87
+ @permission_policy_required = permission_policy_required == true
88
+ @result_parser = result_parser
89
+ @version_check_timeout_sec = Float(version_check_timeout_sec)
90
+ unless @version_check_timeout_sec.positive?
91
+ raise ArgumentError, "version_check_timeout_sec must be positive"
92
+ end
81
93
  @declared_capability_support = build_declared_capability_support
82
94
  freeze
83
95
  end
@@ -102,6 +114,10 @@ module AgentCliRuntime
102
114
  raise ArgumentError,
103
115
  "agent profile #{@name.inspect} cannot enforce read-only sandboxing"
104
116
  end
117
+ if permission_mode.nil? && @permission_policy_required
118
+ raise ConfigurationError,
119
+ "agent profile #{@name.inspect} requires an explicit OpenCode permission policy"
120
+ end
105
121
  return [] unless @permission_skip_flag
106
122
  return [ @permission_skip_flag ] unless @name == :claude && permission_mode
107
123
  return [ @permission_skip_flag ] if permission_mode == "bypassPermissions"
@@ -145,8 +161,8 @@ module AgentCliRuntime
145
161
  @raw_cli_arguments_supported
146
162
  end
147
163
 
148
- def binary_installed?(env: ENV)
149
- executable = bin(env:)
164
+ def binary_installed?(env: ENV, executable: nil)
165
+ executable ||= bin(env:)
150
166
  return File.file?(executable) && File.executable?(executable) if executable.include?(File::SEPARATOR)
151
167
 
152
168
  env.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
@@ -157,10 +173,10 @@ module AgentCliRuntime
157
173
  false
158
174
  end
159
175
 
160
- def check_version!(env: ENV)
161
- executable = bin(env:)
176
+ def check_version!(env: ENV, executable: nil)
177
+ executable ||= bin(env:)
162
178
  out, _err, status = bounded_capture3(
163
- executable, @version_flag, timeout_sec: CAPTURE_TIMEOUT_SECONDS, env: env
179
+ executable, @version_flag, timeout_sec: @version_check_timeout_sec, env: env
164
180
  )
165
181
  unless status.success?
166
182
  raise BinaryUnavailable,
@@ -189,7 +205,7 @@ module AgentCliRuntime
189
205
  "#{@name} binary not runnable: #{executable} (#{e.class.name.split('::').last})"
190
206
  rescue Timeout::Error
191
207
  raise BinaryUnavailable,
192
- "#{@name} version check timed out after #{CAPTURE_TIMEOUT_SECONDS}s: #{executable}"
208
+ "#{@name} version check timed out after #{@version_check_timeout_sec}s: #{executable}"
193
209
  end
194
210
 
195
211
  def auth_configuration(home: nil, env: ENV)
@@ -213,6 +229,32 @@ module AgentCliRuntime
213
229
  nil
214
230
  end
215
231
 
232
+ def extract_error_event(event)
233
+ @error_extractor.call(event)
234
+ rescue StandardError
235
+ nil
236
+ end
237
+
238
+ def parse_run(stdout)
239
+ unless @result_parser
240
+ raise UnsupportedCapability,
241
+ "agent profile #{@name.inspect} has no strict result parser"
242
+ end
243
+
244
+ @result_parser.parse_run(stdout)
245
+ end
246
+
247
+ def normalize_captured_result(captured, requested_route:)
248
+ unless @result_parser
249
+ raise UnsupportedCapability,
250
+ "agent profile #{@name.inspect} has no strict result parser"
251
+ end
252
+
253
+ @result_parser.normalize(
254
+ captured, requested_route: requested_route, profile: self
255
+ )
256
+ end
257
+
216
258
  def configuration_directory(home: nil, env: ENV)
217
259
  if @configuration_environment_key
218
260
  configured = env[@configuration_environment_key].to_s
@@ -250,6 +292,13 @@ module AgentCliRuntime
250
292
  flags.dup
251
293
  end
252
294
 
295
+ def capture_local(*arguments, env: ENV, timeout_sec: CAPTURE_TIMEOUT_SECONDS,
296
+ executable: nil)
297
+ bounded_capture3(
298
+ executable || bin(env:), *arguments, timeout_sec: timeout_sec, env: env
299
+ )
300
+ end
301
+
253
302
  private
254
303
 
255
304
  def immutable_string(value)