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.
@@ -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