agent-harness 0.24.0 → 0.25.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 +7 -0
- data/lib/agent_harness/authentication.rb +146 -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: 1afbed6f9dcafe6c80575c77a128ba73b407f4cd691bd45ce19bfa48b613ccb2
|
|
4
|
+
data.tar.gz: b158dd410320320093c897795839b20eedea35383784692c0038893dca163123
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fb7e2eda150529ba550829380171bc432bbe5025de3ad8d7c5f5799354b8a4a1e6209a788ef2fbc9ca90898d24ee4b1769f4f693619d75d90bd47b2021edc12a
|
|
7
|
+
data.tar.gz: c55f52bf3498750daf7bc4919d1100705e8b5fcb1f8c540852022cd852a28988654cf84771cae0d3bdf08a9f8a7648e7a2514234b5fc06d70e6a54b159ce1ab6
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
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
6
|
|
|
7
7
|
|
|
8
|
+
## [0.25.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.24.0...agent-harness/v0.25.0) (2026-06-26)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **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)
|
|
14
|
+
|
|
8
15
|
## [0.24.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.23.0...agent-harness/v0.24.0) (2026-06-26)
|
|
9
16
|
|
|
10
17
|
|
|
@@ -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,8 @@ 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
|
|
53
56
|
}
|
|
54
57
|
end
|
|
55
58
|
|
|
@@ -126,6 +129,49 @@ module AgentHarness
|
|
|
126
129
|
end
|
|
127
130
|
end
|
|
128
131
|
|
|
132
|
+
# Check whether refresh-token exchange is supported for a provider.
|
|
133
|
+
#
|
|
134
|
+
# @param provider_name [Symbol] the provider name
|
|
135
|
+
# @return [Boolean] true if exchange_refresh_token can be called
|
|
136
|
+
# @raise [ProviderNotFoundError] if provider is unknown
|
|
137
|
+
def exchange_refresh_token_supported?(provider_name)
|
|
138
|
+
auth_capabilities(provider_name)[:exchange]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Exchange a stored refresh token for a fresh access token (and rotated refresh token).
|
|
142
|
+
#
|
|
143
|
+
# Reads the refresh token from the provider's credentials store, posts it to the
|
|
144
|
+
# OAuth token endpoint, persists the rotated tokens, and returns the credential
|
|
145
|
+
# in native claudeAiOauth shape.
|
|
146
|
+
#
|
|
147
|
+
# Serializes through a file lock so that concurrent callers do not race on a
|
|
148
|
+
# single-use/rotating refresh token. If the token server reports that the
|
|
149
|
+
# refresh token has already been consumed (`refresh_token_reused`), raises
|
|
150
|
+
# +AuthenticationError+ so the caller can trigger a full re-auth.
|
|
151
|
+
#
|
|
152
|
+
# @param provider_name [Symbol] the provider name
|
|
153
|
+
# @return [Hash] credential in claudeAiOauth shape:
|
|
154
|
+
# +{ claudeAiOauth: { accessToken:, refreshToken:, expiresAt: } }+
|
|
155
|
+
# @raise [UnsupportedAuthFlowError] if provider doesn't support token exchange
|
|
156
|
+
# @raise [AuthenticationError] if the refresh token is missing, reused, or exchange fails
|
|
157
|
+
def exchange_refresh_token(provider_name)
|
|
158
|
+
provider_name = provider_name.to_sym
|
|
159
|
+
provider = resolve_provider(provider_name)
|
|
160
|
+
|
|
161
|
+
unless provider.auth_type == :oauth
|
|
162
|
+
raise UnsupportedAuthFlowError,
|
|
163
|
+
"Provider #{provider_name} uses #{provider.auth_type} auth and does not support token exchange"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
case provider_name
|
|
167
|
+
when :claude, :anthropic
|
|
168
|
+
exchange_claude_refresh_token
|
|
169
|
+
else
|
|
170
|
+
raise UnsupportedAuthFlowError,
|
|
171
|
+
"Token exchange is not yet implemented for provider #{provider_name}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
129
175
|
private
|
|
130
176
|
|
|
131
177
|
def claude_oauth_flow_provider?(requested_name, canonical_name)
|
|
@@ -251,6 +297,105 @@ module AgentHarness
|
|
|
251
297
|
{success: true}
|
|
252
298
|
end
|
|
253
299
|
|
|
300
|
+
# Token endpoint used for Claude OAuth refresh-token exchange.
|
|
301
|
+
CLAUDE_TOKEN_ENDPOINT = URI("https://claude.ai/oauth/token").freeze
|
|
302
|
+
|
|
303
|
+
def exchange_claude_refresh_token
|
|
304
|
+
credentials_path = claude_credentials_path
|
|
305
|
+
lock_path = "#{credentials_path}.lock"
|
|
306
|
+
|
|
307
|
+
dir = File.dirname(credentials_path)
|
|
308
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
309
|
+
|
|
310
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
311
|
+
lock.flock(File::LOCK_EX)
|
|
312
|
+
|
|
313
|
+
credentials = read_claude_credentials
|
|
314
|
+
refresh_token = credentials&.dig("claudeAiOauth", "refreshToken")
|
|
315
|
+
refresh_token = nil if refresh_token.is_a?(String) && refresh_token.strip.empty?
|
|
316
|
+
|
|
317
|
+
unless refresh_token
|
|
318
|
+
raise AuthenticationError.new(
|
|
319
|
+
"No refresh token found in Claude credentials",
|
|
320
|
+
provider: :claude
|
|
321
|
+
)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
response_body = post_token_exchange(refresh_token)
|
|
325
|
+
|
|
326
|
+
access_token = response_body["access_token"]
|
|
327
|
+
new_refresh_token = response_body["refresh_token"]
|
|
328
|
+
expires_in = response_body["expires_in"]
|
|
329
|
+
|
|
330
|
+
expires_at = expires_in ? (Time.now + expires_in).iso8601 : nil
|
|
331
|
+
|
|
332
|
+
oauth_block = {
|
|
333
|
+
"accessToken" => access_token,
|
|
334
|
+
"refreshToken" => new_refresh_token,
|
|
335
|
+
"expiresAt" => expires_at
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
credentials["claudeAiOauth"] = oauth_block
|
|
339
|
+
credentials["oauth_token"] = access_token
|
|
340
|
+
credentials.delete("expiresAt")
|
|
341
|
+
credentials.delete("expires_at")
|
|
342
|
+
credentials["expiresAt"] = expires_at
|
|
343
|
+
|
|
344
|
+
tmpfile = Tempfile.new(".credentials", dir)
|
|
345
|
+
begin
|
|
346
|
+
tmpfile.write(JSON.pretty_generate(credentials))
|
|
347
|
+
tmpfile.close
|
|
348
|
+
File.chmod(0o600, tmpfile.path)
|
|
349
|
+
File.rename(tmpfile.path, credentials_path)
|
|
350
|
+
rescue
|
|
351
|
+
tmpfile.close!
|
|
352
|
+
raise
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
{claudeAiOauth: oauth_block.transform_keys(&:to_sym)}
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def post_token_exchange(refresh_token)
|
|
360
|
+
http = Net::HTTP.new(CLAUDE_TOKEN_ENDPOINT.host, CLAUDE_TOKEN_ENDPOINT.port)
|
|
361
|
+
http.use_ssl = true
|
|
362
|
+
http.open_timeout = 10
|
|
363
|
+
http.read_timeout = 10
|
|
364
|
+
|
|
365
|
+
request = Net::HTTP::Post.new(CLAUDE_TOKEN_ENDPOINT.path)
|
|
366
|
+
request["Content-Type"] = "application/json"
|
|
367
|
+
request.body = JSON.generate({
|
|
368
|
+
grant_type: "refresh_token",
|
|
369
|
+
refresh_token: refresh_token
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
response = http.request(request)
|
|
373
|
+
|
|
374
|
+
body = begin
|
|
375
|
+
JSON.parse(response.body)
|
|
376
|
+
rescue JSON::ParserError
|
|
377
|
+
{}
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
381
|
+
error_code = body["error"] || body["code"]
|
|
382
|
+
if error_code.to_s.match?(/refresh_token_reused/i)
|
|
383
|
+
raise AuthenticationError.new(
|
|
384
|
+
"Refresh token has already been used (refresh_token_reused). Re-authentication required.",
|
|
385
|
+
provider: :claude
|
|
386
|
+
)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
error_description = body["error_description"] || body["message"] || response.body
|
|
390
|
+
raise AuthenticationError.new(
|
|
391
|
+
"Token exchange failed (HTTP #{response.code}): #{error_description}",
|
|
392
|
+
provider: :claude
|
|
393
|
+
)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
body
|
|
397
|
+
end
|
|
398
|
+
|
|
254
399
|
# Extract a usable token and expiry from Claude credentials,
|
|
255
400
|
# supporting both the native claudeAiOauth shape and the legacy
|
|
256
401
|
# 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,
|