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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +8 -0
- data/lib/agent_harness/authentication.rb +165 -1
- data/lib/agent_harness/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5561c80c0a4ccab10925503a30bb20be2c24c929caa58b9d69a4aac2eb27aac
|
|
4
|
+
data.tar.gz: aae45d0a30105fed84eb442bdbeaaea0c0bc2fc995997bec49bccbed61db4249
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 00eb3ec6f37bbdda7bd430abf55eb09f4f6f79f86b565256c7b287cc2f0b9ef840f108b00a33ba84d457d689784772bb4d307191e08e0aae5941247739ceb651
|
|
7
|
+
data.tar.gz: 51efc497bd800e2d2ef31886ddeb65c502242dee45cfb40947d35f904f76e9ad215bf57520a779bd42a69e1b04759242c37c6b30a6b98b5a75707c759cd66e2a
|
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
|