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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +36 -0
- data/README.md +103 -0
- data/json-2.18.1.gem +0 -0
- data/lib/agent_harness/authentication.rb +224 -0
- data/lib/agent_harness/configuration.rb +2 -1
- data/lib/agent_harness/error_taxonomy.rb +1 -1
- data/lib/agent_harness/errors.rb +8 -1
- data/lib/agent_harness/orchestration/conductor.rb +11 -0
- data/lib/agent_harness/provider_health_check.rb +289 -0
- data/lib/agent_harness/providers/adapter.rb +8 -0
- data/lib/agent_harness/providers/anthropic.rb +42 -3
- data/lib/agent_harness/providers/base.rb +5 -1
- data/lib/agent_harness/providers/codex.rb +2 -2
- data/lib/agent_harness/providers/cursor.rb +5 -1
- data/lib/agent_harness/providers/gemini.rb +4 -0
- data/lib/agent_harness/providers/github_copilot.rb +4 -0
- data/lib/agent_harness/providers/kilocode.rb +3 -3
- data/lib/agent_harness/providers/registry.rb +1 -1
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +52 -0
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 16862141d853f2e8817d000a9e4813162f0544cda2c30343de2efc1ffd7e9e73
|
|
4
|
+
data.tar.gz: ee57bd3611abb7566560675c65c1feb04faee531cd182f8a8812881a73701aae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f5145b28d2c92bef3c8ba6f8a0e8ffe2217f8eb9462c672f28d25e0f6bd6a1bb17f1fa14ae2dcf2e57d35c4fa82a6233fdf51acf7778d399c89c4412ee9c9695
|
|
7
|
+
data.tar.gz: f6f9e464d3f5f84a87f98b98dd3d0687157a1b69249a3a0731ba162653aabf6cc517430f31b0f9e9b1d3e5d67d0b767c4c5a12c4e88425bd8b24f578343a87e9
|
data/.rubocop.yml
ADDED
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
|
|
data/lib/agent_harness/errors.rb
CHANGED
|
@@ -45,7 +45,14 @@ module AgentHarness
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
# Authentication errors
|
|
48
|
-
class AuthenticationError < Error
|
|
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=
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
@@ -12,7 +12,7 @@ module AgentHarness
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def binary_name
|
|
15
|
-
"
|
|
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
|
|
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.
|
|
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)
|
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
|
+
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.
|
|
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.
|
|
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: []
|