agent-harness 0.14.1 → 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: 972e7e3144da1a59c0a25dbf4668766d07693870dde63c74dece4842c960dfe5
4
- data.tar.gz: 3988ed1d19d61ce9144224302c0246c2c32442abdfa1d1c9bc4616abcb4e11c3
3
+ metadata.gz: 34ea90d66e03bff4f53fe144d888ab33909d80e9a198a9a53f7dac00c0ed5d52
4
+ data.tar.gz: 4daf550bb940f4176b06ce186658803e090a90ed2e5811ae7f22d61dc95391b8
5
5
  SHA512:
6
- metadata.gz: 97d598d30445ef7617c172b692d43f3c8d8c896b9c12f28f7f0e56f822128e298312129bdae67adbbfc81eb407686587db677787bda70a2292c3a1f07aea9a60
7
- data.tar.gz: 863739d9ace22d47b37799e44b36c19cacf221a4042947a627525f542838ccaafb933e03c3e0c10141c0d30971283046791b44d4ca8cc7c84c4a0e50184010a8
6
+ metadata.gz: 9273ea29e29a8380e9f64a926019bac4adc160b2205aecceec2d5ba8e0837d16b58ab9106f05e61832153de95c03c9c7a58b1458588a4f4552a0218c210a4c4a
7
+ data.tar.gz: 0ccefa077c32912d9e6dd205d20420d5ba0170fcfa763df22ca65902ea7fcb9ba600c439e91c38c398fc221dc3b41b9662ce5b693e418078dfd1116027378743
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.14.1"
2
+ ".": "0.15.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## [0.14.1](https://github.com/viamin/agent-harness/compare/agent-harness/v0.14.0...agent-harness/v0.14.1) (2026-05-03)
4
11
 
5
12
 
@@ -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
@@ -1042,6 +1042,15 @@ module AgentHarness
1042
1042
  {healthy: true, message: "OK"}
1043
1043
  end
1044
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
+
1045
1054
  # Canonical smoke-test contract for this provider instance.
1046
1055
  #
1047
1056
  # @return [Hash, nil] smoke-test metadata
@@ -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
@@ -537,30 +539,7 @@ module AgentHarness
537
539
  end
538
540
 
539
541
  def auth_status
540
- api_key = ENV["OPENAI_API_KEY"]
541
- if api_key && !api_key.strip.empty?
542
- if api_key.strip.start_with?("sk-")
543
- return {valid: true, expires_at: nil, error: nil, auth_method: :api_key}
544
- else
545
- 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}
546
- end
547
- end
548
-
549
- credentials = read_codex_credentials
550
- if credentials
551
- key = credentials["api_key"] || credentials["apiKey"] || credentials["OPENAI_API_KEY"]
552
- if key.is_a?(String) && !key.strip.empty?
553
- if key.strip.start_with?("sk-")
554
- return {valid: true, expires_at: nil, error: nil, auth_method: :config_file}
555
- else
556
- 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}
557
- end
558
- end
559
- end
560
-
561
- {valid: false, expires_at: nil, error: "No OpenAI API key found. Set OPENAI_API_KEY or configure in #{codex_config_path}", auth_method: nil}
562
- rescue IOError, JSON::ParserError => e
563
- {valid: false, expires_at: nil, error: e.message, auth_method: nil}
542
+ auth_status_for_env({})
564
543
  end
565
544
 
566
545
  def health_status
@@ -576,6 +555,32 @@ module AgentHarness
576
555
  {healthy: true, message: "Codex CLI available and authenticated"}
577
556
  end
578
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
+
579
584
  def validate_config
580
585
  errors = []
581
586
 
@@ -734,6 +739,150 @@ module AgentHarness
734
739
 
735
740
  private
736
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
+
737
886
  def build_streaming_event(event)
738
887
  raw_event, payload, dispatch_type = unwrap_streaming_event(event)
739
888
  return unless payload.is_a?(Hash)
@@ -1644,7 +1793,11 @@ module AgentHarness
1644
1793
  end
1645
1794
 
1646
1795
  def read_codex_credentials
1647
- 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)
1648
1801
  return nil unless File.exist?(path)
1649
1802
 
1650
1803
  parsed = JSON.parse(File.read(path))
@@ -1660,7 +1813,13 @@ module AgentHarness
1660
1813
  end
1661
1814
 
1662
1815
  def codex_config_path
1663
- 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?
1664
1823
  File.join(config_dir, "config.json")
1665
1824
  end
1666
1825
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.14.1"
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.1
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan