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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +15 -0
- data/lib/agent_harness/authentication.rb +310 -1
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +17 -0
- 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,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.
|
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,
|