agent-harness 0.3.0 → 0.5.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/.rubocop.yml +2 -0
- data/CHANGELOG.md +34 -0
- data/README.md +73 -0
- data/lib/agent_harness/authentication.rb +224 -0
- data/lib/agent_harness/command_executor.rb +3 -1
- data/lib/agent_harness/docker_command_executor.rb +74 -0
- 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/providers/adapter.rb +8 -0
- data/lib/agent_harness/providers/anthropic.rb +41 -2
- data/lib/agent_harness/providers/base.rb +7 -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/registry.rb +1 -1
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +33 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 22137a8f8e81a58503c1e19a2349c6a8e1c8617cc5e12a38470fdc6477b483de
|
|
4
|
+
data.tar.gz: 185d6003da7d94edfc5fc48cb55f4916b3ca06601bed95de5cfe3db88d0d49bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 030b8e627328572ad8d01c5245a6f21b2e3070be6255505eb6e6a3df3a2040083218ee0b28c36dd0584622604fd93319ea6e7675308f2d7212007a4b7369320a
|
|
7
|
+
data.tar.gz: 23cb19b897faf13438bfdc875c2f151eadcefa13c704cf9f80d443db3e99b7317856d9d744514b082a9f2abd813a26c7dcbf84f243c3e35879f564f979fe180f
|
data/.rubocop.yml
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.4.0...agent-harness/v0.5.0) (2026-03-03)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* 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)
|
|
9
|
+
* 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)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* add file lock to refresh_claude_auth to prevent lost-update races ([eb00e19](https://github.com/viamin/agent-harness/commit/eb00e1935dcd574f952ea37c263e9794de23f9a7))
|
|
15
|
+
* address code review feedback for authentication module ([6d11067](https://github.com/viamin/agent-harness/commit/6d1106743c79f5ae4c3a98f078e4c4d4c93db465))
|
|
16
|
+
* address code review feedback for resolve_provider and conductor docs ([5975b3b](https://github.com/viamin/agent-harness/commit/5975b3b8e087f681b57cc9935499e0691f865360))
|
|
17
|
+
* address PR review feedback for auth error handling ([70d7ea7](https://github.com/viamin/agent-harness/commit/70d7ea7eb4d13fd80d7c2724af57053a6dea9972))
|
|
18
|
+
* address PR review feedback for authentication module ([b098682](https://github.com/viamin/agent-harness/commit/b098682448104a833a3e50c89531bcb838910b52))
|
|
19
|
+
* address PR review feedback for token handling in authentication ([03398b9](https://github.com/viamin/agent-harness/commit/03398b9be4b43c12c31694d8c7864dfde891da29))
|
|
20
|
+
* address remaining PR review feedback for auth behavior ([893b549](https://github.com/viamin/agent-harness/commit/893b549bb080345bb1c0dfe718bb1840ff2a1f5e))
|
|
21
|
+
* align ErrorTaxonomy auth_expired action with Conductor behavior ([7697637](https://github.com/viamin/agent-harness/commit/76976375708f56c4fbcaf635bebafd8da9f35de1))
|
|
22
|
+
* clear expiry metadata on token refresh and align docs with API ([9bba06e](https://github.com/viamin/agent-harness/commit/9bba06e00c7b65722afef4b4492ec777e65578e0))
|
|
23
|
+
* correct method for checking module inclusion in provider validation ([4cf57fc](https://github.com/viamin/agent-harness/commit/4cf57fcebed92261e065aa6cf526f1f3851f57e7))
|
|
24
|
+
* differentiate credential read errors instead of returning generic nil ([cada3c5](https://github.com/viamin/agent-harness/commit/cada3c5404144b4eaf122d5dbe5f023eb30e5d95))
|
|
25
|
+
* guard against non-Hash JSON in refresh_claude_auth credentials ([74e1301](https://github.com/viamin/agent-harness/commit/74e1301ec7835f929bd43dc15f4a87e62bcf7237))
|
|
26
|
+
* remove accidentally committed bundler binstubs ([8207ef0](https://github.com/viamin/agent-harness/commit/8207ef0df67add5d1db8f3af9ef495c0b832d0b6))
|
|
27
|
+
* validate tokens are non-empty strings in authentication module ([55a12e4](https://github.com/viamin/agent-harness/commit/55a12e45616839079afe509e079c771a1a71a1a5))
|
|
28
|
+
|
|
29
|
+
## [0.4.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.3.0...agent-harness/v0.4.0) (2026-02-16)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Features
|
|
33
|
+
|
|
34
|
+
* add DockerCommandExecutor for container-based command execution ([85826e5](https://github.com/viamin/agent-harness/commit/85826e5ece76d9f073329902769093f846cfd8b7))
|
|
35
|
+
* add DockerCommandExecutor for container-based command execution ([cb18f2e](https://github.com/viamin/agent-harness/commit/cb18f2e2f1d16ef52ea2ce54c51970d73fcae6c8))
|
|
36
|
+
|
|
3
37
|
## [0.3.0](https://github.com/viamin/agent-harness/compare/agent-harness-v0.2.2...agent-harness/v0.3.0) (2026-01-26)
|
|
4
38
|
|
|
5
39
|
|
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,76 @@ 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
|
+
|
|
256
329
|
## Development
|
|
257
330
|
|
|
258
331
|
```bash
|
|
@@ -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
|
|
@@ -87,7 +87,7 @@ module AgentHarness
|
|
|
87
87
|
!which(binary).nil?
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
protected
|
|
91
91
|
|
|
92
92
|
def normalize_command(command)
|
|
93
93
|
case command
|
|
@@ -100,6 +100,8 @@ module AgentHarness
|
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
private
|
|
104
|
+
|
|
103
105
|
def execute_with_timeout(cmd_array, timeout:, env:, stdin_data:)
|
|
104
106
|
stdout = ""
|
|
105
107
|
stderr = ""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentHarness
|
|
4
|
+
# Executes commands inside a Docker container
|
|
5
|
+
#
|
|
6
|
+
# Wraps commands with `docker exec` so they run inside
|
|
7
|
+
# the specified container rather than on the host.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# executor = AgentHarness::DockerCommandExecutor.new(container_id: "abc123")
|
|
11
|
+
# result = executor.execute(["python", "script.py"])
|
|
12
|
+
#
|
|
13
|
+
# @example With environment variables
|
|
14
|
+
# result = executor.execute("echo $FOO", env: { "FOO" => "bar" })
|
|
15
|
+
class DockerCommandExecutor < CommandExecutor
|
|
16
|
+
attr_reader :container_id
|
|
17
|
+
|
|
18
|
+
# Initialize the Docker command executor
|
|
19
|
+
#
|
|
20
|
+
# @param container_id [String] the Docker container ID or name
|
|
21
|
+
# @param logger [Logger, nil] optional logger
|
|
22
|
+
# @raise [CommandExecutionError] if Docker CLI is not found on the host
|
|
23
|
+
def initialize(container_id:, logger: nil)
|
|
24
|
+
raise ArgumentError, "container_id cannot be nil or empty" if container_id.nil? || container_id.empty?
|
|
25
|
+
|
|
26
|
+
super(logger: logger)
|
|
27
|
+
@container_id = container_id
|
|
28
|
+
validate_docker!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Execute a command inside the Docker container
|
|
32
|
+
#
|
|
33
|
+
# Wraps the given command with `docker exec` and delegates
|
|
34
|
+
# to the parent class for actual process execution.
|
|
35
|
+
#
|
|
36
|
+
# @param command [Array<String>, String] command to execute
|
|
37
|
+
# @param timeout [Integer, nil] timeout in seconds
|
|
38
|
+
# @param env [Hash] environment variables to set in the container
|
|
39
|
+
# @param stdin_data [String, nil] data to send to stdin
|
|
40
|
+
# @return [Result] execution result
|
|
41
|
+
def execute(command, timeout: nil, env: {}, stdin_data: nil)
|
|
42
|
+
docker_cmd = build_docker_command(command, env: env, stdin_data: stdin_data)
|
|
43
|
+
super(docker_cmd, timeout: timeout, env: {}, stdin_data: stdin_data)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if a binary exists inside the container
|
|
47
|
+
#
|
|
48
|
+
# @param binary [String] binary name
|
|
49
|
+
# @return [String, nil] full path or nil
|
|
50
|
+
def which(binary)
|
|
51
|
+
result = execute(["which", binary], timeout: 5)
|
|
52
|
+
result.success? ? result.stdout.strip : nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def validate_docker!
|
|
58
|
+
return if ENV["PATH"]&.split(File::PATH_SEPARATOR)&.any? { |path| File.executable?(File.join(path, "docker")) }
|
|
59
|
+
|
|
60
|
+
raise CommandExecutionError, "Docker CLI not found on host PATH"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_docker_command(command, env:, stdin_data:)
|
|
64
|
+
cmd = ["docker", "exec"]
|
|
65
|
+
|
|
66
|
+
env.each { |key, value| cmd.push("--env", "#{key}=#{value}") }
|
|
67
|
+
cmd.push("-i") if stdin_data
|
|
68
|
+
|
|
69
|
+
cmd.push(@container_id)
|
|
70
|
+
|
|
71
|
+
cmd.concat(normalize_command(command))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
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)
|
|
@@ -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?
|
|
@@ -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
|
|
|
@@ -32,7 +32,8 @@ module AgentHarness
|
|
|
32
32
|
class Base
|
|
33
33
|
include Adapter
|
|
34
34
|
|
|
35
|
-
attr_reader :config, :
|
|
35
|
+
attr_reader :config, :logger
|
|
36
|
+
attr_accessor :executor
|
|
36
37
|
|
|
37
38
|
# Initialize the provider
|
|
38
39
|
#
|
|
@@ -178,7 +179,11 @@ module AgentHarness
|
|
|
178
179
|
when :rate_limited
|
|
179
180
|
RateLimitError.new(original_error.message, original_error: original_error)
|
|
180
181
|
when :auth_expired
|
|
181
|
-
AuthenticationError.new(
|
|
182
|
+
AuthenticationError.new(
|
|
183
|
+
original_error.message,
|
|
184
|
+
provider: self.class.provider_name,
|
|
185
|
+
original_error: original_error
|
|
186
|
+
)
|
|
182
187
|
when :timeout
|
|
183
188
|
TimeoutError.new(original_error.message, original_error: original_error)
|
|
184
189
|
else
|
|
@@ -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
|
|
@@ -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,37 @@ 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
|
|
85
116
|
end
|
|
86
117
|
end
|
|
87
118
|
|
|
@@ -89,9 +120,11 @@ end
|
|
|
89
120
|
require_relative "agent_harness/errors"
|
|
90
121
|
require_relative "agent_harness/configuration"
|
|
91
122
|
require_relative "agent_harness/command_executor"
|
|
123
|
+
require_relative "agent_harness/docker_command_executor"
|
|
92
124
|
require_relative "agent_harness/response"
|
|
93
125
|
require_relative "agent_harness/token_tracker"
|
|
94
126
|
require_relative "agent_harness/error_taxonomy"
|
|
127
|
+
require_relative "agent_harness/authentication"
|
|
95
128
|
|
|
96
129
|
# Provider layer
|
|
97
130
|
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.0
|
|
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
|
|
@@ -76,8 +77,10 @@ files:
|
|
|
76
77
|
- bin/console
|
|
77
78
|
- bin/setup
|
|
78
79
|
- lib/agent_harness.rb
|
|
80
|
+
- lib/agent_harness/authentication.rb
|
|
79
81
|
- lib/agent_harness/command_executor.rb
|
|
80
82
|
- lib/agent_harness/configuration.rb
|
|
83
|
+
- lib/agent_harness/docker_command_executor.rb
|
|
81
84
|
- lib/agent_harness/error_taxonomy.rb
|
|
82
85
|
- lib/agent_harness/errors.rb
|
|
83
86
|
- lib/agent_harness/orchestration/circuit_breaker.rb
|
|
@@ -116,7 +119,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
116
119
|
requirements:
|
|
117
120
|
- - ">="
|
|
118
121
|
- !ruby/object:Gem::Version
|
|
119
|
-
version: 3.
|
|
122
|
+
version: 3.2.0
|
|
120
123
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
121
124
|
requirements:
|
|
122
125
|
- - ">="
|