Skip to content

Commit 4db0dd3

Browse files
committed
fix(runtime): validate remote config and harden runtime commands
Fail fast on invalid runtime.connection, remote_url, command arrays, timeout, and path transform returns so misconfiguration is surfaced clearly. Also URL-encode query parameters to avoid malformed API requests with mapped paths.
1 parent 1c7ab09 commit 4db0dd3

11 files changed

Lines changed: 397 additions & 59 deletions

lua/opencode/api_client.lua

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ local server_job = require('opencode.server_job')
22
local state = require('opencode.state')
33
local util = require('opencode.util')
44

5+
local function encode_query_value(value)
6+
return tostring(value):gsub('([^%w%-_%.~])', function(ch)
7+
return string.format('%%%02X', string.byte(ch))
8+
end)
9+
end
10+
511
--- @class OpencodeApiClient
612
--- @field base_url string The base URL of the opencode server
713
local OpencodeApiClient = {}
@@ -80,7 +86,7 @@ function OpencodeApiClient:_call(endpoint, method, body, query)
8086

8187
for k, v in pairs(normalized_query) do
8288
if v ~= nil then
83-
table.insert(params, k .. '=' .. tostring(v))
89+
table.insert(params, k .. '=' .. encode_query_value(v))
8490
end
8591
end
8692

@@ -443,7 +449,7 @@ function OpencodeApiClient:subscribe_to_events(directory, on_event)
443449
local url = self.base_url .. '/event'
444450
directory = util.to_server_path(directory)
445451
if directory then
446-
url = url .. '?directory=' .. directory
452+
url = url .. '?directory=' .. encode_query_value(directory)
447453
end
448454

449455
return server_job.stream_api(url, 'GET', nil, function(chunk)

lua/opencode/core.lua

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -430,18 +430,27 @@ end)
430430

431431
M.opencode_ok = Promise.async(function()
432432
local runtime = config.runtime or {}
433-
local connection = runtime.connection or 'spawn'
433+
local connection, connection_err = util.get_runtime_connection()
434+
if not connection then
435+
vim.notify(connection_err, vim.log.levels.ERROR)
436+
return false
437+
end
434438

435439
if connection == 'remote' then
436-
local remote_url = runtime.remote_url
437-
if type(remote_url) ~= 'string' or remote_url == '' then
438-
vim.notify('runtime.remote_url is required when runtime.connection is "remote"', vim.log.levels.ERROR)
440+
local _, remote_url_err = util.normalize_remote_url(runtime.remote_url)
441+
if remote_url_err then
442+
vim.notify(remote_url_err, vim.log.levels.ERROR)
439443
return false
440444
end
441445
return true
442446
end
443447

444-
local runtime_cmd = util.get_runtime_command()
448+
local runtime_cmd, runtime_cmd_err = util.get_runtime_command()
449+
if not runtime_cmd then
450+
vim.notify(runtime_cmd_err, vim.log.levels.ERROR)
451+
return false
452+
end
453+
445454
if vim.fn.executable(runtime_cmd[1]) == 0 then
446455
vim.notify(
447456
string.format(
@@ -454,7 +463,11 @@ M.opencode_ok = Promise.async(function()
454463
end
455464

456465
if not state.opencode_cli_version or state.opencode_cli_version == '' then
457-
local cmd = util.get_runtime_version_command()
466+
local cmd, cmd_err = util.get_runtime_version_command()
467+
if not cmd then
468+
vim.notify(cmd_err, vim.log.levels.ERROR)
469+
return false
470+
end
458471
local result = Promise.system(cmd):await()
459472
local out = (result and result.stdout or ''):gsub('%s+$', '')
460473
state.opencode_cli_version = out:match('(%d+%%.%d+%%.%d+)') or out

lua/opencode/health.lua

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,19 @@ local function command_exists(cmd)
88
end
99

1010
local function get_opencode_version()
11-
local runtime_cmd = util.get_runtime_command()
11+
local runtime_cmd, runtime_cmd_err = util.get_runtime_command()
12+
if not runtime_cmd then
13+
return nil, runtime_cmd_err
14+
end
15+
1216
if not command_exists(runtime_cmd[1]) then
1317
return nil, 'opencode runtime command not found: ' .. tostring(runtime_cmd[1])
1418
end
1519

16-
local cmd = util.get_runtime_version_command()
20+
local cmd, cmd_err = util.get_runtime_version_command()
21+
if not cmd then
22+
return nil, cmd_err
23+
end
1724

1825
local result = vim.system(cmd):wait()
1926
if result.code ~= 0 then
@@ -29,8 +36,11 @@ local function check_opencode_cli()
2936
health.start('OpenCode CLI')
3037

3138
local config = require('opencode.config')
32-
local runtime = config.runtime or {}
33-
local connection = runtime.connection or 'spawn'
39+
local connection, connection_err = util.get_runtime_connection()
40+
if not connection then
41+
health.error(connection_err)
42+
return
43+
end
3444

3545
if connection == 'remote' then
3646
health.info('CLI executable checks are skipped in remote runtime mode')
@@ -40,7 +50,12 @@ local function check_opencode_cli()
4050
local state = require('opencode.state')
4151
local required_version = state.required_version
4252

43-
local runtime_cmd = util.get_runtime_command()
53+
local runtime_cmd, runtime_cmd_err = util.get_runtime_command()
54+
if not runtime_cmd then
55+
health.error(runtime_cmd_err)
56+
return
57+
end
58+
4459
if not command_exists(runtime_cmd[1]) then
4560
health.error('opencode runtime command not found', {
4661
'Install opencode CLI from: https://docs.opencode.com/installation',
@@ -73,21 +88,19 @@ local function check_opencode_server()
7388

7489
local config = require('opencode.config')
7590
local runtime = config.runtime or {}
76-
local connection = runtime.connection or 'spawn'
91+
local connection, connection_err = util.get_runtime_connection()
92+
if not connection then
93+
health.error(connection_err)
94+
return
95+
end
7796

7897
if connection == 'remote' then
79-
local remote_url = runtime.remote_url
80-
if type(remote_url) ~= 'string' or remote_url == '' then
81-
health.error('runtime.remote_url is required when runtime.connection is "remote"')
98+
local normalized_remote_url, remote_url_err = util.normalize_remote_url(runtime.remote_url)
99+
if not normalized_remote_url then
100+
health.error(remote_url_err)
82101
return
83102
end
84103

85-
local normalized_remote_url = remote_url
86-
if normalized_remote_url:match('^%d+%.%d+%.%d+%.%d+:%d+$') or normalized_remote_url:match('^localhost:%d+$') then
87-
normalized_remote_url = 'http://' .. normalized_remote_url
88-
end
89-
normalized_remote_url = normalized_remote_url:gsub('/$', '')
90-
91104
local server_job = require('opencode.server_job')
92105
local ok, result = pcall(function()
93106
return server_job.call_api(normalized_remote_url .. '/config', 'GET', nil):wait()

lua/opencode/opencode_server.lua

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
local util = require('opencode.util')
22
local safe_call = util.safe_call
33
local Promise = require('opencode.promise')
4-
local config = require('opencode.config')
54

65
--- @class OpencodeServer
76
--- @field job any The vim.system job handle
@@ -198,7 +197,12 @@ function OpencodeServer:spawn(opts)
198197
end
199198
end
200199

201-
local cmd = util.get_runtime_serve_command()
200+
local cmd, cmd_err = util.get_runtime_serve_command()
201+
if not cmd then
202+
self.spawn_promise:reject(cmd_err)
203+
safe_call(opts.on_error, cmd_err)
204+
return self.spawn_promise
205+
end
202206
local system_opts = {
203207
cwd = opts.cwd,
204208
}
@@ -270,7 +274,13 @@ function OpencodeServer:spawn(opts)
270274

271275
self.handle = self.job and self.job.pid
272276

273-
local startup_timeout_ms = tonumber(config.runtime and config.runtime.startup_timeout_ms) or 15000
277+
local startup_timeout_ms, timeout_err = util.get_runtime_startup_timeout_ms()
278+
if not startup_timeout_ms then
279+
self.spawn_promise:reject(timeout_err)
280+
safe_call(opts.on_error, timeout_err)
281+
return self.spawn_promise
282+
end
283+
274284
vim.defer_fn(function()
275285
if ready then
276286
return

lua/opencode/server_job.lua

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,21 +139,34 @@ end
139139
function M.ensure_server()
140140
local promise = Promise.new()
141141
local runtime = config.runtime or {}
142-
local connection = runtime.connection or 'spawn'
142+
local connection, connection_err = util.get_runtime_connection()
143+
144+
if not connection then
145+
log.error(connection_err)
146+
vim.notify(connection_err, vim.log.levels.ERROR)
147+
return promise:reject(connection_err)
148+
end
143149

144150
if state.opencode_server and state.opencode_server:is_running() then
145151
return promise:resolve(state.opencode_server)
146152
end
147153

148154
state.opencode_server = opencode_server.new()
149155
local cwd = vim.fn.getcwd()
150-
local pre_start_command = util.get_runtime_pre_start_command()
156+
local pre_start_command, pre_start_command_err = util.get_runtime_pre_start_command()
157+
158+
if pre_start_command_err then
159+
log.error(pre_start_command_err)
160+
vim.notify(pre_start_command_err, vim.log.levels.ERROR)
161+
state.opencode_server = nil
162+
return promise:reject(pre_start_command_err)
163+
end
151164

152165
local function continue_startup()
153166
if connection == 'remote' then
154-
local remote_url = runtime.remote_url
155-
if type(remote_url) ~= 'string' or remote_url == '' then
156-
local err = 'runtime.remote_url must be set when runtime.connection is "remote"'
167+
local remote_url, remote_url_err = util.normalize_remote_url(runtime.remote_url)
168+
if not remote_url then
169+
local err = remote_url_err
157170
log.error(err)
158171
vim.notify(err, vim.log.levels.ERROR)
159172
state.opencode_server = nil

0 commit comments

Comments
 (0)