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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +7 -0
- data/lib/agent_harness/provider_health_check.rb +60 -3
- data/lib/agent_harness/providers/adapter.rb +9 -0
- data/lib/agent_harness/providers/base.rb +13 -0
- data/lib/agent_harness/providers/codex.rb +185 -26
- data/lib/agent_harness/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34ea90d66e03bff4f53fe144d888ab33909d80e9a198a9a53f7dac00c0ed5d52
|
|
4
|
+
data.tar.gz: 4daf550bb940f4176b06ce186658803e090a90ed2e5811ae7f22d61dc95391b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9273ea29e29a8380e9f64a926019bac4adc160b2205aecceec2d5ba8e0837d16b58ab9106f05e61832153de95c03c9c7a58b1458588a4f4552a0218c210a4c4a
|
|
7
|
+
data.tar.gz: 0ccefa077c32912d9e6dd205d20420d5ba0170fcfa763df22ca65902ea7fcb9ba600c439e91c38c398fc221dc3b41b9662ce5b693e418078dfd1116027378743
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|