agent-harness 0.5.7 → 0.5.9

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.
@@ -6,6 +6,17 @@ module AgentHarness
6
6
  #
7
7
  # Provides integration with the Aider CLI tool.
8
8
  class Aider < Base
9
+ UV_VERSION = "0.8.17"
10
+ SUPPORTED_CLI_VERSION = "0.86.2"
11
+ SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 0.87.0").freeze
12
+ PYTHON_VERSION = "python3.12"
13
+ BINARY_PATH = "/usr/local/bin/aider"
14
+ UV_TOOL_ENV = {
15
+ "UV_TOOL_BIN_DIR" => "/usr/local/bin",
16
+ "UV_TOOL_DIR" => "/opt/uv/tools",
17
+ "UV_PYTHON_INSTALL_DIR" => "/opt/uv/python"
18
+ }.freeze
19
+
9
20
  class << self
10
21
  def provider_name
11
22
  :aider
@@ -50,6 +61,66 @@ module AgentHarness
50
61
  ]
51
62
  end
52
63
 
64
+ def installation_contract(version: SUPPORTED_CLI_VERSION)
65
+ version = version.strip if version.respond_to?(:strip)
66
+
67
+ unless version.is_a?(String) && !version.empty?
68
+ raise ArgumentError,
69
+ "Unsupported Aider CLI version #{version.inspect}; " \
70
+ "supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
71
+ end
72
+
73
+ parsed_version = begin
74
+ Gem::Version.new(version)
75
+ rescue ArgumentError
76
+ raise ArgumentError,
77
+ "Unsupported Aider CLI version #{version.inspect}; " \
78
+ "supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
79
+ end
80
+
81
+ unless SUPPORTED_CLI_REQUIREMENT.satisfied_by?(parsed_version)
82
+ raise ArgumentError,
83
+ "Unsupported Aider CLI version #{version.inspect}; " \
84
+ "supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
85
+ end
86
+
87
+ default_package = "aider-chat==#{version}".freeze
88
+ bootstrap_command = [
89
+ "python3", "-m", "pip", "install", "--no-cache-dir", "--break-system-packages", "uv==#{UV_VERSION}"
90
+ ].freeze
91
+ install_command_prefix = [
92
+ "uv", "tool", "install", "--force", "--python", PYTHON_VERSION, "--with", "pip"
93
+ ].freeze
94
+ install_command = (install_command_prefix + [default_package]).freeze
95
+ supported_versions = [version].freeze
96
+ version_requirement = SUPPORTED_CLI_REQUIREMENT.requirements
97
+ .map { |op, ver| "#{op} #{ver}".freeze }
98
+ .freeze
99
+
100
+ contract = {
101
+ source: :uv_tool,
102
+ bootstrap_source: :pip,
103
+ bootstrap_package: "uv==#{UV_VERSION}",
104
+ bootstrap_commands: [bootstrap_command].freeze,
105
+ install_environment: UV_TOOL_ENV,
106
+ package: default_package,
107
+ package_name: "aider-chat",
108
+ version: version,
109
+ version_format: "%{package_name}==%{version}",
110
+ version_requirement: version_requirement,
111
+ binary_name: binary_name,
112
+ binary_path: BINARY_PATH,
113
+ install_command_prefix: install_command_prefix,
114
+ install_command: install_command,
115
+ supported_versions: supported_versions
116
+ }
117
+
118
+ contract.each_value do |value|
119
+ value.freeze if value.is_a?(String)
120
+ end
121
+ contract.freeze
122
+ end
123
+
53
124
  def smoke_test_contract
54
125
  Base::DEFAULT_SMOKE_TEST_CONTRACT
55
126
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "shellwords"
4
5
 
5
6
  module AgentHarness
6
7
  module Providers
@@ -15,6 +16,12 @@ module AgentHarness
15
16
  class Anthropic < Base
16
17
  # Model name pattern for Anthropic Claude models
17
18
  MODEL_PATTERN = /^claude-[\d.-]+-(?:opus|sonnet|haiku)(?:-\d{8})?$/i
19
+ SUPPORTED_CLI_VERSION = "2.1.92"
20
+ SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 2.2.0").freeze
21
+
22
+ # Matches semver (e.g. "2.1.92"), optional pre-release (e.g. "2.1.92-beta.1"),
23
+ # or channel tokens (e.g. "latest", "stable").
24
+ VALID_VERSION_PATTERN = /\A(?:\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?|latest|stable)\z/
18
25
 
19
26
  class << self
20
27
  def provider_name
@@ -25,11 +32,80 @@ module AgentHarness
25
32
  "claude"
26
33
  end
27
34
 
35
+ def install_contract(version: nil)
36
+ target_version = version.nil? ? SUPPORTED_CLI_VERSION : version
37
+ target_version = target_version.strip if target_version.respond_to?(:strip)
38
+ validate_version!(target_version)
39
+ version_requirement = SUPPORTED_CLI_REQUIREMENT.requirements
40
+ .map { |op, ver| "#{op} #{ver}" }
41
+ .join(", ")
42
+ channel_token = %w[latest stable].include?(target_version.to_s)
43
+
44
+ warning = "Review the downloaded installer before execution and verify any published checksum or signature metadata when available."
45
+ if channel_token
46
+ warning += " Channel '#{target_version}' is not pinned; the resolved version may fall " \
47
+ "outside the supported range (#{version_requirement}). Verify the installed version " \
48
+ "after installation."
49
+ end
50
+
51
+ {
52
+ provider: provider_name,
53
+ binary_name: binary_name,
54
+ binary_paths: [
55
+ "$HOME/.local/bin/claude",
56
+ binary_name
57
+ ],
58
+ install: {
59
+ strategy: :shell,
60
+ source: "official",
61
+ command: "tmp_script=$(mktemp) && trap 'rm -f \"$tmp_script\"' EXIT && curl -fsSL https://claude.ai/install.sh -o \"$tmp_script\" && bash \"$tmp_script\" #{Shellwords.shellescape(target_version)}",
62
+ warning: warning,
63
+ post_install_binary_path: "$HOME/.local/bin/claude",
64
+ # When a channel token is used, include the requirement so
65
+ # consumers can validate the installed version post-install.
66
+ version_not_pinned: channel_token
67
+ },
68
+ supported_versions: {
69
+ default: SUPPORTED_CLI_VERSION,
70
+ requirement: version_requirement,
71
+ channel: "stable"
72
+ },
73
+ runtime_contract: {
74
+ available_via: binary_name,
75
+ build_command: [
76
+ binary_name,
77
+ "--print",
78
+ "--output-format=json"
79
+ ],
80
+ required_features: [
81
+ "print_mode",
82
+ "json_output",
83
+ "mcp_config",
84
+ "mcp_list",
85
+ "dangerously_skip_permissions",
86
+ "models_list"
87
+ ]
88
+ }
89
+ }
90
+ end
91
+
28
92
  def available?
29
93
  executor = AgentHarness.configuration.command_executor
30
94
  !!executor.which(binary_name)
31
95
  end
32
96
 
97
+ def provider_metadata_overrides
98
+ {
99
+ auth: {
100
+ service: :anthropic,
101
+ api_family: :anthropic
102
+ },
103
+ identity: {
104
+ bot_usernames: %w[claude anthropic]
105
+ }
106
+ }
107
+ end
108
+
33
109
  def firewall_requirements
34
110
  {
35
111
  domains: [
@@ -87,6 +163,33 @@ module AgentHarness
87
163
 
88
164
  private
89
165
 
166
+ def validate_version!(version)
167
+ unless version.is_a?(String) && !version.strip.empty?
168
+ raise ArgumentError, "Invalid version: #{version.inspect}. " \
169
+ "Must be a semver string (e.g. '2.1.92'), optional pre-release suffix, or a channel token ('latest', 'stable')."
170
+ end
171
+
172
+ version_str = version.strip
173
+
174
+ unless VALID_VERSION_PATTERN.match?(version_str)
175
+ raise ArgumentError, "Invalid version: #{version.inspect}. " \
176
+ "Must be a semver string (e.g. '2.1.92'), optional pre-release suffix, or a channel token ('latest', 'stable')."
177
+ end
178
+
179
+ return if %w[latest stable].include?(version_str)
180
+
181
+ gem_version = begin
182
+ Gem::Version.new(version_str)
183
+ rescue ArgumentError
184
+ raise ArgumentError, "Invalid version: #{version.inspect}. " \
185
+ "Must be a semver string (e.g. '2.1.92'), optional pre-release suffix, or a channel token ('latest', 'stable')."
186
+ end
187
+ return if SUPPORTED_CLI_REQUIREMENT.satisfied_by?(gem_version)
188
+
189
+ raise ArgumentError, "Version #{version.inspect} is outside the supported range " \
190
+ "(#{SUPPORTED_CLI_REQUIREMENT}). Update SUPPORTED_CLI_REQUIREMENT before targeting this version."
191
+ end
192
+
90
193
  def parse_models_list(output)
91
194
  return [] if output.nil? || output.empty?
92
195
 
@@ -240,7 +343,7 @@ module AgentHarness
240
343
  rate_limited: [
241
344
  /rate.?limit/i,
242
345
  /too.?many.?requests/i,
243
- /429/,
346
+ /\b429\b/,
244
347
  /overloaded/i,
245
348
  /session.?limit/i
246
349
  ],
@@ -249,7 +352,7 @@ module AgentHarness
249
352
  /authentication.*error/i,
250
353
  /invalid.*api.*key/i,
251
354
  /unauthorized/i,
252
- /401/,
355
+ /\b401\b/,
253
356
  /session.*expired/i,
254
357
  /not.*logged.*in/i,
255
358
  /login.*required/i,
@@ -265,17 +368,17 @@ module AgentHarness
265
368
  /connection.*reset/i,
266
369
  /temporary.*error/i,
267
370
  /service.*unavailable/i,
268
- /503/,
269
- /502/,
270
- /504/
371
+ /\b503\b/,
372
+ /\b502\b/,
373
+ /\b504\b/
271
374
  ],
272
375
  permanent: [
273
376
  /invalid.*model/i,
274
377
  /unsupported.*operation/i,
275
378
  /not.*found/i,
276
- /404/,
379
+ /\b404\b/,
277
380
  /bad.*request/i,
278
- /400/,
381
+ /\b400\b/,
279
382
  /model.*deprecated/i,
280
383
  /end-of-life/i
281
384
  ]
@@ -41,7 +41,7 @@ module AgentHarness
41
41
  rate_limited: [
42
42
  /rate.?limit/i,
43
43
  /too.?many.?requests/i,
44
- /429/
44
+ /\b429\b/
45
45
  ],
46
46
  auth_expired: [
47
47
  /invalid.*api.*key/i,
@@ -57,8 +57,8 @@ module AgentHarness
57
57
  /timeout/i,
58
58
  /connection.*error/i,
59
59
  /service.*unavailable/i,
60
- /503/,
61
- /502/
60
+ /\b503\b/,
61
+ /\b502\b/
62
62
  ]
63
63
  }.tap { |patterns| patterns.each_value(&:freeze) }.freeze
64
64
 
@@ -98,6 +98,8 @@ module AgentHarness
98
98
  # @option options [ProviderRuntime, Hash, nil] :provider_runtime per-request
99
99
  # runtime overrides (model, base_url, api_provider, env, flags, metadata).
100
100
  # A plain Hash is automatically coerced into a ProviderRuntime.
101
+ # Providers can derive request-scoped execution preparation from this
102
+ # runtime to materialize config files or other bootstrap state.
101
103
  # @return [Response] the response
102
104
  def send_message(prompt:, **options)
103
105
  log_debug("send_message_start", prompt_length: prompt.length, options: options.keys)
@@ -111,6 +113,7 @@ module AgentHarness
111
113
 
112
114
  # Build command
113
115
  command = build_command(prompt, options)
116
+ preparation = build_execution_preparation(options)
114
117
 
115
118
  # Calculate timeout
116
119
  timeout = options[:timeout] || @config.timeout || default_timeout
@@ -121,6 +124,7 @@ module AgentHarness
121
124
  command,
122
125
  timeout: timeout,
123
126
  env: build_env(options),
127
+ preparation: preparation,
124
128
  **command_execution_options(options)
125
129
  )
126
130
  duration = Time.now - start_time
@@ -210,6 +214,17 @@ module AgentHarness
210
214
  env
211
215
  end
212
216
 
217
+ # Build structured runtime preparation for the executor.
218
+ #
219
+ # Provider subclasses can override to request request-scoped file writes
220
+ # or other bootstrap work without shell-wrapping the main command.
221
+ #
222
+ # @param options [Hash] options
223
+ # @return [ExecutionPreparation, nil] preparation contract or nil
224
+ def build_execution_preparation(options)
225
+ nil
226
+ end
227
+
213
228
  # Parse CLI output into Response - override in subclasses
214
229
  #
215
230
  # Combines stdout and stderr for error classification so that
@@ -309,8 +324,22 @@ module AgentHarness
309
324
  execution_options
310
325
  end
311
326
 
312
- def execute_with_timeout(command, timeout:, env:, stdin_data: nil, **execution_options)
313
- @executor.execute(command, timeout: timeout, env: env, stdin_data: stdin_data, **execution_options)
327
+ def execute_with_timeout(command, timeout:, env:, preparation: nil, stdin_data: nil, **execution_options)
328
+ kwargs = {timeout: timeout, env: env}
329
+ kwargs[:stdin_data] = stdin_data unless stdin_data.nil?
330
+ kwargs[:preparation] = preparation unless preparation.nil?
331
+ kwargs.merge!(execution_options)
332
+
333
+ @executor.execute(command, **kwargs)
334
+ rescue ArgumentError => e
335
+ unknown_keyword_message = e.message
336
+ raise unless unknown_keyword_message.start_with?("unknown keyword", "unknown keywords")
337
+ raise unless unknown_keyword_message.include?(":preparation")
338
+
339
+ raise ProviderError.new(
340
+ "Injected executor #{@executor.class}#execute must accept the preparation: keyword argument",
341
+ original_error: e
342
+ )
314
343
  end
315
344
 
316
345
  def track_tokens(response)
@@ -25,6 +25,15 @@ module AgentHarness
25
25
  !!executor.which(binary_name)
26
26
  end
27
27
 
28
+ def provider_metadata_overrides
29
+ {
30
+ auth: {
31
+ service: :openai,
32
+ api_family: :openai
33
+ }
34
+ }
35
+ end
36
+
28
37
  def firewall_requirements
29
38
  {
30
39
  domains: [
@@ -53,11 +62,33 @@ module AgentHarness
53
62
  ]
54
63
  end
55
64
 
56
- def installation_contract
57
- default_package = "@openai/codex@#{SUPPORTED_CLI_VERSION}".freeze
65
+ def installation_contract(version: SUPPORTED_CLI_VERSION)
66
+ version = version.strip if version.respond_to?(:strip)
67
+
68
+ unless version.is_a?(String) && !version.empty?
69
+ raise ArgumentError,
70
+ "Unsupported Codex CLI version #{version.inspect}; " \
71
+ "supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
72
+ end
73
+
74
+ parsed_version = begin
75
+ Gem::Version.new(version)
76
+ rescue ArgumentError
77
+ raise ArgumentError,
78
+ "Unsupported Codex CLI version #{version.inspect}; " \
79
+ "supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
80
+ end
81
+
82
+ unless SUPPORTED_CLI_REQUIREMENT.satisfied_by?(parsed_version)
83
+ raise ArgumentError,
84
+ "Unsupported Codex CLI version #{version.inspect}; " \
85
+ "supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
86
+ end
87
+
88
+ default_package = "@openai/codex@#{version}".freeze
58
89
  install_command_prefix = ["npm", "install", "-g", "--ignore-scripts"].freeze
59
90
  install_command = (install_command_prefix + [default_package]).freeze
60
- supported_versions = [SUPPORTED_CLI_VERSION].freeze
91
+ supported_versions = [version].freeze
61
92
  version_requirement = SUPPORTED_CLI_REQUIREMENT.requirements
62
93
  .map { |op, ver| "#{op} #{ver}".freeze }
63
94
  .freeze
@@ -66,7 +97,7 @@ module AgentHarness
66
97
  source: :npm,
67
98
  package: default_package,
68
99
  package_name: "@openai/codex",
69
- version: SUPPORTED_CLI_VERSION,
100
+ version: version,
70
101
  version_requirement: version_requirement,
71
102
  binary_name: binary_name,
72
103
  install_command_prefix: install_command_prefix,
@@ -141,7 +172,7 @@ module AgentHarness
141
172
 
142
173
  def error_patterns
143
174
  COMMON_ERROR_PATTERNS.merge(
144
- auth_expired: COMMON_ERROR_PATTERNS[:auth_expired] + [/401/, /incorrect.*api.*key/i],
175
+ auth_expired: COMMON_ERROR_PATTERNS[:auth_expired] + [/\b401\b/, /incorrect.*api.*key/i],
145
176
  transient: COMMON_ERROR_PATTERNS[:transient] + [/connection.*reset/i],
146
177
  sandbox_failure: [
147
178
  /bwrap.*no permissions/i,
@@ -157,7 +188,7 @@ module AgentHarness
157
188
  if api_key.strip.start_with?("sk-")
158
189
  return {valid: true, expires_at: nil, error: nil, auth_method: :api_key}
159
190
  else
160
- return {valid: false, expires_at: nil, error: "OPENAI_API_KEY is set but does not appear to be a valid OpenAI API key"}
191
+ return {valid: false, expires_at: nil, error: "OPENAI_API_KEY is set but does not appear to be a valid OpenAI API key", auth_method: nil}
161
192
  end
162
193
  end
163
194
 
@@ -168,14 +199,14 @@ module AgentHarness
168
199
  if key.strip.start_with?("sk-")
169
200
  return {valid: true, expires_at: nil, error: nil, auth_method: :config_file}
170
201
  else
171
- return {valid: false, expires_at: nil, error: "Config file API key is set but does not appear to be a valid OpenAI API key"}
202
+ return {valid: false, expires_at: nil, error: "Config file API key is set but does not appear to be a valid OpenAI API key", auth_method: nil}
172
203
  end
173
204
  end
174
205
  end
175
206
 
176
- {valid: false, expires_at: nil, error: "No OpenAI API key found. Set OPENAI_API_KEY or configure in #{codex_config_path}"}
207
+ {valid: false, expires_at: nil, error: "No OpenAI API key found. Set OPENAI_API_KEY or configure in #{codex_config_path}", auth_method: nil}
177
208
  rescue IOError, JSON::ParserError => e
178
- {valid: false, expires_at: nil, error: e.message}
209
+ {valid: false, expires_at: nil, error: e.message, auth_method: nil}
179
210
  end
180
211
 
181
212
  def health_status
@@ -12,6 +12,12 @@ module AgentHarness
12
12
  # provider = AgentHarness::Providers::Cursor.new
13
13
  # response = provider.send_message(prompt: "Hello!")
14
14
  class Cursor < Base
15
+ INSTALL_SCRIPT_URL = "https://cursor.com/install"
16
+ INSTALL_TARGET_LATEST = "latest"
17
+ INSTALL_BUILD = "2026.03.30-a5d3e17"
18
+ INSTALL_SCRIPT_SHA256 = "8371988b483abec13c07c10e95cccc839da81ebf9596e430d3c90835a227cbad"
19
+ INSTALL_LINUX_X64_PACKAGE_SHA256 = "e0d4b611db111d2dbe76474386271bff3e1dbb2cc6ddf527f9d5d5801b2ce2a0"
20
+
15
21
  class << self
16
22
  def provider_name
17
23
  :cursor
@@ -26,6 +32,15 @@ module AgentHarness
26
32
  !!executor.which(binary_name)
27
33
  end
28
34
 
35
+ def provider_metadata_overrides
36
+ {
37
+ auth: {
38
+ service: :cursor,
39
+ api_family: :cursor
40
+ }
41
+ }
42
+ end
43
+
29
44
  def firewall_requirements
30
45
  {
31
46
  domains: [
@@ -84,9 +99,66 @@ module AgentHarness
84
99
  family_name.match?(/^(claude|gpt|cursor)-/)
85
100
  end
86
101
 
102
+ def install_metadata(version: nil)
103
+ install_target = normalize_install_target(version)
104
+ linux_x64_package_url = package_url_for(os: "linux", arch: "x64")
105
+
106
+ {
107
+ source: {
108
+ type: :shell_script,
109
+ url: INSTALL_SCRIPT_URL,
110
+ resolved_version: INSTALL_BUILD,
111
+ default_artifact_url: linux_x64_package_url
112
+ },
113
+ checksum: {
114
+ strategy: :sha256,
115
+ targets: {
116
+ script: {
117
+ url: INSTALL_SCRIPT_URL,
118
+ value: INSTALL_SCRIPT_SHA256
119
+ },
120
+ artifacts: {
121
+ "linux/x64" => {
122
+ url: linux_x64_package_url,
123
+ value: INSTALL_LINUX_X64_PACKAGE_SHA256
124
+ }
125
+ }
126
+ }
127
+ },
128
+ binary: {
129
+ name: binary_name,
130
+ path: "$HOME/.local/bin/#{binary_name}",
131
+ suggested_global_path: "/usr/local/bin/#{binary_name}"
132
+ },
133
+ version: {
134
+ default: INSTALL_TARGET_LATEST,
135
+ supported: install_target,
136
+ command: [binary_name, "--version"]
137
+ }
138
+ }
139
+ end
140
+
87
141
  def smoke_test_contract
88
142
  Base::DEFAULT_SMOKE_TEST_CONTRACT
89
143
  end
144
+
145
+ private
146
+
147
+ def package_url_for(os:, arch:)
148
+ format(
149
+ "https://downloads.cursor.com/lab/%<build>s/%<os>s/%<arch>s/agent-cli-package.tar.gz",
150
+ build: INSTALL_BUILD,
151
+ os: os,
152
+ arch: arch
153
+ )
154
+ end
155
+
156
+ def normalize_install_target(version)
157
+ target = version.nil? ? INSTALL_TARGET_LATEST : version.to_s
158
+ return target if target == INSTALL_TARGET_LATEST
159
+
160
+ raise ArgumentError, "Unsupported Cursor install target: #{version.inspect}"
161
+ end
90
162
  end
91
163
 
92
164
  def name
@@ -156,7 +228,7 @@ module AgentHarness
156
228
  rate_limited: [
157
229
  /rate.?limit/i,
158
230
  /too.?many.?requests/i,
159
- /429/
231
+ /\b429\b/
160
232
  ],
161
233
  auth_expired: [
162
234
  /authentication.*error/i,
@@ -192,12 +264,14 @@ module AgentHarness
192
264
 
193
265
  # Execute command with prompt on stdin
194
266
  env = build_env(options)
267
+ preparation = build_execution_preparation(options)
195
268
  start_time = Time.now
196
269
  result = execute_with_timeout(
197
270
  command,
198
271
  timeout: timeout,
199
272
  env: env,
200
273
  stdin_data: prompt,
274
+ preparation: preparation,
201
275
  **command_execution_options(options)
202
276
  )
203
277
  duration = Time.now - start_time
@@ -253,7 +327,7 @@ module AgentHarness
253
327
  return nil unless self.class.available?
254
328
 
255
329
  begin
256
- result = @executor.execute(["cursor-agent", "mcp", "list"], timeout: 5)
330
+ result = @executor.execute([self.class.binary_name, "mcp", "list"], timeout: 5)
257
331
  return nil unless result.success?
258
332
 
259
333
  parse_mcp_servers_output(result.stdout)
@@ -30,7 +30,22 @@ module AgentHarness
30
30
  !!executor.which(binary_name)
31
31
  end
32
32
 
33
+ def provider_metadata_overrides
34
+ {
35
+ auth: {
36
+ service: :google,
37
+ api_family: :gemini
38
+ }
39
+ }
40
+ end
41
+
33
42
  def install_contract(version: SUPPORTED_CLI_VERSION)
43
+ version = version.strip if version.respond_to?(:strip)
44
+
45
+ unless version.is_a?(String) && !version.empty?
46
+ raise ArgumentError, "Unsupported Gemini CLI version #{version.inspect}. Supported requirement: #{SUPPORTED_CLI_REQUIREMENT}"
47
+ end
48
+
34
49
  parsed_version = begin
35
50
  Gem::Version.new(version)
36
51
  rescue ArgumentError
@@ -170,7 +185,7 @@ module AgentHarness
170
185
  rate_limited: [
171
186
  /rate.?limit/i,
172
187
  /quota.?exceeded/i,
173
- /429/
188
+ /\b429\b/
174
189
  ],
175
190
  auth_expired: [
176
191
  /authentication/i,
@@ -184,7 +199,7 @@ module AgentHarness
184
199
  transient: [
185
200
  /timeout/i,
186
201
  /temporary/i,
187
- /503/
202
+ /\b503\b/
188
203
  ]
189
204
  }
190
205
  end
@@ -196,21 +211,21 @@ module AgentHarness
196
211
  end
197
212
 
198
213
  credentials = read_gemini_credentials
199
- return {valid: false, expires_at: nil, error: "No Gemini credentials found. Run 'gemini auth login' or set GEMINI_API_KEY or GOOGLE_API_KEY"} unless credentials
214
+ return {valid: false, expires_at: nil, error: "No Gemini credentials found. Run 'gemini auth login' or set GEMINI_API_KEY or GOOGLE_API_KEY", auth_method: nil} unless credentials
200
215
 
201
216
  token = credentials["access_token"] || credentials["oauth_token"]
202
217
  unless token.is_a?(String) && !token.strip.empty?
203
- return {valid: false, expires_at: nil, error: "No authentication token in Gemini credentials"}
218
+ return {valid: false, expires_at: nil, error: "No authentication token in Gemini credentials", auth_method: nil}
204
219
  end
205
220
 
206
221
  expires_at = parse_gemini_expiry(credentials)
207
222
  if expires_at && expires_at < Time.now
208
- {valid: false, expires_at: expires_at, error: "Gemini session expired. Run 'gemini auth login' to re-authenticate"}
223
+ {valid: false, expires_at: expires_at, error: "Gemini session expired. Run 'gemini auth login' to re-authenticate", auth_method: :oauth}
209
224
  else
210
225
  {valid: true, expires_at: expires_at, error: nil, auth_method: :oauth}
211
226
  end
212
227
  rescue IOError, JSON::ParserError => e
213
- {valid: false, expires_at: nil, error: e.message}
228
+ {valid: false, expires_at: nil, error: e.message, auth_method: nil}
214
229
  end
215
230
 
216
231
  def health_status
@@ -37,6 +37,18 @@ module AgentHarness
37
37
  !!executor.which(binary_name)
38
38
  end
39
39
 
40
+ def provider_metadata_overrides
41
+ {
42
+ auth: {
43
+ service: :github,
44
+ api_family: :github_copilot
45
+ },
46
+ identity: {
47
+ bot_usernames: ["github-copilot[bot]"]
48
+ }
49
+ }
50
+ end
51
+
40
52
  def firewall_requirements
41
53
  {
42
54
  domains: [
@@ -41,6 +41,7 @@ module AgentHarness
41
41
  end
42
42
 
43
43
  def installation_contract(version: DEFAULT_VERSION)
44
+ version = version.strip if version.respond_to?(:strip)
44
45
  validate_install_version!(version)
45
46
  package_spec = "#{PACKAGE_NAME}@#{version}"
46
47
 
@@ -67,8 +68,22 @@ module AgentHarness
67
68
  private
68
69
 
69
70
  def validate_install_version!(version)
71
+ unless version.is_a?(String) && !version.strip.empty?
72
+ raise ArgumentError,
73
+ "Unsupported Kilocode CLI version #{version.inspect}; " \
74
+ "supported versions must satisfy #{SUPPORTED_VERSION_REQUIREMENT}"
75
+ end
76
+
77
+ parsed_version = begin
78
+ Gem::Version.new(version)
79
+ rescue ArgumentError
80
+ raise ArgumentError,
81
+ "Unsupported Kilocode CLI version #{version.inspect}; " \
82
+ "supported versions must satisfy #{SUPPORTED_VERSION_REQUIREMENT}"
83
+ end
84
+
70
85
  requirement = Gem::Requirement.new(SUPPORTED_VERSION_REQUIREMENT)
71
- return if requirement.satisfied_by?(Gem::Version.new(version))
86
+ return if requirement.satisfied_by?(parsed_version)
72
87
 
73
88
  raise ArgumentError,
74
89
  "Unsupported Kilocode CLI version #{version.inspect}; " \