agent-harness 0.24.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: 898eb630251f7c5f1f328b7e76d8d8e8ef587318b3fe013373329a719b2c9fec
4
- data.tar.gz: c3e0c222e24f86e905765beb55439576dcc54c2e25b02b822f1fa350305cf8a3
3
+ metadata.gz: 1afbed6f9dcafe6c80575c77a128ba73b407f4cd691bd45ce19bfa48b613ccb2
4
+ data.tar.gz: b158dd410320320093c897795839b20eedea35383784692c0038893dca163123
5
5
  SHA512:
6
- metadata.gz: ea46cfbe98a5d31c23e5a8a0feffc05ec2d478ea841c066553b1c05d6487d59a94cb3716a4bff29225e76a83b367b58917b77ee0e29db573cf9e89b8a8532ef3
7
- data.tar.gz: 4cf59eaaeec3e2d4d0478e7ecf39ee849a0a66dda12c47449e99d89da852483c595cef6d99a163c947e58a8fdf19e396340b281a49e868d93e2a84d76613e776
6
+ metadata.gz: fb7e2eda150529ba550829380171bc432bbe5025de3ad8d7c5f5799354b8a4a1e6209a788ef2fbc9ca90898d24ee4b1769f4f693619d75d90bd47b2021edc12a
7
+ data.tar.gz: c55f52bf3498750daf7bc4919d1100705e8b5fcb1f8c540852022cd852a28988654cf84771cae0d3bdf08a9f8a7648e7a2514234b5fc06d70e6a54b159ce1ab6
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.24.0"
2
+ ".": "0.25.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@
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
+
8
15
  ## [0.24.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.23.0...agent-harness/v0.24.0) (2026-06-26)
9
16
 
10
17
 
@@ -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)
@@ -251,6 +297,105 @@ module AgentHarness
251
297
  {success: true}
252
298
  end
253
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
+
254
399
  # Extract a usable token and expiry from Claude credentials,
255
400
  # supporting both the native claudeAiOauth shape and the legacy
256
401
  # top-level oauth_token/apiKey shape.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.24.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.24.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan