agent-harness 0.14.0 → 0.15.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffc18c8e54d6a675c551c68e9d3aa3a39352995be92f33bf3aee01cf9dad3134
4
- data.tar.gz: 884049b284c3ced78ecbe22fbf744aa6d6307da57f3b913b732d313e2fba5d75
3
+ metadata.gz: 34ea90d66e03bff4f53fe144d888ab33909d80e9a198a9a53f7dac00c0ed5d52
4
+ data.tar.gz: 4daf550bb940f4176b06ce186658803e090a90ed2e5811ae7f22d61dc95391b8
5
5
  SHA512:
6
- metadata.gz: 9792c3e83b4b6cd2672a9863c3eca7a0c1a9502cd5d42334caf1d7c52cb3e13c1d3764e72189c80fed65d67fd620c8a8b7f181a944d8fb6dafe9c9dd9e347def
7
- data.tar.gz: 37d1825a1361ae4bd3d625f535a1ec52d53dd70eca7e0ec1d40434fd8e53a00cd7ad08efc94d8c866b7835fafda24f204592ce5d3357d56e04d904292af0700f
6
+ metadata.gz: 9273ea29e29a8380e9f64a926019bac4adc160b2205aecceec2d5ba8e0837d16b58ab9106f05e61832153de95c03c9c7a58b1458588a4f4552a0218c210a4c4a
7
+ data.tar.gz: 0ccefa077c32912d9e6dd205d20420d5ba0170fcfa763df22ca65902ea7fcb9ba600c439e91c38c398fc221dc3b41b9662ce5b693e418078dfd1116027378743
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.14.0"
2
+ ".": "0.15.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.15.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.14.1...agent-harness/v0.15.0) (2026-05-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * pre-flight connectivity check API for provider health verification ([#185](https://github.com/viamin/agent-harness/issues/185)) ([3ad6a2f](https://github.com/viamin/agent-harness/commit/3ad6a2ffbfe84b2271e4de968fa096276724e63c))
9
+
10
+ ## [0.14.1](https://github.com/viamin/agent-harness/compare/agent-harness/v0.14.0...agent-harness/v0.14.1) (2026-05-03)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **kilocode:** test_command_overrides never wired into smoke test — kilo hangs without --auto ([#191](https://github.com/viamin/agent-harness/issues/191)) ([7c01d49](https://github.com/viamin/agent-harness/commit/7c01d49713cbedb6fb93758be95b7d92aa4599d3))
16
+
3
17
  ## [0.14.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.13.1...agent-harness/v0.14.0) (2026-05-03)
4
18
 
5
19
 
@@ -186,6 +186,7 @@ module AgentHarness
186
186
  klass = registry.get(provider_name)
187
187
  provider_instance = build_provider(provider_name, klass, executor: executor)
188
188
  host_preflight_allowed = host_preflight_allowed?(executor: executor, provider_runtime: provider_runtime)
189
+ provider_preflight_allowed = provider_preflight_allowed?(executor: executor)
189
190
 
190
191
  auth_degraded = false
191
192
  if host_preflight_allowed
@@ -273,6 +274,26 @@ module AgentHarness
273
274
  )
274
275
  end
275
276
 
277
+ # Only run the provider preflight in host contexts. The preflight
278
+ # hook (e.g. Codex's Net::HTTP probe) executes in the Ruby host
279
+ # process, so its network view may not match a containerised or
280
+ # remote executor. Skipping it avoids marking a provider unhealthy
281
+ # when only the host cannot reach the endpoint.
282
+ if provider_preflight_allowed
283
+ preflight_env = build_preflight_env(provider_instance, provider_runtime)
284
+ preflight = provider_instance.preflight_check(env: preflight_env, timeout: timeout)
285
+ unless preflight[:healthy]
286
+ return build_result(
287
+ name: provider_name,
288
+ status: "error",
289
+ message: preflight[:reason] || "Preflight check failed",
290
+ start_time: start_time,
291
+ error_category: normalize_preflight_error_category(preflight[:error_category]),
292
+ check: :preflight
293
+ )
294
+ end
295
+ end
296
+
276
297
  smoke_contract = provider_instance.smoke_test_contract
277
298
  # Explicitly handle missing smoke-test contract when no custom smoke_test implementation
278
299
  if smoke_contract.nil? && !provider_overrides_method?(provider_instance, :smoke_test)
@@ -363,15 +384,23 @@ module AgentHarness
363
384
 
364
385
  def host_preflight_allowed?(executor:, provider_runtime: nil)
365
386
  effective_executor = executor || AgentHarness.configuration.command_executor
366
- # Skip host preflight only when provider runtime has environment/config overrides
367
- # that could conflict with host-level checks (env, base_url, api_provider, unset_env)
368
387
  if provider_runtime
369
388
  runtime = ProviderRuntime.wrap(provider_runtime)
370
- return false if runtime && (!runtime.env.empty? || !runtime.unset_env.empty? || runtime.base_url || runtime.api_provider)
389
+ return false if runtime_sensitive_host_overrides?(runtime)
371
390
  end
391
+
392
+ provider_preflight_allowed?(executor: effective_executor)
393
+ end
394
+
395
+ def provider_preflight_allowed?(executor:)
396
+ effective_executor = executor || AgentHarness.configuration.command_executor
372
397
  effective_executor.is_a?(CommandExecutor) && !effective_executor.is_a?(DockerCommandExecutor)
373
398
  end
374
399
 
400
+ def runtime_sensitive_host_overrides?(runtime)
401
+ runtime && (!runtime.env.empty? || !runtime.unset_env.empty? || runtime.base_url || runtime.api_provider)
402
+ end
403
+
375
404
  def effective_check_timeout(provider_name, base_timeout)
376
405
  registry = Providers::Registry.instance
377
406
  return base_timeout unless registry.registered?(provider_name)
@@ -410,6 +439,25 @@ module AgentHarness
410
439
  end
411
440
  end
412
441
 
442
+ def normalize_preflight_error_category(category)
443
+ case category&.to_sym
444
+ when :installation
445
+ :installation
446
+ when :auth_expired, :authentication
447
+ :authentication
448
+ when :rate_limited, :rate_limit
449
+ :rate_limit
450
+ when :quota_exceeded, :quota
451
+ :quota
452
+ when :timeout
453
+ :timeout
454
+ when :configuration
455
+ :configuration
456
+ else
457
+ :transient
458
+ end
459
+ end
460
+
413
461
  def installation_failure_message?(message)
414
462
  message.to_s.match?(/(not found in PATH|command not found|No such file or directory|is not installed)/i)
415
463
  end
@@ -453,6 +501,15 @@ module AgentHarness
453
501
  provider
454
502
  end
455
503
 
504
+ def build_preflight_env(provider_instance, provider_runtime)
505
+ return {} unless provider_instance.respond_to?(:build_env, true)
506
+
507
+ runtime = ProviderRuntime.wrap(provider_runtime)
508
+ provider_instance.send(:build_env, {provider_runtime: runtime})
509
+ rescue ArgumentError, NoMethodError
510
+ {}
511
+ end
512
+
456
513
  def monotonic_now
457
514
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
458
515
  end
@@ -765,6 +765,11 @@ module AgentHarness
765
765
  # For providers that delegate to Providers::Base#send_message, a plain Hash
766
766
  # is automatically coerced into a ProviderRuntime. Providers that override
767
767
  # #send_message directly are responsible for handling this option.
768
+ # @option options [Boolean] :smoke_test when +true+, signals that this
769
+ # invocation is a lightweight connectivity/health check issued by
770
+ # {#smoke_test}. Providers may use this flag to adjust command-line
771
+ # arguments (e.g. Kilocode appends +--auto --print-logs+) or skip
772
+ # interactive features that would cause the process to hang.
768
773
  # @return [Response] response object with output and metadata
769
774
  def send_message(prompt:, **options)
770
775
  raise NotImplementedError, "#{self.class} must implement #send_message"
@@ -1037,6 +1042,15 @@ module AgentHarness
1037
1042
  {healthy: true, message: "OK"}
1038
1043
  end
1039
1044
 
1045
+ # Lightweight provider-owned preflight check executed before smoke tests.
1046
+ #
1047
+ # @param env [Hash] request-scoped environment overrides
1048
+ # @param timeout [Numeric] time budget in seconds
1049
+ # @return [Hash] with :healthy and optional :reason keys
1050
+ def preflight_check(env:, timeout: 10)
1051
+ {healthy: true}
1052
+ end
1053
+
1040
1054
  # Canonical smoke-test contract for this provider instance.
1041
1055
  #
1042
1056
  # @return [Hash, nil] smoke-test metadata
@@ -1061,7 +1075,8 @@ module AgentHarness
1061
1075
  response = send_message(
1062
1076
  prompt: prompt,
1063
1077
  timeout: timeout || contract[:timeout],
1064
- provider_runtime: provider_runtime
1078
+ provider_runtime: provider_runtime,
1079
+ smoke_test: true
1065
1080
  )
1066
1081
 
1067
1082
  output = response.output.to_s.strip
@@ -368,6 +368,19 @@ module AgentHarness
368
368
  nil
369
369
  end
370
370
 
371
+ # Run a lightweight provider-owned preflight check before committing to a
372
+ # full prompt execution.
373
+ #
374
+ # Providers can override this to validate request-scoped connectivity,
375
+ # credentials, CLI version, or other fast-fail prerequisites.
376
+ #
377
+ # @param env [Hash] request-scoped environment overrides
378
+ # @param timeout [Numeric] time budget in seconds
379
+ # @return [Hash] with :healthy and optional :reason keys
380
+ def preflight_check(env:, timeout: 10)
381
+ {healthy: true}
382
+ end
383
+
371
384
  protected
372
385
 
373
386
  # Build CLI command - override in subclasses
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "net/http"
5
+ require "uri"
4
6
 
5
7
  module AgentHarness
6
8
  module Providers
@@ -42,6 +44,62 @@ module AgentHarness
42
44
  /failed to refresh token\b.*service(?:\s+(?:is|was))?\s+(?:temporarily\s+)?unavailable/im
43
45
  ].freeze
44
46
 
47
+ SHARED_OUTPUT_ERROR_PATTERNS = {
48
+ quota_exceeded: [
49
+ /free tier limit reached/i,
50
+ /please upgrade to a paid plan/i,
51
+ /quota.*exceeded/i,
52
+ /insufficient.*quota/i,
53
+ /billing/i
54
+ ],
55
+ rate_limited: [
56
+ /rate.?limit/i,
57
+ /too.?many.?requests/i,
58
+ /\b429\b/
59
+ ],
60
+ auth_expired: [
61
+ /authentication_error/i,
62
+ /invalid_grant/i,
63
+ /Token is expired or invalid/i,
64
+ /unauthorized/i
65
+ ],
66
+ sandbox_failure: [
67
+ /bwrap.*no permissions/i,
68
+ /no permissions to create a new namespace/i,
69
+ /unprivileged.*namespace/i
70
+ ],
71
+ transient_error: [
72
+ /timeout/i,
73
+ /connection.*error/i,
74
+ /service.*unavailable/i,
75
+ /\b503\b/,
76
+ /\b502\b/,
77
+ /connection.*reset/i
78
+ ]
79
+ }.tap { |h| h.each_value(&:freeze) }.freeze
80
+
81
+ STDOUT_ERROR_PATTERNS = SHARED_OUTPUT_ERROR_PATTERNS.merge(
82
+ auth_expired: [
83
+ /authentication_error/i,
84
+ /invalid_grant/i,
85
+ /Token is expired or invalid/i,
86
+ /unauthorized/i
87
+ ]
88
+ ).tap { |h| h.each_value(&:freeze) }.freeze
89
+
90
+ STDERR_ERROR_PATTERNS = SHARED_OUTPUT_ERROR_PATTERNS.merge(
91
+ auth_expired: OAUTH_REFRESH_FAILURE_PATTERNS + [
92
+ /invalid.*api.*key/i,
93
+ /unauthorized/i,
94
+ /authentication_error/i,
95
+ /invalid_grant/i,
96
+ /Token is expired or invalid/i,
97
+ /\b401\b/,
98
+ /incorrect.*api.*key/i
99
+ ],
100
+ transient_error: OAUTH_REFRESH_TRANSIENT_PATTERNS + SHARED_OUTPUT_ERROR_PATTERNS[:transient_error]
101
+ ).tap { |h| h.each_value(&:freeze) }.freeze
102
+
45
103
  class << self
46
104
  def provider_name
47
105
  :codex
@@ -51,6 +109,34 @@ module AgentHarness
51
109
  "codex"
52
110
  end
53
111
 
112
+ # Classify a chunk of output text from the provider CLI in real-time
113
+ #
114
+ # Can be called during streaming to classify both stdout and stderr
115
+ # chunks as they arrive. For stdout, attempts to parse JSONL events
116
+ # and extract error information from structured output.
117
+ #
118
+ # Because CommandExecutor reads arbitrary 4096-byte chunks, a single
119
+ # JSONL event may be split across consecutive calls. Pass a String
120
+ # buffer via +stdout_buffer+ that persists across calls so incomplete
121
+ # trailing lines are re-assembled before parsing.
122
+ #
123
+ # @param text [String] the output chunk to classify
124
+ # @param stream [:stdout, :stderr] which stream the text came from
125
+ # @param stdout_buffer [String, nil] mutable String accumulator for
126
+ # incomplete stdout lines across calls (ignored for stderr)
127
+ # @return [nil, Hash] nil if no error detected, or a Hash with
128
+ # :reason (Symbol)
129
+ def classify_output_chunk(text, stream:, stdout_buffer: nil)
130
+ return nil if text.nil? || text.strip.empty?
131
+
132
+ case normalize_output_stream(stream)
133
+ when :stdout
134
+ classify_stdout_chunk(text, stdout_buffer)
135
+ when :stderr
136
+ classify_stderr_chunk(text)
137
+ end
138
+ end
139
+
54
140
  def available?
55
141
  executor = AgentHarness.configuration.command_executor
56
142
  !!executor.which(binary_name)
@@ -168,10 +254,129 @@ module AgentHarness
168
254
 
169
255
  private
170
256
 
257
+ def classify_stdout_chunk(text, buffer)
258
+ # Prepend any leftover data from a previous partial chunk.
259
+ data = buffer ? (buffer.slice!(0..-1) + text) : text
260
+
261
+ lines = data.split("\n", -1)
262
+
263
+ # If the chunk does not end with a newline the last element is an
264
+ # incomplete line — stash it in the buffer for the next call.
265
+ if buffer && !data.end_with?("\n")
266
+ buffer.replace(lines.pop.to_s)
267
+ end
268
+
269
+ lines.each do |line|
270
+ stripped = line.strip
271
+ next if stripped.empty?
272
+
273
+ event = parse_stdout_jsonl_event(stripped)
274
+ next unless event
275
+
276
+ result = classify_jsonl_event(event)
277
+ return result if result
278
+ end
279
+
280
+ nil
281
+ end
282
+
283
+ def classify_stderr_chunk(text)
284
+ match_patterns(text, STDERR_ERROR_PATTERNS)
285
+ end
286
+
287
+ def normalize_output_stream(stream)
288
+ normalized_stream = case stream
289
+ when Symbol
290
+ stream
291
+ when String
292
+ stream.strip.to_sym
293
+ end
294
+
295
+ return normalized_stream if %i[stdout stderr].include?(normalized_stream)
296
+
297
+ raise ArgumentError, "Unknown stream: #{stream.inspect}"
298
+ end
299
+
300
+ def parse_stdout_jsonl_event(text)
301
+ escaped_newline_trimmed = text.sub(/(?:\\r)?\\n\z/, "")
302
+ candidates = if escaped_newline_trimmed == text
303
+ [text]
304
+ else
305
+ [text, escaped_newline_trimmed]
306
+ end
307
+
308
+ candidates.each do |candidate|
309
+ return JSON.parse(candidate)
310
+ rescue JSON::ParserError
311
+ next
312
+ end
313
+
314
+ # Non-JSON stdout line — skip, only classify explicit error events
315
+ nil
316
+ end
317
+
318
+ def classify_jsonl_event(event)
319
+ return nil unless event.is_a?(Hash)
320
+
321
+ payload = unwrap_classification_event(event)
322
+ event = payload if payload.is_a?(Hash)
323
+
324
+ # Only classify events with explicit error payloads — not normal
325
+ # assistant messages whose text happens to contain error-ish words.
326
+ error_text = extract_jsonl_error_text(event)
327
+ return nil unless error_text
328
+
329
+ match_patterns(error_text, STDOUT_ERROR_PATTERNS)
330
+ end
331
+
332
+ def extract_jsonl_error_text(event)
333
+ # Direct error field (top-level "error" key)
334
+ error = event["error"]
335
+ return error if error.is_a?(String) && !error.empty?
336
+
337
+ if error.is_a?(Hash)
338
+ msg = error["message"]
339
+ return msg if msg.is_a?(String) && !msg.empty?
340
+ end
341
+
342
+ return nil unless explicit_jsonl_error_event?(event["type"])
343
+
344
+ # "message" appears on both error events and normal assistant output.
345
+ # Restricting message-based extraction to explicit error event types
346
+ # avoids false positives from user-facing assistant content.
347
+ message = event["message"]
348
+ return message if message.is_a?(String) && !message.empty?
349
+
350
+ nil
351
+ end
352
+
353
+ def match_patterns(text, pattern_groups)
354
+ pattern_groups.each do |category, patterns|
355
+ if patterns.any? { |p| text.match?(p) }
356
+ return {reason: category}
357
+ end
358
+ end
359
+
360
+ nil
361
+ end
362
+
171
363
  def parser_instance
172
364
  @parser_instance ||= allocate.freeze
173
365
  end
174
366
 
367
+ def unwrap_classification_event(event)
368
+ case event["type"]
369
+ when "event_msg", "response_item"
370
+ event["payload"]
371
+ else
372
+ event
373
+ end
374
+ end
375
+
376
+ def explicit_jsonl_error_event?(event_type)
377
+ %w[error turn.failed].include?(event_type)
378
+ end
379
+
175
380
  def tail_nonempty_lines(text, limit:)
176
381
  return [] if limit <= 0
177
382
 
@@ -317,7 +522,10 @@ module AgentHarness
317
522
  ],
318
523
  abort: [
319
524
  /free tier limit reached/i,
320
- /please upgrade to a paid plan/i
525
+ /please upgrade to a paid plan/i,
526
+ /bwrap.*no permissions/i,
527
+ /no permissions to create a new namespace/i,
528
+ /unprivileged.*namespace/i
321
529
  ]
322
530
  )
323
531
  end
@@ -331,30 +539,7 @@ module AgentHarness
331
539
  end
332
540
 
333
541
  def auth_status
334
- api_key = ENV["OPENAI_API_KEY"]
335
- if api_key && !api_key.strip.empty?
336
- if api_key.strip.start_with?("sk-")
337
- return {valid: true, expires_at: nil, error: nil, auth_method: :api_key}
338
- else
339
- return {valid: false, expires_at: nil, error: "OPENAI_API_KEY is set but does not appear to be a valid OpenAI API key", auth_method: nil}
340
- end
341
- end
342
-
343
- credentials = read_codex_credentials
344
- if credentials
345
- key = credentials["api_key"] || credentials["apiKey"] || credentials["OPENAI_API_KEY"]
346
- if key.is_a?(String) && !key.strip.empty?
347
- if key.strip.start_with?("sk-")
348
- return {valid: true, expires_at: nil, error: nil, auth_method: :config_file}
349
- else
350
- return {valid: false, expires_at: nil, error: "Config file API key is set but does not appear to be a valid OpenAI API key", auth_method: nil}
351
- end
352
- end
353
- end
354
-
355
- {valid: false, expires_at: nil, error: "No OpenAI API key found. Set OPENAI_API_KEY or configure in #{codex_config_path}", auth_method: nil}
356
- rescue IOError, JSON::ParserError => e
357
- {valid: false, expires_at: nil, error: e.message, auth_method: nil}
542
+ auth_status_for_env({})
358
543
  end
359
544
 
360
545
  def health_status
@@ -370,6 +555,32 @@ module AgentHarness
370
555
  {healthy: true, message: "Codex CLI available and authenticated"}
371
556
  end
372
557
 
558
+ def preflight_check(env:, timeout: 10)
559
+ auth = auth_status_for_env(env)
560
+ return {healthy: false, reason: auth[:error], error_category: :authentication} unless auth[:valid]
561
+
562
+ version = codex_cli_version(env: env, timeout: timeout)
563
+ unless version
564
+ return {
565
+ healthy: false,
566
+ reason: "Codex CLI version check failed. Ensure 'codex' is installed and available in PATH.",
567
+ error_category: :installation
568
+ }
569
+ end
570
+
571
+ unless SUPPORTED_CLI_REQUIREMENT.satisfied_by?(version)
572
+ return {
573
+ healthy: false,
574
+ reason: "Unsupported Codex CLI version #{version}. Expected #{SUPPORTED_CLI_REQUIREMENT}.",
575
+ error_category: :installation
576
+ }
577
+ end
578
+
579
+ check_base_url_reachability(env: env, timeout: timeout)
580
+ rescue => e
581
+ {healthy: false, reason: "Codex preflight failed: #{e.message}"}
582
+ end
583
+
373
584
  def validate_config
374
585
  errors = []
375
586
 
@@ -528,6 +739,150 @@ module AgentHarness
528
739
 
529
740
  private
530
741
 
742
+ def auth_status_for_env(env)
743
+ api_key = env_fetch(env, "OPENAI_API_KEY")
744
+ # Fall back to process ENV when the provided env hash does not override auth keys
745
+ if api_key.nil? && !env.key?("OPENAI_API_KEY") && !env.key?(:OPENAI_API_KEY)
746
+ api_key = ENV["OPENAI_API_KEY"]
747
+ end
748
+
749
+ if api_key.nil? || api_key.strip.empty?
750
+ credentials = read_codex_credentials_for_env(env)
751
+ if credentials
752
+ key = credentials["api_key"] || credentials["apiKey"] || credentials["OPENAI_API_KEY"]
753
+ if key.is_a?(String) && !key.strip.empty?
754
+ if key.strip.start_with?("sk-")
755
+ return {valid: true, expires_at: nil, error: nil, auth_method: :config_file}
756
+ end
757
+
758
+ return {
759
+ valid: false,
760
+ expires_at: nil,
761
+ error: "Config file API key is set but does not appear to be a valid OpenAI API key",
762
+ auth_method: nil
763
+ }
764
+ end
765
+ end
766
+
767
+ return {
768
+ valid: false,
769
+ expires_at: nil,
770
+ error: "No OpenAI API key found. Set OPENAI_API_KEY or configure in #{codex_config_path_for_env(env)}",
771
+ auth_method: nil
772
+ }
773
+ end
774
+
775
+ if api_key.strip.start_with?("sk-")
776
+ {valid: true, expires_at: nil, error: nil, auth_method: :api_key}
777
+ else
778
+ {
779
+ valid: false,
780
+ expires_at: nil,
781
+ error: "OPENAI_API_KEY is set but does not appear to be a valid OpenAI API key",
782
+ auth_method: nil
783
+ }
784
+ end
785
+ rescue IOError, JSON::ParserError => e
786
+ {valid: false, expires_at: nil, error: e.message, auth_method: nil}
787
+ end
788
+
789
+ def codex_cli_version(env:, timeout:)
790
+ result = @executor.execute([self.class.binary_name, "--version"], timeout: timeout, env: env)
791
+ version_string = [result.stdout, result.stderr].join("\n")[/(\d+\.\d+\.\d+)/, 1]
792
+ return nil unless version_string
793
+
794
+ Gem::Version.new(version_string)
795
+ rescue # rubocop prefers bare rescue; in Ruby this catches StandardError, not Exception/SignalException
796
+ nil
797
+ end
798
+
799
+ def check_base_url_reachability(env:, timeout:)
800
+ uri = codex_base_url_uri(env)
801
+ http = Net::HTTP.new(uri.host, uri.port)
802
+ http.use_ssl = uri.scheme == "https"
803
+ http.open_timeout = timeout
804
+ http.read_timeout = timeout
805
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
806
+
807
+ response = http.start do |client|
808
+ head_response = client.request(Net::HTTP::Head.new(uri))
809
+
810
+ if http_success_or_redirect?(head_response) || http_auth_rejection?(head_response)
811
+ head_response
812
+ else
813
+ client.request(Net::HTTP::Get.new(uri))
814
+ end
815
+ end
816
+
817
+ return {healthy: true} if http_success_or_redirect?(response)
818
+
819
+ response_code = response.code.to_i
820
+ # 401/403 confirm the endpoint exists and is reachable; auth is
821
+ # validated separately by auth_status_for_env.
822
+ return {healthy: true} if http_auth_rejection?(response)
823
+ if invalid_base_url_response_code?(response_code)
824
+ return {
825
+ healthy: false,
826
+ reason: "Codex API base URL #{uri} returned HTTP #{response.code}. Check OPENAI_BASE_URL; the configured URL appears to point at an invalid API path.",
827
+ error_category: :configuration
828
+ }
829
+ end
830
+
831
+ {
832
+ healthy: false,
833
+ reason: "Codex API base URL #{uri} returned HTTP #{response.code}. Check OPENAI_BASE_URL, proxy configuration, and network policy.",
834
+ error_category: (response_code >= 500) ? :transient : :configuration
835
+ }
836
+ rescue URI::InvalidURIError => e
837
+ {
838
+ healthy: false,
839
+ reason: e.message.start_with?("OPENAI_BASE_URL") ? e.message : "OPENAI_BASE_URL is invalid. Check the configured URL format.",
840
+ error_category: :configuration
841
+ }
842
+ rescue SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError => e
843
+ {
844
+ healthy: false,
845
+ reason: "Codex API base URL #{env_fetch(env, "OPENAI_BASE_URL") || "https://api.openai.com"} is unreachable: #{e.message}. Check DNS, proxy settings, and network policy.",
846
+ error_category: :transient
847
+ }
848
+ end
849
+
850
+ def codex_base_url_uri(env)
851
+ raw_url = env_fetch(env, "OPENAI_BASE_URL")
852
+ # Only fall back to the default URL; do not read process ENV here, as the
853
+ # caller may have intentionally omitted OPENAI_BASE_URL to use the default.
854
+ raw_url = "https://api.openai.com" if raw_url.nil? || raw_url.empty?
855
+
856
+ uri = URI.parse(raw_url)
857
+
858
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
859
+ raise URI::InvalidURIError,
860
+ "OPENAI_BASE_URL must be an absolute HTTP or HTTPS URL (got #{raw_url.inspect})"
861
+ end
862
+
863
+ uri.path = "/" if uri.path.nil? || uri.path.empty?
864
+ uri
865
+ end
866
+
867
+ def env_fetch(env, key)
868
+ return env[key] if env.key?(key)
869
+ return env[key.to_sym] if env.key?(key.to_sym)
870
+
871
+ nil
872
+ end
873
+
874
+ def http_success_or_redirect?(response)
875
+ response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
876
+ end
877
+
878
+ def http_auth_rejection?(response)
879
+ [401, 403].include?(response.code.to_i)
880
+ end
881
+
882
+ def invalid_base_url_response_code?(response_code)
883
+ [404, 410].include?(response_code)
884
+ end
885
+
531
886
  def build_streaming_event(event)
532
887
  raw_event, payload, dispatch_type = unwrap_streaming_event(event)
533
888
  return unless payload.is_a?(Hash)
@@ -1017,7 +1372,11 @@ module AgentHarness
1017
1372
  total: total_tokens
1018
1373
  } : nil
1019
1374
  }
1020
- rescue
1375
+ rescue JSON::ParserError => e
1376
+ AgentHarness.logger&.warn("[AgentHarness::Codex] JSONL parse error: #{e.message}")
1377
+ nil
1378
+ rescue => e
1379
+ AgentHarness.logger&.warn("[AgentHarness::Codex] Unexpected error parsing JSONL output: #{e.class}: #{e.message}")
1021
1380
  nil
1022
1381
  end
1023
1382
 
@@ -1434,7 +1793,11 @@ module AgentHarness
1434
1793
  end
1435
1794
 
1436
1795
  def read_codex_credentials
1437
- path = codex_config_path
1796
+ read_codex_credentials_for_env({})
1797
+ end
1798
+
1799
+ def read_codex_credentials_for_env(env)
1800
+ path = codex_config_path_for_env(env)
1438
1801
  return nil unless File.exist?(path)
1439
1802
 
1440
1803
  parsed = JSON.parse(File.read(path))
@@ -1450,7 +1813,13 @@ module AgentHarness
1450
1813
  end
1451
1814
 
1452
1815
  def codex_config_path
1453
- config_dir = ENV["CODEX_CONFIG_DIR"] || File.expand_path("~/.codex")
1816
+ codex_config_path_for_env({})
1817
+ end
1818
+
1819
+ def codex_config_path_for_env(env)
1820
+ config_dir = env_fetch(env, "CODEX_CONFIG_DIR")
1821
+ config_dir = ENV["CODEX_CONFIG_DIR"] if config_dir.nil? || config_dir.empty?
1822
+ config_dir = File.expand_path("~/.codex") if config_dir.nil? || config_dir.empty?
1454
1823
  File.join(config_dir, "config.json")
1455
1824
  end
1456
1825
 
@@ -157,6 +157,7 @@ module AgentHarness
157
157
 
158
158
  def build_command(prompt, options)
159
159
  cmd = [self.class.binary_name, "run", "--format", "json"]
160
+ cmd.concat(test_command_overrides) if options[:smoke_test]
160
161
  cmd << prompt
161
162
  cmd
162
163
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.14.0"
4
+ VERSION = "0.15.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agent-harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan