agent-harness 0.23.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 +14 -0
- data/lib/agent_harness/authentication.rb +191 -10
- 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,20 @@
|
|
|
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
|
+
|
|
15
|
+
## [0.24.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.23.0...agent-harness/v0.24.0) (2026-06-26)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **auth:** parse native Claude claudeAiOauth credentials shape ([#268](https://github.com/viamin/agent-harness/issues/268)) ([178edae](https://github.com/viamin/agent-harness/commit/178edae21e175c4744b75a151acfc6df3b4b3c5d)), closes [#264](https://github.com/viamin/agent-harness/issues/264)
|
|
21
|
+
|
|
8
22
|
## [0.23.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.22.5...agent-harness/v0.23.0) (2026-06-15)
|
|
9
23
|
|
|
10
24
|
|
|
@@ -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)
|
|
@@ -163,12 +209,8 @@ module AgentHarness
|
|
|
163
209
|
credentials = read_claude_credentials
|
|
164
210
|
return {valid: false, expires_at: nil, error: "No credentials found"} unless credentials
|
|
165
211
|
|
|
166
|
-
|
|
167
|
-
oauth_token = credentials["oauth_token"]
|
|
168
|
-
api_key = credentials["apiKey"]
|
|
169
|
-
token = [oauth_token, api_key].find { |t| t.is_a?(String) && !t.strip.empty? }
|
|
212
|
+
token, expires_at = extract_claude_token(credentials)
|
|
170
213
|
if token
|
|
171
|
-
expires_at = parse_expiry(credentials["expiresAt"] || credentials["expires_at"])
|
|
172
214
|
if expires_at && expires_at < Time.now
|
|
173
215
|
{valid: false, expires_at: expires_at, error: "Session expired"}
|
|
174
216
|
else
|
|
@@ -224,10 +266,20 @@ module AgentHarness
|
|
|
224
266
|
|
|
225
267
|
credentials = read_claude_credentials
|
|
226
268
|
credentials = {} unless credentials.is_a?(Hash)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
269
|
+
|
|
270
|
+
if credentials.key?("claudeAiOauth")
|
|
271
|
+
# Preserve the native claudeAiOauth shape
|
|
272
|
+
oauth = credentials["claudeAiOauth"]
|
|
273
|
+
oauth = {} unless oauth.is_a?(Hash)
|
|
274
|
+
oauth["accessToken"] = token.strip
|
|
275
|
+
oauth.delete("expiresAt")
|
|
276
|
+
credentials["claudeAiOauth"] = oauth
|
|
277
|
+
else
|
|
278
|
+
credentials["oauth_token"] = token.strip
|
|
279
|
+
# Clear any existing expiry metadata so refreshed tokens are not treated as expired
|
|
280
|
+
credentials.delete("expiresAt")
|
|
281
|
+
credentials.delete("expires_at")
|
|
282
|
+
end
|
|
231
283
|
|
|
232
284
|
# Write under a file lock using tempfile + rename to avoid corruption and lost updates on concurrent refreshes
|
|
233
285
|
tmpfile = Tempfile.new(".credentials", dir)
|
|
@@ -245,6 +297,135 @@ module AgentHarness
|
|
|
245
297
|
{success: true}
|
|
246
298
|
end
|
|
247
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
|
+
|
|
399
|
+
# Extract a usable token and expiry from Claude credentials,
|
|
400
|
+
# supporting both the native claudeAiOauth shape and the legacy
|
|
401
|
+
# top-level oauth_token/apiKey shape.
|
|
402
|
+
#
|
|
403
|
+
# @return [Array(String, Time)] token and parsed expiry, or nils
|
|
404
|
+
def extract_claude_token(credentials)
|
|
405
|
+
# Prefer the native claudeAiOauth nested shape written by the Claude CLI
|
|
406
|
+
if credentials.key?("claudeAiOauth")
|
|
407
|
+
oauth = credentials["claudeAiOauth"]
|
|
408
|
+
if oauth.is_a?(Hash)
|
|
409
|
+
access_token = oauth["accessToken"]
|
|
410
|
+
if non_blank?(access_token)
|
|
411
|
+
expires_at = parse_expiry(oauth["expiresAt"])
|
|
412
|
+
return [access_token, expires_at]
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Fall back to the legacy top-level shape
|
|
418
|
+
oauth_token = credentials["oauth_token"]
|
|
419
|
+
api_key = credentials["apiKey"]
|
|
420
|
+
token = [oauth_token, api_key].find { |t| non_blank?(t) }
|
|
421
|
+
expires_at = parse_expiry(credentials["expiresAt"] || credentials["expires_at"]) if token
|
|
422
|
+
[token, expires_at]
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def non_blank?(value)
|
|
426
|
+
value.is_a?(String) && !value.strip.empty?
|
|
427
|
+
end
|
|
428
|
+
|
|
248
429
|
def read_claude_credentials
|
|
249
430
|
path = claude_credentials_path
|
|
250
431
|
return nil unless File.exist?(path)
|
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,
|