agent-harness 0.5.8 → 0.6.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.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module AgentHarness
4
6
  # Executes commands inside a Docker container
5
7
  #
@@ -21,7 +23,9 @@ module AgentHarness
21
23
  # @param logger [Logger, nil] optional logger
22
24
  # @raise [CommandExecutionError] if Docker CLI is not found on the host
23
25
  def initialize(container_id:, logger: nil)
24
- raise ArgumentError, "container_id cannot be nil or empty" if container_id.nil? || container_id.empty?
26
+ unless container_id.is_a?(String) && !container_id.strip.empty?
27
+ raise ArgumentError, "container_id cannot be nil or blank"
28
+ end
25
29
 
26
30
  super(logger: logger)
27
31
  @container_id = container_id
@@ -38,17 +42,142 @@ module AgentHarness
38
42
  # @param idle_timeout [Integer, Float, nil] idle timeout in seconds based on output activity
39
43
  # @param env [Hash] environment variables to set in the container
40
44
  # @param stdin_data [String, nil] data to send to stdin
45
+ # @param preparation [ExecutionPreparation, nil] request-scoped bootstrap
46
+ # work to materialize inside the container before the main command runs
41
47
  # @return [Result] execution result
42
- def execute(command, timeout: nil, idle_timeout: nil, env: {}, stdin_data: nil, **execution_options)
43
- docker_cmd = build_docker_command(command, env: env, stdin_data: stdin_data)
44
- super(
45
- docker_cmd,
48
+ def execute(command, timeout: nil, idle_timeout: nil, env: {}, stdin_data: nil, preparation: nil, **execution_options)
49
+ start_time = current_time
50
+ normalized_command = normalize_command(command)
51
+ command_name = normalized_command.first
52
+ deadline = timeout_deadline(timeout)
53
+ cleanup_steps = []
54
+ execution_tracking = nil
55
+ held_preparation_locks = acquire_preparation_locks(
56
+ preparation,
57
+ env: env,
46
58
  timeout: timeout,
47
- idle_timeout: idle_timeout,
48
- env: {},
59
+ deadline: deadline,
60
+ command_name: command_name
61
+ )
62
+ background_cleanup_scheduled = false
63
+
64
+ apply_container_preparation(preparation, timeout: timeout, deadline: deadline, env: env, cleanup_steps: cleanup_steps)
65
+ execution_tracking = if timeout && !preparation.nil? && !preparation.empty?
66
+ build_container_execution_tracking(normalized_command, env: env)
67
+ end
68
+ docker_cmd = build_docker_command_for_execution(
69
+ normalized_command,
70
+ env: env,
49
71
  stdin_data: stdin_data,
50
- **execution_options
72
+ execution_tracking: execution_tracking
73
+ )
74
+ begin
75
+ result = super(
76
+ docker_cmd,
77
+ timeout: remaining_timeout(deadline, timeout:, command_name: command_name),
78
+ idle_timeout: idle_timeout,
79
+ env: {},
80
+ stdin_data: stdin_data,
81
+ **execution_options
82
+ )
83
+ rescue IdleTimeoutError
84
+ raise
85
+ rescue TimeoutError
86
+ schedule_container_cleanup_preparation(
87
+ cleanup_steps,
88
+ held_preparation_locks,
89
+ command_name: command_name,
90
+ termination_command: execution_tracking && execution_tracking[:terminate_command],
91
+ finalizer_command: execution_tracking && execution_tracking[:cleanup_command]
92
+ )
93
+ background_cleanup_scheduled = true
94
+ held_preparation_locks = []
95
+ raise TimeoutError, "Command timed out after #{timeout} seconds: #{command_name}"
96
+ end
97
+ begin
98
+ cleanup_container_preparation(
99
+ cleanup_steps,
100
+ timeout:,
101
+ deadline: cleanup_deadline(deadline, timeout:),
102
+ command_name: command_name
103
+ )
104
+ cleanup_container_execution_tracking(
105
+ execution_tracking,
106
+ timeout:,
107
+ deadline: cleanup_deadline(deadline, timeout:),
108
+ command_name: command_name
109
+ )
110
+ execution_tracking = nil
111
+ rescue TimeoutError
112
+ # The main command already finished; omit termination_command so
113
+ # background cleanup does not TERM/KILL based on a stale PID file
114
+ # that may have been reused by an unrelated process.
115
+ schedule_container_cleanup_preparation(
116
+ cleanup_steps,
117
+ held_preparation_locks,
118
+ command_name: command_name,
119
+ termination_command: nil,
120
+ finalizer_command: execution_tracking && execution_tracking[:cleanup_command]
121
+ )
122
+ background_cleanup_scheduled = true
123
+ held_preparation_locks = []
124
+ end
125
+ Result.new(
126
+ stdout: result.stdout,
127
+ stderr: result.stderr,
128
+ exit_code: result.exit_code,
129
+ duration: current_time - start_time
51
130
  )
131
+ ensure
132
+ pending_exception = $!
133
+ cleanup_pending = !cleanup_steps.nil? && !cleanup_steps.empty?
134
+ tracking_cleanup_pending = !execution_tracking.nil?
135
+ if !background_cleanup_scheduled && (cleanup_pending || tracking_cleanup_pending)
136
+ begin
137
+ cleanup_container_preparation(
138
+ cleanup_steps,
139
+ timeout:,
140
+ deadline: cleanup_deadline(deadline, timeout:),
141
+ command_name: command_name
142
+ )
143
+ cleanup_container_execution_tracking(
144
+ execution_tracking,
145
+ timeout:,
146
+ deadline: cleanup_deadline(deadline, timeout:),
147
+ command_name: command_name
148
+ )
149
+ rescue TimeoutError => e
150
+ raise e if pending_exception.nil?
151
+
152
+ if pending_exception.is_a?(TimeoutError)
153
+ schedule_container_cleanup_preparation(
154
+ cleanup_steps,
155
+ held_preparation_locks,
156
+ command_name: command_name,
157
+ termination_command: execution_tracking && execution_tracking[:terminate_command],
158
+ finalizer_command: execution_tracking && execution_tracking[:cleanup_command]
159
+ )
160
+ background_cleanup_scheduled = true
161
+ held_preparation_locks = []
162
+ else
163
+ # Preserve the original non-timeout exception; surface that
164
+ # cleanup also timed out so callers know bootstrap state may
165
+ # have leaked.
166
+ raise pending_exception.class,
167
+ "#{pending_exception.message} (cleanup also failed: #{e.message})"
168
+ end
169
+ rescue => e
170
+ raise e if pending_exception.nil?
171
+
172
+ # Surface cleanup failures even when unwinding from another exception,
173
+ # so callers know request-scoped bootstrap state may have leaked.
174
+ raise pending_exception.class,
175
+ "#{pending_exception.message} (cleanup also failed: #{e.message})"
176
+ end
177
+ end
178
+ unless background_cleanup_scheduled || held_preparation_locks.nil? || held_preparation_locks.empty?
179
+ release_preparation_locks(held_preparation_locks)
180
+ end
52
181
  end
53
182
 
54
183
  # Check if a binary exists inside the container
@@ -62,12 +191,374 @@ module AgentHarness
62
191
 
63
192
  private
64
193
 
194
+ def preparation_lock_scope
195
+ "docker:#{container_id}"
196
+ end
197
+
198
+ def preparation_lock_keys(preparation, env)
199
+ preparation.file_writes.map do |write|
200
+ "#{preparation_lock_scope}:#{normalize_container_lock_path(write.path, env)}"
201
+ end.uniq.sort
202
+ end
203
+
204
+ def apply_container_preparation(preparation, timeout:, deadline:, env:, cleanup_steps:)
205
+ return if preparation.nil? || preparation.empty?
206
+
207
+ preparation.file_writes.each do |write|
208
+ # materialize_file_write records the cleanup command after backing up
209
+ # the target, and its own rescue block runs that cleanup if any later
210
+ # step (mkdir, write, chmod) raises partway through. Only fully
211
+ # materialized writes are appended to cleanup_steps here.
212
+ cleanup = materialize_file_write(write, timeout:, deadline:, env:)
213
+ cleanup_steps << cleanup
214
+ rescue => e
215
+ begin
216
+ cleanup_container_preparation(cleanup_steps, timeout:, deadline:, command_name: "docker")
217
+ rescue => cleanup_error
218
+ log_debug("Failed to clean up container runtime preparation", error: cleanup_error.message)
219
+ end
220
+ raise e
221
+ end
222
+ end
223
+
224
+ def cleanup_container_preparation(cleanup_steps, timeout:, deadline:, command_name:)
225
+ until cleanup_steps.empty?
226
+ cleanup = cleanup_steps.last
227
+ run_host_command(
228
+ cleanup[:command],
229
+ timeout: remaining_timeout(deadline, timeout:, command_name:),
230
+ stdin_data: nil
231
+ )
232
+ cleanup_steps.pop
233
+ end
234
+ end
235
+
236
+ def schedule_container_cleanup_preparation(
237
+ cleanup_steps,
238
+ held_preparation_locks,
239
+ command_name:,
240
+ termination_command: nil,
241
+ finalizer_command: nil
242
+ )
243
+ cleanup_deadline = timeout_deadline(PREPARATION_CLEANUP_GRACE_PERIOD)
244
+ Thread.new(
245
+ cleanup_steps,
246
+ held_preparation_locks,
247
+ cleanup_deadline,
248
+ command_name,
249
+ termination_command,
250
+ finalizer_command
251
+ ) do |steps, locks, deadline_at, cleanup_command_name, terminate_cmd, finalizer_cmd|
252
+ Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)
253
+
254
+ begin
255
+ if terminate_cmd
256
+ run_host_command(
257
+ terminate_cmd,
258
+ timeout: remaining_timeout(deadline_at, timeout: PREPARATION_CLEANUP_GRACE_PERIOD, command_name: cleanup_command_name)
259
+ )
260
+ end
261
+ cleanup_container_preparation(
262
+ steps,
263
+ timeout: PREPARATION_CLEANUP_GRACE_PERIOD,
264
+ deadline: deadline_at,
265
+ command_name: cleanup_command_name
266
+ )
267
+ rescue => e
268
+ log_debug("Failed to clean up container runtime preparation after timeout", error: e.message)
269
+ ensure
270
+ if finalizer_cmd
271
+ begin
272
+ run_host_command(
273
+ finalizer_cmd,
274
+ timeout: remaining_timeout(deadline_at, timeout: PREPARATION_CLEANUP_GRACE_PERIOD, command_name: cleanup_command_name)
275
+ )
276
+ rescue => e
277
+ log_debug("Failed to clean up container execution tracking after timeout", error: e.message)
278
+ end
279
+ end
280
+ release_preparation_locks(locks) unless locks.nil? || locks.empty?
281
+ end
282
+ end
283
+ end
284
+
285
+ def build_container_execution_tracking(command, env:)
286
+ state_dir_path = "/tmp/agent-harness-execution-#{SecureRandom.hex(8)}"
287
+ state_dir = shell_path(state_dir_path)
288
+ pid_file = shell_path(File.join(state_dir_path, "pid"))
289
+ tracked_script = "printf %s $$ > #{pid_file} && exec #{Shellwords.join(normalize_command(command))}"
290
+ tracked_command = [
291
+ "sh",
292
+ "-lc",
293
+ "umask 077 && mkdir -p #{state_dir} && if command -v setsid >/dev/null 2>&1; then " \
294
+ "exec setsid sh -lc #{Shellwords.escape(tracked_script)}; else exec sh -lc " \
295
+ "#{Shellwords.escape(tracked_script)}; fi"
296
+ ]
297
+
298
+ {
299
+ command: tracked_command,
300
+ terminate_command: build_container_shell_command(
301
+ "if [ -f #{pid_file} ]; then pid=$(cat #{pid_file} 2>/dev/null); if [ -n \"$pid\" ]; then " \
302
+ "kill -TERM -- \"-$pid\" 2>/dev/null || kill -TERM \"$pid\" 2>/dev/null || true; " \
303
+ "i=0; while kill -0 \"$pid\" 2>/dev/null && [ \"$i\" -lt 10 ]; do sleep 0.1; i=$((i + 1)); done; " \
304
+ "kill -KILL -- \"-$pid\" 2>/dev/null || kill -KILL \"$pid\" 2>/dev/null || true; fi; fi",
305
+ env: env
306
+ ),
307
+ cleanup_command: build_container_shell_command("rm -rf #{state_dir}", env: env)
308
+ }
309
+ end
310
+
311
+ def build_docker_command_for_execution(command, env:, stdin_data:, execution_tracking:)
312
+ actual_command = execution_tracking ? execution_tracking[:command] : command
313
+ build_docker_command(actual_command, env: env, stdin_data: stdin_data)
314
+ end
315
+
316
+ def cleanup_container_execution_tracking(execution_tracking, timeout:, deadline:, command_name:)
317
+ return if execution_tracking.nil?
318
+
319
+ run_host_command(
320
+ execution_tracking.fetch(:cleanup_command),
321
+ timeout: remaining_timeout(deadline, timeout:, command_name:),
322
+ stdin_data: nil
323
+ )
324
+ end
325
+
326
+ def materialize_file_write(write, timeout:, deadline:, env:)
327
+ validate_preparation_path_security!(write.path)
328
+ validate_preparation_path_env!(write.path, env)
329
+ validate_home_relative_preparation_path!(write.path, env)
330
+ resolved_write_path = resolve_container_preparation_path(write.path, env)
331
+ path = shell_path(resolved_write_path)
332
+ dir = shell_path(File.dirname(resolved_write_path))
333
+ state_dir_path = "/tmp/agent-harness-preparation-#{SecureRandom.hex(8)}"
334
+ state_dir = shell_path(state_dir_path)
335
+ backup = shell_path(File.join(state_dir_path, "backup"))
336
+ state = shell_path(File.join(state_dir_path, "state"))
337
+ symlink_target = shell_path(File.join(state_dir_path, "symlink_target"))
338
+ created_directories = shell_path(File.join(state_dir_path, "created_directories"))
339
+ invalid_target_message = Shellwords.escape(
340
+ "preparation target must be a regular file or symlink: #{write.path}"
341
+ )
342
+ directory_change_message = Shellwords.escape(
343
+ "preparation target changed into a directory during execution: #{write.path}"
344
+ )
345
+ cleanup_state_dir_cmd = build_container_shell_command("rm -rf #{state_dir}", env: env)
346
+ backup_cmd = build_container_shell_command(
347
+ "umask 077 && mkdir -p #{state_dir} && : > #{created_directories} && " \
348
+ "#{record_created_directories_script(resolved_write_path, created_directories)}" \
349
+ "if [ -L #{path} ]; then readlink #{path} > #{symlink_target} && printf symlink > #{state}; " \
350
+ "elif [ -d #{path} ]; then printf '%s\\n' #{invalid_target_message} >&2; exit 1; " \
351
+ "elif [ -e #{path} ]; then cp -p #{path} #{backup} && printf file > #{state}; " \
352
+ "else printf missing > #{state}; fi",
353
+ env: env
354
+ )
355
+ run_host_command(backup_cmd, timeout: remaining_timeout(deadline, timeout:, command_name: "docker"))
356
+ cleanup = {
357
+ command: build_container_shell_command(
358
+ "cleanup_status=0; state_value=$(cat #{state} 2>/dev/null); if [ -d #{path} ] && [ ! -L #{path} ]; then " \
359
+ "printf '%s\\n' #{directory_change_message} >&2; cleanup_status=1; " \
360
+ "elif [ \"$state_value\" = symlink ]; then " \
361
+ "mkdir -p #{dir} && rm -f -- #{path} && ln -s -- \"$(cat #{symlink_target})\" #{path} || cleanup_status=$?; " \
362
+ "elif [ \"$state_value\" = file ]; then " \
363
+ "if [ -f #{backup} ]; then mkdir -p #{dir} && rm -f -- #{path} && cp -p #{backup} #{path} || cleanup_status=$?; " \
364
+ "else echo \"missing runtime preparation backup: #{backup}\" >&2; cleanup_status=1; fi; " \
365
+ "elif [ \"$state_value\" = missing ]; then rm -f -- #{path} || cleanup_status=$?; " \
366
+ "else cleanup_status=1; " \
367
+ "fi; #{cleanup_created_directories_script(created_directories)}" \
368
+ "if [ $cleanup_status -eq 0 ]; then rm -rf #{state_dir}; fi; exit $cleanup_status",
369
+ env: env
370
+ )
371
+ }
372
+
373
+ mkdir_cmd = build_container_shell_command("mkdir -p #{dir}", env: env)
374
+ run_host_command(mkdir_cmd, timeout: remaining_timeout(deadline, timeout:, command_name: "docker"))
375
+
376
+ remove_symlink_cmd = build_container_shell_command(
377
+ "state_value=$(cat #{state} 2>/dev/null); if [ \"$state_value\" = symlink ]; then rm -f -- #{path}; fi",
378
+ env: env
379
+ )
380
+ run_host_command(remove_symlink_cmd, timeout: remaining_timeout(deadline, timeout:, command_name: "docker"))
381
+
382
+ write_cmd = build_container_shell_command("cat > #{path}", env: env, stdin_data: write.content)
383
+ run_host_command(
384
+ write_cmd,
385
+ timeout: remaining_timeout(deadline, timeout:, command_name: "docker"),
386
+ stdin_data: write.content
387
+ )
388
+
389
+ if write.mode
390
+ chmod_cmd = build_container_shell_command("chmod #{write.mode.to_s(8)} #{path}", env: env)
391
+ run_host_command(chmod_cmd, timeout: remaining_timeout(deadline, timeout:, command_name: "docker"))
392
+ end
393
+
394
+ cleanup
395
+ rescue => e
396
+ if cleanup
397
+ begin
398
+ run_host_command(
399
+ cleanup[:command],
400
+ timeout: remaining_timeout(deadline, timeout:, command_name: "docker")
401
+ )
402
+ rescue => cleanup_error
403
+ log_debug("Failed to clean up container runtime preparation", error: cleanup_error.message)
404
+ end
405
+ elsif defined?(cleanup_state_dir_cmd)
406
+ begin
407
+ run_host_command(
408
+ cleanup_state_dir_cmd,
409
+ timeout: remaining_timeout(deadline, timeout:, command_name: "docker")
410
+ )
411
+ rescue => cleanup_error
412
+ log_debug("Failed to clean up container runtime preparation", error: cleanup_error.message)
413
+ end
414
+ end
415
+ raise e
416
+ end
417
+
418
+ def record_created_directories_script(path, created_directories_file)
419
+ parent_preparation_paths(path).map do |parent_path|
420
+ rendered_parent = shell_path(parent_path)
421
+ "if [ ! -e #{rendered_parent} ]; then printf '%s\\n' #{rendered_parent} >> #{created_directories_file}; fi; "
422
+ end.join
423
+ end
424
+
425
+ def cleanup_created_directories_script(created_directories_file)
426
+ "while IFS= read -r cleanup_dir; do " \
427
+ "if [ -d \"$cleanup_dir\" ] && [ ! -L \"$cleanup_dir\" ]; then rmdir -- \"$cleanup_dir\" 2>/dev/null || true; fi; " \
428
+ "done < #{created_directories_file}; "
429
+ end
430
+
431
+ def parent_preparation_paths(path)
432
+ parents = []
433
+ current = File.dirname(path)
434
+
435
+ until current == "." || current == "/" || current == File.dirname(current)
436
+ parents << current
437
+ current = File.dirname(current)
438
+ end
439
+
440
+ parents << current if current == "~"
441
+ parents
442
+ end
443
+
444
+ def run_host_command(command, timeout:, stdin_data: nil)
445
+ result = CommandExecutor.instance_method(:execute).bind_call(
446
+ self,
447
+ command,
448
+ timeout: timeout,
449
+ env: {},
450
+ stdin_data: stdin_data,
451
+ preparation: nil
452
+ )
453
+ return result if result.success?
454
+
455
+ message = result.stderr.to_s.strip
456
+ message = result.stdout.to_s.strip if message.empty?
457
+ message = "command failed with exit code #{result.exit_code}" if message.empty?
458
+ raise CommandExecutionError, "Failed to apply runtime preparation: #{message}"
459
+ end
460
+
65
461
  def validate_docker!
66
462
  return if ENV["PATH"]&.split(File::PATH_SEPARATOR)&.any? { |path| File.executable?(File.join(path, "docker")) }
67
463
 
68
464
  raise CommandExecutionError, "Docker CLI not found on host PATH"
69
465
  end
70
466
 
467
+ def build_container_shell_command(script, env:, stdin_data: nil)
468
+ build_docker_command(["sh", "-lc", script], env: env, stdin_data: stdin_data)
469
+ end
470
+
471
+ def resolve_preparation_path_env_var(key, env)
472
+ unless env.key?(key)
473
+ raise ArgumentError, "#{key} cannot be nil or empty for env-backed preparation paths"
474
+ end
475
+
476
+ value = env[key]
477
+ raise ArgumentError, "#{key} cannot be nil or empty for env-backed preparation paths" if value.nil? || value.empty?
478
+ raise ArgumentError, "#{key} must not contain path traversal" if value.include?("..")
479
+
480
+ value
481
+ end
482
+
483
+ def normalize_container_lock_path(path, env)
484
+ validate_home_relative_preparation_path!(path, env)
485
+
486
+ expanded_path = path.gsub(/\$(\w+)|\$\{([^}]+)\}/) do
487
+ key = Regexp.last_match(1) || Regexp.last_match(2)
488
+ resolve_preparation_path_env_var(key, env)
489
+ end
490
+
491
+ return env.key?("HOME") ? File.expand_path(env.fetch("HOME")) : "home" if expanded_path == "~"
492
+ if expanded_path.start_with?("~/")
493
+ if env.key?("HOME")
494
+ return File.expand_path(File.join(env.fetch("HOME"), expanded_path.delete_prefix("~/")))
495
+ end
496
+
497
+ normalized = File.expand_path(expanded_path.delete_prefix("~/"), "/").delete_prefix("/")
498
+ return normalized.empty? ? "home" : "home/#{normalized}"
499
+ end
500
+
501
+ return File.expand_path(expanded_path) if expanded_path.start_with?("/")
502
+
503
+ normalized = File.expand_path(expanded_path, "/").delete_prefix("/")
504
+ normalized.empty? ? "relative" : "relative/#{normalized}"
505
+ end
506
+
507
+ def validate_home_relative_preparation_path!(path, env)
508
+ return unless path == "~" || path.start_with?("~/")
509
+ return unless env.key?("HOME")
510
+
511
+ home = env["HOME"]
512
+ raise ArgumentError, "HOME cannot be nil or empty for home-relative preparation paths" if home.nil? || home.empty?
513
+ raise ArgumentError, "HOME must not contain path traversal" if home.include?("..")
514
+ end
515
+
516
+ def resolve_container_preparation_path(path, env)
517
+ return path unless path == "~" || path.start_with?("~/")
518
+ return path unless env.key?("HOME")
519
+
520
+ home = env.fetch("HOME")
521
+ return home if path == "~"
522
+
523
+ File.join(home, path.delete_prefix("~/"))
524
+ end
525
+
526
+ def shell_path(path)
527
+ return "~" if path == "~"
528
+ return shell_escaped_path(path) unless path.start_with?("~/")
529
+
530
+ suffix = path.delete_prefix("~/")
531
+ escaped_suffix = suffix.split("/").map { |segment| shell_path_segment(segment) }.join("/")
532
+ "~/#{escaped_suffix}"
533
+ end
534
+
535
+ def shell_escaped_path(path)
536
+ prefix = path.start_with?("/") ? "/" : ""
537
+ trimmed_path = path.delete_prefix("/")
538
+ escaped_segments = trimmed_path.split("/").map { |segment| shell_path_segment(segment) }
539
+ "#{prefix}#{escaped_segments.join("/")}"
540
+ end
541
+
542
+ def shell_path_segment(segment)
543
+ rendered = +""
544
+ index = 0
545
+
546
+ segment.to_enum(:scan, /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/).each do
547
+ match = Regexp.last_match
548
+ literal = segment[index...match.begin(0)]
549
+ rendered << Shellwords.escape(literal) unless literal.empty?
550
+
551
+ var_name = match[1] || match[2]
552
+ rendered << %("${#{var_name}}")
553
+ index = match.end(0)
554
+ end
555
+
556
+ tail = segment[index..]
557
+ rendered << Shellwords.escape(tail) unless tail.nil? || tail.empty?
558
+
559
+ rendered.empty? ? "''" : rendered
560
+ end
561
+
71
562
  def build_docker_command(command, env:, stdin_data:)
72
563
  cmd = ["docker", "exec"]
73
564
  unset_env_keys = []
@@ -122,17 +122,17 @@ module AgentHarness
122
122
  case message
123
123
  when /idle.?timeout/i
124
124
  :idle_timeout
125
- when /rate.?limit|too many requests|429/i
125
+ when /rate.?limit|too many requests|\b429\b/i
126
126
  :rate_limited
127
127
  when /quota|usage.?limit|billing/i
128
128
  :quota_exceeded
129
- when /auth|unauthorized|forbidden|invalid.*(key|token)|401|403/i
129
+ when /auth|unauthorized|forbidden|invalid.*(key|token)|\b401\b|\b403\b/i
130
130
  :auth_expired
131
131
  when /timeout|timed.?out/i
132
132
  :timeout
133
- when /temporary|retry|503|502|500/i
133
+ when /temporary|retry|\b503\b|\b502\b|\b500\b/i
134
134
  :transient
135
- when /invalid|malformed|bad.?request|400/i
135
+ when /invalid|malformed|bad.?request|\b400\b/i
136
136
  :permanent
137
137
  else
138
138
  :unknown
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ # Structured runtime bootstrap contract for request-scoped executor setup.
5
+ #
6
+ # Providers can use this to request file materialization or similar
7
+ # preparation without forcing downstream applications to shell-wrap the main
8
+ # provider command.
9
+ class ExecutionPreparation
10
+ # Declarative file write request that executors can materialize in their
11
+ # own runtime environment.
12
+ FileWrite = Struct.new(:path, :content, :mode, keyword_init: true) do
13
+ def initialize(path:, content:, mode: nil)
14
+ raise ArgumentError, "path must be a non-empty String" unless path.is_a?(String) && !path.empty?
15
+ raise ArgumentError, "content must be a String" unless content.is_a?(String)
16
+ if !mode.nil? && (!mode.is_a?(Integer) || mode.negative?)
17
+ raise ArgumentError, "mode must be a non-negative Integer or nil"
18
+ end
19
+
20
+ super
21
+ freeze
22
+ end
23
+ end
24
+
25
+ attr_reader :file_writes
26
+
27
+ def initialize(file_writes: [])
28
+ writes = file_writes || []
29
+ unless writes.is_a?(Array)
30
+ raise ArgumentError, "file_writes must be an Array (got #{writes.class})"
31
+ end
32
+
33
+ @file_writes = writes.map.with_index do |write, index|
34
+ case write
35
+ when FileWrite
36
+ write
37
+ when Hash
38
+ FileWrite.new(
39
+ path: fetch_value(write, :path),
40
+ content: fetch_value(write, :content),
41
+ mode: write.key?(:mode) ? write[:mode] : write["mode"]
42
+ )
43
+ else
44
+ raise ArgumentError,
45
+ "file_writes must contain FileWrite or Hash entries; invalid element at index #{index}: #{write.inspect} (#{write.class})"
46
+ end
47
+ end.freeze
48
+
49
+ freeze
50
+ end
51
+
52
+ def empty?
53
+ file_writes.empty?
54
+ end
55
+
56
+ private
57
+
58
+ def fetch_value(hash, key)
59
+ return hash[key] if hash.key?(key)
60
+
61
+ hash[key.to_s]
62
+ end
63
+ end
64
+ end