Class: AgentHarness::Providers::Opencode

Inherits:
Base
  • Object
show all
Defined in:
lib/agent_harness/providers/opencode.rb

Overview

OpenCode CLI provider

Provides integration with the OpenCode CLI tool.

Constant Summary collapse

CLI_PACKAGE =
"opencode-ai"
SUPPORTED_CLI_VERSION =
"1.18.19"
SUPPORTED_CLI_REQUIREMENT =
Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 2.0.0").freeze
INSTALL_COMMAND_PREFIX =
["npm", "install", "-g", "--ignore-scripts"].freeze
DEFAULT_PERMISSION_EXTERNAL_DIRECTORY_PATTERNS =

Allowlist of external_directory patterns auto-approved in non-interactive execution. See the DEFAULT_PERMISSION_RULE comment below for the rationale (precedent: #289/#282/#277/#280).

["/tmp/**", "/home/agent/**"].freeze
DEFAULT_PERMISSION_CONFIG =
{
  "external_directory" => DEFAULT_PERMISSION_EXTERNAL_DIRECTORY_PATTERNS
    .to_h { |pattern| [pattern, "allow"] }
    .freeze
}.freeze
SUPPORTED_CLI_VERSIONS =
[SUPPORTED_CLI_VERSION].freeze
POSTINSTALL_COMMAND =
[
  "set -eu",
  "raw_platform=$(uname -s)",
  "raw_arch=$(uname -m)",
  "case \"$raw_platform\" in Linux) target_platform=linux ;; Darwin) target_platform=darwin ;; *) echo \"Unsupported OpenCode platform: ${raw_platform}/${raw_arch}\" >&2; exit 1 ;; esac",
  "case \"$raw_arch\" in x86_64|amd64) target_arch=x64 ;; aarch64|arm64) target_arch=arm64 ;; *) echo \"Unsupported OpenCode architecture: ${raw_platform}/${raw_arch}\" >&2; exit 1 ;; esac",
  "postinstall_path=\"$(npm root -g)/opencode-ai/postinstall.mjs\"",
  "binary_path=\"$(npm root -g)/opencode-ai/bin/opencode.exe\"",
  "node -e 'const os = require(\"os\"); const {pathToFileURL} = require(\"url\"); const platform = process.argv[1]; const arch = process.argv[2]; const scriptPath = process.argv[3]; os.platform = () => platform; os.arch = () => arch; import(pathToFileURL(scriptPath).href);' \"$target_platform\" \"$target_arch\" \"$postinstall_path\"",
  "\"$binary_path\" --version >/dev/null"
].join(" && ").freeze
VERSION_REQUIREMENT_STRINGS =
SUPPORTED_CLI_REQUIREMENT.requirements
.map { |op, ver| "#{op} #{ver}".freeze }
.freeze
DEFAULT_INSTALLATION_CONTRACT =
build_installation_contract(SUPPORTED_CLI_VERSION)
DEFAULT_PERMISSION_RULE =

OpenCode's stock default only allowlists external_directory access to /tmp/opencode/*, but its own sub-agent delegation pattern (e.g. the Explore-Agent used to summarize large diffs/outputs) writes scratch files to other /tmp/* paths. In non-interactive execution there is no human to approve the resulting permission prompt, so dedicated read/write/edit tool calls targeting paths outside the project dir are auto-rejected and the agent silently loses access to its own scratch output (or its own config/data files) and never completes the task.

This default rule broadens the allowlist (see DEFAULT_PERMISSION_EXTERNAL_DIRECTORY_PATTERNS) to all of /tmp (so the agent can read back files it or its sub-agents wrote there) and to the full agent home directory (so the agent can inspect/maintain its own config, cache, and data files such as ~/.config/opencode, ~/.local/share/opencode, ~/.cache). The container is already isolated (Docker, non-root user) and the agent runs with --auto which approves everything inside the project dir. See #289 (precedent: #282/#277/#280).

{
  "external_directory" => DEFAULT_PERMISSION_EXTERNAL_DIRECTORY_PATTERNS
    .to_h { |pattern| [pattern, "allow"] }
    .freeze
}.freeze

Constants inherited from Base

Base::COMMON_ERROR_PATTERNS, Base::DEFAULT_SMOKE_TEST_CONTRACT

Instance Attribute Summary

Attributes inherited from Base

#config, #executor, #logger

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#api_key_env_var_names, #api_key_unset_vars, #check_quota, #cli_env_overrides, #configure, #initialize, #parse_container_output, #parse_test_error, #plan_execution, #preflight_check, #sandboxed_environment?, #send_chat_message, #send_message, #subscription_unset_vars, #test_command_overrides, #update_quota_from_headers

Methods included from RateLimitResetParsing

#parse_rate_limit_reset

Methods included from Adapter

#auth_lock_config, #auth_type, #build_mcp_flags, #chat_transport, #chat_transport_type, #config_file_content, #dangerous_mode_flags, #error_classification_patterns, #fetch_mcp_servers, #health_status, included, metadata_package_name, #noisy_error_patterns, normalize_metadata_installation, normalize_metadata_runtime_requirements, normalize_metadata_source_type, normalize_metadata_version_requirement, #notify_hook_content, #parse_rate_limit_reset, #plan_execution, #preflight_check, #send_message, #session_flags, #smoke_test, #smoke_test_contract, #supported_mcp_transports, #supports_chat?, #supports_dangerous_mode?, #supports_mcp?, #supports_message_tool_injection?, #supports_sessions?, #supports_text_mode?, #supports_token_counting?, #supports_tool_control?, #token_usage_from_api_response, #translate_error, #validate_config, #validate_mcp_servers!

Constructor Details

This class inherits a constructor from AgentHarness::Providers::Base

Class Method Details

.available?Boolean

Returns:

  • (Boolean)


50
51
52
53
# File 'lib/agent_harness/providers/opencode.rb', line 50

def available?
  executor = AgentHarness.configuration.command_executor
  !!executor.which(binary_name)
end

.binary_nameObject



46
47
48
# File 'lib/agent_harness/providers/opencode.rb', line 46

def binary_name
  "opencode"
end

.discover_modelsObject



77
78
79
80
# File 'lib/agent_harness/providers/opencode.rb', line 77

def discover_models
  return [] unless available?
  []
end

.firewall_requirementsObject



64
65
66
67
68
69
70
71
# File 'lib/agent_harness/providers/opencode.rb', line 64

def firewall_requirements
  {
    domains: [
      "api.openai.com"
    ],
    ip_ranges: []
  }
end

.install_command(version: SUPPORTED_CLI_VERSION) ⇒ Object



89
90
91
# File 'lib/agent_harness/providers/opencode.rb', line 89

def install_command(version: SUPPORTED_CLI_VERSION)
  installation_contract(version: version)[:install_command]
end

.installation_contract(version: SUPPORTED_CLI_VERSION) ⇒ Object



82
83
84
85
86
87
# File 'lib/agent_harness/providers/opencode.rb', line 82

def installation_contract(version: SUPPORTED_CLI_VERSION)
  normalized_version = normalize_install_version(version)
  return DEFAULT_INSTALLATION_CONTRACT if normalized_version == SUPPORTED_CLI_VERSION

  build_installation_contract(normalized_version)
end

.instruction_file_pathsObject



73
74
75
# File 'lib/agent_harness/providers/opencode.rb', line 73

def instruction_file_paths
  []
end

.provider_metadata_overridesObject



55
56
57
58
59
60
61
62
# File 'lib/agent_harness/providers/opencode.rb', line 55

def 
  {
    auth: {
      service: :openai,
      api_family: :openai_compatible
    }
  }
end

.provider_nameObject



42
43
44
# File 'lib/agent_harness/providers/opencode.rb', line 42

def provider_name
  :opencode
end

.smoke_test_contractObject



93
94
95
# File 'lib/agent_harness/providers/opencode.rb', line 93

def smoke_test_contract
  Base::DEFAULT_SMOKE_TEST_CONTRACT
end

Instance Method Details

#capabilitiesObject



184
185
186
187
188
189
190
191
192
193
194
# File 'lib/agent_harness/providers/opencode.rb', line 184

def capabilities
  {
    streaming: false,
    file_upload: false,
    vision: false,
    tool_use: false,
    json_mode: false,
    mcp: false,
    dangerous_mode: false
  }
end

#configuration_schemaObject



176
177
178
179
180
181
182
# File 'lib/agent_harness/providers/opencode.rb', line 176

def configuration_schema
  {
    fields: [],
    auth_modes: [:api_key],
    openai_compatible: true
  }
end

#display_nameObject



172
173
174
# File 'lib/agent_harness/providers/opencode.rb', line 172

def display_name
  "OpenCode CLI"
end

#error_patternsObject



229
230
231
# File 'lib/agent_harness/providers/opencode.rb', line 229

def error_patterns
  COMMON_ERROR_PATTERNS
end

#execution_semanticsObject



233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/agent_harness/providers/opencode.rb', line 233

def execution_semantics
  {
    prompt_delivery: :arg,
    output_format: :text,
    sandbox_aware: false,
    uses_subcommand: true,
    non_interactive_flag: nil,
    legitimate_exit_codes: [0],
    stderr_is_diagnostic: true,
    parses_rate_limit_reset: true
  }
end

#heartbeat_integration(heartbeat_file_path:) ⇒ Object



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/agent_harness/providers/opencode.rb', line 200

def heartbeat_integration(heartbeat_file_path:)
  unless heartbeat_file_path.is_a?(String) && !heartbeat_file_path.strip.empty?
    raise ArgumentError, "heartbeat_file_path must be a non-empty String"
  end
  unless heartbeat_file_path.start_with?("/")
    raise ArgumentError, "heartbeat_file_path must be an absolute path (got #{heartbeat_file_path.inspect})"
  end

  hook_script = heartbeat_hook_script(heartbeat_file_path)
  config_payload = merge_heartbeat_hooks(hook_script)

  preparation = ExecutionPreparation.new(
    file_writes: [
      {
        path: heartbeat_hook_config_path,
        content: serialize_opencode_config(config_payload),
        mode: 0o600
      }
    ]
  )

  {
    supported: true,
    env: {"OPENCODE_HEARTBEAT_FILE" => heartbeat_file_path},
    preparation: preparation,
    granularity: :tool_call
  }
end

#nameObject



168
169
170
# File 'lib/agent_harness/providers/opencode.rb', line 168

def name
  "opencode"
end

#supports_activity_heartbeat?Boolean

Returns:

  • (Boolean)


196
197
198
# File 'lib/agent_harness/providers/opencode.rb', line 196

def supports_activity_heartbeat?
  true
end