agent-cli-runtime 0.1.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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +156 -0
- data/agent-cli-runtime.gemspec +48 -0
- data/exe/agent-runtime +5 -0
- data/lib/agent_cli_runtime/cli.rb +98 -0
- data/lib/agent_cli_runtime/errors.rb +17 -0
- data/lib/agent_cli_runtime/probe.rb +91 -0
- data/lib/agent_cli_runtime/profile.rb +390 -0
- data/lib/agent_cli_runtime/profiles.rb +190 -0
- data/lib/agent_cli_runtime/redactor.rb +42 -0
- data/lib/agent_cli_runtime/runtime.rb +289 -0
- data/lib/agent_cli_runtime/usage_extractors.rb +115 -0
- data/lib/agent_cli_runtime/values.rb +174 -0
- data/lib/agent_cli_runtime/version.rb +3 -0
- data/lib/agent_cli_runtime.rb +47 -0
- metadata +121 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "timeout"
|
|
3
|
+
require "rubygems/version"
|
|
4
|
+
|
|
5
|
+
module AgentCliRuntime
|
|
6
|
+
class Profile
|
|
7
|
+
PROMPT_STYLES = %i[positional headless_flag_value stdin].freeze
|
|
8
|
+
WORKSPACE_WRITE_PERMISSION_MODE = "workspace-write".freeze
|
|
9
|
+
READ_ONLY_PERMISSION_MODE = "read-only".freeze
|
|
10
|
+
CAPTURE_TIMEOUT_SECONDS = 10
|
|
11
|
+
CAPTURE_POLL_SECONDS = 0.01
|
|
12
|
+
CAPTURE_TERM_GRACE_SECONDS = 0.2
|
|
13
|
+
CAPTURE_REAP_GRACE_SECONDS = 0.2
|
|
14
|
+
VERSION_TOKEN_PATTERN =
|
|
15
|
+
/(?<![0-9A-Za-z])v?(\d+\.\d+\.\d+(?:[.-][0-9A-Za-z]+)*)(?![0-9A-Za-z])/
|
|
16
|
+
RESERVED_CAPABILITY_NAMES = %i[
|
|
17
|
+
headless version auth_configuration add_directory allowed_tools
|
|
18
|
+
disallowed_tools model effort budget raw_cli_arguments installation
|
|
19
|
+
].freeze
|
|
20
|
+
private_constant :VERSION_TOKEN_PATTERN, :RESERVED_CAPABILITY_NAMES
|
|
21
|
+
|
|
22
|
+
attr_reader :name, :bin_default, :env_bin_override_keys, :headless_flag,
|
|
23
|
+
:permission_skip_flag, :workspace_write_flags,
|
|
24
|
+
:read_only_flags, :add_dir_flag, :tool_scope_flags,
|
|
25
|
+
:budget_flag, :output_format_flags, :version_flag,
|
|
26
|
+
:min_version, :prompt_style, :model_argument_builder,
|
|
27
|
+
:effort_argument_builder, :launcher_identity,
|
|
28
|
+
:cli_capabilities, :declared_capability_support
|
|
29
|
+
|
|
30
|
+
def initialize(name:, bin_default:, headless_flag:, version_flag:,
|
|
31
|
+
env_bin_override_keys: [], permission_skip_flag: nil,
|
|
32
|
+
workspace_write_flags: [], read_only_flags: [],
|
|
33
|
+
add_dir_flag: nil, tool_scope_flags: {}, budget_flag: nil,
|
|
34
|
+
output_format_flags: [], min_version: nil,
|
|
35
|
+
prompt_style: :positional, model_argument_builder: nil,
|
|
36
|
+
effort_argument_builder: nil, launcher_identity: nil,
|
|
37
|
+
usage_extractor: nil, auth_configuration_probe: nil,
|
|
38
|
+
cli_capabilities: {}, raw_cli_arguments_supported: false)
|
|
39
|
+
normalized_prompt_style = prompt_style.to_sym
|
|
40
|
+
unless PROMPT_STYLES.include?(normalized_prompt_style)
|
|
41
|
+
raise ArgumentError,
|
|
42
|
+
"unknown prompt_style #{prompt_style.inspect}; valid: #{PROMPT_STYLES.inspect}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@name = name.to_sym
|
|
46
|
+
@bin_default = immutable_string(bin_default)
|
|
47
|
+
@env_bin_override_keys = immutable_strings(env_bin_override_keys)
|
|
48
|
+
@headless_flag = immutable_string(headless_flag)
|
|
49
|
+
@permission_skip_flag =
|
|
50
|
+
permission_skip_flag.nil? ? nil : immutable_string(permission_skip_flag)
|
|
51
|
+
@workspace_write_flags = immutable_strings(workspace_write_flags)
|
|
52
|
+
@read_only_flags = immutable_strings(read_only_flags)
|
|
53
|
+
@add_dir_flag = add_dir_flag.nil? ? nil : immutable_string(add_dir_flag)
|
|
54
|
+
@tool_scope_flags = normalize_tool_scope_flags(tool_scope_flags)
|
|
55
|
+
@budget_flag = budget_flag.nil? ? nil : immutable_string(budget_flag)
|
|
56
|
+
@output_format_flags = immutable_strings(output_format_flags)
|
|
57
|
+
@version_flag = immutable_string(version_flag)
|
|
58
|
+
@min_version = min_version.nil? ? nil : immutable_string(min_version)
|
|
59
|
+
@prompt_style = normalized_prompt_style
|
|
60
|
+
@model_argument_builder = model_argument_builder
|
|
61
|
+
@effort_argument_builder = effort_argument_builder
|
|
62
|
+
@launcher_identity =
|
|
63
|
+
immutable_string(launcher_identity || "agent-cli-runtime/v1:#{@name}")
|
|
64
|
+
@usage_extractor = usage_extractor || ->(_event) { nil }
|
|
65
|
+
@auth_configuration_probe = auth_configuration_probe
|
|
66
|
+
@cli_capabilities = normalize_cli_capabilities(cli_capabilities)
|
|
67
|
+
@raw_cli_arguments_supported = raw_cli_arguments_supported == true
|
|
68
|
+
@declared_capability_support = build_declared_capability_support
|
|
69
|
+
freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def bin(env: ENV)
|
|
73
|
+
key = @env_bin_override_keys.find do |candidate|
|
|
74
|
+
!env[candidate].to_s.empty?
|
|
75
|
+
end
|
|
76
|
+
key ? env.fetch(key) : @bin_default
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def permission_flags(permission_mode = nil)
|
|
80
|
+
if permission_mode == WORKSPACE_WRITE_PERMISSION_MODE
|
|
81
|
+
return @workspace_write_flags.dup unless @workspace_write_flags.empty?
|
|
82
|
+
|
|
83
|
+
raise ArgumentError,
|
|
84
|
+
"agent profile #{@name.inspect} cannot enforce workspace-write sandboxing"
|
|
85
|
+
end
|
|
86
|
+
if permission_mode == READ_ONLY_PERMISSION_MODE
|
|
87
|
+
return @read_only_flags.dup unless @read_only_flags.empty?
|
|
88
|
+
|
|
89
|
+
raise ArgumentError,
|
|
90
|
+
"agent profile #{@name.inspect} cannot enforce read-only sandboxing"
|
|
91
|
+
end
|
|
92
|
+
return [] unless @permission_skip_flag
|
|
93
|
+
return [ @permission_skip_flag ] unless @name == :claude && permission_mode
|
|
94
|
+
return [ @permission_skip_flag ] if permission_mode == "bypassPermissions"
|
|
95
|
+
|
|
96
|
+
[ "--permission-mode", permission_mode.to_s ]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def identity_arguments(model:, effort:, pin_model: true)
|
|
100
|
+
normalized_model = nonempty_value(model, "model")
|
|
101
|
+
normalized_effort = effort.nil? ? nil : nonempty_value(effort, "effort")
|
|
102
|
+
native_arguments = []
|
|
103
|
+
if pin_model
|
|
104
|
+
unless @model_argument_builder
|
|
105
|
+
raise UnsupportedCapability,
|
|
106
|
+
"agent profile #{@name.inspect} cannot pin a model"
|
|
107
|
+
end
|
|
108
|
+
native_arguments.concat(@model_argument_builder.call(normalized_model))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
effective_effort = nil
|
|
112
|
+
if normalized_effort
|
|
113
|
+
unless @effort_argument_builder
|
|
114
|
+
raise UnsupportedCapability,
|
|
115
|
+
"agent profile #{@name.inspect} cannot set reasoning effort"
|
|
116
|
+
end
|
|
117
|
+
native_arguments.concat(@effort_argument_builder.call(normalized_effort))
|
|
118
|
+
effective_effort = normalized_effort
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
IdentityArguments.new(
|
|
122
|
+
model: normalized_model,
|
|
123
|
+
requested_effort: normalized_effort,
|
|
124
|
+
effective_effort: effective_effort,
|
|
125
|
+
effort_supported: !@effort_argument_builder.nil?,
|
|
126
|
+
model_pinned: pin_model,
|
|
127
|
+
native_arguments: validate_arguments(native_arguments)
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def raw_cli_arguments_supported?
|
|
132
|
+
@raw_cli_arguments_supported
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def binary_installed?(env: ENV)
|
|
136
|
+
executable = bin(env:)
|
|
137
|
+
return File.file?(executable) && File.executable?(executable) if executable.include?(File::SEPARATOR)
|
|
138
|
+
|
|
139
|
+
env.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
|
|
140
|
+
candidate = File.join(directory, executable)
|
|
141
|
+
File.file?(candidate) && File.executable?(candidate)
|
|
142
|
+
end
|
|
143
|
+
rescue ArgumentError
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def check_version!(env: ENV)
|
|
148
|
+
executable = bin(env:)
|
|
149
|
+
out, _err, status = bounded_capture3(
|
|
150
|
+
executable, @version_flag, timeout_sec: CAPTURE_TIMEOUT_SECONDS, env: env
|
|
151
|
+
)
|
|
152
|
+
unless status.success?
|
|
153
|
+
raise BinaryUnavailable,
|
|
154
|
+
"#{@name} binary not runnable: #{executable}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
versions = out.scan(VERSION_TOKEN_PATTERN).flatten.uniq
|
|
158
|
+
if versions.empty?
|
|
159
|
+
raise VersionError,
|
|
160
|
+
"could not parse #{@name} #{@version_flag} output"
|
|
161
|
+
end
|
|
162
|
+
if versions.length > 1
|
|
163
|
+
raise VersionError,
|
|
164
|
+
"ambiguous #{@name} #{@version_flag} output: " \
|
|
165
|
+
"#{versions.join(', ')}"
|
|
166
|
+
end
|
|
167
|
+
version = versions.fetch(0)
|
|
168
|
+
if @min_version &&
|
|
169
|
+
Gem::Version.new(version) < Gem::Version.new(@min_version)
|
|
170
|
+
raise VersionError,
|
|
171
|
+
"#{@name} #{version} below minimum #{@min_version}"
|
|
172
|
+
end
|
|
173
|
+
version.freeze
|
|
174
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
175
|
+
raise BinaryUnavailable,
|
|
176
|
+
"#{@name} binary not runnable: #{executable} (#{e.class.name.split('::').last})"
|
|
177
|
+
rescue Timeout::Error
|
|
178
|
+
raise BinaryUnavailable,
|
|
179
|
+
"#{@name} version check timed out after #{CAPTURE_TIMEOUT_SECONDS}s: #{executable}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def auth_configuration(home: nil, env: ENV)
|
|
183
|
+
return AuthConfiguration.new(status: :not_checked) unless @auth_configuration_probe
|
|
184
|
+
|
|
185
|
+
result = @auth_configuration_probe.call(home: home, env: env)
|
|
186
|
+
return result if result.is_a?(AuthConfiguration)
|
|
187
|
+
|
|
188
|
+
raise TypeError,
|
|
189
|
+
"auth_configuration_probe must return AgentCliRuntime::AuthConfiguration"
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
AuthConfiguration.new(
|
|
192
|
+
status: :missing,
|
|
193
|
+
diagnostic: Redactor.diagnostic(e)
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def extract_usage_event(event)
|
|
198
|
+
@usage_extractor.call(event)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def require_cli_capability!(capability)
|
|
204
|
+
capability_name = capability.to_sym
|
|
205
|
+
flags = @cli_capabilities[capability_name]
|
|
206
|
+
unless flags
|
|
207
|
+
raise UnsupportedCapability,
|
|
208
|
+
"agent profile #{@name.inspect} does not declare CLI capability " \
|
|
209
|
+
"#{capability_name.inspect}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
help = capture_help(flags)
|
|
213
|
+
missing = flags.grep(/\A-/).reject { |flag| flag_advertised?(help, flag) }
|
|
214
|
+
unless missing.empty?
|
|
215
|
+
raise UnsupportedCapability,
|
|
216
|
+
"#{@name} does not advertise required #{capability_name} capability " \
|
|
217
|
+
"(missing #{missing.join(', ')})"
|
|
218
|
+
end
|
|
219
|
+
flags.dup
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def immutable_string(value)
|
|
225
|
+
value.to_s.dup.freeze
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def immutable_strings(values)
|
|
229
|
+
Array(values).map { |value| immutable_string(value) }.freeze
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def normalize_tool_scope_flags(flags)
|
|
233
|
+
unless flags.is_a?(Hash)
|
|
234
|
+
raise ArgumentError, "tool_scope_flags must be a Hash"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
flags.each_with_object({}) do |(scope, flag), normalized|
|
|
238
|
+
name = scope.to_sym
|
|
239
|
+
unless %i[allowed disallowed].include?(name)
|
|
240
|
+
raise ArgumentError, "unknown tool scope #{scope.inspect}"
|
|
241
|
+
end
|
|
242
|
+
normalized[name] = nonempty_value(flag, "tool scope flag")
|
|
243
|
+
end.freeze
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def normalize_cli_capabilities(capabilities)
|
|
247
|
+
unless capabilities.is_a?(Hash)
|
|
248
|
+
raise ArgumentError, "cli_capabilities must be a Hash"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
capabilities.each_with_object({}) do |(name, flags), normalized|
|
|
252
|
+
capability_name = name.to_sym
|
|
253
|
+
if RESERVED_CAPABILITY_NAMES.include?(capability_name)
|
|
254
|
+
raise ArgumentError,
|
|
255
|
+
"CLI capability #{name.inspect} collides with a standard capability"
|
|
256
|
+
end
|
|
257
|
+
values = immutable_strings(flags)
|
|
258
|
+
raise ArgumentError, "CLI capability #{name.inspect} is empty" if values.empty?
|
|
259
|
+
|
|
260
|
+
normalized[capability_name] = values
|
|
261
|
+
end.freeze
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def build_declared_capability_support
|
|
265
|
+
support = {
|
|
266
|
+
headless: true,
|
|
267
|
+
add_directory: !@add_dir_flag.nil?,
|
|
268
|
+
allowed_tools: @tool_scope_flags.key?(:allowed),
|
|
269
|
+
disallowed_tools: @tool_scope_flags.key?(:disallowed),
|
|
270
|
+
model: !@model_argument_builder.nil?,
|
|
271
|
+
effort: !@effort_argument_builder.nil?,
|
|
272
|
+
budget: !@budget_flag.nil?,
|
|
273
|
+
raw_cli_arguments: @raw_cli_arguments_supported
|
|
274
|
+
}
|
|
275
|
+
@cli_capabilities.each_key { |capability| support[capability] = true }
|
|
276
|
+
support.freeze
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def nonempty_value(value, label)
|
|
280
|
+
string = value.to_s
|
|
281
|
+
if string.empty? || string.include?("\0")
|
|
282
|
+
raise ArgumentError, "#{label} must be a non-empty string without NUL bytes"
|
|
283
|
+
end
|
|
284
|
+
string.freeze
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def validate_arguments(arguments)
|
|
288
|
+
Array(arguments).map do |argument|
|
|
289
|
+
nonempty_value(argument, "native argument")
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def capture_help(flags)
|
|
294
|
+
out, err, status = bounded_capture3(
|
|
295
|
+
bin, *flags, "--help", timeout_sec: CAPTURE_TIMEOUT_SECONDS
|
|
296
|
+
)
|
|
297
|
+
unless status.success?
|
|
298
|
+
raise UnsupportedCapability,
|
|
299
|
+
"#{@name} capability check failed"
|
|
300
|
+
end
|
|
301
|
+
"#{out}\n#{err}"
|
|
302
|
+
rescue Errno::ENOENT, Errno::EACCES, Timeout::Error => e
|
|
303
|
+
raise UnsupportedCapability,
|
|
304
|
+
"#{@name} capability check could not run (#{e.class.name.split('::').last})"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def flag_advertised?(help, flag)
|
|
308
|
+
help.match?(/(?:\A|[\s,])#{Regexp.escape(flag)}(?:[\s,=\[]|$)/)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def bounded_capture3(*argv, timeout_sec:, env: nil)
|
|
312
|
+
popen_arguments = env ? [ env, *argv ] : argv
|
|
313
|
+
stdin, stdout, stderr, waiter =
|
|
314
|
+
Open3.popen3(*popen_arguments, pgroup: true)
|
|
315
|
+
stdin.close
|
|
316
|
+
readers = [ capture_reader(stdout), capture_reader(stderr) ]
|
|
317
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_sec
|
|
318
|
+
|
|
319
|
+
loop do
|
|
320
|
+
unless waiter.alive?
|
|
321
|
+
status = waiter.value
|
|
322
|
+
return [ readers[0].value, readers[1].value, status ] if readers.none?(&:alive?)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
326
|
+
raise Timeout::Error if remaining <= 0
|
|
327
|
+
|
|
328
|
+
sleep [ CAPTURE_POLL_SECONDS, remaining ].min
|
|
329
|
+
end
|
|
330
|
+
rescue Timeout::Error
|
|
331
|
+
terminate_process_group(waiter) if waiter
|
|
332
|
+
stop_capture_readers(readers, stdout, stderr)
|
|
333
|
+
raise
|
|
334
|
+
ensure
|
|
335
|
+
[ stdin, stdout, stderr ].each do |io|
|
|
336
|
+
io.close if io && !io.closed?
|
|
337
|
+
rescue IOError
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def capture_reader(io)
|
|
343
|
+
Thread.new do
|
|
344
|
+
Thread.current.report_on_exception = false
|
|
345
|
+
io.read
|
|
346
|
+
rescue IOError
|
|
347
|
+
""
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def terminate_process_group(waiter)
|
|
352
|
+
pid = waiter.pid
|
|
353
|
+
signal_process_group("TERM", pid)
|
|
354
|
+
deadline =
|
|
355
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) +
|
|
356
|
+
CAPTURE_TERM_GRACE_SECONDS
|
|
357
|
+
while process_group_alive?(pid) &&
|
|
358
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
359
|
+
sleep CAPTURE_POLL_SECONDS
|
|
360
|
+
end
|
|
361
|
+
signal_process_group("KILL", pid) if process_group_alive?(pid)
|
|
362
|
+
waiter.join(CAPTURE_REAP_GRACE_SECONDS)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def stop_capture_readers(readers, *streams)
|
|
366
|
+
Array(readers).each { |reader| reader.join(CAPTURE_REAP_GRACE_SECONDS) }
|
|
367
|
+
streams.each do |stream|
|
|
368
|
+
stream.close if stream && !stream.closed?
|
|
369
|
+
rescue IOError
|
|
370
|
+
nil
|
|
371
|
+
end
|
|
372
|
+
Array(readers).each { |reader| reader.kill if reader.alive? }
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def signal_process_group(signal, pid)
|
|
376
|
+
Process.kill(signal, -Integer(pid))
|
|
377
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
378
|
+
nil
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def process_group_alive?(pid)
|
|
382
|
+
Process.kill(0, -Integer(pid))
|
|
383
|
+
true
|
|
384
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
385
|
+
false
|
|
386
|
+
rescue Errno::EPERM
|
|
387
|
+
true
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
module AgentCliRuntime
|
|
2
|
+
module Profiles
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def names
|
|
6
|
+
PROVIDER_ORDER
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def fetch(name)
|
|
10
|
+
PROFILES.fetch(name.to_sym) do
|
|
11
|
+
raise UnknownProvider,
|
|
12
|
+
"unknown provider #{name.inspect}; expected one of #{PROVIDER_ORDER.join(', ')}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resolve(profile)
|
|
17
|
+
profile.is_a?(Profile) ? profile : fetch(profile)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def auth_from_file(path, source:)
|
|
21
|
+
configured = File.file?(path) &&
|
|
22
|
+
!File.read(path).strip.match?(/\A(?:|\{\s*\})\z/m)
|
|
23
|
+
AuthConfiguration.new(
|
|
24
|
+
status: configured ? :configured : :missing,
|
|
25
|
+
source: source
|
|
26
|
+
)
|
|
27
|
+
rescue SystemCallError, ArgumentError => e
|
|
28
|
+
AuthConfiguration.new(
|
|
29
|
+
status: :missing,
|
|
30
|
+
source: source,
|
|
31
|
+
diagnostic: Redactor.diagnostic(e)
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def home_path(home, *parts)
|
|
36
|
+
File.join(home || Dir.home, *parts)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def env_configured?(env, *keys)
|
|
40
|
+
keys.any? { |key| !env[key].to_s.strip.empty? }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def grok_auth_path(home:, env:)
|
|
44
|
+
auth_path = env["GROK_AUTH_PATH"]
|
|
45
|
+
grok_home = env["GROK_HOME"]
|
|
46
|
+
if !auth_path.to_s.empty?
|
|
47
|
+
raise ArgumentError, "GROK_AUTH_PATH must be absolute" unless File.absolute_path?(auth_path)
|
|
48
|
+
|
|
49
|
+
return auth_path
|
|
50
|
+
end
|
|
51
|
+
if !grok_home.to_s.empty?
|
|
52
|
+
raise ArgumentError, "GROK_HOME must be absolute" unless File.absolute_path?(grok_home)
|
|
53
|
+
|
|
54
|
+
return File.join(grok_home, "auth.json")
|
|
55
|
+
end
|
|
56
|
+
home_path(home, ".grok", "auth.json")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
CLAUDE = Profile.new(
|
|
60
|
+
name: :claude,
|
|
61
|
+
bin_default: "claude",
|
|
62
|
+
env_bin_override_keys: %w[AGENT_CLI_RUNTIME_CLAUDE_BIN HIVE_CLAUDE_BIN],
|
|
63
|
+
headless_flag: "-p",
|
|
64
|
+
permission_skip_flag: "--dangerously-skip-permissions",
|
|
65
|
+
add_dir_flag: "--add-dir",
|
|
66
|
+
tool_scope_flags: {
|
|
67
|
+
allowed: "--allowedTools",
|
|
68
|
+
disallowed: "--disallowedTools"
|
|
69
|
+
},
|
|
70
|
+
budget_flag: "--max-budget-usd",
|
|
71
|
+
output_format_flags: [
|
|
72
|
+
"--output-format", "stream-json",
|
|
73
|
+
"--include-partial-messages", "--verbose", "--no-session-persistence"
|
|
74
|
+
],
|
|
75
|
+
version_flag: "--version",
|
|
76
|
+
min_version: "2.1.118",
|
|
77
|
+
model_argument_builder: ->(model) { [ "--model", model ] },
|
|
78
|
+
effort_argument_builder: ->(effort) { [ "--effort", effort ] },
|
|
79
|
+
launcher_identity: "claude-code/v1",
|
|
80
|
+
usage_extractor: UsageExtractors::CLAUDE,
|
|
81
|
+
raw_cli_arguments_supported: true,
|
|
82
|
+
auth_configuration_probe: lambda do |home:, env:|
|
|
83
|
+
if env_configured?(env, "ANTHROPIC_API_KEY")
|
|
84
|
+
AuthConfiguration.new(status: :configured, source: "environment")
|
|
85
|
+
else
|
|
86
|
+
auth_from_file(
|
|
87
|
+
home_path(home, ".claude", ".credentials.json"),
|
|
88
|
+
source: "~/.claude/.credentials.json"
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
CODEX = Profile.new(
|
|
95
|
+
name: :codex,
|
|
96
|
+
bin_default: "codex",
|
|
97
|
+
env_bin_override_keys: %w[AGENT_CLI_RUNTIME_CODEX_BIN HIVE_CODEX_BIN],
|
|
98
|
+
headless_flag: "exec",
|
|
99
|
+
prompt_style: :stdin,
|
|
100
|
+
permission_skip_flag: "--dangerously-bypass-approvals-and-sandbox",
|
|
101
|
+
workspace_write_flags: [
|
|
102
|
+
"--sandbox", "workspace-write",
|
|
103
|
+
"-c", 'approval_policy="never"',
|
|
104
|
+
"--ephemeral", "--ignore-user-config", "--ignore-rules"
|
|
105
|
+
],
|
|
106
|
+
read_only_flags: [
|
|
107
|
+
"--sandbox", "read-only",
|
|
108
|
+
"-c", 'approval_policy="never"',
|
|
109
|
+
"--ephemeral", "--ignore-user-config", "--ignore-rules"
|
|
110
|
+
],
|
|
111
|
+
add_dir_flag: "--add-dir",
|
|
112
|
+
output_format_flags: [ "--json" ],
|
|
113
|
+
version_flag: "--version",
|
|
114
|
+
min_version: "0.125.0",
|
|
115
|
+
model_argument_builder: ->(model) { [ "--model", model ] },
|
|
116
|
+
effort_argument_builder:
|
|
117
|
+
->(effort) { [ "-c", "model_reasoning_effort=#{effort}" ] },
|
|
118
|
+
launcher_identity: "codex-cli/v1",
|
|
119
|
+
usage_extractor: UsageExtractors::CODEX,
|
|
120
|
+
auth_configuration_probe: lambda do |home:, env:|
|
|
121
|
+
if env_configured?(env, "OPENAI_API_KEY")
|
|
122
|
+
AuthConfiguration.new(status: :configured, source: "environment")
|
|
123
|
+
else
|
|
124
|
+
auth_from_file(
|
|
125
|
+
home_path(home, ".codex", "auth.json"),
|
|
126
|
+
source: "~/.codex/auth.json"
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
PI = Profile.new(
|
|
133
|
+
name: :pi,
|
|
134
|
+
bin_default: "pi",
|
|
135
|
+
env_bin_override_keys: %w[AGENT_CLI_RUNTIME_PI_BIN HIVE_PI_BIN],
|
|
136
|
+
headless_flag: "-p",
|
|
137
|
+
output_format_flags: [ "--mode", "json", "--no-session" ],
|
|
138
|
+
version_flag: "--version",
|
|
139
|
+
min_version: "0.70.2",
|
|
140
|
+
model_argument_builder: ->(model) { [ "--model", model ] },
|
|
141
|
+
launcher_identity: "pi-coding-agent/v1",
|
|
142
|
+
usage_extractor: UsageExtractors::PI,
|
|
143
|
+
auth_configuration_probe: lambda do |home:, env:|
|
|
144
|
+
directory = env["PI_CODING_AGENT_DIR"]
|
|
145
|
+
path =
|
|
146
|
+
if directory.to_s.empty?
|
|
147
|
+
home_path(home, ".pi", "agent", "auth.json")
|
|
148
|
+
else
|
|
149
|
+
raise ArgumentError, "PI_CODING_AGENT_DIR must be absolute" unless File.absolute_path?(directory)
|
|
150
|
+
|
|
151
|
+
File.join(directory, "auth.json")
|
|
152
|
+
end
|
|
153
|
+
auth_from_file(path, source: "pi auth.json")
|
|
154
|
+
end
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
GROK = Profile.new(
|
|
158
|
+
name: :grok,
|
|
159
|
+
bin_default: "grok",
|
|
160
|
+
env_bin_override_keys: %w[AGENT_CLI_RUNTIME_GROK_BIN HIVE_GROK_BIN],
|
|
161
|
+
headless_flag: "-p",
|
|
162
|
+
prompt_style: :headless_flag_value,
|
|
163
|
+
permission_skip_flag: "--always-approve",
|
|
164
|
+
output_format_flags: [ "--output-format", "streaming-json" ],
|
|
165
|
+
version_flag: "--version",
|
|
166
|
+
min_version: "0.2.90",
|
|
167
|
+
model_argument_builder: ->(model) { [ "--model", model ] },
|
|
168
|
+
effort_argument_builder:
|
|
169
|
+
->(effort) { [ "--reasoning-effort", effort ] },
|
|
170
|
+
launcher_identity: "grok-cli/v1",
|
|
171
|
+
usage_extractor: UsageExtractors::GROK,
|
|
172
|
+
auth_configuration_probe: lambda do |home:, env:|
|
|
173
|
+
if env_configured?(env, "XAI_API_KEY", "GROK_CODE_XAI_API_KEY")
|
|
174
|
+
AuthConfiguration.new(status: :configured, source: "environment")
|
|
175
|
+
else
|
|
176
|
+
auth_from_file(
|
|
177
|
+
grok_auth_path(home: home, env: env),
|
|
178
|
+
source: "grok auth.json"
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
PROFILES = [ CLAUDE, CODEX, PI, GROK ].to_h do |profile|
|
|
185
|
+
[ profile.name, profile ]
|
|
186
|
+
end.freeze
|
|
187
|
+
PROVIDER_ORDER = PROFILES.keys.freeze
|
|
188
|
+
private_constant :PROFILES
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module AgentCliRuntime
|
|
2
|
+
module Redactor
|
|
3
|
+
PATTERNS = {
|
|
4
|
+
github_token: /\b(?:github_pat_[A-Za-z0-9_]{20,}|gh[psou]_[A-Za-z0-9]{36,})\b/,
|
|
5
|
+
openai_api_key: /\bsk-[A-Za-z0-9]{20,}/,
|
|
6
|
+
anthropic_api_key: /\bsk-ant-[A-Za-z0-9_-]{20,}/,
|
|
7
|
+
generic_api_key:
|
|
8
|
+
/\bapi[_-]?key\b['"]?[\s:=]{0,3}['"]?[A-Za-z0-9_-]{20,}['"]?(?=[\s,;}\]]|$)/i,
|
|
9
|
+
password:
|
|
10
|
+
/\b(?:password|passwd|pwd)\b['"]?[\s:=]{0,3}['"]?[^\s'"]{6,}['"]?(?=[\s,;}\]]|$)/i,
|
|
11
|
+
authorization:
|
|
12
|
+
/\bauthorization\s*[:=]\s*['"]?(?:Bearer|Basic|Token)\s+[A-Za-z0-9._+\/=-]{8,}['"]?/i,
|
|
13
|
+
private_key:
|
|
14
|
+
/-----BEGIN (?:RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----.*?-----END (?:RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----/m,
|
|
15
|
+
private_key_header:
|
|
16
|
+
/-----BEGIN (?:RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----[\s\S]*\z/,
|
|
17
|
+
jwt: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def redact(value)
|
|
23
|
+
output = value.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?")
|
|
24
|
+
PATTERNS.each do |name, pattern|
|
|
25
|
+
output.gsub!(pattern, "[REDACTED:#{name}]")
|
|
26
|
+
end
|
|
27
|
+
output
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def diagnostic(value, bytes: AgentCliRuntime::DIAGNOSTIC_BYTES)
|
|
31
|
+
message = value.respond_to?(:message) ? value.message : value.to_s
|
|
32
|
+
redacted = redact(message)
|
|
33
|
+
return nil if redacted.empty?
|
|
34
|
+
return redacted.freeze if redacted.bytesize <= bytes
|
|
35
|
+
|
|
36
|
+
redacted.byteslice(0, bytes).to_s
|
|
37
|
+
.force_encoding(Encoding::UTF_8)
|
|
38
|
+
.scrub("?")
|
|
39
|
+
.freeze
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|