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.
@@ -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 = +""