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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +15 -0
- data/lib/agent_harness/authentication.rb +160 -0
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +18 -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: c88e49fef781a9b839029456b728f0f0bc99f0678a507084e044d9583d6a1220
|
|
4
|
+
data.tar.gz: 55d80c399dca1371e95346ac51aa2b83523a6c18a075f325d1eedf6f75e2839f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4f2b742d3b20a8499cff4480e89e6d0f519626de0c752a8a837af33f2a3bacd65d1a4dfaff499846964c128c240176c1f70824d00e89cfc653bc1d1d4eb79ff5
|
|
7
|
+
data.tar.gz: 73d8251bc434bc664486759fa84f07e6b27eaa693bc16d98e48522b09b732c3053f3f5e3682aa25980d5cd7b622c247861a01d8ae2a4a50d40d3da9e589be9c8
|
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
|
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
|