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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/AUDIT_DISPOSITION.md +111 -0
- data/CHANGELOG.md +18 -0
- data/lib/agent_harness/command_executor.rb +450 -13
- data/lib/agent_harness/docker_command_executor.rb +499 -8
- data/lib/agent_harness/error_taxonomy.rb +4 -4
- data/lib/agent_harness/execution_preparation.rb +64 -0
- data/lib/agent_harness/provider_runtime.rb +38 -11
- data/lib/agent_harness/providers/adapter.rb +8 -1
- data/lib/agent_harness/providers/aider.rb +378 -5
- data/lib/agent_harness/providers/anthropic.rb +21 -12
- data/lib/agent_harness/providers/base.rb +34 -5
- data/lib/agent_harness/providers/codex.rb +59 -9
- data/lib/agent_harness/providers/cursor.rb +3 -1
- data/lib/agent_harness/providers/gemini.rb +12 -6
- data/lib/agent_harness/providers/kilocode.rb +16 -1
- data/lib/agent_harness/providers/opencode.rb +55 -3
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +1 -0
- metadata +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
133
|
+
when /temporary|retry|\b503\b|\b502\b|\b500\b/i
|
|
134
134
|
:transient
|
|
135
|
-
when /invalid|malformed|bad.?request
|
|
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
|