agent-harness 0.25.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: 1afbed6f9dcafe6c80575c77a128ba73b407f4cd691bd45ce19bfa48b613ccb2
4
- data.tar.gz: b158dd410320320093c897795839b20eedea35383784692c0038893dca163123
3
+ metadata.gz: d5561c80c0a4ccab10925503a30bb20be2c24c929caa58b9d69a4aac2eb27aac
4
+ data.tar.gz: aae45d0a30105fed84eb442bdbeaaea0c0bc2fc995997bec49bccbed61db4249
5
5
  SHA512:
6
- metadata.gz: fb7e2eda150529ba550829380171bc432bbe5025de3ad8d7c5f5799354b8a4a1e6209a788ef2fbc9ca90898d24ee4b1769f4f693619d75d90bd47b2021edc12a
7
- data.tar.gz: c55f52bf3498750daf7bc4919d1100705e8b5fcb1f8c540852022cd852a28988654cf84771cae0d3bdf08a9f8a7648e7a2514234b5fc06d70e6a54b159ce1ab6
6
+ metadata.gz: 00eb3ec6f37bbdda7bd430abf55eb09f4f6f79f86b565256c7b287cc2f0b9ef840f108b00a33ba84d457d689784772bb4d307191e08e0aae5941247739ceb651
7
+ data.tar.gz: 51efc497bd800e2d2ef31886ddeb65c502242dee45cfb40947d35f904f76e9ad215bf57520a779bd42a69e1b04759242c37c6b30a6b98b5a75707c759cd66e2a
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.25.0"
2
+ ".": "0.26.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -3,8 +3,16 @@
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
+
8
16
  ## [0.25.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.24.0...agent-harness/v0.25.0) (2026-06-26)
9
17
 
10
18
 
@@ -52,7 +52,8 @@ module AgentHarness
52
52
  auth_type: provider.auth_type,
53
53
  auth_url: flow_supported,
54
54
  refresh: flow_supported,
55
- exchange: flow_supported
55
+ exchange: flow_supported,
56
+ code_exchange: flow_supported
56
57
  }
57
58
  end
58
59
 
@@ -172,8 +173,74 @@ module AgentHarness
172
173
  end
173
174
  end
174
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
+
175
233
  private
176
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
+
177
244
  def claude_oauth_flow_provider?(requested_name, canonical_name)
178
245
  [:claude, :anthropic].include?(requested_name) || canonical_name == :claude
179
246
  end
@@ -356,6 +423,103 @@ module AgentHarness
356
423
  end
357
424
  end
358
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
+
359
523
  def post_token_exchange(refresh_token)
360
524
  http = Net::HTTP.new(CLAUDE_TOKEN_ENDPOINT.host, CLAUDE_TOKEN_ENDPOINT.port)
361
525
  http.use_ssl = true
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.25.0"
4
+ VERSION = "0.26.0"
5
5
  end
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.25.0
4
+ version: 0.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan