agent-harness 0.4.0 → 0.5.1

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: a3f57fefa5527c5cdc436d4da8c10e5f638c69363b32e689213feb9bdccadf7d
4
- data.tar.gz: ae98c1fc1f1a919adf7919f8d4dc0dc308286f1cb0e02d4a719d9060e29451b4
3
+ metadata.gz: 16862141d853f2e8817d000a9e4813162f0544cda2c30343de2efc1ffd7e9e73
4
+ data.tar.gz: ee57bd3611abb7566560675c65c1feb04faee531cd182f8a8812881a73701aae
5
5
  SHA512:
6
- metadata.gz: eeafca1c0fe7183572056d50caf4e30f28e7a410a5fdb0f2bd3271ae3c0ef5a679f48c559266143a14cd03130de714a6e9af286d7e6e990eb8977fa3972c74df
7
- data.tar.gz: c29ad51bcd248190e44d089d28daf6914a319a800536a3dfce25be6ad9b993a1582b6f317bf7df5f0989864c0a6b663cbb76de1921155b2c2f1aad10037bdd9e
6
+ metadata.gz: f5145b28d2c92bef3c8ba6f8a0e8ffe2217f8eb9462c672f28d25e0f6bd6a1bb17f1fa14ae2dcf2e57d35c4fa82a6233fdf51acf7778d399c89c4412ee9c9695
7
+ data.tar.gz: f6f9e464d3f5f84a87f98b98dd3d0687157a1b69249a3a0731ba162653aabf6cc517430f31b0f9e9b1d3e5d67d0b767c4c5a12c4e88425bd8b24f578343a87e9
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.4.0"
2
+ ".": "0.5.1"
3
3
  }
data/.rubocop.yml ADDED
@@ -0,0 +1,2 @@
1
+ inherit_gem:
2
+ standard: config/base.yml
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.1](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.0...agent-harness/v0.5.1) (2026-03-24)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * 30: fix(codex): use 'codex exec' subcommand instead of --prompt ([#35](https://github.com/viamin/agent-harness/issues/35)) ([1093a23](https://github.com/viamin/agent-harness/commit/1093a23dd001a7ea3caf13306d284fe3b5b976c5))
9
+ * **anthropic:** use positional argument instead of --prompt for Claude CLI ([4ba59bd](https://github.com/viamin/agent-harness/commit/4ba59bd55394cf9ff1d1994ce787e0e285725b93)), closes [#29](https://github.com/viamin/agent-harness/issues/29)
10
+ * **kilocode:** use 'kilo run' subcommand instead of --prompt flag ([f850f54](https://github.com/viamin/agent-harness/commit/f850f54cfac595fe910298303beb373c7bc68376))
11
+ * **test:** use correct RSpec matcher `end_with` instead of `ending_with` ([3a9d68b](https://github.com/viamin/agent-harness/commit/3a9d68b90a0e788683a382303108ebe28cc24e63))
12
+
13
+ ## [0.5.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.4.0...agent-harness/v0.5.0) (2026-03-03)
14
+
15
+
16
+ ### Features
17
+
18
+ * parse token usage from Claude CLI JSON output ([a0e6d7c](https://github.com/viamin/agent-harness/commit/a0e6d7cafb5f5b74806a44d3d4f487e87fdfa05e)), closes [#19](https://github.com/viamin/agent-harness/issues/19)
19
+ * support authentication error detection and token refresh for CLI agents ([83f2c71](https://github.com/viamin/agent-harness/commit/83f2c71c555483322c8a19d8a6ae195bd7720296)), closes [#20](https://github.com/viamin/agent-harness/issues/20)
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * add file lock to refresh_claude_auth to prevent lost-update races ([eb00e19](https://github.com/viamin/agent-harness/commit/eb00e1935dcd574f952ea37c263e9794de23f9a7))
25
+ * address code review feedback for authentication module ([6d11067](https://github.com/viamin/agent-harness/commit/6d1106743c79f5ae4c3a98f078e4c4d4c93db465))
26
+ * address code review feedback for resolve_provider and conductor docs ([5975b3b](https://github.com/viamin/agent-harness/commit/5975b3b8e087f681b57cc9935499e0691f865360))
27
+ * address PR review feedback for auth error handling ([70d7ea7](https://github.com/viamin/agent-harness/commit/70d7ea7eb4d13fd80d7c2724af57053a6dea9972))
28
+ * address PR review feedback for authentication module ([b098682](https://github.com/viamin/agent-harness/commit/b098682448104a833a3e50c89531bcb838910b52))
29
+ * address PR review feedback for token handling in authentication ([03398b9](https://github.com/viamin/agent-harness/commit/03398b9be4b43c12c31694d8c7864dfde891da29))
30
+ * address remaining PR review feedback for auth behavior ([893b549](https://github.com/viamin/agent-harness/commit/893b549bb080345bb1c0dfe718bb1840ff2a1f5e))
31
+ * align ErrorTaxonomy auth_expired action with Conductor behavior ([7697637](https://github.com/viamin/agent-harness/commit/76976375708f56c4fbcaf635bebafd8da9f35de1))
32
+ * clear expiry metadata on token refresh and align docs with API ([9bba06e](https://github.com/viamin/agent-harness/commit/9bba06e00c7b65722afef4b4492ec777e65578e0))
33
+ * correct method for checking module inclusion in provider validation ([4cf57fc](https://github.com/viamin/agent-harness/commit/4cf57fcebed92261e065aa6cf526f1f3851f57e7))
34
+ * differentiate credential read errors instead of returning generic nil ([cada3c5](https://github.com/viamin/agent-harness/commit/cada3c5404144b4eaf122d5dbe5f023eb30e5d95))
35
+ * guard against non-Hash JSON in refresh_claude_auth credentials ([74e1301](https://github.com/viamin/agent-harness/commit/74e1301ec7835f929bd43dc15f4a87e62bcf7237))
36
+ * remove accidentally committed bundler binstubs ([8207ef0](https://github.com/viamin/agent-harness/commit/8207ef0df67add5d1db8f3af9ef495c0b832d0b6))
37
+ * validate tokens are non-empty strings in authentication module ([55a12e4](https://github.com/viamin/agent-harness/commit/55a12e45616839079afe509e079c771a1a71a1a5))
38
+
3
39
  ## [0.4.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.3.0...agent-harness/v0.4.0) (2026-02-16)
4
40
 
5
41
 
data/README.md CHANGED
@@ -227,6 +227,9 @@ AgentHarness.token_tracker.summary
227
227
  ```ruby
228
228
  begin
229
229
  response = AgentHarness.send_message("Hello")
230
+ rescue AgentHarness::AuthenticationError => e
231
+ puts "Auth failed for provider: #{e.provider}"
232
+ # Optionally trigger re-auth flow (see Authentication Management below)
230
233
  rescue AgentHarness::TimeoutError => e
231
234
  puts "Request timed out"
232
235
  rescue AgentHarness::RateLimitError => e
@@ -253,6 +256,106 @@ AgentHarness::ErrorTaxonomy.action_for(category)
253
256
  # => :switch_provider
254
257
  ```
255
258
 
259
+ ## Authentication Management
260
+
261
+ AgentHarness can detect authentication failures and manage credentials for CLI agents.
262
+
263
+ ### Auth Type
264
+
265
+ Providers declare their authentication type:
266
+
267
+ ```ruby
268
+ provider = AgentHarness.provider(:claude)
269
+ provider.auth_type
270
+ # => :oauth (token-based auth that can expire)
271
+
272
+ provider = AgentHarness.provider(:aider)
273
+ provider.auth_type
274
+ # => :api_key (static API key, no refresh needed)
275
+ ```
276
+
277
+ ### Auth Status Check
278
+
279
+ Pre-flight check auth before starting a run:
280
+
281
+ ```ruby
282
+ AgentHarness.auth_valid?(:claude)
283
+ # => true/false
284
+
285
+ AgentHarness.auth_status(:claude)
286
+ # => { valid: false, expires_at: <Time>, error: "Session expired" }
287
+ ```
288
+
289
+ For providers without a built-in auth check (including `:api_key` providers), `auth_valid?` returns `false` and `auth_status` returns an error indicating the check is not implemented. Custom providers can implement an `auth_status` instance method to provide their own check.
290
+
291
+ ### Auth Error Detection
292
+
293
+ When a CLI agent fails due to expired or invalid authentication, `send_message` raises `AuthenticationError` with the provider name. Authentication errors are always surfaced directly to the caller (never auto-switched to another provider) so your application can trigger the appropriate re-auth flow:
294
+
295
+ ```ruby
296
+ begin
297
+ AgentHarness.send_message("Hello", provider: :claude)
298
+ rescue AgentHarness::AuthenticationError => e
299
+ puts e.provider # => :claude
300
+ puts e.message # => "oauth token expired"
301
+ # Trigger re-authentication flow for the specific provider
302
+ end
303
+ ```
304
+
305
+ ### OAuth URL Generation
306
+
307
+ For OAuth providers, get the URL the user should visit to start the login flow:
308
+
309
+ ```ruby
310
+ AgentHarness.auth_url(:claude)
311
+ # => "https://claude.ai/oauth/authorize"
312
+ ```
313
+
314
+ This raises `NotImplementedError` for `:api_key` providers.
315
+
316
+ ### Credential Refresh
317
+
318
+ Accept a pre-exchanged OAuth token and update the provider's stored credentials. The OAuth authorization code exchange is provider-specific and should be handled by your application or CLI login command before calling this method:
319
+
320
+ ```ruby
321
+ AgentHarness.refresh_auth(:claude, token: "new-oauth-token")
322
+ # => { success: true }
323
+ ```
324
+
325
+ Any existing expiry metadata in the credentials file is cleared on refresh so that `auth_valid?` returns `true` immediately after a successful refresh.
326
+
327
+ This raises `NotImplementedError` for `:api_key` providers. Credential file paths respect the `CLAUDE_CONFIG_DIR` environment variable.
328
+
329
+ ## Provider Health Checks
330
+
331
+ Pre-flight check that configured providers are registered and authenticated. Reachability and configuration validation depend on provider-specific `health_status` and `validate_config` overrides; providers that don't implement these use safe defaults (healthy / valid).
332
+
333
+ > **Note:** These methods provide the library-level API. CLI flag (`--check-providers`) and HTTP endpoint (`GET /providers/status`) integration are not yet implemented and are tracked separately.
334
+
335
+ ```ruby
336
+ # Check all enabled providers
337
+ results = AgentHarness.check_providers
338
+ results.each do |r|
339
+ puts "#{r[:name]}: #{r[:status]} - #{r[:message]} (#{r[:latency_ms]}ms)"
340
+ end
341
+
342
+ # Check a single provider
343
+ result = AgentHarness.check_provider(:claude)
344
+ puts result[:status] # => "ok", "degraded", or "error"
345
+
346
+ # Formatted CLI output
347
+ puts AgentHarness::ProviderHealthCheck.format_results(results)
348
+ ```
349
+
350
+ Each result is a hash with keys:
351
+
352
+ - `:name` — provider name (Symbol)
353
+ - `:status` — `"ok"` (all checks passed), `"degraded"` (partial issues such as unimplemented auth status), or `"error"` (provider unavailable or authentication failed)
354
+ - `:message` — human-readable description
355
+ - `:latency_ms` — time taken for the check in milliseconds
356
+
357
+ Health checks run five steps per provider: registration, CLI availability, authentication, provider health status, and configuration validation. The default timeout per provider is configurable via `orchestration.health_check.timeout` (default: 5 seconds).
358
+
256
359
  ## Development
257
360
 
258
361
  ```bash
data/json-2.18.1.gem ADDED
Binary file
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "tempfile"
6
+ require "time"
7
+
8
+ module AgentHarness
9
+ # Authentication management for CLI agent providers
10
+ #
11
+ # Provides methods for checking auth status, generating OAuth URLs,
12
+ # and refreshing credentials for providers that support it.
13
+ module Authentication
14
+ class << self
15
+ # Check if authentication is valid for a provider
16
+ #
17
+ # @param provider_name [Symbol] the provider name
18
+ # @return [Boolean] true if auth is valid, false otherwise
19
+ def auth_valid?(provider_name)
20
+ status = auth_status(provider_name)
21
+ !!status[:valid]
22
+ end
23
+
24
+ # Get detailed authentication status for a provider
25
+ #
26
+ # @param provider_name [Symbol] the provider name
27
+ # @return [Hash] status with :valid, :expires_at, :error keys
28
+ def auth_status(provider_name)
29
+ provider_name = provider_name.to_sym
30
+ case provider_name
31
+ when :claude, :anthropic
32
+ claude_auth_status
33
+ else
34
+ generic_auth_status(provider_name)
35
+ end
36
+ end
37
+
38
+ # Generate an OAuth URL for a provider
39
+ #
40
+ # Only supported for :oauth auth type providers.
41
+ #
42
+ # @param provider_name [Symbol] the provider name
43
+ # @return [String] the OAuth authorization URL
44
+ # @raise [NotImplementedError] if provider doesn't support OAuth
45
+ def auth_url(provider_name)
46
+ provider_name = provider_name.to_sym
47
+ provider = resolve_provider(provider_name)
48
+
49
+ unless provider.auth_type == :oauth
50
+ raise NotImplementedError,
51
+ "Provider #{provider_name} uses #{provider.auth_type} auth and does not support OAuth URL generation"
52
+ end
53
+
54
+ case provider_name
55
+ when :claude, :anthropic
56
+ claude_auth_url
57
+ else
58
+ raise NotImplementedError,
59
+ "OAuth URL generation is not yet implemented for provider #{provider_name}"
60
+ end
61
+ end
62
+
63
+ # Refresh authentication credentials for a provider
64
+ #
65
+ # For OAuth providers, stores a pre-exchanged token directly.
66
+ # This method accepts a token (not an authorization code) because
67
+ # the OAuth code-exchange flow is provider-specific and should be
68
+ # handled by the caller or a CLI login command before calling this.
69
+ # For API key providers, raises NotImplementedError.
70
+ #
71
+ # @param provider_name [Symbol] the provider name
72
+ # @param token [String] OAuth token to store (must be non-blank)
73
+ # @return [Hash] result with :success key
74
+ # @raise [NotImplementedError] if provider doesn't support credential refresh
75
+ def refresh_auth(provider_name, token: nil)
76
+ provider_name = provider_name.to_sym
77
+ provider = resolve_provider(provider_name)
78
+
79
+ unless provider.auth_type == :oauth
80
+ raise NotImplementedError,
81
+ "Provider #{provider_name} uses #{provider.auth_type} auth and does not support credential refresh"
82
+ end
83
+
84
+ case provider_name
85
+ when :claude, :anthropic
86
+ refresh_claude_auth(token: token)
87
+ else
88
+ raise NotImplementedError,
89
+ "Credential refresh is not yet implemented for provider #{provider_name}"
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def resolve_provider(provider_name)
96
+ klass = Providers::Registry.instance.get(provider_name)
97
+ # Construct the provider with config/executor/logger to match
98
+ # ProviderManager#create_provider and support custom providers
99
+ # that may rely on these initializer arguments.
100
+ config = AgentHarness.configuration.providers[provider_name]
101
+ klass.new(
102
+ config: config,
103
+ executor: AgentHarness.configuration.command_executor,
104
+ logger: AgentHarness.logger
105
+ )
106
+ rescue ConfigurationError
107
+ raise ProviderNotFoundError, "Unknown provider: #{provider_name}"
108
+ end
109
+
110
+ # Claude Code auth status check
111
+ def claude_auth_status
112
+ credentials = read_claude_credentials
113
+ return {valid: false, expires_at: nil, error: "No credentials found"} unless credentials
114
+
115
+ # Check if the credentials file has a token, preferring a non-blank oauth_token over apiKey
116
+ oauth_token = credentials["oauth_token"]
117
+ api_key = credentials["apiKey"]
118
+ token = [oauth_token, api_key].find { |t| t.is_a?(String) && !t.strip.empty? }
119
+ if token
120
+ expires_at = parse_expiry(credentials["expiresAt"] || credentials["expires_at"])
121
+ if expires_at && expires_at < Time.now
122
+ {valid: false, expires_at: expires_at, error: "Session expired"}
123
+ else
124
+ {valid: true, expires_at: expires_at, error: nil}
125
+ end
126
+ else
127
+ {valid: false, expires_at: nil, error: "No authentication token found"}
128
+ end
129
+ rescue IOError, JSON::ParserError => e
130
+ {valid: false, expires_at: nil, error: e.message}
131
+ end
132
+
133
+ # Generic auth status for non-Claude providers
134
+ def generic_auth_status(provider_name)
135
+ provider = resolve_provider(provider_name)
136
+
137
+ # Prefer a provider-specific auth_status hook when available
138
+ if provider.respond_to?(:auth_status)
139
+ return provider.auth_status
140
+ end
141
+
142
+ if provider.auth_type == :api_key
143
+ {valid: false, expires_at: nil, error: "Auth status check not implemented for api_key providers"}
144
+ else
145
+ {valid: false, expires_at: nil, error: "Auth status check not implemented for #{provider_name}"}
146
+ end
147
+ rescue ProviderNotFoundError => e
148
+ {valid: false, expires_at: nil, error: e.message}
149
+ end
150
+
151
+ def claude_auth_url
152
+ "https://claude.ai/oauth/authorize"
153
+ end
154
+
155
+ def refresh_claude_auth(token: nil)
156
+ raise ArgumentError, "token must be a non-empty string" unless token.is_a?(String) && !token.strip.empty?
157
+
158
+ credentials_path = claude_credentials_path
159
+ dir = File.dirname(credentials_path)
160
+ FileUtils.mkdir_p(dir, mode: 0o700)
161
+
162
+ lock_path = "#{credentials_path}.lock"
163
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
164
+ lock.flock(File::LOCK_EX)
165
+
166
+ credentials = read_claude_credentials
167
+ credentials = {} unless credentials.is_a?(Hash)
168
+ credentials["oauth_token"] = token.strip
169
+ # Clear any existing expiry metadata so refreshed tokens are not treated as expired
170
+ credentials.delete("expiresAt")
171
+ credentials.delete("expires_at")
172
+
173
+ # Write under a file lock using tempfile + rename to avoid corruption and lost updates on concurrent refreshes
174
+ tmpfile = Tempfile.new(".credentials", dir)
175
+ begin
176
+ tmpfile.write(JSON.pretty_generate(credentials))
177
+ tmpfile.close
178
+ File.chmod(0o600, tmpfile.path)
179
+ File.rename(tmpfile.path, credentials_path)
180
+ rescue
181
+ tmpfile.close!
182
+ raise
183
+ end
184
+ end
185
+
186
+ {success: true}
187
+ end
188
+
189
+ def read_claude_credentials
190
+ path = claude_credentials_path
191
+ return nil unless File.exist?(path)
192
+
193
+ JSON.parse(File.read(path))
194
+ rescue Errno::ENOENT
195
+ # File was removed between the existence check and the read; treat as missing
196
+ nil
197
+ rescue Errno::EACCES => e
198
+ raise IOError, "Permission denied when reading Claude credentials at #{path}: #{e.message}"
199
+ rescue JSON::ParserError => e
200
+ raise JSON::ParserError, "Invalid JSON in Claude credentials at #{path}: #{e.message}"
201
+ end
202
+
203
+ def claude_credentials_path
204
+ config_dir = ENV["CLAUDE_CONFIG_DIR"] || File.expand_path("~/.claude")
205
+ File.join(config_dir, ".credentials.json")
206
+ end
207
+
208
+ def parse_expiry(value)
209
+ return nil unless value
210
+
211
+ case value
212
+ when Time
213
+ value
214
+ when Integer, Float
215
+ Time.at(value)
216
+ when String
217
+ Time.parse(value)
218
+ end
219
+ rescue ArgumentError
220
+ nil
221
+ end
222
+ end
223
+ end
224
+ end
@@ -221,12 +221,13 @@ module AgentHarness
221
221
 
222
222
  # Health check configuration
223
223
  class HealthCheckConfig
224
- attr_accessor :enabled, :interval, :failure_threshold
224
+ attr_accessor :enabled, :interval, :failure_threshold, :timeout
225
225
 
226
226
  def initialize
227
227
  @enabled = true
228
228
  @interval = 60 # 1 minute
229
229
  @failure_threshold = 3
230
+ @timeout = 5 # seconds per provider check
230
231
  end
231
232
  end
232
233
 
@@ -16,7 +16,7 @@ module AgentHarness
16
16
  },
17
17
  auth_expired: {
18
18
  description: "Authentication failed or expired",
19
- action: :switch_provider,
19
+ action: :reauthenticate,
20
20
  retryable: false
21
21
  },
22
22
  quota_exceeded: {
@@ -45,7 +45,14 @@ module AgentHarness
45
45
  end
46
46
 
47
47
  # Authentication errors
48
- class AuthenticationError < Error; end
48
+ class AuthenticationError < Error
49
+ attr_reader :provider
50
+
51
+ def initialize(message = nil, provider: nil, **kwargs)
52
+ @provider = provider
53
+ super(message, **kwargs)
54
+ end
55
+ end
49
56
 
50
57
  # Configuration errors
51
58
  class ConfigurationError < Error; end
@@ -101,6 +101,17 @@ module AgentHarness
101
101
  @provider_manager.record_success(provider_name)
102
102
 
103
103
  response
104
+ rescue AuthenticationError => e
105
+ # Authentication errors are intentionally NOT retried or switched.
106
+ # Unlike transient provider errors, auth failures indicate expired
107
+ # or invalid credentials that require user re-authentication — switching
108
+ # to another provider would mask the real problem. The error is surfaced
109
+ # directly so callers can trigger a re-auth flow (e.g. via Authentication.refresh_auth).
110
+ # We also skip @provider_manager.record_failure to avoid tripping the
111
+ # circuit breaker, since auth failures are credential issues, not
112
+ # provider health issues.
113
+ @metrics.record_failure(provider_name, e)
114
+ raise
104
115
  rescue RateLimitError => e
105
116
  @provider_manager.mark_rate_limited(provider_name, reset_at: e.reset_time)
106
117
  handle_provider_failure(e, provider_name, :switch)
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module AgentHarness
6
+ # Performs health checks on configured providers
7
+ #
8
+ # Validates provider setup, authentication status, and reachability.
9
+ # Returns per-provider status objects with name, status, message, and latency.
10
+ #
11
+ # @example Check all providers
12
+ # results = AgentHarness::ProviderHealthCheck.check_all
13
+ # results.each { |r| puts "#{r[:name]}: #{r[:status]}" }
14
+ #
15
+ # @example Check a single provider
16
+ # result = AgentHarness::ProviderHealthCheck.check(:claude)
17
+ # puts result[:status] # => "ok", "error", or "degraded"
18
+ class ProviderHealthCheck
19
+ # Single source of truth: derive the fallback from HealthCheckConfig's default
20
+ # so that the timeout isn't duplicated here and in configuration.rb.
21
+ DEFAULT_TIMEOUT = HealthCheckConfig.new.timeout
22
+
23
+ class << self
24
+ # Check health of all configured providers
25
+ #
26
+ # @param timeout [Integer] timeout in seconds for each check
27
+ # @return [Array<Hash>] health status for each provider
28
+ def check_all(timeout: configured_timeout)
29
+ provider_names = if AgentHarness.configuration.providers.empty?
30
+ Providers::Registry.instance.all
31
+ else
32
+ enabled_provider_names
33
+ end
34
+
35
+ provider_names.map { |name| check(name, timeout: timeout) }
36
+ end
37
+
38
+ # Check health of a single provider
39
+ #
40
+ # @param provider_name [Symbol, String] the provider name
41
+ # @param timeout [Integer] timeout in seconds
42
+ # @return [Hash] health status with :name, :status, :message, :latency_ms keys
43
+ def check(provider_name, timeout: configured_timeout)
44
+ name = normalize_name(provider_name)
45
+ start_time = monotonic_now
46
+ timeout = validate_timeout(timeout)
47
+
48
+ Timeout.timeout(timeout) do
49
+ perform_check(name, start_time)
50
+ end
51
+ rescue Timeout::Error
52
+ build_result(
53
+ name: name,
54
+ status: "error",
55
+ message: "Health check timed out after #{timeout}s",
56
+ start_time: start_time || monotonic_now
57
+ )
58
+ rescue NotImplementedError => e
59
+ # NotImplementedError inherits from ScriptError, not StandardError,
60
+ # so it must be rescued explicitly. Its messages are safe internal
61
+ # setup errors (e.g., missing provider methods) that help users
62
+ # diagnose configuration problems.
63
+ AgentHarness.logger&.error("ProviderHealthCheck error for #{name}: #{e.class}")
64
+ build_result(
65
+ name: name,
66
+ status: "error",
67
+ message: "Health check failed: #{e.class}: #{e.message}",
68
+ start_time: start_time || monotonic_now
69
+ )
70
+ rescue => e
71
+ # Return a generic message to avoid leaking sensitive details
72
+ # (e.g., tokens embedded in exception messages). Log only the
73
+ # exception class (not the message) to avoid leaking secrets.
74
+ AgentHarness.logger&.error("ProviderHealthCheck error for #{name}: #{e.class}")
75
+ build_result(
76
+ name: name,
77
+ status: "error",
78
+ message: "Health check failed: #{e.class}",
79
+ start_time: start_time || monotonic_now
80
+ )
81
+ end
82
+
83
+ # Format health check results for CLI output
84
+ #
85
+ # @param results [Array<Hash>] health check results
86
+ # @return [String] formatted output
87
+ def format_results(results)
88
+ lines = ["Checking providers..."]
89
+
90
+ if results.empty?
91
+ lines << ""
92
+ lines << "No providers checked."
93
+ return lines.join("\n")
94
+ end
95
+
96
+ results.each do |result|
97
+ name = result[:name].to_s.ljust(16)
98
+ case result[:status]
99
+ when "ok"
100
+ latency = result[:latency_ms] ? "(#{result[:latency_ms]}ms)" : ""
101
+ lines << " ✓ #{name} OK #{latency}".rstrip
102
+ when "degraded"
103
+ lines << " ~ #{name} #{result[:message]}"
104
+ else
105
+ lines << " ✗ #{name} #{result[:message]}"
106
+ end
107
+ end
108
+
109
+ failed = results.count { |r| r[:status] == "error" }
110
+ degraded = results.count { |r| r[:status] == "degraded" }
111
+ total = results.size
112
+
113
+ lines << ""
114
+ summary_parts = []
115
+ summary_parts << "#{failed} failed" if failed > 0
116
+ summary_parts << "#{degraded} degraded" if degraded > 0
117
+
118
+ provider_word = (total == 1) ? "provider" : "providers"
119
+ lines << if summary_parts.any?
120
+ "#{total} #{provider_word} checked: #{summary_parts.join(", ")}."
121
+ else
122
+ "All #{total} #{provider_word} healthy."
123
+ end
124
+
125
+ lines.join("\n")
126
+ end
127
+
128
+ private
129
+
130
+ def enabled_provider_names
131
+ AgentHarness.configuration.providers.select { |_name, config| config.enabled }.keys
132
+ end
133
+
134
+ def validate_timeout(timeout)
135
+ (timeout.is_a?(Numeric) && timeout.positive?) ? timeout : configured_timeout
136
+ end
137
+
138
+ def configured_timeout
139
+ timeout = AgentHarness.configuration.orchestration_config.health_check_config.timeout
140
+ (timeout.is_a?(Numeric) && timeout.positive?) ? timeout : DEFAULT_TIMEOUT
141
+ rescue NoMethodError
142
+ DEFAULT_TIMEOUT
143
+ end
144
+
145
+ def normalize_name(provider_name)
146
+ provider_name.to_sym
147
+ rescue NoMethodError, ArgumentError, TypeError
148
+ :unknown
149
+ end
150
+
151
+ def perform_check(provider_name, start_time)
152
+ # Step 1: Check provider is registered
153
+ registry = Providers::Registry.instance
154
+ unless registry.registered?(provider_name)
155
+ return build_result(
156
+ name: provider_name,
157
+ status: "error",
158
+ message: "Provider not registered",
159
+ start_time: start_time
160
+ )
161
+ end
162
+
163
+ # Step 2: Check CLI availability
164
+ klass = registry.get(provider_name)
165
+ unless klass.available?
166
+ return build_result(
167
+ name: provider_name,
168
+ status: "error",
169
+ message: "CLI '#{klass.binary_name}' not found in PATH",
170
+ start_time: start_time
171
+ )
172
+ end
173
+
174
+ # Step 3: Check authentication
175
+ # Treat "not implemented" auth status as degraded rather than error,
176
+ # since most built-in providers don't implement auth_status hooks.
177
+ # In either case, continue to steps 4/5 so health and config issues
178
+ # are still surfaced for providers that lack an auth_status hook.
179
+ auth = Authentication.auth_status(provider_name)
180
+ auth_degraded = false
181
+ unless auth[:valid]
182
+ unless auth_not_implemented?(auth)
183
+ return build_result(
184
+ name: provider_name,
185
+ status: "error",
186
+ message: auth[:error] || "Authentication failed",
187
+ start_time: start_time
188
+ )
189
+ end
190
+ auth_degraded = true
191
+ end
192
+
193
+ # Step 4: Check provider-level health (e.g., endpoint reachability)
194
+ # The Adapter default always returns {healthy: true}, so providers
195
+ # that haven't implemented a real health check are reported as ok
196
+ # with a note that the check is not implemented.
197
+ provider_instance = build_provider(provider_name, klass)
198
+ health = provider_instance.health_status
199
+ unless health[:healthy]
200
+ return build_result(
201
+ name: provider_name,
202
+ status: "degraded",
203
+ message: health[:message] || "Provider health check failed",
204
+ start_time: start_time
205
+ )
206
+ end
207
+
208
+ # Step 5: Validate provider config
209
+ # The Adapter default always returns {valid: true}, so providers
210
+ # that haven't implemented real config validation pass by default.
211
+ validation = provider_instance.validate_config
212
+ unless validation[:valid]
213
+ errors_msg = Array(validation[:errors]).join(", ")
214
+ errors_msg = "check provider configuration" if errors_msg.empty?
215
+ return build_result(
216
+ name: provider_name,
217
+ status: "degraded",
218
+ message: "Configuration issues: #{errors_msg}",
219
+ start_time: start_time
220
+ )
221
+ end
222
+
223
+ # If auth was not implemented but health/config passed, report degraded
224
+ if auth_degraded
225
+ return build_result(
226
+ name: provider_name,
227
+ status: "degraded",
228
+ message: "Auth status check not implemented; health and config checks passed",
229
+ start_time: start_time
230
+ )
231
+ end
232
+
233
+ message = if provider_overrides_method?(provider_instance, :health_status) ||
234
+ provider_overrides_method?(provider_instance, :validate_config)
235
+ "All checks passed"
236
+ else
237
+ "Registered and authenticated (health/config checks use defaults)"
238
+ end
239
+
240
+ build_result(
241
+ name: provider_name,
242
+ status: "ok",
243
+ message: message,
244
+ start_time: start_time
245
+ )
246
+ end
247
+
248
+ def auth_not_implemented?(auth)
249
+ # Prefer explicit flags over brittle string matching on error messages.
250
+ # This keeps backward compatibility with existing callers that only set :error,
251
+ # while allowing newer callers to pass structured reasons.
252
+ if auth.respond_to?(:[])
253
+ return true if auth.key?(:implemented) && auth[:implemented] == false
254
+ return true if auth.key?(:reason) && auth[:reason] == :not_implemented
255
+ end
256
+
257
+ error = auth[:error].to_s
258
+ error.include?("not implemented")
259
+ end
260
+
261
+ def provider_overrides_method?(provider_instance, method_name)
262
+ provider_instance.method(method_name).owner != Providers::Adapter
263
+ end
264
+
265
+ def build_result(name:, status:, message:, start_time:)
266
+ latency = ((monotonic_now - start_time) * 1000).round
267
+ {
268
+ name: name,
269
+ status: status,
270
+ message: message,
271
+ latency_ms: latency
272
+ }
273
+ end
274
+
275
+ def build_provider(provider_name, klass)
276
+ config = AgentHarness.configuration.providers[provider_name]
277
+ klass.new(
278
+ config: config,
279
+ executor: AgentHarness.configuration.command_executor,
280
+ logger: AgentHarness.logger
281
+ )
282
+ end
283
+
284
+ def monotonic_now
285
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
286
+ end
287
+ end
288
+ end
289
+ end
@@ -102,6 +102,14 @@ module AgentHarness
102
102
  {}
103
103
  end
104
104
 
105
+ # Authentication type for this provider
106
+ #
107
+ # @return [Symbol] :oauth for token-based auth that can expire,
108
+ # :api_key for static API key auth
109
+ def auth_type
110
+ :api_key
111
+ end
112
+
105
113
  # Check if provider supports MCP
106
114
  #
107
115
  # @return [Boolean] true if MCP is supported
@@ -184,6 +184,10 @@ module AgentHarness
184
184
  ["--dangerously-skip-permissions"]
185
185
  end
186
186
 
187
+ def auth_type
188
+ :oauth
189
+ end
190
+
187
191
  def error_patterns
188
192
  {
189
193
  rate_limited: [
@@ -198,7 +202,11 @@ module AgentHarness
198
202
  /authentication.*error/i,
199
203
  /invalid.*api.*key/i,
200
204
  /unauthorized/i,
201
- /401/
205
+ /401/,
206
+ /session.*expired/i,
207
+ /not.*logged.*in/i,
208
+ /login.*required/i,
209
+ /credentials.*expired/i
202
210
  ],
203
211
  quota_exceeded: [
204
212
  /quota.*exceeded/i,
@@ -246,7 +254,7 @@ module AgentHarness
246
254
  def build_command(prompt, options)
247
255
  cmd = [self.class.binary_name]
248
256
 
249
- cmd += ["--print", "--output-format=text"]
257
+ cmd += ["--print", "--output-format=json"]
250
258
 
251
259
  # Add model if specified
252
260
  if @config.model && !@config.model.empty?
@@ -261,7 +269,7 @@ module AgentHarness
261
269
  # Add custom flags from config
262
270
  cmd += @config.default_flags if @config.default_flags&.any?
263
271
 
264
- cmd += ["--prompt", prompt]
272
+ cmd << prompt
265
273
 
266
274
  cmd
267
275
  end
@@ -269,18 +277,27 @@ module AgentHarness
269
277
  def parse_response(result, duration:)
270
278
  output = result.stdout
271
279
  error = nil
280
+ tokens = nil
272
281
 
273
282
  if result.failed?
274
283
  combined = [result.stdout, result.stderr].compact.join("\n")
275
284
  error = classify_error_message(combined)
276
285
  end
277
286
 
287
+ # Parse JSON output to extract result text and token usage
288
+ parsed = parse_json_output(output)
289
+ if parsed
290
+ output = parsed["result"] || output
291
+ tokens = extract_tokens(parsed)
292
+ end
293
+
278
294
  Response.new(
279
295
  output: output,
280
296
  exit_code: result.exit_code,
281
297
  duration: duration,
282
298
  provider: self.class.provider_name,
283
299
  model: @config.model,
300
+ tokens: tokens,
284
301
  error: error
285
302
  )
286
303
  end
@@ -291,6 +308,28 @@ module AgentHarness
291
308
 
292
309
  private
293
310
 
311
+ def parse_json_output(output)
312
+ return nil if output.nil? || output.empty?
313
+
314
+ JSON.parse(output)
315
+ rescue JSON::ParserError
316
+ nil
317
+ end
318
+
319
+ def extract_tokens(parsed)
320
+ usage = parsed["usage"]
321
+ return nil unless usage
322
+
323
+ input = usage["input_tokens"]
324
+ output = usage["output_tokens"]
325
+ return nil unless input || output
326
+
327
+ input ||= 0
328
+ output ||= 0
329
+
330
+ {input: input, output: output, total: input + output}
331
+ end
332
+
294
333
  def classify_error_message(message)
295
334
  msg_lower = message.downcase
296
335
 
@@ -179,7 +179,11 @@ module AgentHarness
179
179
  when :rate_limited
180
180
  RateLimitError.new(original_error.message, original_error: original_error)
181
181
  when :auth_expired
182
- AuthenticationError.new(original_error.message, original_error: original_error)
182
+ AuthenticationError.new(
183
+ original_error.message,
184
+ provider: self.class.provider_name,
185
+ original_error: original_error
186
+ )
183
187
  when :timeout
184
188
  TimeoutError.new(original_error.message, original_error: original_error)
185
189
  else
@@ -81,13 +81,13 @@ module AgentHarness
81
81
  protected
82
82
 
83
83
  def build_command(prompt, options)
84
- cmd = [self.class.binary_name]
84
+ cmd = [self.class.binary_name, "exec"]
85
85
 
86
86
  if options[:session]
87
87
  cmd += session_flags(options[:session])
88
88
  end
89
89
 
90
- cmd += ["--prompt", prompt]
90
+ cmd << prompt
91
91
 
92
92
  cmd
93
93
  end
@@ -114,6 +114,10 @@ module AgentHarness
114
114
  fetch_mcp_servers_cli || fetch_mcp_servers_config
115
115
  end
116
116
 
117
+ def auth_type
118
+ :oauth
119
+ end
120
+
117
121
  def error_patterns
118
122
  {
119
123
  rate_limited: [
@@ -265,7 +269,7 @@ module AgentHarness
265
269
  when :rate_limited
266
270
  raise RateLimitError.new(error.message, original_error: error)
267
271
  when :auth_expired
268
- raise AuthenticationError.new(error.message, original_error: error)
272
+ raise AuthenticationError.new(error.message, provider: self.class.provider_name, original_error: error)
269
273
  when :timeout
270
274
  raise TimeoutError.new(error.message, original_error: error)
271
275
  else
@@ -92,6 +92,10 @@ module AgentHarness
92
92
  }
93
93
  end
94
94
 
95
+ def auth_type
96
+ :oauth
97
+ end
98
+
95
99
  def error_patterns
96
100
  {
97
101
  rate_limited: [
@@ -106,6 +106,10 @@ module AgentHarness
106
106
  ["--resume", session_id]
107
107
  end
108
108
 
109
+ def auth_type
110
+ :oauth
111
+ end
112
+
109
113
  def error_patterns
110
114
  {
111
115
  auth_expired: [
@@ -12,7 +12,7 @@ module AgentHarness
12
12
  end
13
13
 
14
14
  def binary_name
15
- "kilocode"
15
+ "kilo"
16
16
  end
17
17
 
18
18
  def available?
@@ -60,8 +60,8 @@ module AgentHarness
60
60
  protected
61
61
 
62
62
  def build_command(prompt, options)
63
- cmd = [self.class.binary_name]
64
- cmd += ["--prompt", prompt]
63
+ cmd = [self.class.binary_name, "run"]
64
+ cmd << prompt
65
65
  cmd
66
66
  end
67
67
 
@@ -95,7 +95,7 @@ module AgentHarness
95
95
  end
96
96
 
97
97
  def validate_provider_class!(klass)
98
- includes_adapter = klass.included_modules.include?(Adapter)
98
+ includes_adapter = klass.include?(Adapter)
99
99
  has_required_methods = klass.respond_to?(:provider_name) &&
100
100
  klass.respond_to?(:available?) &&
101
101
  klass.respond_to?(:binary_name)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.1"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -82,6 +82,56 @@ module AgentHarness
82
82
  def provider(name)
83
83
  conductor.provider_manager.get_provider(name)
84
84
  end
85
+
86
+ # Check if authentication is valid for a provider
87
+ # @param provider_name [Symbol] the provider name
88
+ # @return [Boolean] true if auth is valid
89
+ def auth_valid?(provider_name)
90
+ Authentication.auth_valid?(provider_name)
91
+ end
92
+
93
+ # Get detailed authentication status for a provider
94
+ # @param provider_name [Symbol] the provider name
95
+ # @return [Hash] status with :valid, :expires_at, :error keys
96
+ def auth_status(provider_name)
97
+ Authentication.auth_status(provider_name)
98
+ end
99
+
100
+ # Generate an OAuth URL for a provider
101
+ # @param provider_name [Symbol] the provider name
102
+ # @return [String] the OAuth authorization URL
103
+ # @raise [NotImplementedError] if provider doesn't support OAuth
104
+ def auth_url(provider_name)
105
+ Authentication.auth_url(provider_name)
106
+ end
107
+
108
+ # Refresh authentication credentials for a provider
109
+ # @param provider_name [Symbol] the provider name
110
+ # @param token [String, nil] OAuth token to store
111
+ # @return [Hash] result with :success key
112
+ # @raise [NotImplementedError] if provider doesn't support credential refresh
113
+ def refresh_auth(provider_name, token: nil)
114
+ Authentication.refresh_auth(provider_name, token: token)
115
+ end
116
+
117
+ # Check health of all configured providers.
118
+ #
119
+ # Validates each enabled provider through registration, CLI availability,
120
+ # authentication, provider health status, and config validation checks.
121
+ #
122
+ # @param timeout [Integer] timeout in seconds for each check (defaults to configured value)
123
+ # @return [Array<Hash>] health status for each provider
124
+ def check_providers(timeout: nil)
125
+ timeout ? ProviderHealthCheck.check_all(timeout: timeout) : ProviderHealthCheck.check_all
126
+ end
127
+
128
+ # Check health of a single provider
129
+ # @param provider_name [Symbol] the provider name
130
+ # @param timeout [Integer, nil] timeout in seconds (nil lets ProviderHealthCheck apply its validated default)
131
+ # @return [Hash] health status with :name, :status, :message, :latency_ms
132
+ def check_provider(provider_name, timeout: nil)
133
+ timeout ? ProviderHealthCheck.check(provider_name, timeout: timeout) : ProviderHealthCheck.check(provider_name)
134
+ end
85
135
  end
86
136
  end
87
137
 
@@ -93,6 +143,8 @@ require_relative "agent_harness/docker_command_executor"
93
143
  require_relative "agent_harness/response"
94
144
  require_relative "agent_harness/token_tracker"
95
145
  require_relative "agent_harness/error_taxonomy"
146
+ require_relative "agent_harness/authentication"
147
+ require_relative "agent_harness/provider_health_check"
96
148
 
97
149
  # Provider layer
98
150
  require_relative "agent_harness/providers/registry"
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.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -66,6 +66,7 @@ files:
66
66
  - ".markdownlintignore"
67
67
  - ".release-please-manifest.json"
68
68
  - ".rspec"
69
+ - ".rubocop.yml"
69
70
  - ".simplecov"
70
71
  - ".tool-versions"
71
72
  - CHANGELOG.md
@@ -75,7 +76,9 @@ files:
75
76
  - Rakefile
76
77
  - bin/console
77
78
  - bin/setup
79
+ - json-2.18.1.gem
78
80
  - lib/agent_harness.rb
81
+ - lib/agent_harness/authentication.rb
79
82
  - lib/agent_harness/command_executor.rb
80
83
  - lib/agent_harness/configuration.rb
81
84
  - lib/agent_harness/docker_command_executor.rb
@@ -87,6 +90,7 @@ files:
87
90
  - lib/agent_harness/orchestration/metrics.rb
88
91
  - lib/agent_harness/orchestration/provider_manager.rb
89
92
  - lib/agent_harness/orchestration/rate_limiter.rb
93
+ - lib/agent_harness/provider_health_check.rb
90
94
  - lib/agent_harness/providers/adapter.rb
91
95
  - lib/agent_harness/providers/aider.rb
92
96
  - lib/agent_harness/providers/anthropic.rb
@@ -117,14 +121,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
117
121
  requirements:
118
122
  - - ">="
119
123
  - !ruby/object:Gem::Version
120
- version: 3.3.0
124
+ version: 3.2.0
121
125
  required_rubygems_version: !ruby/object:Gem::Requirement
122
126
  requirements:
123
127
  - - ">="
124
128
  - !ruby/object:Gem::Version
125
129
  version: '0'
126
130
  requirements: []
127
- rubygems_version: 4.0.3
131
+ rubygems_version: 4.0.6
128
132
  specification_version: 4
129
133
  summary: Unified interface for CLI-based AI coding agents
130
134
  test_files: []