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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/AUDIT_DISPOSITION.md +111 -0
- data/CHANGELOG.md +11 -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 +17 -1
- 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 +22 -6
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f476f6224ed25cc79c7bc9c8fb239c1df9f787cd59ff279b8c509b7d515d2727
|
|
4
|
+
data.tar.gz: c0faee9c6d5c139db019707b9174d2d25a4a2fc4a85684ce55eb7baede9fd3be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 113c0d8afbf5e77c6abcb1f78f0793646b72137e48a9776bfd951ee98c5412d3606628ea19ce4b105a6d8c05d1143a12a5607e1bad82462c629f6bdf3907985b
|
|
7
|
+
data.tar.gz: 4cc2f012c75314a2d096147e4bae9afbed565181878a907339410f7cfdb3ea0cff03e96704a6d3d93cd4f3825eb9f21a97d5351b852d72ec4e09b21a3359df68
|
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
cmd_array,
|
|
85
|
+
held_preparation_locks = acquire_preparation_locks(
|
|
86
|
+
preparation,
|
|
87
|
+
env: env,
|
|
73
88
|
timeout: timeout,
|
|
74
|
-
|
|
89
|
+
deadline: deadline,
|
|
90
|
+
command_name: command_name
|
|
91
|
+
)
|
|
92
|
+
apply_preparation(
|
|
93
|
+
preparation,
|
|
75
94
|
env: env,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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 = +""
|