agent-cli-runtime 0.2.0 → 0.2.4
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/CHANGELOG.md +82 -15
- data/README.md +54 -18
- data/agent-cli-runtime.gemspec +7 -6
- data/lib/agent_cli_runtime/error_extractors.rb +95 -0
- data/lib/agent_cli_runtime/opencode/overlay.rb +35 -120
- data/lib/agent_cli_runtime/opencode/permissions.rb +144 -0
- data/lib/agent_cli_runtime/opencode/probe.rb +40 -16
- data/lib/agent_cli_runtime/opencode/result_parser.rb +24 -10
- data/lib/agent_cli_runtime/profile.rb +28 -13
- data/lib/agent_cli_runtime/profiles.rb +28 -2
- data/lib/agent_cli_runtime/runtime.rb +102 -11
- data/lib/agent_cli_runtime/usage_extractors.rb +119 -32
- data/lib/agent_cli_runtime/values.rb +54 -8
- data/lib/agent_cli_runtime/version.rb +1 -1
- data/lib/agent_cli_runtime.rb +6 -0
- metadata +9 -7
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module AgentCliRuntime
|
|
4
|
+
module OpenCode
|
|
5
|
+
# Compiles provider-neutral permission modes into OpenCode's
|
|
6
|
+
# per-run permission document. The document can be written into an
|
|
7
|
+
# isolated overlay or supplied through OPENCODE_PERMISSION while the CLI
|
|
8
|
+
# continues to use its native configuration and login.
|
|
9
|
+
module Permissions
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def compile(permission_mode:, permission_policy: nil,
|
|
13
|
+
working_directory:, additional_read_roots: [],
|
|
14
|
+
additional_write_roots: [], edit_patterns: [],
|
|
15
|
+
bash_patterns: [], plugins: [], runtime_write_roots: [])
|
|
16
|
+
mode = permission_mode
|
|
17
|
+
if mode.nil?
|
|
18
|
+
return nil unless permission_policy
|
|
19
|
+
unless permission_policy.is_a?(OpenCodePermissionPolicy)
|
|
20
|
+
raise ArgumentError,
|
|
21
|
+
"permission_policy must be an OpenCodePermissionPolicy"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
return deep_copy(permission_policy.rules)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unless %w[read-only workspace-write].include?(mode)
|
|
28
|
+
raise ConfigurationError, "unsupported OpenCode permission mode"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
roots = {
|
|
32
|
+
working: File.expand_path(working_directory),
|
|
33
|
+
read: expanded_roots(additional_read_roots),
|
|
34
|
+
write: expanded_roots(additional_write_roots)
|
|
35
|
+
}
|
|
36
|
+
runtime_roots = expanded_roots(runtime_write_roots)
|
|
37
|
+
external = { "*" => "deny" }
|
|
38
|
+
[ *roots.fetch(:read), *roots.fetch(:write), *runtime_roots ].uniq.each do |root|
|
|
39
|
+
external[root] = "allow"
|
|
40
|
+
external["#{root}/**"] = "allow"
|
|
41
|
+
end
|
|
42
|
+
common = {
|
|
43
|
+
"*" => "deny",
|
|
44
|
+
"read" => {
|
|
45
|
+
"*" => "allow",
|
|
46
|
+
"*.env" => "deny",
|
|
47
|
+
"*.env.*" => "deny",
|
|
48
|
+
"*.env.example" => "allow"
|
|
49
|
+
},
|
|
50
|
+
"glob" => "allow",
|
|
51
|
+
"grep" => "allow",
|
|
52
|
+
"list" => "allow",
|
|
53
|
+
"lsp" => "allow",
|
|
54
|
+
"skill" => Array(plugins).empty? ? "deny" : "allow",
|
|
55
|
+
"external_directory" => external
|
|
56
|
+
}
|
|
57
|
+
if mode == "read-only"
|
|
58
|
+
return common.merge(
|
|
59
|
+
"edit" => "deny", "bash" => "deny", "task" => "deny",
|
|
60
|
+
"webfetch" => "deny", "websearch" => "deny",
|
|
61
|
+
"question" => "deny"
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
writable_roots = [
|
|
66
|
+
roots.fetch(:working), *roots.fetch(:write), *runtime_roots
|
|
67
|
+
].uniq
|
|
68
|
+
edit = { "*" => "deny" }
|
|
69
|
+
allows = if Array(edit_patterns).empty?
|
|
70
|
+
writable_roots.flat_map do |root|
|
|
71
|
+
root_edit_patterns(root, working: roots.fetch(:working))
|
|
72
|
+
end
|
|
73
|
+
else
|
|
74
|
+
normalize_declared_edit_patterns(
|
|
75
|
+
edit_patterns, writable_roots, working: roots.fetch(:working)
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
allows.each { |pattern| edit[pattern] = "allow" }
|
|
79
|
+
(roots.fetch(:read) - roots.fetch(:write)).each do |root|
|
|
80
|
+
root_edit_patterns(root, working: roots.fetch(:working)).each do |pattern|
|
|
81
|
+
edit[pattern] = "deny"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
bash = { "*" => "deny" }
|
|
85
|
+
Array(bash_patterns).each { |pattern| bash[pattern.to_s] = "allow" }
|
|
86
|
+
common.merge(
|
|
87
|
+
"edit" => edit,
|
|
88
|
+
"bash" => Array(bash_patterns).empty? ? "deny" : bash,
|
|
89
|
+
"task" => "deny",
|
|
90
|
+
"webfetch" => "deny", "websearch" => "deny",
|
|
91
|
+
"question" => "deny"
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def expanded_roots(values)
|
|
96
|
+
Array(values).map { |value| File.expand_path(value) }.uniq.freeze
|
|
97
|
+
end
|
|
98
|
+
private_class_method :expanded_roots
|
|
99
|
+
|
|
100
|
+
def root_edit_patterns(root, working:)
|
|
101
|
+
if root == working
|
|
102
|
+
[ "**" ]
|
|
103
|
+
elsif root.start_with?(working + File::SEPARATOR)
|
|
104
|
+
relative = root.delete_prefix(working + File::SEPARATOR)
|
|
105
|
+
[ relative, "#{relative}/**" ]
|
|
106
|
+
else
|
|
107
|
+
[ root, "#{root}/**" ]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
private_class_method :root_edit_patterns
|
|
111
|
+
|
|
112
|
+
def normalize_declared_edit_patterns(patterns, writable_roots, working:)
|
|
113
|
+
Array(patterns).map do |value|
|
|
114
|
+
pattern = value.to_s.sub(%r{\A//}, "/")
|
|
115
|
+
unless File.absolute_path?(pattern) && !pattern.include?("\0")
|
|
116
|
+
raise ConfigurationError,
|
|
117
|
+
"OpenCode edit patterns must be absolute path patterns"
|
|
118
|
+
end
|
|
119
|
+
literal_prefix = pattern.split(/[*?]/, 2).first.sub(%r{/+\z}, "")
|
|
120
|
+
unless writable_roots.any? do |root|
|
|
121
|
+
literal_prefix == root ||
|
|
122
|
+
literal_prefix.start_with?(root + File::SEPARATOR)
|
|
123
|
+
end
|
|
124
|
+
raise ConfigurationError,
|
|
125
|
+
"OpenCode edit pattern is outside the declared write roots"
|
|
126
|
+
end
|
|
127
|
+
if pattern == working
|
|
128
|
+
"**"
|
|
129
|
+
elsif pattern.start_with?(working + File::SEPARATOR)
|
|
130
|
+
pattern.delete_prefix(working + File::SEPARATOR)
|
|
131
|
+
else
|
|
132
|
+
pattern
|
|
133
|
+
end
|
|
134
|
+
end.uniq.freeze
|
|
135
|
+
end
|
|
136
|
+
private_class_method :normalize_declared_edit_patterns
|
|
137
|
+
|
|
138
|
+
def deep_copy(value)
|
|
139
|
+
JSON.parse(JSON.generate(value))
|
|
140
|
+
end
|
|
141
|
+
private_class_method :deep_copy
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -6,6 +6,7 @@ module AgentCliRuntime
|
|
|
6
6
|
REQUIRED_RUN_FLAGS = %w[
|
|
7
7
|
--model --variant --format --dir --pure --auto
|
|
8
8
|
].freeze
|
|
9
|
+
MODEL_INVENTORY_TIMEOUT_SECONDS = 30
|
|
9
10
|
SAFE_ENVIRONMENT_KEYS = %w[
|
|
10
11
|
HOME LANG LC_ALL LOGNAME PATH SHELL SSL_CERT_DIR SSL_CERT_FILE
|
|
11
12
|
TMPDIR USER
|
|
@@ -19,11 +20,11 @@ module AgentCliRuntime
|
|
|
19
20
|
call!(request, env:)
|
|
20
21
|
rescue Error => e
|
|
21
22
|
profile = Profiles.resolve(request.profile)
|
|
22
|
-
executable = profile.bin(env:)
|
|
23
|
+
executable = request.executable || profile.bin(env:)
|
|
23
24
|
RouteProbeResult.new(
|
|
24
25
|
provider: profile.name,
|
|
25
26
|
ready: false,
|
|
26
|
-
installed: profile.binary_installed?(env:),
|
|
27
|
+
installed: profile.binary_installed?(env:, executable:),
|
|
27
28
|
executable: executable,
|
|
28
29
|
version: nil,
|
|
29
30
|
minimum_version: profile.min_version,
|
|
@@ -54,20 +55,25 @@ module AgentCliRuntime
|
|
|
54
55
|
raise ArgumentError,
|
|
55
56
|
"route-aware ProbeRequest currently requires profile :opencode"
|
|
56
57
|
end
|
|
57
|
-
installed = profile.binary_installed?(env:)
|
|
58
|
+
installed = profile.binary_installed?(env:, executable: request.executable)
|
|
58
59
|
unless installed
|
|
59
60
|
raise BinaryUnavailable,
|
|
60
|
-
"opencode binary not runnable:
|
|
61
|
+
"opencode binary not runnable: " \
|
|
62
|
+
"#{request.executable || profile.bin(env:)}"
|
|
61
63
|
end
|
|
62
64
|
|
|
63
65
|
child_env = child_environment(profile, request, env:)
|
|
64
|
-
version = profile.check_version!(
|
|
66
|
+
version = profile.check_version!(
|
|
67
|
+
env: child_env, executable: request.executable
|
|
68
|
+
)
|
|
65
69
|
evidence = [
|
|
66
70
|
evidence(profile, :installation),
|
|
67
71
|
evidence(profile, :version, [ version ])
|
|
68
72
|
]
|
|
69
73
|
|
|
70
|
-
run_help = capture!(
|
|
74
|
+
run_help = capture!(
|
|
75
|
+
profile, child_env, "run", "--help", executable: request.executable
|
|
76
|
+
)
|
|
71
77
|
missing = REQUIRED_RUN_FLAGS.reject { |flag| advertised?(run_help, flag) }
|
|
72
78
|
unless missing.empty?
|
|
73
79
|
raise UnsupportedCapability,
|
|
@@ -77,14 +83,18 @@ module AgentCliRuntime
|
|
|
77
83
|
evidence(profile, capability_for(flag), [ flag ])
|
|
78
84
|
end)
|
|
79
85
|
|
|
80
|
-
export_help = capture!(
|
|
86
|
+
export_help = capture!(
|
|
87
|
+
profile, child_env, "export", "--help", executable: request.executable
|
|
88
|
+
)
|
|
81
89
|
unless advertised?(export_help, "--sanitize")
|
|
82
90
|
raise UnsupportedCapability,
|
|
83
91
|
"OpenCode export is missing required --sanitize capability"
|
|
84
92
|
end
|
|
85
93
|
evidence << evidence(profile, :sanitized_export, [ "--sanitize" ])
|
|
86
94
|
|
|
87
|
-
auth_output = capture!(
|
|
95
|
+
auth_output = capture!(
|
|
96
|
+
profile, child_env, "auth", "list", executable: request.executable
|
|
97
|
+
)
|
|
88
98
|
configured_key = request.credential_environment_keys.find do |key|
|
|
89
99
|
!env[key].to_s.empty?
|
|
90
100
|
end
|
|
@@ -106,19 +116,30 @@ module AgentCliRuntime
|
|
|
106
116
|
)
|
|
107
117
|
evidence << evidence(profile, :auth_configuration)
|
|
108
118
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
configured_variants = request.configured_variants
|
|
120
|
+
inventory_variants = if configured_variants.nil?
|
|
121
|
+
models_output = capture!(
|
|
122
|
+
profile, child_env, "models", request.route.provider, "--verbose",
|
|
123
|
+
executable: request.executable,
|
|
124
|
+
timeout_sec: MODEL_INVENTORY_TIMEOUT_SECONDS
|
|
125
|
+
)
|
|
126
|
+
variants_for(models_output, request.route.to_s)
|
|
127
|
+
end
|
|
128
|
+
if configured_variants.nil? && inventory_variants.nil?
|
|
114
129
|
raise RouteUnavailable,
|
|
115
130
|
"requested OpenCode route is unavailable in the local model inventory"
|
|
116
131
|
end
|
|
132
|
+
variants = [ *inventory_variants, *configured_variants ].uniq.sort.freeze
|
|
117
133
|
if request.variant && !variants.include?(request.variant)
|
|
118
134
|
raise RouteUnavailable,
|
|
119
135
|
"requested OpenCode variant is unavailable for the exact route"
|
|
120
136
|
end
|
|
121
137
|
evidence << evidence(profile, :model_route, [ request.route.to_s ])
|
|
138
|
+
if inventory_variants.nil?
|
|
139
|
+
evidence << evidence(
|
|
140
|
+
profile, :configured_model_route, [ request.route.to_s ]
|
|
141
|
+
)
|
|
142
|
+
end
|
|
122
143
|
evidence << evidence(profile, :model_variant, [ request.variant ]) if
|
|
123
144
|
request.variant
|
|
124
145
|
|
|
@@ -126,7 +147,7 @@ module AgentCliRuntime
|
|
|
126
147
|
provider: profile.name,
|
|
127
148
|
ready: true,
|
|
128
149
|
installed: true,
|
|
129
|
-
executable: profile.bin(env: child_env),
|
|
150
|
+
executable: request.executable || profile.bin(env: child_env),
|
|
130
151
|
version: version,
|
|
131
152
|
minimum_version: profile.min_version,
|
|
132
153
|
auth_configuration: auth,
|
|
@@ -165,8 +186,11 @@ module AgentCliRuntime
|
|
|
165
186
|
end
|
|
166
187
|
private_class_method :child_environment
|
|
167
188
|
|
|
168
|
-
def capture!(profile, environment, *arguments
|
|
169
|
-
|
|
189
|
+
def capture!(profile, environment, *arguments, executable: nil,
|
|
190
|
+
timeout_sec: nil)
|
|
191
|
+
options = { env: environment, executable: executable }
|
|
192
|
+
options[:timeout_sec] = timeout_sec if timeout_sec
|
|
193
|
+
out, err, status = profile.capture_local(*arguments, **options)
|
|
170
194
|
unless status.success?
|
|
171
195
|
diagnostic = normalized_output("#{err}\n#{out}")
|
|
172
196
|
raise ConfigurationError,
|
|
@@ -4,7 +4,9 @@ module AgentCliRuntime
|
|
|
4
4
|
module OpenCode
|
|
5
5
|
module ResultParser
|
|
6
6
|
MAX_RUN_BYTES = 4 * 1024 * 1024
|
|
7
|
-
|
|
7
|
+
# Sanitized exports contain the complete session and every tool result,
|
|
8
|
+
# so their bounded limit must accommodate long implementation sessions.
|
|
9
|
+
MAX_EXPORT_BYTES = 64 * 1024 * 1024
|
|
8
10
|
MAX_FINAL_MESSAGE_BYTES = 1024 * 1024
|
|
9
11
|
MAX_EVENTS = 10_000
|
|
10
12
|
MAX_UNKNOWN_EVENTS = 16
|
|
@@ -22,8 +24,11 @@ module AgentCliRuntime
|
|
|
22
24
|
AUTH_PATTERN = /auth|credential|api[ _-]?key|unauthorized|forbidden/i
|
|
23
25
|
CONFIGURATION_PATTERN =
|
|
24
26
|
/\b(?:Config(?:uration)?Error|UnknownProvider|UnknownModel|ModelNotFound|RouteUnavailable|VariantUnavailable)\b|\b(?:invalid|unknown|unsupported) (?:configuration|provider|model|route|variant)\b|\b(?:provider|model|route|variant)(?: [^\n]+)? (?:not found|unavailable)\b/i
|
|
27
|
+
UPSTREAM_TIMEOUT_PATTERN =
|
|
28
|
+
/\b(?:upstream\s+)?(?:idle\s+)?timeout\b|\btimed out\b|error_type['"\s:=>]+timeout\b|\bcode['"\s:=>]+504\b/i
|
|
25
29
|
private_constant :KNOWN_EVENT_TYPES, :EVENT_PART_TYPES,
|
|
26
|
-
:AUTH_PATTERN, :CONFIGURATION_PATTERN
|
|
30
|
+
:AUTH_PATTERN, :CONFIGURATION_PATTERN,
|
|
31
|
+
:UPSTREAM_TIMEOUT_PATTERN
|
|
27
32
|
|
|
28
33
|
module_function
|
|
29
34
|
|
|
@@ -88,8 +93,6 @@ module AgentCliRuntime
|
|
|
88
93
|
message = texts.filter_map do |message_id, text|
|
|
89
94
|
text if message_id == terminal.fetch(:message_id)
|
|
90
95
|
end.join
|
|
91
|
-
malformed!("OpenCode terminal assistant message is empty") if message.empty?
|
|
92
|
-
|
|
93
96
|
final_message_truncated = message.bytesize > MAX_FINAL_MESSAGE_BYTES
|
|
94
97
|
ParsedRun.new(
|
|
95
98
|
session_id: session_id,
|
|
@@ -166,18 +169,21 @@ module AgentCliRuntime
|
|
|
166
169
|
messages = export["messages"]
|
|
167
170
|
malformed!("OpenCode sanitized export messages must be an array") unless
|
|
168
171
|
messages.is_a?(Array)
|
|
169
|
-
|
|
172
|
+
assistant = nil
|
|
173
|
+
messages.each do |message|
|
|
170
174
|
next unless message.is_a?(Hash) && message["info"].is_a?(Hash)
|
|
171
175
|
|
|
172
176
|
record = message.fetch("info")
|
|
173
177
|
next unless record["id"] == message_id
|
|
174
178
|
|
|
175
|
-
|
|
179
|
+
if assistant
|
|
180
|
+
malformed!("OpenCode sanitized export must contain one terminal assistant record")
|
|
181
|
+
end
|
|
182
|
+
assistant = record
|
|
176
183
|
end
|
|
177
|
-
unless
|
|
184
|
+
unless assistant
|
|
178
185
|
malformed!("OpenCode sanitized export must contain one terminal assistant record")
|
|
179
186
|
end
|
|
180
|
-
assistant = matches.fetch(0)
|
|
181
187
|
unless assistant["role"] == "assistant" &&
|
|
182
188
|
assistant["sessionID"] == session_id
|
|
183
189
|
malformed!("OpenCode sanitized export terminal record is not correlated")
|
|
@@ -208,7 +214,10 @@ module AgentCliRuntime
|
|
|
208
214
|
cache_read: numeric(cache, "read", integer: true),
|
|
209
215
|
cache_write: numeric(cache, "write", integer: true),
|
|
210
216
|
reasoning: numeric(tokens, "reasoning", integer: true),
|
|
211
|
-
|
|
217
|
+
input_includes_cache_read: cache.key?("read") ? true : nil,
|
|
218
|
+
input_includes_cache_write: cache.key?("write") ? true : nil,
|
|
219
|
+
output_includes_reasoning: tokens.key?("reasoning") ? false : nil,
|
|
220
|
+
provider_reported_cost: numeric(assistant, "cost", integer: false)
|
|
212
221
|
)
|
|
213
222
|
}.freeze
|
|
214
223
|
rescue JSON::ParserError => e
|
|
@@ -231,7 +240,10 @@ module AgentCliRuntime
|
|
|
231
240
|
cache_read: required_numeric(cache, "read", integer: true),
|
|
232
241
|
cache_write: required_numeric(cache, "write", integer: true),
|
|
233
242
|
reasoning: required_numeric(tokens, "reasoning", integer: true),
|
|
234
|
-
|
|
243
|
+
input_includes_cache_read: true,
|
|
244
|
+
input_includes_cache_write: true,
|
|
245
|
+
output_includes_reasoning: false,
|
|
246
|
+
provider_reported_cost: required_numeric(part, "cost", integer: false)
|
|
235
247
|
)
|
|
236
248
|
}.freeze
|
|
237
249
|
rescue ArgumentError => e
|
|
@@ -279,6 +291,8 @@ module AgentCliRuntime
|
|
|
279
291
|
:authentication_failure
|
|
280
292
|
elsif corpus.match?(CONFIGURATION_PATTERN)
|
|
281
293
|
:configuration_failure
|
|
294
|
+
elsif corpus.match?(UPSTREAM_TIMEOUT_PATTERN)
|
|
295
|
+
:timed_out
|
|
282
296
|
else
|
|
283
297
|
:cli_failure
|
|
284
298
|
end
|
|
@@ -4,10 +4,10 @@ require "rubygems/version"
|
|
|
4
4
|
|
|
5
5
|
module AgentCliRuntime
|
|
6
6
|
class Profile
|
|
7
|
-
PROMPT_STYLES = %i[positional headless_flag_value stdin].freeze
|
|
7
|
+
PROMPT_STYLES = %i[positional headless_flag_value stdin piped_stdin].freeze
|
|
8
8
|
WORKSPACE_WRITE_PERMISSION_MODE = "workspace-write".freeze
|
|
9
9
|
READ_ONLY_PERMISSION_MODE = "read-only".freeze
|
|
10
|
-
CAPTURE_TIMEOUT_SECONDS =
|
|
10
|
+
CAPTURE_TIMEOUT_SECONDS = 120
|
|
11
11
|
CAPTURE_POLL_SECONDS = 0.01
|
|
12
12
|
CAPTURE_TERM_GRACE_SECONDS = 0.2
|
|
13
13
|
CAPTURE_REAP_GRACE_SECONDS = 0.2
|
|
@@ -28,7 +28,8 @@ module AgentCliRuntime
|
|
|
28
28
|
:cli_capabilities, :declared_capability_support,
|
|
29
29
|
:credential_environment_keys, :configuration_environment_key,
|
|
30
30
|
:default_configuration_directory,
|
|
31
|
-
:permission_policy_required, :result_parser
|
|
31
|
+
:permission_policy_required, :result_parser,
|
|
32
|
+
:version_check_timeout_sec
|
|
32
33
|
|
|
33
34
|
def initialize(name:, bin_default:, headless_flag:, version_flag:,
|
|
34
35
|
env_bin_override_keys: [], permission_skip_flag: nil,
|
|
@@ -37,11 +38,13 @@ module AgentCliRuntime
|
|
|
37
38
|
output_format_flags: [], min_version: nil,
|
|
38
39
|
prompt_style: :positional, model_argument_builder: nil,
|
|
39
40
|
effort_argument_builder: nil, launcher_identity: nil,
|
|
40
|
-
usage_extractor: nil,
|
|
41
|
+
usage_extractor: nil, error_extractor: nil,
|
|
42
|
+
auth_configuration_probe: nil,
|
|
41
43
|
cli_capabilities: {}, raw_cli_arguments_supported: false,
|
|
42
44
|
credential_environment_keys: [], configuration_environment_key: nil,
|
|
43
45
|
default_configuration_directory: nil,
|
|
44
|
-
permission_policy_required: false, result_parser: nil
|
|
46
|
+
permission_policy_required: false, result_parser: nil,
|
|
47
|
+
version_check_timeout_sec: CAPTURE_TIMEOUT_SECONDS)
|
|
45
48
|
normalized_prompt_style = prompt_style.to_sym
|
|
46
49
|
unless PROMPT_STYLES.include?(normalized_prompt_style)
|
|
47
50
|
raise ArgumentError,
|
|
@@ -68,6 +71,7 @@ module AgentCliRuntime
|
|
|
68
71
|
@launcher_identity =
|
|
69
72
|
immutable_string(launcher_identity || "agent-cli-runtime/v1:#{@name}")
|
|
70
73
|
@usage_extractor = usage_extractor || ->(_event) { nil }
|
|
74
|
+
@error_extractor = error_extractor || ErrorExtractors::DEFAULT
|
|
71
75
|
@auth_configuration_probe = auth_configuration_probe
|
|
72
76
|
@cli_capabilities = normalize_cli_capabilities(cli_capabilities)
|
|
73
77
|
@raw_cli_arguments_supported = raw_cli_arguments_supported == true
|
|
@@ -82,6 +86,10 @@ module AgentCliRuntime
|
|
|
82
86
|
)
|
|
83
87
|
@permission_policy_required = permission_policy_required == true
|
|
84
88
|
@result_parser = result_parser
|
|
89
|
+
@version_check_timeout_sec = Float(version_check_timeout_sec)
|
|
90
|
+
unless @version_check_timeout_sec.positive?
|
|
91
|
+
raise ArgumentError, "version_check_timeout_sec must be positive"
|
|
92
|
+
end
|
|
85
93
|
@declared_capability_support = build_declared_capability_support
|
|
86
94
|
freeze
|
|
87
95
|
end
|
|
@@ -153,8 +161,8 @@ module AgentCliRuntime
|
|
|
153
161
|
@raw_cli_arguments_supported
|
|
154
162
|
end
|
|
155
163
|
|
|
156
|
-
def binary_installed?(env: ENV)
|
|
157
|
-
executable
|
|
164
|
+
def binary_installed?(env: ENV, executable: nil)
|
|
165
|
+
executable ||= bin(env:)
|
|
158
166
|
return File.file?(executable) && File.executable?(executable) if executable.include?(File::SEPARATOR)
|
|
159
167
|
|
|
160
168
|
env.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
|
|
@@ -165,10 +173,10 @@ module AgentCliRuntime
|
|
|
165
173
|
false
|
|
166
174
|
end
|
|
167
175
|
|
|
168
|
-
def check_version!(env: ENV)
|
|
169
|
-
executable
|
|
176
|
+
def check_version!(env: ENV, executable: nil)
|
|
177
|
+
executable ||= bin(env:)
|
|
170
178
|
out, _err, status = bounded_capture3(
|
|
171
|
-
executable, @version_flag, timeout_sec:
|
|
179
|
+
executable, @version_flag, timeout_sec: @version_check_timeout_sec, env: env
|
|
172
180
|
)
|
|
173
181
|
unless status.success?
|
|
174
182
|
raise BinaryUnavailable,
|
|
@@ -197,7 +205,7 @@ module AgentCliRuntime
|
|
|
197
205
|
"#{@name} binary not runnable: #{executable} (#{e.class.name.split('::').last})"
|
|
198
206
|
rescue Timeout::Error
|
|
199
207
|
raise BinaryUnavailable,
|
|
200
|
-
"#{@name} version check timed out after #{
|
|
208
|
+
"#{@name} version check timed out after #{@version_check_timeout_sec}s: #{executable}"
|
|
201
209
|
end
|
|
202
210
|
|
|
203
211
|
def auth_configuration(home: nil, env: ENV)
|
|
@@ -221,6 +229,12 @@ module AgentCliRuntime
|
|
|
221
229
|
nil
|
|
222
230
|
end
|
|
223
231
|
|
|
232
|
+
def extract_error_event(event)
|
|
233
|
+
@error_extractor.call(event)
|
|
234
|
+
rescue StandardError
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
224
238
|
def parse_run(stdout)
|
|
225
239
|
unless @result_parser
|
|
226
240
|
raise UnsupportedCapability,
|
|
@@ -278,9 +292,10 @@ module AgentCliRuntime
|
|
|
278
292
|
flags.dup
|
|
279
293
|
end
|
|
280
294
|
|
|
281
|
-
def capture_local(*arguments, env: ENV, timeout_sec: CAPTURE_TIMEOUT_SECONDS
|
|
295
|
+
def capture_local(*arguments, env: ENV, timeout_sec: CAPTURE_TIMEOUT_SECONDS,
|
|
296
|
+
executable: nil)
|
|
282
297
|
bounded_capture3(
|
|
283
|
-
bin(env:), *arguments, timeout_sec: timeout_sec, env: env
|
|
298
|
+
executable || bin(env:), *arguments, timeout_sec: timeout_sec, env: env
|
|
284
299
|
)
|
|
285
300
|
end
|
|
286
301
|
|
|
@@ -139,6 +139,8 @@ module AgentCliRuntime
|
|
|
139
139
|
bin_default: "claude",
|
|
140
140
|
env_bin_override_keys: %w[AGENT_CLI_RUNTIME_CLAUDE_BIN HIVE_CLAUDE_BIN],
|
|
141
141
|
headless_flag: "-p",
|
|
142
|
+
# Print mode reads stdin; keep large review prompts out of exec arguments.
|
|
143
|
+
prompt_style: :piped_stdin,
|
|
142
144
|
permission_skip_flag: "--dangerously-skip-permissions",
|
|
143
145
|
add_dir_flag: "--add-dir",
|
|
144
146
|
tool_scope_flags: {
|
|
@@ -156,6 +158,7 @@ module AgentCliRuntime
|
|
|
156
158
|
effort_argument_builder: ->(effort) { [ "--effort", effort ] },
|
|
157
159
|
launcher_identity: "claude-code/v1",
|
|
158
160
|
usage_extractor: UsageExtractors::CLAUDE,
|
|
161
|
+
error_extractor: ErrorExtractors::CLAUDE,
|
|
159
162
|
credential_environment_keys: %w[
|
|
160
163
|
ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_API_KEY
|
|
161
164
|
],
|
|
@@ -220,12 +223,17 @@ module AgentCliRuntime
|
|
|
220
223
|
bin_default: "pi",
|
|
221
224
|
env_bin_override_keys: %w[AGENT_CLI_RUNTIME_PI_BIN HIVE_PI_BIN],
|
|
222
225
|
headless_flag: "-p",
|
|
226
|
+
# Pi reads a non-TTY stdin stream into its initial message without an
|
|
227
|
+
# argv placeholder. Keeping the prompt out of argv also avoids Linux's
|
|
228
|
+
# per-argument size limit on real implementation plans.
|
|
229
|
+
prompt_style: :piped_stdin,
|
|
223
230
|
output_format_flags: [ "--mode", "json", "--no-session" ],
|
|
224
231
|
version_flag: "--version",
|
|
225
232
|
min_version: "0.70.2",
|
|
226
233
|
model_argument_builder: ->(model) { [ "--model", model ] },
|
|
227
234
|
launcher_identity: "pi-coding-agent/v1",
|
|
228
235
|
usage_extractor: UsageExtractors::PI,
|
|
236
|
+
error_extractor: ErrorExtractors::PI,
|
|
229
237
|
credential_environment_keys: PI_CREDENTIAL_ENVIRONMENT_KEYS,
|
|
230
238
|
configuration_environment_key: "PI_CODING_AGENT_DIR",
|
|
231
239
|
default_configuration_directory: ".pi/agent",
|
|
@@ -247,9 +255,18 @@ module AgentCliRuntime
|
|
|
247
255
|
name: :grok,
|
|
248
256
|
bin_default: "grok",
|
|
249
257
|
env_bin_override_keys: %w[AGENT_CLI_RUNTIME_GROK_BIN HIVE_GROK_BIN],
|
|
250
|
-
|
|
251
|
-
|
|
258
|
+
# Grok's Unix CLI reads a prompt file; stdin already has a managed lifetime.
|
|
259
|
+
headless_flag: "--prompt-file=/dev/stdin",
|
|
260
|
+
prompt_style: :piped_stdin,
|
|
252
261
|
permission_skip_flag: "--always-approve",
|
|
262
|
+
# Grok confines the filesystem natively, the same shape codex uses.
|
|
263
|
+
# `workspace` limits writes to the working directory, `read-only`
|
|
264
|
+
# forbids them entirely; both are built-in profiles (custom ones extend
|
|
265
|
+
# them from ~/.grok/sandbox.toml). `--always-approve` suppresses the
|
|
266
|
+
# interactive approval prompt, which a headless reviewer can never answer
|
|
267
|
+
# — the sandbox, not the prompt, is what actually bounds the agent.
|
|
268
|
+
workspace_write_flags: [ "--sandbox", "workspace", "--always-approve" ],
|
|
269
|
+
read_only_flags: [ "--sandbox", "read-only", "--always-approve" ],
|
|
253
270
|
output_format_flags: [ "--output-format", "streaming-json" ],
|
|
254
271
|
version_flag: "--version",
|
|
255
272
|
min_version: "0.2.90",
|
|
@@ -282,6 +299,14 @@ module AgentCliRuntime
|
|
|
282
299
|
headless_flag: "run",
|
|
283
300
|
output_format_flags: [ "--format", "json" ],
|
|
284
301
|
version_flag: "--version",
|
|
302
|
+
# `opencode run` reads a non-TTY stdin stream as the initial message.
|
|
303
|
+
# Keep implementation-sized prompts out of one argv element: Linux
|
|
304
|
+
# rejects a single argument around 128 KiB with E2BIG even when the
|
|
305
|
+
# complete argv remains far below ARG_MAX.
|
|
306
|
+
prompt_style: :piped_stdin,
|
|
307
|
+
# Bun-backed OpenCode startup can exceed the generic 10-second bound
|
|
308
|
+
# under sustained host I/O even though the executable is healthy.
|
|
309
|
+
version_check_timeout_sec: 30,
|
|
285
310
|
min_version: "1.18.16",
|
|
286
311
|
model_argument_builder: ->(model) { opencode_model_arguments(model) },
|
|
287
312
|
effort_argument_builder:
|
|
@@ -291,6 +316,7 @@ module AgentCliRuntime
|
|
|
291
316
|
configuration_environment_key: "OPENCODE_CONFIG_DIR",
|
|
292
317
|
default_configuration_directory: ".config/opencode",
|
|
293
318
|
permission_policy_required: true,
|
|
319
|
+
error_extractor: ErrorExtractors::OPENCODE,
|
|
294
320
|
result_parser: OpenCode::ResultParser,
|
|
295
321
|
cli_capabilities: {
|
|
296
322
|
json_events: [ "run", "--format" ],
|