agent-harness 0.28.2 → 0.28.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e26544498cd552f3992c81ebe33b869b51daf38e199c6f0cdc733437f39f531
4
- data.tar.gz: 55d5a1527f9ef1c208706dbfccbce73246751a329f87ff64a514e14301d81283
3
+ metadata.gz: 8d0d7ed4f6ef8391d0b495868dd13f0fbfb84ee496c63ccabdd2900bba04f3d0
4
+ data.tar.gz: 0c8dd8c73feee507a47397dce38c1575889d25413cf4fb93fe66407e736f18c0
5
5
  SHA512:
6
- metadata.gz: 9fd530fe7ab66107b79b776d170523d9095212b0988f6b2e8e5a160b792927f758b951faf1c4a5ef2fa2035d86d2ab8a1d60185c4f0631bb3c66d0d7740153bd
7
- data.tar.gz: 8b4f298b243c6185965633e41b0f629648ae308ab8d2acd62f47a73256e1cac22398cc77442b28ca69d8b77afd933415a45e678bf60a1744007d1151db0d3961
6
+ metadata.gz: f22bd0d258e06b594fd56a2cb523f6823d975988002f9b50338b0e46d1ebabd478c90f43fd3acaae8dced8842635c249e8d7b27a02fad45e528fc060b59143c1
7
+ data.tar.gz: 6d8b8f987d8fb747c134743729ed47c105bb4ba0689c350bd8868674c07a3da17df84cfe6a64a137403b022032800fb21477a7319d02820ee996fe5f8b3bd367
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.28.2"
2
+ ".": "0.28.4"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@
6
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)).
7
7
 
8
8
 
9
+ ## [0.28.4](https://github.com/viamin/agent-harness/compare/agent-harness/v0.28.3...agent-harness/v0.28.4) (2026-07-10)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * Codex model_compatibility returns 'unknown' for models definitively unsupported with subscription auth (e.g. gpt-5.5-pro) ([#287](https://github.com/viamin/agent-harness/issues/287)) ([a6f87ce](https://github.com/viamin/agent-harness/commit/a6f87ce07a2514e069559d57d8496dd323bd0891))
15
+
16
+ ## [0.28.3](https://github.com/viamin/agent-harness/compare/agent-harness/v0.28.2...agent-harness/v0.28.3) (2026-07-10)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * Kilocode: default external_directory permission blocks /tmp scratch files, silently killing non-interactive runs ([#285](https://github.com/viamin/agent-harness/issues/285)) ([d703c0a](https://github.com/viamin/agent-harness/commit/d703c0abb7b91fb7a5ba8081e612486edff5e8f5))
22
+
9
23
  ## [0.28.2](https://github.com/viamin/agent-harness/compare/agent-harness/v0.28.1...agent-harness/v0.28.2) (2026-07-10)
10
24
 
11
25
 
@@ -31,12 +31,21 @@ module AgentHarness
31
31
  # result. Distinct from :unknown_model — the runner *does* know the
32
32
  # model; it just cannot confirm the installed CLI is new enough.
33
33
  UNKNOWN_CLI_VERSION_REASON = :cli_version_unknown
34
+ # Issued when the runner needs an auth mode to answer definitively for
35
+ # an auth-gated model but the caller did not supply one. Pairs with
36
+ # :supported_auth_modes on the result details. Distinct from
37
+ # :unknown_model — the runner *does* know the model; it just cannot
38
+ # confirm the requested auth mode is allowed for it.
39
+ UNKNOWN_AUTH_MODE_REASON = :auth_mode_unknown
34
40
  # Issued when the runner supports the model but the installed CLI is too
35
41
  # old. Pairs with :minimum_cli_version on the result.
36
42
  UNSUPPORTED_CLI_VERSION_REASON = :cli_version_too_old
37
43
  # Issued when the runner supports the model but the requested auth mode
38
44
  # is not part of the runner's contract for it.
39
45
  UNSUPPORTED_AUTH_MODE_REASON = :auth_mode_not_supported
46
+ # Issued when the runner accepts the requested auth mode generally, but
47
+ # the specific model is not available under that auth mode.
48
+ UNSUPPORTED_AUTH_MODE_FOR_MODEL_REASON = :unsupported_auth_mode_for_model
40
49
  # Default supported reason.
41
50
  SUPPORTED_REASON = :supported
42
51
 
@@ -27,9 +27,11 @@ module AgentHarness
27
27
  # instead of relying on whichever model the CLI's default points at.
28
28
  DEFAULT_COMPATIBLE_MODEL_ID = "gpt-5-codex"
29
29
 
30
- # Known CLI-gated model facts. Each entry expresses the minimum Codex
31
- # CLI version required to drive that model. Keep entries here only when
32
- # the requirement is durable runner contract knowledge — not
30
+ # Known CLI-gated model facts. Each entry may express a minimum Codex
31
+ # CLI version required to drive the model, and/or auth-mode
32
+ # restrictions that are part of the durable runner contract. Keep
33
+ # entries here only when the requirement is durable runner contract
34
+ # knowledge — not
33
35
  # provider-side experiments or one-off CLI defaults.
34
36
  #
35
37
  # The +gpt-5.5+ entry tracks the failure class observed in
@@ -37,7 +39,8 @@ module AgentHarness
37
39
  # CLI builds (e.g. 0.115.x) could not drive the +gpt-5.5+ family.
38
40
  MODEL_COMPATIBILITY_FACTS = {
39
41
  "gpt-5.5" => {minimum_cli_version: "0.116.0"},
40
- "gpt-5.5-codex" => {minimum_cli_version: "0.116.0"}
42
+ "gpt-5.5-codex" => {minimum_cli_version: "0.116.0"},
43
+ "gpt-5.5-pro" => {auth_modes: [:api_key].freeze}
41
44
  }.each_value(&:freeze).freeze
42
45
 
43
46
  # Models that the runner contract considers supported on every Codex
@@ -276,16 +279,25 @@ module AgentHarness
276
279
  #
277
280
  # Returns an {AgentHarness::ModelCompatibility::Result} for the
278
281
  # combination of +model_id+, +auth_mode+, and installed
279
- # +cli_version+. The contract surfaces three concrete outcomes:
282
+ # +cli_version+. The contract surfaces these concrete outcomes:
280
283
  #
281
- # 1. **Supported** — the model is in {BASELINE_SUPPORTED_MODELS} (or
282
- # a known CLI-gated model whose minimum CLI version is met).
283
- # 2. **Unsupported due to CLI version** the model is known to need
284
+ # 1. **Supported** — the model is in {BASELINE_SUPPORTED_MODELS}, or
285
+ # a known CLI-gated model whose stated restrictions are met (a
286
+ # minimum Codex CLI version and/or an auth-mode restriction; some
287
+ # gated models express only one of these).
288
+ # 2. **Unsupported due to auth mode** — the requested auth mode is
289
+ # not accepted by the runner at all, or is not available for the
290
+ # specific model (e.g. an api-key-only model requested with
291
+ # subscription auth).
292
+ # 3. **Unsupported due to CLI version** — the model is known to need
284
293
  # a newer Codex CLI than was supplied; the result carries
285
294
  # +:minimum_cli_version+ so callers can act on it.
286
- # 3. **Unknown / dynamic** — the model is not in this static
287
- # contract. Callers must treat this as "ask the provider" rather
288
- # than as approval.
295
+ # 4. **Unknown / dynamic** — the model is not in this static
296
+ # contract, or a gated model was queried without the gating input
297
+ # needed to answer definitively (an auth mode for an
298
+ # auth-restricted model, or an installed CLI version for a
299
+ # version-gated model). Callers must treat this as "ask the
300
+ # provider" rather than as approval.
289
301
  #
290
302
  # @param model_id [String, Symbol]
291
303
  # @param auth_mode [Symbol, nil] :api_key or :subscription
@@ -312,42 +324,94 @@ module AgentHarness
312
324
 
313
325
  gated_fact = MODEL_COMPATIBILITY_FACTS[normalized_model_id]
314
326
  if gated_fact
315
- minimum_version = gated_fact[:minimum_cli_version]
316
- requirement = Gem::Requirement.new(">= #{minimum_version}")
317
- comparable_version = comparable_cli_version(normalized_cli_version)
318
-
319
- # A CLI-gated model without a comparable installed version must
320
- # stay explicit. Returning :supported here would re-introduce the
321
- # exact `gpt-5.5` failure class this contract is designed to
322
- # prevent — a caller that cannot supply a version would get
323
- # `supported? == true` and may still schedule a run onto an old
324
- # CLI (e.g. 0.115.x). Surface :unknown with the requirement
325
- # attached so callers can decide deliberately.
326
- if comparable_version.nil?
327
+ supported_auth_modes = gated_fact[:auth_modes]
328
+ if normalized_auth_mode && supported_auth_modes && !supported_auth_modes.include?(normalized_auth_mode)
329
+ return AgentHarness::ModelCompatibility.build_result(
330
+ runner: provider_name,
331
+ model_id: normalized_model_id,
332
+ auth_mode: normalized_auth_mode,
333
+ cli_version: normalized_cli_version,
334
+ supported: false,
335
+ reason: AgentHarness::ModelCompatibility::UNSUPPORTED_AUTH_MODE_FOR_MODEL_REASON,
336
+ fallback_model_id: DEFAULT_COMPATIBLE_MODEL_ID,
337
+ source: :static_contract,
338
+ details: {supported_auth_modes: supported_auth_modes}
339
+ )
340
+ end
341
+
342
+ # An auth-gated model queried without an auth mode must stay
343
+ # explicit. Returning :supported here would re-introduce the
344
+ # exact permissive false-positive this contract is designed to
345
+ # prevent — `auth_mode` defaults to nil on the public API, so a
346
+ # caller that queries availability without an auth mode and
347
+ # treats `supported? == true` as approval could then schedule an
348
+ # api-key-only model (e.g. gpt-5.5-pro) under subscription. This
349
+ # mirrors the sibling CLI-version dimension below: when the
350
+ # gating input is missing, surface :unknown with the allowed
351
+ # auth modes attached so callers can decide deliberately.
352
+ if supported_auth_modes && normalized_auth_mode.nil?
327
353
  return AgentHarness::ModelCompatibility.unknown_result(
328
354
  runner: provider_name,
329
355
  model_id: normalized_model_id,
330
356
  auth_mode: normalized_auth_mode,
331
357
  cli_version: normalized_cli_version,
332
- reason: AgentHarness::ModelCompatibility::UNKNOWN_CLI_VERSION_REASON,
333
- minimum_cli_version: minimum_version,
334
- cli_version_requirement: requirement.to_s,
358
+ reason: AgentHarness::ModelCompatibility::UNKNOWN_AUTH_MODE_REASON,
335
359
  fallback_model_id: DEFAULT_COMPATIBLE_MODEL_ID,
336
- source: :static_contract
360
+ source: :static_contract,
361
+ details: {supported_auth_modes: supported_auth_modes}
337
362
  )
338
363
  end
339
364
 
340
- unless requirement.satisfied_by?(comparable_version)
365
+ minimum_version = gated_fact[:minimum_cli_version]
366
+ if minimum_version
367
+ requirement = Gem::Requirement.new(">= #{minimum_version}")
368
+ comparable_version = comparable_cli_version(normalized_cli_version)
369
+
370
+ # A CLI-gated model without a comparable installed version must
371
+ # stay explicit. Returning :supported here would re-introduce the
372
+ # exact `gpt-5.5` failure class this contract is designed to
373
+ # prevent — a caller that cannot supply a version would get
374
+ # `supported? == true` and may still schedule a run onto an old
375
+ # CLI (e.g. 0.115.x). Surface :unknown with the requirement
376
+ # attached so callers can decide deliberately.
377
+ if comparable_version.nil?
378
+ return AgentHarness::ModelCompatibility.unknown_result(
379
+ runner: provider_name,
380
+ model_id: normalized_model_id,
381
+ auth_mode: normalized_auth_mode,
382
+ cli_version: normalized_cli_version,
383
+ reason: AgentHarness::ModelCompatibility::UNKNOWN_CLI_VERSION_REASON,
384
+ minimum_cli_version: minimum_version,
385
+ cli_version_requirement: requirement.to_s,
386
+ fallback_model_id: DEFAULT_COMPATIBLE_MODEL_ID,
387
+ source: :static_contract
388
+ )
389
+ end
390
+
391
+ unless requirement.satisfied_by?(comparable_version)
392
+ return AgentHarness::ModelCompatibility.build_result(
393
+ runner: provider_name,
394
+ model_id: normalized_model_id,
395
+ auth_mode: normalized_auth_mode,
396
+ cli_version: normalized_cli_version,
397
+ supported: false,
398
+ reason: AgentHarness::ModelCompatibility::UNSUPPORTED_CLI_VERSION_REASON,
399
+ minimum_cli_version: minimum_version,
400
+ cli_version_requirement: requirement.to_s,
401
+ fallback_model_id: DEFAULT_COMPATIBLE_MODEL_ID,
402
+ source: :static_contract
403
+ )
404
+ end
405
+
341
406
  return AgentHarness::ModelCompatibility.build_result(
342
407
  runner: provider_name,
343
408
  model_id: normalized_model_id,
344
409
  auth_mode: normalized_auth_mode,
345
410
  cli_version: normalized_cli_version,
346
- supported: false,
347
- reason: AgentHarness::ModelCompatibility::UNSUPPORTED_CLI_VERSION_REASON,
411
+ supported: true,
412
+ reason: AgentHarness::ModelCompatibility::SUPPORTED_REASON,
348
413
  minimum_cli_version: minimum_version,
349
414
  cli_version_requirement: requirement.to_s,
350
- fallback_model_id: DEFAULT_COMPATIBLE_MODEL_ID,
351
415
  source: :static_contract
352
416
  )
353
417
  end
@@ -359,8 +423,6 @@ module AgentHarness
359
423
  cli_version: normalized_cli_version,
360
424
  supported: true,
361
425
  reason: AgentHarness::ModelCompatibility::SUPPORTED_REASON,
362
- minimum_cli_version: minimum_version,
363
- cli_version_requirement: requirement.to_s,
364
426
  source: :static_contract
365
427
  )
366
428
  end
@@ -13,6 +13,18 @@ module AgentHarness
13
13
  DEFAULT_VERSION = "7.1.3"
14
14
  SUPPORTED_VERSION_REQUIREMENT = "= #{DEFAULT_VERSION}"
15
15
  STRUCTURED_EVENT_TYPES = %w[text error step_finish result usage].freeze
16
+ # Kilo CLI (an OpenCode fork) ships the same external_directory
17
+ # permission category as OpenCode, defaulting to "ask" for anything
18
+ # outside the project dir. In non-interactive execution there is no
19
+ # human to answer the prompt, so reads of scratch files under /tmp are
20
+ # auto-rejected and the agent silently loses access to its own scratch
21
+ # output, killing the run without a recoverable error. This default rule
22
+ # broadens the allowlist to all of /tmp so the agent can read back files
23
+ # it (or its sub-agents) wrote there. See #282 (precedent: #277/#280).
24
+ DEFAULT_PERMISSION_EXTERNAL_DIRECTORY_PATTERN = "/tmp/**"
25
+ DEFAULT_PERMISSION_CONFIG = {
26
+ "external_directory" => {DEFAULT_PERMISSION_EXTERNAL_DIRECTORY_PATTERN => "allow"}
27
+ }.freeze
16
28
  USAGE_EVENT_TYPES = %w[result usage].freeze
17
29
  TOKEN_USAGE_KEYS = %w[
18
30
  input_tokens
@@ -106,21 +118,6 @@ module AgentHarness
106
118
  end
107
119
  end
108
120
 
109
- # Kilo CLI (an opencode fork) ships the same `external_directory`
110
- # permission category as OpenCode, defaulting to `ask` for anything
111
- # outside the project dir. In non-interactive execution there is no human
112
- # to answer the resulting permission prompt, so reads/writes of scratch
113
- # files under `/tmp/*` via the dedicated read/write/edit tools are
114
- # auto-rejected — even though bash I/O to the same paths succeeds (shell
115
- # redirection is not a tool call). The agent then silently loses access to
116
- # its own scratch output and never completes the task. This default rule
117
- # broadens the allowlist to all of `/tmp` so the agent can read back files
118
- # it (or its sub-agents) wrote there.
119
- DEFAULT_PERMISSION_EXTERNAL_DIRECTORY_PATTERN = "/tmp/**"
120
- DEFAULT_PERMISSION_RULE = {
121
- "external_directory" => {DEFAULT_PERMISSION_EXTERNAL_DIRECTORY_PATTERN => "allow"}
122
- }.freeze
123
-
124
121
  def name
125
122
  "kilocode"
126
123
  end
@@ -185,10 +182,9 @@ module AgentHarness
185
182
  provider_name = options[:provider_name] || "openai"
186
183
  model_id = options[:model_id]
187
184
 
188
- config = {provider: {provider_name => {}}}
189
- config[:model] = "#{provider_name}/#{model_id}" if model_id
190
- config[:permission] = deep_dup(options[:permission]) if options[:permission]
191
- apply_default_external_directory_permission(config)
185
+ config = {"provider" => {provider_name => {}}}
186
+ config["model"] = "#{provider_name}/#{model_id}" if model_id
187
+ apply_default_external_directory_permission(config, options)
192
188
 
193
189
  config.to_json
194
190
  end
@@ -219,6 +215,31 @@ module AgentHarness
219
215
  cmd
220
216
  end
221
217
 
218
+ # Materialize the permissive external_directory permission (and any other
219
+ # runtime config extras) to +~/.config/kilocode/kilo.json+ before the CLI
220
+ # boots. This is the runtime path that actually fixes #282: without it the
221
+ # permission lives only in {config_file_content}'s return value, which no
222
+ # execution caller invokes, so Kilo would boot with stock "ask" defaults
223
+ # and auto-reject /tmp reads in non-interactive runs. Mirrors the OpenCode
224
+ # precedent in #280.
225
+ def build_execution_preparation(options)
226
+ runtime = options[:provider_runtime]
227
+ return nil unless runtime
228
+
229
+ config_payload = kilocode_config_payload(runtime)
230
+ return nil unless config_payload
231
+
232
+ ExecutionPreparation.new(
233
+ file_writes: [
234
+ {
235
+ path: kilocode_config_path,
236
+ content: serialize_kilocode_config(config_payload),
237
+ mode: 0o600
238
+ }
239
+ ]
240
+ )
241
+ end
242
+
222
243
  def parse_response(result, duration:)
223
244
  output = result.stdout
224
245
  tokens = nil
@@ -349,12 +370,49 @@ module AgentHarness
349
370
 
350
371
  private
351
372
 
352
- def apply_default_external_directory_permission(payload)
353
- # Respect any caller-supplied `permission` block verbatim: if a caller
354
- # takes responsibility for permission config, we do not override it.
355
- return if payload.key?(:permission) || payload.key?("permission")
373
+ # Default-merge a permissive external_directory permission into the
374
+ # generated Kilo config unless the caller takes responsibility for
375
+ # permission config. A caller-supplied permission (passed via the
376
+ # +:permission+ option or already present on the config under the
377
+ # +"permission"+ key, e.g. from runtime metadata config extras) is
378
+ # honored verbatim and never overridden, and an invalid or empty
379
+ # caller permission is ignored in favor of the default rule.
380
+ def apply_default_external_directory_permission(config, options = {})
381
+ caller_permission = options[:permission] || options["permission"] || config["permission"]
382
+ if caller_permission.is_a?(Hash) && !caller_permission.empty?
383
+ config["permission"] = deep_dup(caller_permission)
384
+ return
385
+ end
386
+
387
+ config["permission"] = deep_dup(DEFAULT_PERMISSION_CONFIG)
388
+ end
389
+
390
+ def kilocode_config_path
391
+ "~/.config/kilocode/kilo.json"
392
+ end
356
393
 
357
- payload[:permission] = deep_dup(DEFAULT_PERMISSION_RULE)
394
+ def kilocode_config_payload(runtime)
395
+ metadata = runtime.metadata
396
+ config_extras = metadata[:config] || metadata["config"] || {}
397
+ unless config_extras.is_a?(Hash)
398
+ raise ArgumentError,
399
+ "Kilocode runtime metadata config must be a Hash of provider-specific extras (got #{config_extras.class})"
400
+ end
401
+
402
+ payload = stringify_keys(config_extras)
403
+ payload["model"] = runtime.model if runtime.model
404
+ apply_default_external_directory_permission(payload)
405
+ payload.empty? ? nil : payload
406
+ end
407
+
408
+ def serialize_kilocode_config(payload)
409
+ JSON.pretty_generate(payload)
410
+ end
411
+
412
+ def stringify_keys(hash)
413
+ hash.each_with_object({}) do |(key, value), result|
414
+ result[key.to_s] = value
415
+ end
358
416
  end
359
417
 
360
418
  def heartbeat_hook_script(heartbeat_file_path)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.28.2"
4
+ VERSION = "0.28.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agent-harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.28.2
4
+ version: 0.28.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan