agent-harness 0.23.0 → 0.25.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: '0874add2f55550c3c9f9b723c84a4036a2dd79dd717dd62069062cfbde630820'
4
- data.tar.gz: 53c6335c3a47e4e0e18ea3151dad061f9302519013e27591c7541ebf6dfe51db
3
+ metadata.gz: 1afbed6f9dcafe6c80575c77a128ba73b407f4cd691bd45ce19bfa48b613ccb2
4
+ data.tar.gz: b158dd410320320093c897795839b20eedea35383784692c0038893dca163123
5
5
  SHA512:
6
- metadata.gz: a7297580e4a5a34631421730cab4c0fc5712448864bacefe8c8f3c43c7652b5a42cb9f7c2a603f585f308d2a54117dff918804a45a30c3bb302ec223e545eed4
7
- data.tar.gz: 36428c8e91bee3395d235cd4ae309f6a43c177acc6991cdf3cd33ed4f0cc433a566849a8b282137466a455da5becdc63820a87a01a36eb8399f857ecbe457ef7
6
+ metadata.gz: fb7e2eda150529ba550829380171bc432bbe5025de3ad8d7c5f5799354b8a4a1e6209a788ef2fbc9ca90898d24ee4b1769f4f693619d75d90bd47b2021edc12a
7
+ data.tar.gz: c55f52bf3498750daf7bc4919d1100705e8b5fcb1f8c540852022cd852a28988654cf84771cae0d3bdf08a9f8a7648e7a2514234b5fc06d70e6a54b159ce1ab6
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.23.0"
2
+ ".": "0.25.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@
5
5
  * add runner model compatibility contract (`AgentHarness.model_compatibility`) with structured `ModelCompatibility::Result` outcomes. Codex exposes static facts for CLI-gated models (e.g. `gpt-5.5` requires Codex CLI `>= 0.116.0`), a baseline supported-model list, supported auth modes, and a `DEFAULT_COMPATIBLE_MODEL_ID` fallback so downstream orchestrators can validate tier/model assignments before scheduling agent runs ([#259](https://github.com/viamin/agent-harness/issues/259)).
6
6
 
7
7
 
8
+ ## [0.25.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.24.0...agent-harness/v0.25.0) (2026-06-26)
9
+
10
+
11
+ ### Features
12
+
13
+ * **auth:** add refresh-token exchange API for Claude OAuth ([#269](https://github.com/viamin/agent-harness/issues/269)) ([076b1aa](https://github.com/viamin/agent-harness/commit/076b1aa91a3dcb58280d1c550bdc4329ce162a92)), closes [#265](https://github.com/viamin/agent-harness/issues/265)
14
+
15
+ ## [0.24.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.23.0...agent-harness/v0.24.0) (2026-06-26)
16
+
17
+
18
+ ### Features
19
+
20
+ * **auth:** parse native Claude claudeAiOauth credentials shape ([#268](https://github.com/viamin/agent-harness/issues/268)) ([178edae](https://github.com/viamin/agent-harness/commit/178edae21e175c4744b75a151acfc6df3b4b3c5d)), closes [#264](https://github.com/viamin/agent-harness/issues/264)
21
+
8
22
  ## [0.23.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.22.5...agent-harness/v0.23.0) (2026-06-15)
9
23
 
10
24
 
@@ -2,8 +2,10 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
+ require "net/http"
5
6
  require "tempfile"
6
7
  require "time"
8
+ require "uri"
7
9
 
8
10
  module AgentHarness
9
11
  # Authentication management for CLI agent providers
@@ -49,7 +51,8 @@ module AgentHarness
49
51
  {
50
52
  auth_type: provider.auth_type,
51
53
  auth_url: flow_supported,
52
- refresh: flow_supported
54
+ refresh: flow_supported,
55
+ exchange: flow_supported
53
56
  }
54
57
  end
55
58
 
@@ -126,6 +129,49 @@ module AgentHarness
126
129
  end
127
130
  end
128
131
 
132
+ # Check whether refresh-token exchange is supported for a provider.
133
+ #
134
+ # @param provider_name [Symbol] the provider name
135
+ # @return [Boolean] true if exchange_refresh_token can be called
136
+ # @raise [ProviderNotFoundError] if provider is unknown
137
+ def exchange_refresh_token_supported?(provider_name)
138
+ auth_capabilities(provider_name)[:exchange]
139
+ end
140
+
141
+ # Exchange a stored refresh token for a fresh access token (and rotated refresh token).
142
+ #
143
+ # Reads the refresh token from the provider's credentials store, posts it to the
144
+ # OAuth token endpoint, persists the rotated tokens, and returns the credential
145
+ # in native claudeAiOauth shape.
146
+ #
147
+ # Serializes through a file lock so that concurrent callers do not race on a
148
+ # single-use/rotating refresh token. If the token server reports that the
149
+ # refresh token has already been consumed (`refresh_token_reused`), raises
150
+ # +AuthenticationError+ so the caller can trigger a full re-auth.
151
+ #
152
+ # @param provider_name [Symbol] the provider name
153
+ # @return [Hash] credential in claudeAiOauth shape:
154
+ # +{ claudeAiOauth: { accessToken:, refreshToken:, expiresAt: } }+
155
+ # @raise [UnsupportedAuthFlowError] if provider doesn't support token exchange
156
+ # @raise [AuthenticationError] if the refresh token is missing, reused, or exchange fails
157
+ def exchange_refresh_token(provider_name)
158
+ provider_name = provider_name.to_sym
159
+ provider = resolve_provider(provider_name)
160
+
161
+ unless provider.auth_type == :oauth
162
+ raise UnsupportedAuthFlowError,
163
+ "Provider #{provider_name} uses #{provider.auth_type} auth and does not support token exchange"
164
+ end
165
+
166
+ case provider_name
167
+ when :claude, :anthropic
168
+ exchange_claude_refresh_token
169
+ else
170
+ raise UnsupportedAuthFlowError,
171
+ "Token exchange is not yet implemented for provider #{provider_name}"
172
+ end
173
+ end
174
+
129
175
  private
130
176
 
131
177
  def claude_oauth_flow_provider?(requested_name, canonical_name)
@@ -163,12 +209,8 @@ module AgentHarness
163
209
  credentials = read_claude_credentials
164
210
  return {valid: false, expires_at: nil, error: "No credentials found"} unless credentials
165
211
 
166
- # Check if the credentials file has a token, preferring a non-blank oauth_token over apiKey
167
- oauth_token = credentials["oauth_token"]
168
- api_key = credentials["apiKey"]
169
- token = [oauth_token, api_key].find { |t| t.is_a?(String) && !t.strip.empty? }
212
+ token, expires_at = extract_claude_token(credentials)
170
213
  if token
171
- expires_at = parse_expiry(credentials["expiresAt"] || credentials["expires_at"])
172
214
  if expires_at && expires_at < Time.now
173
215
  {valid: false, expires_at: expires_at, error: "Session expired"}
174
216
  else
@@ -224,10 +266,20 @@ module AgentHarness
224
266
 
225
267
  credentials = read_claude_credentials
226
268
  credentials = {} unless credentials.is_a?(Hash)
227
- credentials["oauth_token"] = token.strip
228
- # Clear any existing expiry metadata so refreshed tokens are not treated as expired
229
- credentials.delete("expiresAt")
230
- credentials.delete("expires_at")
269
+
270
+ if credentials.key?("claudeAiOauth")
271
+ # Preserve the native claudeAiOauth shape
272
+ oauth = credentials["claudeAiOauth"]
273
+ oauth = {} unless oauth.is_a?(Hash)
274
+ oauth["accessToken"] = token.strip
275
+ oauth.delete("expiresAt")
276
+ credentials["claudeAiOauth"] = oauth
277
+ else
278
+ credentials["oauth_token"] = token.strip
279
+ # Clear any existing expiry metadata so refreshed tokens are not treated as expired
280
+ credentials.delete("expiresAt")
281
+ credentials.delete("expires_at")
282
+ end
231
283
 
232
284
  # Write under a file lock using tempfile + rename to avoid corruption and lost updates on concurrent refreshes
233
285
  tmpfile = Tempfile.new(".credentials", dir)
@@ -245,6 +297,135 @@ module AgentHarness
245
297
  {success: true}
246
298
  end
247
299
 
300
+ # Token endpoint used for Claude OAuth refresh-token exchange.
301
+ CLAUDE_TOKEN_ENDPOINT = URI("https://claude.ai/oauth/token").freeze
302
+
303
+ def exchange_claude_refresh_token
304
+ credentials_path = claude_credentials_path
305
+ lock_path = "#{credentials_path}.lock"
306
+
307
+ dir = File.dirname(credentials_path)
308
+ FileUtils.mkdir_p(dir, mode: 0o700)
309
+
310
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
311
+ lock.flock(File::LOCK_EX)
312
+
313
+ credentials = read_claude_credentials
314
+ refresh_token = credentials&.dig("claudeAiOauth", "refreshToken")
315
+ refresh_token = nil if refresh_token.is_a?(String) && refresh_token.strip.empty?
316
+
317
+ unless refresh_token
318
+ raise AuthenticationError.new(
319
+ "No refresh token found in Claude credentials",
320
+ provider: :claude
321
+ )
322
+ end
323
+
324
+ response_body = post_token_exchange(refresh_token)
325
+
326
+ access_token = response_body["access_token"]
327
+ new_refresh_token = response_body["refresh_token"]
328
+ expires_in = response_body["expires_in"]
329
+
330
+ expires_at = expires_in ? (Time.now + expires_in).iso8601 : nil
331
+
332
+ oauth_block = {
333
+ "accessToken" => access_token,
334
+ "refreshToken" => new_refresh_token,
335
+ "expiresAt" => expires_at
336
+ }
337
+
338
+ credentials["claudeAiOauth"] = oauth_block
339
+ credentials["oauth_token"] = access_token
340
+ credentials.delete("expiresAt")
341
+ credentials.delete("expires_at")
342
+ credentials["expiresAt"] = expires_at
343
+
344
+ tmpfile = Tempfile.new(".credentials", dir)
345
+ begin
346
+ tmpfile.write(JSON.pretty_generate(credentials))
347
+ tmpfile.close
348
+ File.chmod(0o600, tmpfile.path)
349
+ File.rename(tmpfile.path, credentials_path)
350
+ rescue
351
+ tmpfile.close!
352
+ raise
353
+ end
354
+
355
+ {claudeAiOauth: oauth_block.transform_keys(&:to_sym)}
356
+ end
357
+ end
358
+
359
+ def post_token_exchange(refresh_token)
360
+ http = Net::HTTP.new(CLAUDE_TOKEN_ENDPOINT.host, CLAUDE_TOKEN_ENDPOINT.port)
361
+ http.use_ssl = true
362
+ http.open_timeout = 10
363
+ http.read_timeout = 10
364
+
365
+ request = Net::HTTP::Post.new(CLAUDE_TOKEN_ENDPOINT.path)
366
+ request["Content-Type"] = "application/json"
367
+ request.body = JSON.generate({
368
+ grant_type: "refresh_token",
369
+ refresh_token: refresh_token
370
+ })
371
+
372
+ response = http.request(request)
373
+
374
+ body = begin
375
+ JSON.parse(response.body)
376
+ rescue JSON::ParserError
377
+ {}
378
+ end
379
+
380
+ unless response.is_a?(Net::HTTPSuccess)
381
+ error_code = body["error"] || body["code"]
382
+ if error_code.to_s.match?(/refresh_token_reused/i)
383
+ raise AuthenticationError.new(
384
+ "Refresh token has already been used (refresh_token_reused). Re-authentication required.",
385
+ provider: :claude
386
+ )
387
+ end
388
+
389
+ error_description = body["error_description"] || body["message"] || response.body
390
+ raise AuthenticationError.new(
391
+ "Token exchange failed (HTTP #{response.code}): #{error_description}",
392
+ provider: :claude
393
+ )
394
+ end
395
+
396
+ body
397
+ end
398
+
399
+ # Extract a usable token and expiry from Claude credentials,
400
+ # supporting both the native claudeAiOauth shape and the legacy
401
+ # top-level oauth_token/apiKey shape.
402
+ #
403
+ # @return [Array(String, Time)] token and parsed expiry, or nils
404
+ def extract_claude_token(credentials)
405
+ # Prefer the native claudeAiOauth nested shape written by the Claude CLI
406
+ if credentials.key?("claudeAiOauth")
407
+ oauth = credentials["claudeAiOauth"]
408
+ if oauth.is_a?(Hash)
409
+ access_token = oauth["accessToken"]
410
+ if non_blank?(access_token)
411
+ expires_at = parse_expiry(oauth["expiresAt"])
412
+ return [access_token, expires_at]
413
+ end
414
+ end
415
+ end
416
+
417
+ # Fall back to the legacy top-level shape
418
+ oauth_token = credentials["oauth_token"]
419
+ api_key = credentials["apiKey"]
420
+ token = [oauth_token, api_key].find { |t| non_blank?(t) }
421
+ expires_at = parse_expiry(credentials["expiresAt"] || credentials["expires_at"]) if token
422
+ [token, expires_at]
423
+ end
424
+
425
+ def non_blank?(value)
426
+ value.is_a?(String) && !value.strip.empty?
427
+ end
428
+
248
429
  def read_claude_credentials
249
430
  path = claude_credentials_path
250
431
  return nil unless File.exist?(path)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.23.0"
4
+ VERSION = "0.25.0"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -339,6 +339,23 @@ module AgentHarness
339
339
  Authentication.refresh_auth(provider_name, token: token)
340
340
  end
341
341
 
342
+ # Check whether refresh-token exchange is supported for a provider
343
+ # @param provider_name [Symbol] the provider name
344
+ # @return [Boolean] true if exchange_refresh_token can be called
345
+ # @raise [ProviderNotFoundError] if provider is unknown
346
+ def exchange_refresh_token_supported?(provider_name)
347
+ Authentication.exchange_refresh_token_supported?(provider_name)
348
+ end
349
+
350
+ # Exchange a stored refresh token for a fresh access token
351
+ # @param provider_name [Symbol] the provider name
352
+ # @return [Hash] credential in claudeAiOauth shape
353
+ # @raise [UnsupportedAuthFlowError] if provider doesn't support token exchange
354
+ # @raise [AuthenticationError] if the refresh token is missing, reused, or exchange fails
355
+ def exchange_refresh_token(provider_name)
356
+ Authentication.exchange_refresh_token(provider_name)
357
+ end
358
+
342
359
  # Check health of all configured providers.
343
360
  #
344
361
  # Validates each enabled provider through registration, CLI availability,
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.23.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan