agent-harness 0.5.8 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93f5a11872f464b3eb9834c849f076d9f393f9e1174bc9824952f4c217c9278f
4
- data.tar.gz: 25bbd835fd6739f84597d0d5653443ecd238b0e89f20f5e0b20029cb5d8bb119
3
+ metadata.gz: f476f6224ed25cc79c7bc9c8fb239c1df9f787cd59ff279b8c509b7d515d2727
4
+ data.tar.gz: c0faee9c6d5c139db019707b9174d2d25a4a2fc4a85684ce55eb7baede9fd3be
5
5
  SHA512:
6
- metadata.gz: '081955132b518b5d94498bfeb54ddd7641f1d6968fcb53debf143f7762a666edd40dde6a1369553a42c0dc4985cbff9cb41d7ade8dd727fa80572e7a7293cd2f'
7
- data.tar.gz: 0345fc32a87b27ba001ae0a6cd26c5b1cc2765f64d7d81bad025b461182cdb0a814c8cdd031478b4e7dcf8bbe1f6b2638e3814cbf3c9537e038dc8bca1255a1f
6
+ metadata.gz: 113c0d8afbf5e77c6abcb1f78f0793646b72137e48a9776bfd951ee98c5412d3606628ea19ce4b105a6d8c05d1143a12a5607e1bad82462c629f6bdf3907985b
7
+ data.tar.gz: 4cc2f012c75314a2d096147e4bae9afbed565181878a907339410f7cfdb3ea0cff03e96704a6d3d93cd4f3825eb9f21a97d5351b852d72ec4e09b21a3359df68
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.5.8"
2
+ ".": "0.5.9"
3
3
  }
@@ -0,0 +1,111 @@
1
+ # PR Review Audit Disposition (Issue #91)
2
+
3
+ Audit of PRs merged before review completion in `viamin/agent-harness`.
4
+
5
+ ## P1 PRs
6
+
7
+ ### PR #55 — Per-request provider runtime overrides
8
+
9
+ | Finding | Disposition |
10
+ | ------- | ----------- |
11
+ | env stringifies only keys, not values | Fixed in PR #55 itself |
12
+ | Token tracking uses config model instead of runtime model | Fixed in PR #55 itself |
13
+ | nil env/metadata raises NoMethodError | Fixed in PR #55 itself |
14
+ | flags values not validated as Strings | Fixed in PR #55 itself |
15
+ | Adapter Hash-to-ProviderRuntime docs misleading | Fixed in PR #55 itself |
16
+ | `Array(flags)` freezes caller's array | Fixed in PR #55 itself |
17
+ | freezes caller-provided metadata Hash | Fixed in PR #55 itself |
18
+ | env type validation missing | Fixed in PR #55 itself |
19
+ | `.from_hash` no input type check | Fixed in PR #55 itself |
20
+ | flags accepts single String silently | Fixed in PR #55 itself |
21
+ | Cursor never normalizes/uses provider_runtime | Fixed in PR #55 itself |
22
+ | Cursor reimplements build_env instead of calling super | Fixed in PR #55 itself |
23
+ | runtime.model CLI vs telemetry mismatch | Fixed in PR #55 itself |
24
+ | Missing precedence spec | Fixed in PR #55 itself |
25
+ | `.wrap` kwargs bug on Ruby >= 3.2 | Fixed in PR #55 itself |
26
+ | runtime.model precedence unclear | Fixed in PR #55 itself |
27
+ | **base_url/model/api_provider not type-validated** | **Fixed forward in this PR** |
28
+ | **`.from_hash` treats `false` same as missing** | **Fixed forward in this PR** (hash_value helper) |
29
+ | Cursor not mentioned in PR description | Accepted (docs-only, no code impact) |
30
+
31
+ ### PR #81 — Kilocode CLI installation contract
32
+
33
+ | Finding | Disposition |
34
+ | ------- | ----------- |
35
+ | `installation_contract` can raise NoMethodError for non-Adapter providers | Fixed in PR #81 itself |
36
+ | `provider_installation_contract` doesn't forward `version:` | Fixed in PR #81 itself |
37
+ | Default `installation_contract` doesn't accept `**options` | Fixed in PR #81 itself |
38
+ | Registry forwards `**options` unconditionally | Fixed in PR #81 itself |
39
+ | **`install_command` doesn't handle `version: nil`** | Accepted (produces clear error message via Gem::Version coercion) |
40
+ | **`validate_install_version!` gives generic error for bad input** | **Fixed forward in this PR** |
41
+
42
+ ### PR #57 — Provider configuration capabilities
43
+
44
+ | Finding | Disposition |
45
+ | ------- | ----------- |
46
+ | All 19 findings (schema field removal, auth_modes derivation, model hints, accepts_arbitrary alignment) | All fixed in PR #57 itself across 5 review rounds |
47
+
48
+ ### PR #42 — Gemini and Codex health/auth checks
49
+
50
+ | Finding | Disposition |
51
+ | ------- | ----------- |
52
+ | Temp dir from Dir.mktmpdir never cleaned up | Fixed in PR #42 itself |
53
+ | Config-file API key not validated for sk- prefix | Fixed in PR #42 itself |
54
+ | Error message hardcodes ~/.codex/config.json | Fixed in PR #42 itself |
55
+ | default_flags assumed to be Array, no guard | Fixed in PR #42 itself |
56
+ | Error message only mentions GEMINI_API_KEY | Fixed in PR #42 itself |
57
+ | validate_config doesn't check model/flags types | Fixed in PR #42 itself |
58
+ | JSON parse error leaks credential file contents | Fixed in PR #42 itself |
59
+ | Blank GEMINI_API_KEY doesn't fall back | Fixed in PR #42 itself |
60
+ | validate_config validates default_flags but build_command never uses them | Fixed in PR #42 itself |
61
+ | build_command raises if default_flags is non-Array | Fixed in PR #42 itself |
62
+ | auth_status assumes parsed JSON is Hash | Fixed in PR #42 itself |
63
+ | **auth_status returns inconsistent hash shape (missing auth_method)** | **Fixed forward in this PR** (both Codex and Gemini) |
64
+ | **Numeric status-code regexes can match unrelated numbers** | **Fixed forward in this PR** (added `\b` word boundaries) |
65
+
66
+ ## P2 PRs
67
+
68
+ ### PR #16 — DockerCommandExecutor
69
+
70
+ | Finding | Disposition |
71
+ | ------- | ----------- |
72
+ | Duplicated `normalize_command` method | Already resolved (inherits from protected parent) |
73
+ | `which` missing timeout | Already resolved (timeout: 5 added) |
74
+ | **container_id not validated for whitespace-only** | **Fixed forward in this PR** |
75
+ | Test stubs don't assert command arguments | Accepted (test quality, not a runtime defect) |
76
+
77
+ ### PR #83 — OpenCode CLI installation contract
78
+
79
+ | Finding | Disposition |
80
+ | ------- | ----------- |
81
+ | No unresolved review comments | No action needed |
82
+
83
+ ### PR #80 — Gemini CLI installation contract
84
+
85
+ | Finding | Disposition |
86
+ | ------- | ----------- |
87
+ | `install_contract` signature mismatch between base adapter and callers | Already resolved in current adapter.rb |
88
+ | No guard for providers without `install_contract` | Already resolved in registry.rb |
89
+ | **Malformed version strings not handled gracefully** | Already resolved (Gemini rescues ArgumentError from Gem::Version) |
90
+ | Missing test coverage for edge cases | Accepted (test quality, not a runtime defect) |
91
+
92
+ ### PR #1 — Initial extraction
93
+
94
+ | Finding | Disposition |
95
+ | ------- | ----------- |
96
+ | `timecop` gem not declared in Gemfile | Already resolved (timecop removed from codebase) |
97
+ | Missing YamlLoader/EnvLoader files | Already resolved (references removed) |
98
+ | README binary name for Cursor incorrect | Already resolved (README says cursor-agent, matches code) |
99
+ | README binary name for GitHub Copilot incorrect | Already resolved (README updated) |
100
+ | bin/ excluded from gemspec | Accepted (intentional for gem packaging) |
101
+
102
+ ## Summary of fixes in this PR
103
+
104
+ 1. **ProviderRuntime**: Added type validation for `model`, `base_url`, `api_provider` (must be String or nil)
105
+ 2. **ProviderRuntime**: Replaced `||`-based hash key lookup in `.from_hash` with `hash_value` helper that distinguishes nil from missing keys
106
+ 3. **Codex auth_status**: Added consistent `auth_method` key to all return paths
107
+ 4. **Gemini auth_status**: Added consistent `auth_method` key to all return paths
108
+ 5. **Codex error_patterns**: Added `\b` word boundaries to `/401/` regex
109
+ 6. **Gemini error_patterns**: Added `\b` word boundaries to `/429/` and `/503/` regexes
110
+ 7. **Kilocode**: `validate_install_version!` now rescues malformed version strings with a provider-specific error message
111
+ 8. **DockerCommandExecutor**: Validates whitespace-only `container_id` values
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.9](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.8...agent-harness/v0.5.9) (2026-04-12)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * 91: audit and fix-forward defects from PRs merged before review completion ([ec0af12](https://github.com/viamin/agent-harness/commit/ec0af12728f6524b651ebc9b552c477059a2adac))
9
+ * **providers:** close path traversal bypass in env-var-prefixed preparation paths ([493bf55](https://github.com/viamin/agent-harness/commit/493bf55be088a8bde39d143791f3f9b0c90d9c0c))
10
+ * **providers:** harden preparation path validation against env value traversal and command substitution ([ed4b92b](https://github.com/viamin/agent-harness/commit/ed4b92b8760ec4dfa1f50d69b0736563140905b4))
11
+ * **providers:** harden preparation path validation against injection and traversal ([9cb0faf](https://github.com/viamin/agent-harness/commit/9cb0fafbf63c908c7ef7b465bf541ceb5dbf58bb))
12
+ * **providers:** preserve docker idle timeouts ([adde6b4](https://github.com/viamin/agent-harness/commit/adde6b4491023c8baf37249147770cddeff1b0ee))
13
+
3
14
  ## [0.5.8](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.7...agent-harness/v0.5.8) (2026-04-07)
4
15
 
5
16
 
@@ -3,6 +3,10 @@
3
3
  require "open3"
4
4
  require "timeout"
5
5
  require "shellwords"
6
+ require "fileutils"
7
+ require "tempfile"
8
+ require "tmpdir"
9
+ require "digest"
6
10
 
7
11
  module AgentHarness
8
12
  # Executes shell commands with timeout support
@@ -18,6 +22,10 @@ module AgentHarness
18
22
  # @example With timeout
19
23
  # result = executor.execute("claude --print", timeout: 300)
20
24
  class CommandExecutor
25
+ PREPARATION_LOCK_POLL_INTERVAL = 0.01
26
+ PREPARATION_CLEANUP_GRACE_PERIOD = 5
27
+ PREPARATION_LOCK_ROOT = File.join(Dir.tmpdir, "agent-harness-preparation-locks")
28
+
21
29
  # Result of a command execution
22
30
  Result = Struct.new(:stdout, :stderr, :exit_code, :duration, keyword_init: true) do
23
31
  def success?
@@ -42,6 +50,8 @@ module AgentHarness
42
50
  # @param idle_timeout [Integer, Float, nil] idle timeout in seconds based on output activity
43
51
  # @param env [Hash] environment variables
44
52
  # @param stdin_data [String, nil] data to send to stdin
53
+ # @param preparation [ExecutionPreparation, nil] request-scoped bootstrap
54
+ # work for the runtime environment
45
55
  # @param on_stdout_chunk [Proc, nil] callback for stdout chunks as they are produced
46
56
  # @param on_stderr_chunk [Proc, nil] callback for stderr chunks as they are produced
47
57
  # @param on_heartbeat [Proc, nil] callback invoked periodically while the command is running
@@ -51,7 +61,7 @@ module AgentHarness
51
61
  # @return [Result] execution result
52
62
  # @raise [TimeoutError] if the command times out
53
63
  # @raise [IdleTimeoutError] if the command exceeds the idle timeout
54
- def execute(command, timeout: nil, idle_timeout: nil, env: {}, stdin_data: nil,
64
+ def execute(command, timeout: nil, idle_timeout: nil, env: {}, stdin_data: nil, preparation: nil,
55
65
  on_stdout_chunk: nil, on_stderr_chunk: nil, on_heartbeat: nil,
56
66
  heartbeat_interval: 1.0, observer: nil)
57
67
  validate_duration!(timeout, name: :timeout, allow_nil: true)
@@ -60,28 +70,70 @@ module AgentHarness
60
70
 
61
71
  cmd_array = normalize_command(command)
62
72
  cmd_string = cmd_array.shelljoin
73
+ command_name = cmd_array.first
74
+ start_time = current_time
75
+ deadline = timeout_deadline(timeout)
76
+ applied_preparation = []
77
+ held_preparation_locks = []
78
+ background_cleanup_scheduled = false
63
79
 
64
80
  log_debug("Executing command",
65
81
  command: cmd_string,
66
82
  timeout: timeout,
67
83
  idle_timeout: idle_timeout)
68
84
 
69
- start_time = Time.now
70
-
71
- stdout, stderr, status = execute_streaming(
72
- cmd_array,
85
+ held_preparation_locks = acquire_preparation_locks(
86
+ preparation,
87
+ env: env,
73
88
  timeout: timeout,
74
- idle_timeout: idle_timeout,
89
+ deadline: deadline,
90
+ command_name: command_name
91
+ )
92
+ apply_preparation(
93
+ preparation,
75
94
  env: env,
76
- stdin_data: stdin_data,
77
- on_stdout_chunk: on_stdout_chunk,
78
- on_stderr_chunk: on_stderr_chunk,
79
- on_heartbeat: on_heartbeat,
80
- heartbeat_interval: heartbeat_interval,
81
- observer: observer
95
+ timeout: timeout,
96
+ deadline: deadline,
97
+ command_name: command_name,
98
+ applied_preparation: applied_preparation
82
99
  )
83
100
 
84
- duration = Time.now - start_time
101
+ begin
102
+ stdout, stderr, status = execute_streaming(
103
+ cmd_array,
104
+ timeout: remaining_timeout(deadline, timeout:, command_name: command_name),
105
+ idle_timeout: idle_timeout,
106
+ env: env,
107
+ stdin_data: stdin_data,
108
+ on_stdout_chunk: on_stdout_chunk,
109
+ on_stderr_chunk: on_stderr_chunk,
110
+ on_heartbeat: on_heartbeat,
111
+ heartbeat_interval: heartbeat_interval,
112
+ observer: observer
113
+ )
114
+ rescue TimeoutError => e
115
+ raise e if e.is_a?(IdleTimeoutError)
116
+
117
+ raise TimeoutError, "Command timed out after #{timeout} seconds: #{command_name}"
118
+ end
119
+
120
+ begin
121
+ cleanup_preparation(
122
+ applied_preparation,
123
+ command_name: command_name,
124
+ timeout: timeout,
125
+ deadline: cleanup_deadline(deadline, timeout:)
126
+ )
127
+ rescue TimeoutError
128
+ schedule_cleanup_preparation(
129
+ applied_preparation,
130
+ held_preparation_locks,
131
+ command_name: command_name
132
+ )
133
+ background_cleanup_scheduled = true
134
+ held_preparation_locks = []
135
+ end
136
+ duration = current_time - start_time
85
137
 
86
138
  Result.new(
87
139
  stdout: stdout,
@@ -89,6 +141,46 @@ module AgentHarness
89
141
  exit_code: status.exitstatus,
90
142
  duration: duration
91
143
  )
144
+ ensure
145
+ pending_exception = $!
146
+ unless background_cleanup_scheduled || applied_preparation.nil? || applied_preparation.empty?
147
+ begin
148
+ cleanup_preparation(
149
+ applied_preparation,
150
+ command_name: command_name,
151
+ timeout: timeout,
152
+ deadline: cleanup_deadline(deadline, timeout:)
153
+ )
154
+ rescue TimeoutError => e
155
+ raise e if pending_exception.nil?
156
+
157
+ if pending_exception.is_a?(TimeoutError)
158
+ schedule_cleanup_preparation(
159
+ applied_preparation,
160
+ held_preparation_locks,
161
+ command_name: command_name
162
+ )
163
+ background_cleanup_scheduled = true
164
+ held_preparation_locks = []
165
+ else
166
+ # Preserve the original non-timeout exception; surface that
167
+ # cleanup also timed out so callers know bootstrap state may
168
+ # have leaked.
169
+ raise pending_exception.class,
170
+ "#{pending_exception.message} (cleanup also failed: #{e.message})"
171
+ end
172
+ rescue => e
173
+ raise e if pending_exception.nil?
174
+
175
+ # Surface cleanup failures even when unwinding from another exception,
176
+ # so callers know request-scoped bootstrap state may have leaked.
177
+ raise pending_exception.class,
178
+ "#{pending_exception.message} (cleanup also failed: #{e.message})"
179
+ end
180
+ end
181
+ unless background_cleanup_scheduled || held_preparation_locks.nil? || held_preparation_locks.empty?
182
+ release_preparation_locks(held_preparation_locks)
183
+ end
92
184
  end
93
185
 
94
186
  # Check if a binary exists in PATH
@@ -126,6 +218,351 @@ module AgentHarness
126
218
 
127
219
  private
128
220
 
221
+ def acquire_preparation_locks(preparation, env:, timeout:, deadline:, command_name:)
222
+ return [] if preparation.nil? || preparation.empty?
223
+
224
+ preparation.file_writes.each { |write| validate_preparation_path_security!(write.path) }
225
+
226
+ acquired_locks = []
227
+
228
+ preparation_lock_keys(preparation, env).each do |key|
229
+ acquired_locks << acquire_preparation_lock(key, timeout:, deadline:, command_name:)
230
+ end
231
+ acquired_locks
232
+ rescue
233
+ release_preparation_locks(acquired_locks) if acquired_locks && !acquired_locks.empty?
234
+ raise
235
+ end
236
+
237
+ def acquire_preparation_lock(key, timeout:, deadline:, command_name:)
238
+ lock_path = preparation_lock_path(key)
239
+ FileUtils.mkdir_p(File.dirname(lock_path), mode: 0o700)
240
+ lock_file = File.open(lock_path, File::RDWR | File::CREAT, 0o600)
241
+
242
+ begin
243
+ if timeout.nil?
244
+ lock_file.flock(File::LOCK_EX)
245
+ else
246
+ until lock_file.flock(File::LOCK_EX | File::LOCK_NB)
247
+ sleep([PREPARATION_LOCK_POLL_INTERVAL, remaining_timeout(deadline, timeout:, command_name:)].min)
248
+ end
249
+ end
250
+ rescue
251
+ lock_file.close unless lock_file.closed?
252
+ raise
253
+ end
254
+
255
+ {key: key, file: lock_file}
256
+ end
257
+
258
+ def release_preparation_locks(held_preparation_locks)
259
+ held_preparation_locks.reverse_each do |lock|
260
+ file = lock[:file]
261
+ next if file.nil? || file.closed?
262
+
263
+ file.flock(File::LOCK_UN)
264
+ file.close
265
+ end
266
+ end
267
+
268
+ def preparation_lock_keys(preparation, env)
269
+ preparation.file_writes.map do |write|
270
+ "#{preparation_lock_scope}:#{expand_preparation_path(write.path, env)}"
271
+ end.uniq.sort
272
+ end
273
+
274
+ def preparation_lock_scope
275
+ "host"
276
+ end
277
+
278
+ def preparation_lock_path(key)
279
+ File.join(PREPARATION_LOCK_ROOT, "#{Digest::SHA256.hexdigest(key)}.lock")
280
+ end
281
+
282
+ def apply_preparation(preparation, env:, timeout:, deadline:, command_name:, applied_preparation:)
283
+ return if preparation.nil? || preparation.empty?
284
+
285
+ preparation.file_writes.each do |write|
286
+ validate_preparation_path_security!(write.path)
287
+ validate_preparation_path_env!(write.path, env)
288
+ validate_home_relative_preparation_path!(write.path, env)
289
+ resolved_path = expand_preparation_path(write.path, env)
290
+ created_directories = missing_parent_directories(resolved_path)
291
+ snapshot = within_timeout(deadline, timeout:, command_name:) do
292
+ snapshot_file_state(resolved_path)
293
+ end
294
+ applied_preparation << {
295
+ path: resolved_path,
296
+ snapshot: snapshot,
297
+ created_directories: created_directories
298
+ }
299
+
300
+ within_timeout(deadline, timeout:, command_name:) do
301
+ FileUtils.mkdir_p(File.dirname(resolved_path))
302
+ delete_preparation_path(resolved_path) if snapshot[:type] == :symlink
303
+ File.binwrite(resolved_path, write.content)
304
+ File.chmod(write.mode, resolved_path) if write.mode
305
+ end
306
+ rescue => e
307
+ begin
308
+ cleanup_preparation(
309
+ applied_preparation,
310
+ command_name: command_name,
311
+ timeout: timeout,
312
+ deadline: cleanup_deadline(deadline, timeout:)
313
+ )
314
+ rescue => cleanup_error
315
+ log_debug("Failed to clean up runtime preparation", error: cleanup_error.message)
316
+ end
317
+ raise e
318
+ end
319
+ end
320
+
321
+ def cleanup_preparation(applied_preparation, command_name:, timeout: nil, deadline: nil)
322
+ applied_preparation.reverse_each do |entry|
323
+ within_timeout(deadline, timeout:, command_name:) do
324
+ unless entry[:restored]
325
+ restore_file_state(entry[:path], entry[:snapshot])
326
+ entry[:restored] = true
327
+ end
328
+ cleanup_created_directories(entry[:created_directories])
329
+ end
330
+ end
331
+ applied_preparation.clear
332
+ end
333
+
334
+ def missing_parent_directories(path)
335
+ directories = []
336
+ current = File.dirname(path)
337
+
338
+ until current == File.dirname(current) || File.exist?(current) || File.symlink?(current)
339
+ directories << current
340
+ current = File.dirname(current)
341
+ end
342
+
343
+ directories
344
+ end
345
+
346
+ def cleanup_created_directories(directories)
347
+ directories.each do |directory|
348
+ next unless File.directory?(directory) && !File.symlink?(directory)
349
+
350
+ Dir.rmdir(directory)
351
+ rescue Errno::ENOENT
352
+ next
353
+ rescue Errno::ENOTEMPTY, Errno::EEXIST
354
+ break
355
+ end
356
+ end
357
+
358
+ def schedule_cleanup_preparation(applied_preparation, held_preparation_locks, command_name:)
359
+ cleanup_deadline = timeout_deadline(PREPARATION_CLEANUP_GRACE_PERIOD)
360
+ Thread.new(applied_preparation, held_preparation_locks, cleanup_deadline, command_name) do |entries, locks, deadline_at, cleanup_command_name|
361
+ Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)
362
+
363
+ begin
364
+ cleanup_preparation(
365
+ entries,
366
+ command_name: cleanup_command_name,
367
+ timeout: PREPARATION_CLEANUP_GRACE_PERIOD,
368
+ deadline: deadline_at
369
+ )
370
+ rescue => e
371
+ log_debug("Failed to clean up runtime preparation after timeout", error: e.message)
372
+ ensure
373
+ release_preparation_locks(locks) unless locks.nil? || locks.empty?
374
+ end
375
+ end
376
+ end
377
+
378
+ def expand_preparation_path(path, env)
379
+ expanded_path = path.gsub(/\$(\w+)|\$\{([^}]+)\}/) do
380
+ key = Regexp.last_match(1) || Regexp.last_match(2)
381
+ resolve_preparation_path_env_var(key, env)
382
+ end
383
+
384
+ if expanded_path == "~"
385
+ return File.expand_path(resolve_preparation_home(env))
386
+ end
387
+
388
+ if expanded_path.start_with?("~/")
389
+ return File.expand_path(File.join(resolve_preparation_home(env), expanded_path.delete_prefix("~/")))
390
+ end
391
+
392
+ File.expand_path(expanded_path)
393
+ end
394
+
395
+ def validate_preparation_path_env!(path, env)
396
+ path.scan(/\$(\w+)|\$\{([^}]+)\}/) do |match|
397
+ key = match.compact.first
398
+ resolve_preparation_path_env_var(key, env)
399
+ end
400
+ end
401
+
402
+ def validate_preparation_path_security!(path)
403
+ if path.include?("\x00")
404
+ raise ArgumentError, "preparation path must not contain null bytes"
405
+ end
406
+
407
+ if path.include?("\n")
408
+ raise ArgumentError, "preparation path must not contain newline characters"
409
+ end
410
+
411
+ if path.include?("\r")
412
+ raise ArgumentError, "preparation path must not contain carriage return characters"
413
+ end
414
+
415
+ if path.include?("`")
416
+ raise ArgumentError, "preparation path must not contain backtick characters"
417
+ end
418
+
419
+ if path.include?(";")
420
+ raise ArgumentError, "preparation path must not contain semicolon characters"
421
+ end
422
+
423
+ if path.include?("|")
424
+ raise ArgumentError, "preparation path must not contain pipe characters"
425
+ end
426
+
427
+ if path.include?("$(")
428
+ raise ArgumentError, "preparation path must not contain command substitution"
429
+ end
430
+
431
+ if path.include?("..")
432
+ raise ArgumentError, "preparation path must not contain path traversal"
433
+ end
434
+ end
435
+
436
+ def validate_home_relative_preparation_path!(path, env)
437
+ return unless path == "~" || path.start_with?("~/")
438
+ return unless env.key?("HOME")
439
+
440
+ home = env["HOME"]
441
+ raise ArgumentError, "HOME cannot be nil or empty for home-relative preparation paths" if home.nil? || home.empty?
442
+ raise ArgumentError, "HOME must not contain path traversal" if home.include?("..")
443
+ end
444
+
445
+ def resolve_preparation_path_env_var(key, env)
446
+ unless env.key?(key)
447
+ raise ArgumentError, "#{key} cannot be nil or empty for env-backed preparation paths"
448
+ end
449
+
450
+ value = env[key]
451
+ raise ArgumentError, "#{key} cannot be nil or empty for env-backed preparation paths" if value.nil? || value.empty?
452
+ raise ArgumentError, "#{key} must not contain path traversal" if value.include?("..")
453
+
454
+ value
455
+ end
456
+
457
+ def resolve_preparation_home(env)
458
+ if env.key?("HOME")
459
+ home = env["HOME"]
460
+ raise ArgumentError, "HOME cannot be nil or empty for home-relative preparation paths" if home.nil? || home.empty?
461
+ raise ArgumentError, "HOME must not contain path traversal" if home.include?("..")
462
+
463
+ return home
464
+ end
465
+
466
+ ENV["HOME"] || Dir.home
467
+ end
468
+
469
+ def snapshot_file_state(path)
470
+ stat = File.lstat(path)
471
+
472
+ if stat.symlink?
473
+ {
474
+ existed: true,
475
+ type: :symlink,
476
+ target: File.readlink(path)
477
+ }
478
+ elsif stat.file?
479
+ backup_file = Tempfile.new("agent-harness-preparation")
480
+ backup_path = backup_file.path
481
+ backup_file.close!
482
+ FileUtils.cp(path, backup_path, preserve: true)
483
+
484
+ {
485
+ existed: true,
486
+ type: :file,
487
+ backup_path: backup_path
488
+ }
489
+ else
490
+ raise ArgumentError, "preparation target must be a regular file or symlink: #{path}"
491
+ end
492
+ rescue Errno::ENOENT
493
+ {existed: false}
494
+ rescue
495
+ FileUtils.rm_f(backup_path) if defined?(backup_path) && backup_path
496
+ raise
497
+ end
498
+
499
+ def restore_file_state(path, snapshot)
500
+ if snapshot[:type] == :symlink
501
+ delete_preparation_path(path)
502
+ FileUtils.mkdir_p(File.dirname(path))
503
+ File.symlink(snapshot[:target], path)
504
+ elsif snapshot[:existed]
505
+ backup_path = snapshot.fetch(:backup_path)
506
+ raise ArgumentError, "missing runtime preparation backup: #{backup_path}" unless File.exist?(backup_path)
507
+
508
+ delete_preparation_path(path)
509
+ FileUtils.mkdir_p(File.dirname(path))
510
+ FileUtils.cp(backup_path, path, preserve: true)
511
+ else
512
+ delete_preparation_path(path)
513
+ end
514
+
515
+ # Only remove the backup after restore succeeds. If restore fails (e.g. the
516
+ # prepared path was replaced by a directory), the backup must survive so
517
+ # later cleanup retries can still restore the original user file.
518
+ FileUtils.rm_f(snapshot[:backup_path]) if snapshot[:type] == :file && snapshot[:backup_path]
519
+ end
520
+
521
+ def delete_preparation_path(path)
522
+ return unless File.exist?(path) || File.symlink?(path)
523
+
524
+ raise ArgumentError, "preparation target changed into a directory during execution: #{path}" if File.directory?(path) && !File.symlink?(path)
525
+
526
+ File.delete(path)
527
+ end
528
+
529
+ def timeout_deadline(timeout)
530
+ return nil if timeout.nil?
531
+
532
+ current_time + timeout
533
+ end
534
+
535
+ def cleanup_deadline(deadline, timeout:)
536
+ return nil if timeout.nil?
537
+
538
+ # Keep synchronous cleanup within the caller's original timeout budget.
539
+ # If cleanup overruns after a successful command or after a timeout/error,
540
+ # execute schedules bounded background cleanup instead of extending execute.
541
+ deadline
542
+ end
543
+
544
+ def remaining_timeout(deadline, timeout:, command_name:)
545
+ return nil if deadline.nil?
546
+
547
+ remaining = deadline - current_time
548
+ raise TimeoutError, "Command timed out after #{timeout} seconds: #{command_name}" if remaining <= 0
549
+
550
+ remaining
551
+ end
552
+
553
+ def within_timeout(deadline, timeout:, command_name:)
554
+ remaining = remaining_timeout(deadline, timeout:, command_name:)
555
+ return yield if remaining.nil?
556
+
557
+ Timeout.timeout(remaining) { yield }
558
+ rescue Timeout::Error
559
+ raise TimeoutError, "Command timed out after #{timeout} seconds: #{command_name}"
560
+ end
561
+
562
+ def current_time
563
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
564
+ end
565
+
129
566
  def execute_streaming(cmd_array, timeout:, idle_timeout:, env:, stdin_data:,
130
567
  on_stdout_chunk:, on_stderr_chunk:, on_heartbeat:, heartbeat_interval:, observer:)
131
568
  stdout = +""