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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/lib/agent_harness/provider_health_check.rb +60 -3
- data/lib/agent_harness/providers/adapter.rb +16 -1
- data/lib/agent_harness/providers/base.rb +13 -0
- data/lib/agent_harness/providers/codex.rb +397 -28
- data/lib/agent_harness/providers/kilocode.rb +1 -0
- 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,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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|