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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb82dfba3d7a36312cfcb32c86b39f88726b558828643361be96a3b69ca1a3ea
4
- data.tar.gz: 19d643749ed639f4932f82ebdb8ef92f984cb7a4fc12c8c3d05a201103464d5a
3
+ metadata.gz: 22137a8f8e81a58503c1e19a2349c6a8e1c8617cc5e12a38470fdc6477b483de
4
+ data.tar.gz: 185d6003da7d94edfc5fc48cb55f4916b3ca06601bed95de5cfe3db88d0d49bb
5
5
  SHA512:
6
- metadata.gz: 575d764e422d9b465233ea8ffd0db147165be8cc6f6e70fd19cb549d13677da5054acf8d2ec0632d20a95f22c02d27a9add9c12f137a62133a88ebce0b00a70b
7
- data.tar.gz: 87122eb8b3feef772bbd7b8d8fb6a29bd99a18cadd5e260eb74b00a55c489b01e5b0c9d82d93412e270b339ca7fab5ab559999fcd1a9171a2ab7c79325bd688d
6
+ metadata.gz: 030b8e627328572ad8d01c5245a6f21b2e3070be6255505eb6e6a3df3a2040083218ee0b28c36dd0584622604fd93319ea6e7675308f2d7212007a4b7369320a
7
+ data.tar.gz: 23cb19b897faf13438bfdc875c2f151eadcefa13c704cf9f80d443db3e99b7317856d9d744514b082a9f2abd813a26c7dcbf84f243c3e35879f564f979fe180f
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.3.0"
2
+ ".": "0.5.0"
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,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
- private
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
@@ -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)
@@ -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?
@@ -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, :executor, :logger
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(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
+ )
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
@@ -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: [
@@ -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.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
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.3.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.3.0
122
+ version: 3.2.0
120
123
  required_rubygems_version: !ruby/object:Gem::Requirement
121
124
  requirements:
122
125
  - - ">="