agent-harness 0.25.0 → 0.27.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: c88e49fef781a9b839029456b728f0f0bc99f0678a507084e044d9583d6a1220
4
+ data.tar.gz: 55d80c399dca1371e95346ac51aa2b83523a6c18a075f325d1eedf6f75e2839f
5
5
  SHA512:
6
- metadata.gz: fb7e2eda150529ba550829380171bc432bbe5025de3ad8d7c5f5799354b8a4a1e6209a788ef2fbc9ca90898d24ee4b1769f4f693619d75d90bd47b2021edc12a
7
- data.tar.gz: c55f52bf3498750daf7bc4919d1100705e8b5fcb1f8c540852022cd852a28988654cf84771cae0d3bdf08a9f8a7648e7a2514234b5fc06d70e6a54b159ce1ab6
6
+ metadata.gz: 4f2b742d3b20a8499cff4480e89e6d0f519626de0c752a8a837af33f2a3bacd65d1a4dfaff499846964c128c240176c1f70824d00e89cfc653bc1d1d4eb79ff5
7
+ data.tar.gz: 73d8251bc434bc664486759fa84f07e6b27eaa693bc16d98e48522b09b732c3053f3f5e3682aa25980d5cd7b622c247861a01d8ae2a4a50d40d3da9e589be9c8
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.25.0"
2
+ ".": "0.27.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.27.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.26.0...agent-harness/v0.27.0) (2026-06-27)
10
+
11
+
12
+ ### Features
13
+
14
+ * Authentication: Claude OAuth PKCE code-exchange API ([#267](https://github.com/viamin/agent-harness/issues/267)) ([7cabc73](https://github.com/viamin/agent-harness/commit/7cabc73f2a660e292533175e9db3a94d18326031))
15
+
16
+ ## [0.26.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.25.0...agent-harness/v0.26.0) (2026-06-26)
17
+
18
+
19
+ ### Features
20
+
21
+ * **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)
22
+
8
23
  ## [0.25.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.24.0...agent-harness/v0.25.0) (2026-06-26)
9
24
 
10
25
 
@@ -51,6 +51,7 @@ module AgentHarness
51
51
  {
52
52
  auth_type: provider.auth_type,
53
53
  auth_url: flow_supported,
54
+ exchange_code: flow_supported,
54
55
  refresh: flow_supported,
55
56
  exchange: flow_supported
56
57
  }
@@ -90,6 +91,45 @@ module AgentHarness
90
91
  end
91
92
  end
92
93
 
94
+ # Check whether PKCE code exchange is supported for a provider.
95
+ #
96
+ # @param provider_name [Symbol] the provider name
97
+ # @return [Boolean] true if exchange_code can be called for the provider
98
+ # @raise [ProviderNotFoundError] if provider is unknown
99
+ def exchange_code_supported?(provider_name)
100
+ auth_capabilities(provider_name)[:exchange_code]
101
+ end
102
+
103
+ # Exchange an OAuth authorization code for tokens using PKCE.
104
+ #
105
+ # Performs the code→token exchange with the provider's token endpoint
106
+ # and stores the resulting credentials in native shape.
107
+ #
108
+ # @param provider_name [Symbol] the provider name
109
+ # @param code [String] the authorization code from the OAuth redirect
110
+ # @param code_verifier [String] the PKCE code verifier used when generating the auth URL
111
+ # @return [Hash] result with :success and :credentials keys
112
+ # @raise [UnsupportedAuthFlowError] if provider doesn't support code exchange
113
+ # @raise [ArgumentError] if code or code_verifier are blank
114
+ # @raise [AuthenticationError] if the token exchange request fails
115
+ def exchange_code(provider_name, code:, code_verifier:)
116
+ provider_name = provider_name.to_sym
117
+ provider = resolve_provider(provider_name)
118
+
119
+ unless provider.auth_type == :oauth
120
+ raise UnsupportedAuthFlowError,
121
+ "Provider #{provider_name} uses #{provider.auth_type} auth and does not support PKCE code exchange"
122
+ end
123
+
124
+ case provider_name
125
+ when :claude, :anthropic
126
+ exchange_claude_code(code: code, code_verifier: code_verifier)
127
+ else
128
+ raise UnsupportedAuthFlowError,
129
+ "PKCE code exchange is not yet implemented for provider #{provider_name}"
130
+ end
131
+ end
132
+
93
133
  # Check whether credential refresh is supported for a provider.
94
134
  #
95
135
  # @param provider_name [Symbol] the provider name
@@ -245,6 +285,126 @@ module AgentHarness
245
285
  "https://claude.ai/oauth/authorize"
246
286
  end
247
287
 
288
+ def claude_token_url
289
+ "https://claude.ai/oauth/token"
290
+ end
291
+
292
+ # Public OAuth client_id for the Claude Code CLI. This value is the
293
+ # well-known public client identifier used by the Claude Code CLI's
294
+ # PKCE login flow; callers building the auth_url must use the same
295
+ # client_id so the token exchange succeeds.
296
+ def claude_oauth_client_id
297
+ "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
298
+ end
299
+
300
+ # Redirect URI registered for the Claude Code CLI OAuth client. Must
301
+ # match the redirect_uri used in the authorization request — per RFC
302
+ # 6749 §4.1.3 the token endpoint validates that they are identical.
303
+ def claude_oauth_redirect_uri
304
+ "https://console.anthropic.com/oauth/code/callback"
305
+ end
306
+
307
+ def exchange_claude_code(code:, code_verifier:)
308
+ raise ArgumentError, "code must be a non-empty string" unless code.is_a?(String) && !code.strip.empty?
309
+ raise ArgumentError, "code_verifier must be a non-empty string" unless code_verifier.is_a?(String) && !code_verifier.strip.empty?
310
+
311
+ uri = URI.parse(claude_token_url)
312
+ request = Net::HTTP::Post.new(uri)
313
+ request.content_type = "application/json"
314
+ # Include client_id and redirect_uri per RFC 6749 §4.1.3: the token
315
+ # endpoint requires client_id for public clients and redirect_uri
316
+ # when one was sent in the authorization request. Callers using
317
+ # claude_auth_url are expected to build the authorization request
318
+ # with these same values.
319
+ request.body = JSON.generate({
320
+ grant_type: "authorization_code",
321
+ client_id: claude_oauth_client_id,
322
+ redirect_uri: claude_oauth_redirect_uri,
323
+ code: code.strip,
324
+ code_verifier: code_verifier.strip
325
+ })
326
+
327
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
328
+ http.open_timeout = 10
329
+ http.read_timeout = 30
330
+ http.request(request)
331
+ end
332
+
333
+ unless response.is_a?(Net::HTTPSuccess)
334
+ error_body = begin
335
+ JSON.parse(response.body)
336
+ rescue JSON::ParserError
337
+ {"error" => response.body}
338
+ end
339
+ raise AuthenticationError.new(
340
+ "PKCE code exchange failed (HTTP #{response.code}): #{error_body["error"] || error_body["error_description"] || response.body}",
341
+ provider: :claude
342
+ )
343
+ end
344
+
345
+ token_data = JSON.parse(response.body)
346
+
347
+ unless token_data["access_token"].is_a?(String) && !token_data["access_token"].strip.empty?
348
+ raise AuthenticationError.new(
349
+ "PKCE code exchange returned no access_token",
350
+ provider: :claude
351
+ )
352
+ end
353
+
354
+ store_claude_token_response(token_data)
355
+ end
356
+
357
+ def store_claude_token_response(token_data)
358
+ credentials_path = claude_credentials_path
359
+ dir = File.dirname(credentials_path)
360
+ FileUtils.mkdir_p(dir, mode: 0o700)
361
+
362
+ lock_path = "#{credentials_path}.lock"
363
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
364
+ lock.flock(File::LOCK_EX)
365
+
366
+ credentials = read_claude_credentials
367
+ credentials = {} unless credentials.is_a?(Hash)
368
+
369
+ if credentials.key?("claudeAiOauth")
370
+ # Preserve the native claudeAiOauth shape so extract_claude_token
371
+ # picks up the newly exchanged token instead of a stale nested value.
372
+ oauth = credentials["claudeAiOauth"]
373
+ oauth = {} unless oauth.is_a?(Hash)
374
+ oauth["accessToken"] = token_data["access_token"]
375
+ oauth["refreshToken"] = token_data["refresh_token"] if token_data["refresh_token"]
376
+ if token_data["expires_in"]
377
+ oauth["expiresAt"] = (Time.now + token_data["expires_in"].to_i).iso8601
378
+ else
379
+ oauth.delete("expiresAt")
380
+ end
381
+ credentials["claudeAiOauth"] = oauth
382
+ else
383
+ credentials["oauth_token"] = token_data["access_token"]
384
+ credentials["refreshToken"] = token_data["refresh_token"] if token_data["refresh_token"]
385
+ if token_data["expires_in"]
386
+ credentials["expiresAt"] = (Time.now + token_data["expires_in"].to_i).iso8601
387
+ else
388
+ credentials.delete("expiresAt")
389
+ credentials.delete("expires_at")
390
+ end
391
+ end
392
+
393
+ tmpfile = Tempfile.new(".credentials", dir)
394
+ begin
395
+ tmpfile.write(JSON.pretty_generate(credentials))
396
+ tmpfile.close
397
+ File.chmod(0o600, tmpfile.path)
398
+ File.rename(tmpfile.path, credentials_path)
399
+ rescue
400
+ tmpfile.close!
401
+ raise
402
+ end
403
+ end
404
+
405
+ {success: true, credentials: token_data}
406
+ end
407
+
248
408
  def provider_config_for(requested_name, canonical_name:)
249
409
  requested_key = requested_name.to_sym
250
410
  canonical_key = canonical_name.to_sym
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.25.0"
4
+ VERSION = "0.27.0"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -322,6 +322,24 @@ module AgentHarness
322
322
  Authentication.auth_url(provider_name)
323
323
  end
324
324
 
325
+ # Check whether PKCE code exchange is supported for a provider
326
+ # @param provider_name [Symbol] the provider name
327
+ # @return [Boolean] true if exchange_code can be called for the provider
328
+ # @raise [ProviderNotFoundError] if provider is unknown
329
+ def exchange_code_supported?(provider_name)
330
+ Authentication.exchange_code_supported?(provider_name)
331
+ end
332
+
333
+ # Exchange an OAuth authorization code for tokens using PKCE
334
+ # @param provider_name [Symbol] the provider name
335
+ # @param code [String] the authorization code
336
+ # @param code_verifier [String] the PKCE code verifier
337
+ # @return [Hash] result with :success and :credentials keys
338
+ # @raise [UnsupportedAuthFlowError] if provider doesn't support code exchange
339
+ def exchange_code(provider_name, code:, code_verifier:)
340
+ Authentication.exchange_code(provider_name, code: code, code_verifier: code_verifier)
341
+ end
342
+
325
343
  # Check whether credential refresh is supported for a provider
326
344
  # @param provider_name [Symbol] the provider name
327
345
  # @return [Boolean] true if refresh_auth can be called for the provider
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.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan