agent-cli-runtime 0.1.1 → 0.2.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/CHANGELOG.md +20 -0
- data/README.md +139 -7
- data/agent-cli-runtime.gemspec +3 -2
- data/lib/agent_cli_runtime/errors.rb +7 -0
- data/lib/agent_cli_runtime/opencode/inspection.rb +32 -0
- data/lib/agent_cli_runtime/opencode/overlay.rb +573 -0
- data/lib/agent_cli_runtime/opencode/probe.rb +280 -0
- data/lib/agent_cli_runtime/opencode/result_parser.rb +402 -0
- data/lib/agent_cli_runtime/profile.rb +36 -2
- data/lib/agent_cli_runtime/profiles.rb +70 -1
- data/lib/agent_cli_runtime/runtime.rb +16 -2
- data/lib/agent_cli_runtime/values.rb +432 -0
- data/lib/agent_cli_runtime/version.rb +1 -1
- data/lib/agent_cli_runtime.rb +23 -3
- metadata +8 -3
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module AgentCliRuntime
|
|
5
|
+
module OpenCode
|
|
6
|
+
module Overlay
|
|
7
|
+
MAX_CONFIGURATION_BYTES = 1_048_576
|
|
8
|
+
SECRET_KEY_PATTERN = /(?:api[_-]?key|token|secret|password|credential)/i
|
|
9
|
+
ENV_PLACEHOLDER_PATTERN = /\A\{env:[A-Z][A-Z0-9_]*\}\z/
|
|
10
|
+
private_constant :SECRET_KEY_PATTERN, :ENV_PLACEHOLDER_PATTERN
|
|
11
|
+
|
|
12
|
+
class Cleanup
|
|
13
|
+
def initialize(root)
|
|
14
|
+
stat = File.lstat(root)
|
|
15
|
+
@root = root.freeze
|
|
16
|
+
@device = stat.dev
|
|
17
|
+
@inode = stat.ino
|
|
18
|
+
@owner = stat.uid
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@cleaned = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
return if @cleaned
|
|
26
|
+
unless File.exist?(@root) || File.symlink?(@root)
|
|
27
|
+
@cleaned = true
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
stat = File.lstat(@root)
|
|
32
|
+
if stat.symlink? || !stat.directory? || stat.dev != @device ||
|
|
33
|
+
stat.ino != @inode || stat.uid != @owner
|
|
34
|
+
raise UnsafePathError,
|
|
35
|
+
"refusing to clean a replaced OpenCode invocation root"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
FileUtils.remove_entry_secure(@root)
|
|
39
|
+
@cleaned = true
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
private_constant :Cleanup
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
def prepare!(preparation, env: ENV)
|
|
48
|
+
unless preparation.is_a?(OpenCodePreparationRequest)
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"request must be an AgentCliRuntime::OpenCodePreparationRequest"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
profile = Profiles.resolve(preparation.request.profile)
|
|
54
|
+
unless profile.name == :opencode
|
|
55
|
+
raise ConfigurationError,
|
|
56
|
+
"OpenCode preparation requires profile :opencode"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
source_config, source_label = load_configuration(preparation)
|
|
60
|
+
requested_route = resolve_route(preparation.request, source_config)
|
|
61
|
+
validate_provider!(source_config, requested_route.provider)
|
|
62
|
+
validate_nonsecret!(source_config)
|
|
63
|
+
roots = resolve_roots(preparation)
|
|
64
|
+
credential_environment_keys = provider_credential_environment_keys(
|
|
65
|
+
source_config, requested_route.provider,
|
|
66
|
+
preparation.credential_environment_keys
|
|
67
|
+
)
|
|
68
|
+
plugins = selected_plugins(source_config, preparation.plugins)
|
|
69
|
+
permission = permission_rules(preparation, roots, plugins: plugins)
|
|
70
|
+
|
|
71
|
+
root = create_root!(preparation.invocation_root)
|
|
72
|
+
cleanup = Cleanup.new(root)
|
|
73
|
+
begin
|
|
74
|
+
paths = create_directories(root)
|
|
75
|
+
pure = preparation.pure && plugins.empty?
|
|
76
|
+
config = generated_configuration(
|
|
77
|
+
source_config, plugins, requested_route, permission
|
|
78
|
+
)
|
|
79
|
+
configuration_path = File.join(paths.fetch(:source), "opencode.json")
|
|
80
|
+
write_private_file(configuration_path, JSON.pretty_generate(config) + "\n")
|
|
81
|
+
generated_paths = [ root, *paths.values, configuration_path ]
|
|
82
|
+
|
|
83
|
+
staged_credential = stage_credential_file(
|
|
84
|
+
preparation.credential_file,
|
|
85
|
+
File.join(paths.fetch(:data), "opencode", "auth.json")
|
|
86
|
+
)
|
|
87
|
+
generated_paths.concat(staged_credential ? staged_credential : [])
|
|
88
|
+
|
|
89
|
+
environment = overlay_environment(
|
|
90
|
+
paths, configuration_path, pure: pure
|
|
91
|
+
)
|
|
92
|
+
executable =
|
|
93
|
+
preparation.request.executable || profile.bin(env: env)
|
|
94
|
+
probe_env = env.to_h.merge(
|
|
95
|
+
"AGENT_CLI_RUNTIME_OPENCODE_BIN" => executable
|
|
96
|
+
)
|
|
97
|
+
probe_request = ProbeRequest.new(
|
|
98
|
+
profile: profile,
|
|
99
|
+
route: requested_route,
|
|
100
|
+
variant: preparation.request.effort,
|
|
101
|
+
environment: environment,
|
|
102
|
+
credential_environment_keys: credential_environment_keys,
|
|
103
|
+
credential_file_staged:
|
|
104
|
+
staged_credential && credential_file_supports_provider?(
|
|
105
|
+
staged_credential.last, requested_route.provider
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
probe_result = OpenCode::Probe.call!(probe_request, env: probe_env)
|
|
109
|
+
invocation = compile_invocation(
|
|
110
|
+
preparation, profile, requested_route, roots,
|
|
111
|
+
executable:, pure: pure,
|
|
112
|
+
probe_result:
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
PreparedInvocation.new(
|
|
116
|
+
invocation: invocation,
|
|
117
|
+
environment: environment,
|
|
118
|
+
credential_environment_keys: credential_environment_keys,
|
|
119
|
+
invocation_root: root,
|
|
120
|
+
generated_paths: generated_paths.uniq,
|
|
121
|
+
configuration_path: configuration_path,
|
|
122
|
+
requested_route: requested_route,
|
|
123
|
+
configuration_source: source_label,
|
|
124
|
+
probe_result: probe_result,
|
|
125
|
+
cleanup: cleanup,
|
|
126
|
+
executable: executable
|
|
127
|
+
)
|
|
128
|
+
rescue Exception
|
|
129
|
+
cleanup.call
|
|
130
|
+
raise
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def load_configuration(preparation)
|
|
135
|
+
if preparation.configuration_path
|
|
136
|
+
path = safe_source_file!(
|
|
137
|
+
preparation.configuration_path, label: "OpenCode configuration"
|
|
138
|
+
)
|
|
139
|
+
if File.size(path) > MAX_CONFIGURATION_BYTES
|
|
140
|
+
raise ConfigurationError,
|
|
141
|
+
"OpenCode configuration exceeds the bounded input size"
|
|
142
|
+
end
|
|
143
|
+
[ JSON.parse(File.read(path)), path ]
|
|
144
|
+
elsif preparation.configuration
|
|
145
|
+
[ JSON.parse(JSON.generate(preparation.configuration)), "inline" ]
|
|
146
|
+
else
|
|
147
|
+
raise ConfigurationError,
|
|
148
|
+
"OpenCode preparation requires an explicit configuration source"
|
|
149
|
+
end
|
|
150
|
+
rescue JSON::ParserError
|
|
151
|
+
raise ConfigurationError,
|
|
152
|
+
"OpenCode configuration must be valid JSON"
|
|
153
|
+
end
|
|
154
|
+
private_class_method :load_configuration
|
|
155
|
+
|
|
156
|
+
def resolve_route(request, config)
|
|
157
|
+
value = request.model
|
|
158
|
+
value = config["model"] if value.to_s.empty?
|
|
159
|
+
if value.to_s.empty?
|
|
160
|
+
raise RouteUnavailable,
|
|
161
|
+
"OpenCode requires an exact provider/model route or explicit overlay default"
|
|
162
|
+
end
|
|
163
|
+
Route.parse(value)
|
|
164
|
+
rescue ArgumentError => e
|
|
165
|
+
raise RouteUnavailable, Redactor.diagnostic(e)
|
|
166
|
+
end
|
|
167
|
+
private_class_method :resolve_route
|
|
168
|
+
|
|
169
|
+
def validate_provider!(config, provider)
|
|
170
|
+
definitions = config["provider"]
|
|
171
|
+
unless definitions.is_a?(Hash) && definitions[provider].is_a?(Hash)
|
|
172
|
+
raise ConfigurationError,
|
|
173
|
+
"requested OpenCode provider is absent from the selected configuration"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
private_class_method :validate_provider!
|
|
177
|
+
|
|
178
|
+
def validate_nonsecret!(value, key = nil)
|
|
179
|
+
case value
|
|
180
|
+
when Hash
|
|
181
|
+
value.each { |child_key, child| validate_nonsecret!(child, child_key) }
|
|
182
|
+
when Array
|
|
183
|
+
value.each { |child| validate_nonsecret!(child, key) }
|
|
184
|
+
when String
|
|
185
|
+
if key.to_s.match?(SECRET_KEY_PATTERN) &&
|
|
186
|
+
!value.match?(ENV_PLACEHOLDER_PATTERN)
|
|
187
|
+
raise ConfigurationError,
|
|
188
|
+
"OpenCode provider definitions cannot contain credential values"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
private_class_method :validate_nonsecret!
|
|
193
|
+
|
|
194
|
+
PROVIDER_ENVIRONMENT_KEY_ALIASES = {
|
|
195
|
+
"anthropic" => %w[ANTHROPIC CLAUDE],
|
|
196
|
+
"google" => %w[GOOGLE GEMINI],
|
|
197
|
+
"gemini" => %w[GOOGLE GEMINI],
|
|
198
|
+
"xai" => %w[XAI GROK],
|
|
199
|
+
"github-copilot" => %w[COPILOT GITHUB],
|
|
200
|
+
"opencode" => %w[OPENCODE]
|
|
201
|
+
}.freeze
|
|
202
|
+
private_constant :PROVIDER_ENVIRONMENT_KEY_ALIASES
|
|
203
|
+
|
|
204
|
+
def provider_credential_environment_keys(config, provider, configured_keys)
|
|
205
|
+
definition = config.fetch("provider").fetch(provider)
|
|
206
|
+
referenced = environment_placeholders(definition)
|
|
207
|
+
aliases = PROVIDER_ENVIRONMENT_KEY_ALIASES.fetch(
|
|
208
|
+
provider, [ provider.upcase.gsub(/[^A-Z0-9]+/, "_") ]
|
|
209
|
+
)
|
|
210
|
+
configured_keys.select do |key|
|
|
211
|
+
referenced.include?(key) || aliases.any? { |prefix| key.start_with?("#{prefix}_") }
|
|
212
|
+
end.freeze
|
|
213
|
+
end
|
|
214
|
+
private_class_method :provider_credential_environment_keys
|
|
215
|
+
|
|
216
|
+
def environment_placeholders(value)
|
|
217
|
+
case value
|
|
218
|
+
when Hash
|
|
219
|
+
value.values.flat_map { |child| environment_placeholders(child) }
|
|
220
|
+
when Array
|
|
221
|
+
value.flat_map { |child| environment_placeholders(child) }
|
|
222
|
+
when String
|
|
223
|
+
match = /\A\{env:(?<key>[A-Z][A-Z0-9_]*)\}\z/.match(value)
|
|
224
|
+
match ? [ match[:key] ] : []
|
|
225
|
+
else
|
|
226
|
+
[]
|
|
227
|
+
end.uniq
|
|
228
|
+
end
|
|
229
|
+
private_class_method :environment_placeholders
|
|
230
|
+
|
|
231
|
+
def resolve_roots(preparation)
|
|
232
|
+
working = safe_directory!(preparation.working_directory, label: "working directory")
|
|
233
|
+
reads = preparation.additional_read_roots.map do |path|
|
|
234
|
+
safe_directory!(path, label: "additional read root")
|
|
235
|
+
end
|
|
236
|
+
writes = preparation.additional_write_roots.map do |path|
|
|
237
|
+
safe_directory!(path, label: "additional write root")
|
|
238
|
+
end
|
|
239
|
+
{
|
|
240
|
+
working: working,
|
|
241
|
+
read: reads.uniq.freeze,
|
|
242
|
+
write: writes.uniq.freeze
|
|
243
|
+
}.freeze
|
|
244
|
+
end
|
|
245
|
+
private_class_method :resolve_roots
|
|
246
|
+
|
|
247
|
+
def permission_rules(preparation, roots, plugins: preparation.plugins)
|
|
248
|
+
mode = preparation.request.permission_mode
|
|
249
|
+
if mode.nil?
|
|
250
|
+
policy = preparation.permission_policy
|
|
251
|
+
unless policy
|
|
252
|
+
raise ConfigurationError,
|
|
253
|
+
"OpenCode requires an explicit OpenCode permission policy when permission_mode is nil"
|
|
254
|
+
end
|
|
255
|
+
return deep_copy(policy.rules)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
unless %w[read-only workspace-write].include?(mode)
|
|
259
|
+
raise ConfigurationError,
|
|
260
|
+
"unsupported OpenCode permission mode"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
external = { "*" => "deny" }
|
|
264
|
+
(roots.fetch(:read) + roots.fetch(:write)).uniq.each do |root|
|
|
265
|
+
external[root] = "allow"
|
|
266
|
+
external["#{root}/**"] = "allow"
|
|
267
|
+
end
|
|
268
|
+
common = {
|
|
269
|
+
"*" => "deny",
|
|
270
|
+
"read" => {
|
|
271
|
+
"*" => "allow",
|
|
272
|
+
"*.env" => "deny",
|
|
273
|
+
"*.env.*" => "deny",
|
|
274
|
+
"*.env.example" => "allow"
|
|
275
|
+
},
|
|
276
|
+
"glob" => "allow",
|
|
277
|
+
"grep" => "allow",
|
|
278
|
+
"list" => "allow",
|
|
279
|
+
"lsp" => "allow",
|
|
280
|
+
"skill" => plugins.empty? ? "deny" : "allow",
|
|
281
|
+
"external_directory" => external
|
|
282
|
+
}
|
|
283
|
+
if mode == "read-only"
|
|
284
|
+
return common.merge(
|
|
285
|
+
"edit" => "deny", "bash" => "deny", "task" => "deny",
|
|
286
|
+
"webfetch" => "deny", "websearch" => "deny",
|
|
287
|
+
"question" => "deny"
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# OpenCode applies the last matching permission rule. Start from a
|
|
292
|
+
# denial and append only the invocation's declared write roots. The
|
|
293
|
+
# working directory is an implicit write root for workspace-write;
|
|
294
|
+
# additional read roots remain explicitly denied unless the caller
|
|
295
|
+
# also declared them writable.
|
|
296
|
+
writable_roots = [ roots.fetch(:working), *roots.fetch(:write) ].uniq
|
|
297
|
+
edit = { "*" => "deny" }
|
|
298
|
+
allows = if preparation.edit_patterns.empty?
|
|
299
|
+
writable_roots.flat_map do |root|
|
|
300
|
+
edit_patterns(root, working: roots.fetch(:working))
|
|
301
|
+
end
|
|
302
|
+
else
|
|
303
|
+
normalize_declared_edit_patterns(
|
|
304
|
+
preparation.edit_patterns, writable_roots,
|
|
305
|
+
working: roots.fetch(:working)
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
allows.each do |pattern|
|
|
309
|
+
edit[pattern] = "allow"
|
|
310
|
+
end
|
|
311
|
+
(roots.fetch(:read) - roots.fetch(:write)).each do |root|
|
|
312
|
+
edit_patterns(root, working: roots.fetch(:working)).each do |pattern|
|
|
313
|
+
edit[pattern] = "deny"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
common.merge(
|
|
317
|
+
"edit" => edit, "bash" => "deny", "task" => "deny",
|
|
318
|
+
"webfetch" => "deny", "websearch" => "deny",
|
|
319
|
+
"question" => "deny"
|
|
320
|
+
)
|
|
321
|
+
end
|
|
322
|
+
private_class_method :permission_rules
|
|
323
|
+
|
|
324
|
+
def edit_patterns(root, working:)
|
|
325
|
+
if root == working
|
|
326
|
+
[ "**" ]
|
|
327
|
+
elsif root.start_with?(working + File::SEPARATOR)
|
|
328
|
+
relative = root.delete_prefix(working + File::SEPARATOR)
|
|
329
|
+
[ relative, "#{relative}/**" ]
|
|
330
|
+
else
|
|
331
|
+
[ root, "#{root}/**" ]
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
private_class_method :edit_patterns
|
|
335
|
+
|
|
336
|
+
def normalize_declared_edit_patterns(patterns, writable_roots, working:)
|
|
337
|
+
patterns.map do |value|
|
|
338
|
+
pattern = value.sub(%r{\A//}, "/")
|
|
339
|
+
unless File.absolute_path?(pattern) && !pattern.include?("\0")
|
|
340
|
+
raise ConfigurationError,
|
|
341
|
+
"OpenCode edit patterns must be absolute path patterns"
|
|
342
|
+
end
|
|
343
|
+
literal_prefix = pattern.split(/[*?]/, 2).first.sub(%r{/+\z}, "")
|
|
344
|
+
unless writable_roots.any? do |root|
|
|
345
|
+
literal_prefix == root || literal_prefix.start_with?(root + File::SEPARATOR)
|
|
346
|
+
end
|
|
347
|
+
raise ConfigurationError,
|
|
348
|
+
"OpenCode edit pattern is outside the declared write roots"
|
|
349
|
+
end
|
|
350
|
+
if pattern == working
|
|
351
|
+
"**"
|
|
352
|
+
elsif pattern.start_with?(working + File::SEPARATOR)
|
|
353
|
+
pattern.delete_prefix(working + File::SEPARATOR)
|
|
354
|
+
else
|
|
355
|
+
pattern
|
|
356
|
+
end
|
|
357
|
+
end.uniq.freeze
|
|
358
|
+
end
|
|
359
|
+
private_class_method :normalize_declared_edit_patterns
|
|
360
|
+
|
|
361
|
+
def create_root!(value)
|
|
362
|
+
path = File.expand_path(value)
|
|
363
|
+
unless File.absolute_path?(value.to_s)
|
|
364
|
+
raise UnsafePathError,
|
|
365
|
+
"OpenCode invocation root must be absolute"
|
|
366
|
+
end
|
|
367
|
+
parent = File.realpath(File.dirname(path))
|
|
368
|
+
path = File.join(parent, File.basename(path))
|
|
369
|
+
if File.exist?(path) || File.symlink?(path)
|
|
370
|
+
raise UnsafePathError,
|
|
371
|
+
"OpenCode invocation root must not already exist"
|
|
372
|
+
end
|
|
373
|
+
validate_ancestors!(parent)
|
|
374
|
+
Dir.mkdir(path, 0o700)
|
|
375
|
+
path.freeze
|
|
376
|
+
rescue Errno::EEXIST, Errno::ENOENT, Errno::EACCES => e
|
|
377
|
+
raise UnsafePathError, Redactor.diagnostic(e)
|
|
378
|
+
end
|
|
379
|
+
private_class_method :create_root!
|
|
380
|
+
|
|
381
|
+
def validate_ancestors!(path)
|
|
382
|
+
current = File.expand_path(path)
|
|
383
|
+
loop do
|
|
384
|
+
stat = File.lstat(current)
|
|
385
|
+
unless stat.directory? && !stat.symlink?
|
|
386
|
+
raise UnsafePathError,
|
|
387
|
+
"OpenCode invocation root has an unsafe ancestor"
|
|
388
|
+
end
|
|
389
|
+
parent = File.dirname(current)
|
|
390
|
+
break if parent == current
|
|
391
|
+
|
|
392
|
+
current = parent
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
private_class_method :validate_ancestors!
|
|
396
|
+
|
|
397
|
+
def create_directories(root)
|
|
398
|
+
{
|
|
399
|
+
config: "config-home",
|
|
400
|
+
data: "data-home",
|
|
401
|
+
cache: "cache-home",
|
|
402
|
+
state: "state-home",
|
|
403
|
+
source: "selected-config",
|
|
404
|
+
temporary: "tmp"
|
|
405
|
+
}.transform_values do |relative|
|
|
406
|
+
path = File.join(root, relative)
|
|
407
|
+
Dir.mkdir(path, 0o700)
|
|
408
|
+
path.freeze
|
|
409
|
+
end.freeze
|
|
410
|
+
end
|
|
411
|
+
private_class_method :create_directories
|
|
412
|
+
|
|
413
|
+
def selected_plugins(source, requested)
|
|
414
|
+
values = requested.empty? ? source.fetch("plugin", []) : requested
|
|
415
|
+
unless values.is_a?(Array) &&
|
|
416
|
+
values.all? { |plugin| plugin.is_a?(String) && !plugin.empty? }
|
|
417
|
+
raise ConfigurationError,
|
|
418
|
+
"OpenCode plugin selection must be an array of non-empty strings"
|
|
419
|
+
end
|
|
420
|
+
values.uniq.freeze
|
|
421
|
+
end
|
|
422
|
+
private_class_method :selected_plugins
|
|
423
|
+
|
|
424
|
+
def generated_configuration(source, plugins, route, permission)
|
|
425
|
+
config = deep_copy(source)
|
|
426
|
+
if config["agent"].is_a?(Hash)
|
|
427
|
+
config["agent"].each_value do |agent|
|
|
428
|
+
agent.delete("permission") if agent.is_a?(Hash)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
config["$schema"] ||= "https://opencode.ai/config.json"
|
|
432
|
+
config["model"] = route.to_s
|
|
433
|
+
config["permission"] = permission
|
|
434
|
+
if plugins.empty?
|
|
435
|
+
config.delete("plugin")
|
|
436
|
+
else
|
|
437
|
+
config["plugin"] = plugins.dup
|
|
438
|
+
end
|
|
439
|
+
config
|
|
440
|
+
end
|
|
441
|
+
private_class_method :generated_configuration
|
|
442
|
+
|
|
443
|
+
def stage_credential_file(source, destination)
|
|
444
|
+
return nil if source.nil?
|
|
445
|
+
|
|
446
|
+
source_path = safe_source_file!(source, label: "OpenCode credential file")
|
|
447
|
+
directory = File.dirname(destination)
|
|
448
|
+
Dir.mkdir(directory, 0o700)
|
|
449
|
+
contents = File.binread(source_path)
|
|
450
|
+
write_private_file(destination, contents)
|
|
451
|
+
[ directory, destination ]
|
|
452
|
+
end
|
|
453
|
+
private_class_method :stage_credential_file
|
|
454
|
+
|
|
455
|
+
def credential_file_supports_provider?(path, provider)
|
|
456
|
+
value = JSON.parse(File.binread(path))
|
|
457
|
+
value.is_a?(Hash) && value.key?(provider)
|
|
458
|
+
rescue JSON::ParserError
|
|
459
|
+
false
|
|
460
|
+
end
|
|
461
|
+
private_class_method :credential_file_supports_provider?
|
|
462
|
+
|
|
463
|
+
def safe_source_file!(value, label:)
|
|
464
|
+
path = File.expand_path(value)
|
|
465
|
+
unless File.absolute_path?(value.to_s)
|
|
466
|
+
raise UnsafePathError, "#{label} must be absolute"
|
|
467
|
+
end
|
|
468
|
+
stat = File.lstat(path)
|
|
469
|
+
unless stat.file? && !stat.symlink? && stat.uid == Process.uid
|
|
470
|
+
raise UnsafePathError,
|
|
471
|
+
"#{label} must be an owner-controlled regular file"
|
|
472
|
+
end
|
|
473
|
+
path.freeze
|
|
474
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
475
|
+
raise UnsafePathError, Redactor.diagnostic(e)
|
|
476
|
+
end
|
|
477
|
+
private_class_method :safe_source_file!
|
|
478
|
+
|
|
479
|
+
def safe_directory!(value, label:)
|
|
480
|
+
path = File.expand_path(value)
|
|
481
|
+
unless File.absolute_path?(value.to_s)
|
|
482
|
+
raise UnsafePathError, "#{label} must be absolute"
|
|
483
|
+
end
|
|
484
|
+
stat = File.lstat(path)
|
|
485
|
+
unless stat.directory? && !stat.symlink? && stat.uid == Process.uid
|
|
486
|
+
raise UnsafePathError,
|
|
487
|
+
"#{label} must be an owner-controlled real directory"
|
|
488
|
+
end
|
|
489
|
+
File.realpath(path).freeze
|
|
490
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
491
|
+
raise UnsafePathError, Redactor.diagnostic(e)
|
|
492
|
+
end
|
|
493
|
+
private_class_method :safe_directory!
|
|
494
|
+
|
|
495
|
+
def write_private_file(path, contents)
|
|
496
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
497
|
+
file.write(contents)
|
|
498
|
+
end
|
|
499
|
+
File.chmod(0o600, path)
|
|
500
|
+
path
|
|
501
|
+
end
|
|
502
|
+
private_class_method :write_private_file
|
|
503
|
+
|
|
504
|
+
def overlay_environment(paths, configuration_path, pure:)
|
|
505
|
+
environment = {
|
|
506
|
+
"XDG_CONFIG_HOME" => paths.fetch(:config),
|
|
507
|
+
"XDG_DATA_HOME" => paths.fetch(:data),
|
|
508
|
+
"XDG_CACHE_HOME" => paths.fetch(:cache),
|
|
509
|
+
"XDG_STATE_HOME" => paths.fetch(:state),
|
|
510
|
+
"TMPDIR" => paths.fetch(:temporary),
|
|
511
|
+
"OPENCODE_CONFIG" => configuration_path,
|
|
512
|
+
"OPENCODE_DISABLE_PROJECT_CONFIG" => "true",
|
|
513
|
+
"OPENCODE_DISABLE_CLAUDE_CODE" => "true",
|
|
514
|
+
"OPENCODE_DISABLE_MODELS_FETCH" => "true",
|
|
515
|
+
"OPENCODE_DISABLE_AUTOUPDATE" => "true",
|
|
516
|
+
"OPENCODE_PURE" => pure ? "true" : "false"
|
|
517
|
+
}
|
|
518
|
+
unless environment.keys.sort == OPENCODE_OVERLAY_ENVIRONMENT_KEYS.sort
|
|
519
|
+
raise ConfigurationError, "OpenCode overlay environment contract drifted"
|
|
520
|
+
end
|
|
521
|
+
environment.freeze
|
|
522
|
+
end
|
|
523
|
+
private_class_method :overlay_environment
|
|
524
|
+
|
|
525
|
+
def compile_invocation(preparation, profile, route, roots,
|
|
526
|
+
executable:, pure:, probe_result:)
|
|
527
|
+
source = preparation.request
|
|
528
|
+
trusted = source.trusted_cli_arguments.dup
|
|
529
|
+
trusted.concat([ "--dir", roots.fetch(:working) ])
|
|
530
|
+
trusted << "--pure" if pure
|
|
531
|
+
request = Request.new(
|
|
532
|
+
profile: profile,
|
|
533
|
+
prompt: source.prompt,
|
|
534
|
+
permission_mode: source.permission_mode,
|
|
535
|
+
permission_arguments: [ "--auto" ],
|
|
536
|
+
add_dirs: [],
|
|
537
|
+
require_add_dirs: false,
|
|
538
|
+
allowed_tools: nil,
|
|
539
|
+
disallowed_tools: nil,
|
|
540
|
+
# OpenCode has no verified per-run budget flag in the pinned
|
|
541
|
+
# contract. The caller may enforce external limits, but preparation
|
|
542
|
+
# must not claim that this value reached the CLI.
|
|
543
|
+
max_budget_usd: nil,
|
|
544
|
+
model: route.to_s,
|
|
545
|
+
effort: source.effort,
|
|
546
|
+
pin_model: true,
|
|
547
|
+
identity_arguments: source.identity_arguments,
|
|
548
|
+
capabilities: [],
|
|
549
|
+
raw_cli_arguments: source.raw_cli_arguments,
|
|
550
|
+
trusted_cli_arguments: trusted,
|
|
551
|
+
executable: executable,
|
|
552
|
+
command_prefix: source.command_prefix,
|
|
553
|
+
include_output_format: source.include_output_format
|
|
554
|
+
)
|
|
555
|
+
compiled = Runtime.compile(request)
|
|
556
|
+
CompiledInvocation.new(
|
|
557
|
+
argv: compiled.argv,
|
|
558
|
+
stdin_data: compiled.stdin_data,
|
|
559
|
+
provider: compiled.provider,
|
|
560
|
+
launcher_identity: compiled.launcher_identity,
|
|
561
|
+
capability_evidence:
|
|
562
|
+
compiled.capability_evidence + probe_result.capability_evidence
|
|
563
|
+
)
|
|
564
|
+
end
|
|
565
|
+
private_class_method :compile_invocation
|
|
566
|
+
|
|
567
|
+
def deep_copy(value)
|
|
568
|
+
JSON.parse(JSON.generate(value))
|
|
569
|
+
end
|
|
570
|
+
private_class_method :deep_copy
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
end
|