agent-harness 0.24.0 → 0.26.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: d5561c80c0a4ccab10925503a30bb20be2c24c929caa58b9d69a4aac2eb27aac
4
+ data.tar.gz: aae45d0a30105fed84eb442bdbeaaea0c0bc2fc995997bec49bccbed61db4249
5
5
  SHA512:
6
- metadata.gz: ea46cfbe98a5d31c23e5a8a0feffc05ec2d478ea841c066553b1c05d6487d59a94cb3716a4bff29225e76a83b367b58917b77ee0e29db573cf9e89b8a8532ef3
7
- data.tar.gz: 4cf59eaaeec3e2d4d0478e7ecf39ee849a0a66dda12c47449e99d89da852483c595cef6d99a163c947e58a8fdf19e396340b281a49e868d93e2a84d76613e776
6
+ metadata.gz: 00eb3ec6f37bbdda7bd430abf55eb09f4f6f79f86b565256c7b287cc2f0b9ef840f108b00a33ba84d457d689784772bb4d307191e08e0aae5941247739ceb651
7
+ data.tar.gz: 51efc497bd800e2d2ef31886ddeb65c502242dee45cfb40947d35f904f76e9ad215bf57520a779bd42a69e1b04759242c37c6b30a6b98b5a75707c759cd66e2a
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.24.0"
2
+ ".": "0.26.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -3,8 +3,23 @@
3
3
  ### Features
4
4
 
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
+ * **auth:** add provider-owned PKCE code-exchange API for Claude OAuth (`AgentHarness::Authentication.exchange_code`). Takes an authorization code plus PKCE verifier (and `redirect_uri`/`client_id`), posts an `authorization_code` grant to the Claude token endpoint, and persists the resulting access/refresh tokens in the native `claudeAiOauth` shape. Adds `exchange_code_supported?` and a `code_exchange` key to `auth_capabilities` ([#266](https://github.com/viamin/agent-harness/issues/266)).
6
7
 
7
8
 
9
+ ## [0.26.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.25.0...agent-harness/v0.26.0) (2026-06-26)
10
+
11
+
12
+ ### Features
13
+
14
+ * **auth:** add PKCE code-exchange API for Claude OAuth ([#272](https://github.com/viamin/agent-harness/issues/272)) ([22f873b](https://github.com/viamin/agent-harness/commit/22f873bb5fe58c67ab94c2a7b0c57b3bcbfe38b9)), closes [#266](https://github.com/viamin/agent-harness/issues/266)
15
+
16
+ ## [0.25.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.24.0...agent-harness/v0.25.0) (2026-06-26)
17
+
18
+
19
+ ### Features
20
+
21
+ * **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)
22
+
8
23
  ## [0.24.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.23.0...agent-harness/v0.24.0) (2026-06-26)
9
24
 
10
25
 
@@ -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,9 @@ 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,
56
+ code_exchange: flow_supported
53
57
  }
54
58
  end
55
59
 
@@ -126,8 +130,117 @@ module AgentHarness
126
130
  end
127
131
  end
128
132
 
133
+ # Check whether refresh-token exchange is supported for a provider.
134
+ #
135
+ # @param provider_name [Symbol] the provider name
136
+ # @return [Boolean] true if exchange_refresh_token can be called
137
+ # @raise [ProviderNotFoundError] if provider is unknown
138
+ def exchange_refresh_token_supported?(provider_name)
139
+ auth_capabilities(provider_name)[:exchange]
140
+ end
141
+
142
+ # Exchange a stored refresh token for a fresh access token (and rotated refresh token).
143
+ #
144
+ # Reads the refresh token from the provider's credentials store, posts it to the
145
+ # OAuth token endpoint, persists the rotated tokens, and returns the credential
146
+ # in native claudeAiOauth shape.
147
+ #
148
+ # Serializes through a file lock so that concurrent callers do not race on a
149
+ # single-use/rotating refresh token. If the token server reports that the
150
+ # refresh token has already been consumed (`refresh_token_reused`), raises
151
+ # +AuthenticationError+ so the caller can trigger a full re-auth.
152
+ #
153
+ # @param provider_name [Symbol] the provider name
154
+ # @return [Hash] credential in claudeAiOauth shape:
155
+ # +{ claudeAiOauth: { accessToken:, refreshToken:, expiresAt: } }+
156
+ # @raise [UnsupportedAuthFlowError] if provider doesn't support token exchange
157
+ # @raise [AuthenticationError] if the refresh token is missing, reused, or exchange fails
158
+ def exchange_refresh_token(provider_name)
159
+ provider_name = provider_name.to_sym
160
+ provider = resolve_provider(provider_name)
161
+
162
+ unless provider.auth_type == :oauth
163
+ raise UnsupportedAuthFlowError,
164
+ "Provider #{provider_name} uses #{provider.auth_type} auth and does not support token exchange"
165
+ end
166
+
167
+ case provider_name
168
+ when :claude, :anthropic
169
+ exchange_claude_refresh_token
170
+ else
171
+ raise UnsupportedAuthFlowError,
172
+ "Token exchange is not yet implemented for provider #{provider_name}"
173
+ end
174
+ end
175
+
176
+ # Check whether PKCE authorization-code exchange is supported for a provider.
177
+ #
178
+ # @param provider_name [Symbol] the provider name
179
+ # @return [Boolean] true if exchange_code can be called
180
+ # @raise [ProviderNotFoundError] if provider is unknown
181
+ def exchange_code_supported?(provider_name)
182
+ auth_capabilities(provider_name)[:code_exchange]
183
+ end
184
+
185
+ # Exchange a PKCE authorization code for tokens and persist them in native shape.
186
+ #
187
+ # Posts the authorization code and PKCE verifier to the provider's OAuth
188
+ # token endpoint, then writes the resulting access/refresh tokens to the
189
+ # credentials store using the provider's native shape (e.g. +claudeAiOauth+
190
+ # for Claude).
191
+ #
192
+ # Serializes through a file lock so that concurrent callers do not race on
193
+ # credential writes.
194
+ #
195
+ # @param provider_name [Symbol] the provider name
196
+ # @param code [String] authorization code returned from the OAuth redirect (required)
197
+ # @param code_verifier [String] PKCE code verifier matching the code_challenge sent on the auth URL (required)
198
+ # @param redirect_uri [String] redirect URI registered with the authorization request (required)
199
+ # @param client_id [String] OAuth client identifier (required)
200
+ # @param state [String, nil] optional state parameter echoed from the authorization request
201
+ # @return [Hash] credential in claudeAiOauth shape for Claude:
202
+ # +{ claudeAiOauth: { accessToken:, refreshToken:, expiresAt: } }+
203
+ # @raise [UnsupportedAuthFlowError] if provider doesn't support PKCE code exchange
204
+ # @raise [ArgumentError] if any required parameter is blank
205
+ # @raise [AuthenticationError] if the exchange fails
206
+ def exchange_code(provider_name, code:, code_verifier:, redirect_uri:, client_id:, state: nil)
207
+ provider_name = provider_name.to_sym
208
+ provider = resolve_provider(provider_name)
209
+
210
+ unless provider.auth_type == :oauth
211
+ raise UnsupportedAuthFlowError,
212
+ "Provider #{provider_name} uses #{provider.auth_type} auth and does not support code exchange"
213
+ end
214
+
215
+ validate_code_exchange_params!(code: code, code_verifier: code_verifier,
216
+ redirect_uri: redirect_uri, client_id: client_id)
217
+
218
+ case provider_name
219
+ when :claude, :anthropic
220
+ exchange_claude_code(
221
+ code: code.strip,
222
+ code_verifier: code_verifier.strip,
223
+ redirect_uri: redirect_uri.strip,
224
+ client_id: client_id.strip,
225
+ state: state
226
+ )
227
+ else
228
+ raise UnsupportedAuthFlowError,
229
+ "Code exchange is not yet implemented for provider #{provider_name}"
230
+ end
231
+ end
232
+
129
233
  private
130
234
 
235
+ def validate_code_exchange_params!(code:, code_verifier:, redirect_uri:, client_id:)
236
+ {code: code, code_verifier: code_verifier,
237
+ redirect_uri: redirect_uri, client_id: client_id}.each do |name, value|
238
+ unless non_blank?(value)
239
+ raise ArgumentError, "#{name} must be a non-empty string"
240
+ end
241
+ end
242
+ end
243
+
131
244
  def claude_oauth_flow_provider?(requested_name, canonical_name)
132
245
  [:claude, :anthropic].include?(requested_name) || canonical_name == :claude
133
246
  end
@@ -251,6 +364,202 @@ module AgentHarness
251
364
  {success: true}
252
365
  end
253
366
 
367
+ # Token endpoint used for Claude OAuth refresh-token exchange.
368
+ CLAUDE_TOKEN_ENDPOINT = URI("https://claude.ai/oauth/token").freeze
369
+
370
+ def exchange_claude_refresh_token
371
+ credentials_path = claude_credentials_path
372
+ lock_path = "#{credentials_path}.lock"
373
+
374
+ dir = File.dirname(credentials_path)
375
+ FileUtils.mkdir_p(dir, mode: 0o700)
376
+
377
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
378
+ lock.flock(File::LOCK_EX)
379
+
380
+ credentials = read_claude_credentials
381
+ refresh_token = credentials&.dig("claudeAiOauth", "refreshToken")
382
+ refresh_token = nil if refresh_token.is_a?(String) && refresh_token.strip.empty?
383
+
384
+ unless refresh_token
385
+ raise AuthenticationError.new(
386
+ "No refresh token found in Claude credentials",
387
+ provider: :claude
388
+ )
389
+ end
390
+
391
+ response_body = post_token_exchange(refresh_token)
392
+
393
+ access_token = response_body["access_token"]
394
+ new_refresh_token = response_body["refresh_token"]
395
+ expires_in = response_body["expires_in"]
396
+
397
+ expires_at = expires_in ? (Time.now + expires_in).iso8601 : nil
398
+
399
+ oauth_block = {
400
+ "accessToken" => access_token,
401
+ "refreshToken" => new_refresh_token,
402
+ "expiresAt" => expires_at
403
+ }
404
+
405
+ credentials["claudeAiOauth"] = oauth_block
406
+ credentials["oauth_token"] = access_token
407
+ credentials.delete("expiresAt")
408
+ credentials.delete("expires_at")
409
+ credentials["expiresAt"] = expires_at
410
+
411
+ tmpfile = Tempfile.new(".credentials", dir)
412
+ begin
413
+ tmpfile.write(JSON.pretty_generate(credentials))
414
+ tmpfile.close
415
+ File.chmod(0o600, tmpfile.path)
416
+ File.rename(tmpfile.path, credentials_path)
417
+ rescue
418
+ tmpfile.close!
419
+ raise
420
+ end
421
+
422
+ {claudeAiOauth: oauth_block.transform_keys(&:to_sym)}
423
+ end
424
+ end
425
+
426
+ def exchange_claude_code(code:, code_verifier:, redirect_uri:, client_id:, state:)
427
+ credentials_path = claude_credentials_path
428
+ lock_path = "#{credentials_path}.lock"
429
+
430
+ dir = File.dirname(credentials_path)
431
+ FileUtils.mkdir_p(dir, mode: 0o700)
432
+
433
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
434
+ lock.flock(File::LOCK_EX)
435
+
436
+ response_body = post_code_exchange(
437
+ code: code,
438
+ code_verifier: code_verifier,
439
+ redirect_uri: redirect_uri,
440
+ client_id: client_id,
441
+ state: state
442
+ )
443
+
444
+ access_token = response_body["access_token"]
445
+ new_refresh_token = response_body["refresh_token"]
446
+ expires_in = response_body["expires_in"]
447
+
448
+ unless non_blank?(access_token)
449
+ raise AuthenticationError.new(
450
+ "Code exchange response did not include an access_token",
451
+ provider: :claude
452
+ )
453
+ end
454
+
455
+ expires_at = expires_in ? (Time.now + expires_in).iso8601 : nil
456
+
457
+ oauth_block = {
458
+ "accessToken" => access_token,
459
+ "refreshToken" => new_refresh_token,
460
+ "expiresAt" => expires_at
461
+ }
462
+
463
+ credentials = read_claude_credentials
464
+ credentials = {} unless credentials.is_a?(Hash)
465
+ credentials["claudeAiOauth"] = oauth_block
466
+ credentials["oauth_token"] = access_token
467
+ credentials.delete("expires_at")
468
+ credentials["expiresAt"] = expires_at
469
+
470
+ tmpfile = Tempfile.new(".credentials", dir)
471
+ begin
472
+ tmpfile.write(JSON.pretty_generate(credentials))
473
+ tmpfile.close
474
+ File.chmod(0o600, tmpfile.path)
475
+ File.rename(tmpfile.path, credentials_path)
476
+ rescue
477
+ tmpfile.close!
478
+ raise
479
+ end
480
+
481
+ {claudeAiOauth: oauth_block.transform_keys(&:to_sym)}
482
+ end
483
+ end
484
+
485
+ def post_code_exchange(code:, code_verifier:, redirect_uri:, client_id:, state:)
486
+ payload = {
487
+ grant_type: "authorization_code",
488
+ code: code,
489
+ code_verifier: code_verifier,
490
+ redirect_uri: redirect_uri,
491
+ client_id: client_id
492
+ }
493
+ payload[:state] = state if non_blank?(state)
494
+
495
+ http = Net::HTTP.new(CLAUDE_TOKEN_ENDPOINT.host, CLAUDE_TOKEN_ENDPOINT.port)
496
+ http.use_ssl = true
497
+ http.open_timeout = 10
498
+ http.read_timeout = 10
499
+
500
+ request = Net::HTTP::Post.new(CLAUDE_TOKEN_ENDPOINT.path)
501
+ request["Content-Type"] = "application/json"
502
+ request.body = JSON.generate(payload)
503
+
504
+ response = http.request(request)
505
+
506
+ body = begin
507
+ JSON.parse(response.body)
508
+ rescue JSON::ParserError
509
+ {}
510
+ end
511
+
512
+ unless response.is_a?(Net::HTTPSuccess)
513
+ error_description = body["error_description"] || body["error"] || body["message"] || response.body
514
+ raise AuthenticationError.new(
515
+ "Code exchange failed (HTTP #{response.code}): #{error_description}",
516
+ provider: :claude
517
+ )
518
+ end
519
+
520
+ body
521
+ end
522
+
523
+ def post_token_exchange(refresh_token)
524
+ http = Net::HTTP.new(CLAUDE_TOKEN_ENDPOINT.host, CLAUDE_TOKEN_ENDPOINT.port)
525
+ http.use_ssl = true
526
+ http.open_timeout = 10
527
+ http.read_timeout = 10
528
+
529
+ request = Net::HTTP::Post.new(CLAUDE_TOKEN_ENDPOINT.path)
530
+ request["Content-Type"] = "application/json"
531
+ request.body = JSON.generate({
532
+ grant_type: "refresh_token",
533
+ refresh_token: refresh_token
534
+ })
535
+
536
+ response = http.request(request)
537
+
538
+ body = begin
539
+ JSON.parse(response.body)
540
+ rescue JSON::ParserError
541
+ {}
542
+ end
543
+
544
+ unless response.is_a?(Net::HTTPSuccess)
545
+ error_code = body["error"] || body["code"]
546
+ if error_code.to_s.match?(/refresh_token_reused/i)
547
+ raise AuthenticationError.new(
548
+ "Refresh token has already been used (refresh_token_reused). Re-authentication required.",
549
+ provider: :claude
550
+ )
551
+ end
552
+
553
+ error_description = body["error_description"] || body["message"] || response.body
554
+ raise AuthenticationError.new(
555
+ "Token exchange failed (HTTP #{response.code}): #{error_description}",
556
+ provider: :claude
557
+ )
558
+ end
559
+
560
+ body
561
+ end
562
+
254
563
  # Extract a usable token and expiry from Claude credentials,
255
564
  # supporting both the native claudeAiOauth shape and the legacy
256
565
  # 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.26.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.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan