Skip to content

Commit 1c18392

Browse files
committed
fix(opencode): inject fresh Authorization per-request via chat.headers plugin (#190)
opencode reads provider.options.headers.Authorization from opencode.json once at startup and never again, so a long-lived session keeps sending a bearer token that may since have been rotated or revoked. Ship a ucode-managed opencode plugin (chat.headers hook, auto-loaded from the opencode plugin dir) that sets a fresh Authorization header on every chat request for our Databricks providers, sourcing the token from `ucode auth-token` but cached in-process with a short TTL (60s) plus an in-flight promise guard so it doesn't shell out on every request or spawn concurrent subprocesses. The static apiKey/headers in opencode.json are left untouched as the bootstrap/fallback the plugin fails open to on any refresh error. write_tool_config now also writes the plugin, scoped to only the provider ids actually configured. `ucode revert` deletes the (entirely ucode-generated, no-backup-needed) plugin file.
1 parent f7c597f commit 1c18392

3 files changed

Lines changed: 287 additions & 0 deletions

File tree

src/ucode/agents/opencode.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import json
56
import os
67
import signal
78
import subprocess
@@ -15,9 +16,11 @@
1516
deep_merge_dict,
1617
read_json_safe,
1718
write_json_file,
19+
write_text_file,
1820
)
1921
from ucode.databricks import (
2022
TOKEN_REFRESH_INTERVAL_SECONDS,
23+
build_auth_token_argv,
2124
build_opencode_base_urls,
2225
get_databricks_token,
2326
model_token_limits,
@@ -31,6 +34,18 @@
3134
OPENCODE_BACKUP_PATH = APP_DIR / "opencode-config.backup.json"
3235
OPENCODE_MCP_AUTH_HEADER_VALUE = "Bearer {env:OAUTH_TOKEN}"
3336

37+
# Axis B of #190: opencode bakes provider.options.headers.Authorization into
38+
# opencode.json once at `ucode configure`/refresh time and never re-reads it
39+
# for the life of the process, so a long-lived session keeps sending a
40+
# bearer token that may since have been rotated or revoked. This
41+
# ucode-managed plugin's `chat.headers` hook overrides that static header on
42+
# every chat request with a freshly fetched (and short-TTL-cached) token.
43+
# The static opencode.json header stays in place as the bootstrap/fallback
44+
# the plugin fails open to if the refresh call errors.
45+
OPENCODE_PLUGIN_DIR = OPENCODE_CONFIG_DIR / "plugin"
46+
OPENCODE_PLUGIN_PATH = OPENCODE_PLUGIN_DIR / "ucode-databricks-auth.js"
47+
AUTH_PLUGIN_TOKEN_TTL_SECONDS = 60
48+
3449
SPEC: ToolSpec = {
3550
"binary": "opencode",
3651
"package": "opencode-ai",
@@ -155,6 +170,109 @@ def render_overlay(
155170
return overlay, keys
156171

157172

173+
def _managed_provider_ids(managed_keys: list[list[str]]) -> list[str]:
174+
"""Provider ids actually configured, derived from render_overlay's managed_keys.
175+
176+
`managed_keys` entries are key paths like `["provider", "databricks-anthropic"]`
177+
for each provider render_overlay populated (only providers with at least one
178+
configured model are included); this just picks those out."""
179+
return [key[1] for key in managed_keys if len(key) == 2 and key[0] == "provider"]
180+
181+
182+
def render_auth_plugin(workspace: str, profile: str | None, provider_ids: list[str]) -> str:
183+
"""Return the JS source for the ucode-managed opencode `chat.headers` plugin.
184+
185+
On every chat request, the plugin sets a freshly fetched
186+
`Authorization: Bearer <token>` header for our Databricks providers
187+
(identified by `provider_ids`), overriding the static header baked into
188+
opencode.json. The token comes from `ucode auth-token` -- the same
189+
cross-platform helper used elsewhere (see `build_auth_token_argv`) -- but
190+
is cached in-process for `AUTH_PLUGIN_TOKEN_TTL_SECONDS` so it doesn't
191+
shell out on every request; a single in-flight promise coalesces
192+
concurrent requests within the process. On any error, the hook leaves
193+
`output.headers` untouched (fail open to the static bootstrap token)."""
194+
argv = build_auth_token_argv(workspace, profile)
195+
argv_literal = json.dumps(argv)
196+
provider_ids_literal = json.dumps(sorted(provider_ids))
197+
ttl_ms = AUTH_PLUGIN_TOKEN_TTL_SECONDS * 1000
198+
199+
return f"""\
200+
// Generated and managed by ucode -- do not edit by hand. Overwritten on every
201+
// `ucode configure` / token refresh.
202+
//
203+
// Fixes #190: opencode reads provider.options.headers.Authorization from
204+
// opencode.json once at startup and never again, so a long-lived session
205+
// keeps sending a token that may since have been rotated or revoked. This
206+
// `chat.headers` hook sets a fresh Authorization header on every chat
207+
// request for our Databricks providers, overriding the static header.
208+
209+
import {{ execFile }} from "node:child_process";
210+
import {{ promisify }} from "node:util";
211+
212+
const execFileAsync = promisify(execFile);
213+
214+
const UCODE_AUTH_TOKEN_ARGV = {argv_literal};
215+
const MANAGED_PROVIDER_IDS = new Set({provider_ids_literal});
216+
const TTL_MS = {ttl_ms};
217+
218+
let cachedToken = null;
219+
let cachedAt = 0;
220+
let inflight = null;
221+
222+
async function fetchToken() {{
223+
const [cmd, ...args] = UCODE_AUTH_TOKEN_ARGV;
224+
const {{ stdout }} = await execFileAsync(cmd, args);
225+
return stdout.trim();
226+
}}
227+
228+
async function getToken() {{
229+
const now = Date.now();
230+
if (cachedToken && now - cachedAt < TTL_MS) {{
231+
return cachedToken;
232+
}}
233+
if (inflight) {{
234+
return inflight;
235+
}}
236+
inflight = fetchToken()
237+
.then((token) => {{
238+
cachedToken = token;
239+
cachedAt = Date.now();
240+
return token;
241+
}})
242+
.finally(() => {{
243+
inflight = null;
244+
}});
245+
return inflight;
246+
}}
247+
248+
export const UcodeDatabricksAuth = async (_ctx) => ({{
249+
"chat.headers": async (input, output) => {{
250+
const providerId = input.provider?.info?.id;
251+
if (!providerId || !MANAGED_PROVIDER_IDS.has(providerId)) {{
252+
return;
253+
}}
254+
try {{
255+
const token = await getToken();
256+
output.headers["Authorization"] = "Bearer " + token;
257+
}} catch (err) {{
258+
console.error("[ucode] failed to refresh Databricks token:", err);
259+
// Fail open: leave the static bootstrap token from opencode.json.
260+
}}
261+
}},
262+
}});
263+
"""
264+
265+
266+
def write_auth_plugin(state: dict, provider_ids: list[str]) -> None:
267+
"""Write the ucode-managed opencode auth plugin (see `render_auth_plugin`).
268+
269+
Always writes -- including with an empty `provider_ids` -- so a
270+
workspace/model change that drops the last provider still overwrites a
271+
stale plugin rather than leaving one referencing removed provider ids."""
272+
plugin_js = render_auth_plugin(state["workspace"], state.get("profile"), provider_ids)
273+
write_text_file(OPENCODE_PLUGIN_PATH, plugin_js)
274+
275+
158276
def write_tool_config(
159277
state: dict,
160278
model: str,
@@ -188,6 +306,9 @@ def write_tool_config(
188306
providers.pop(stale, None)
189307
merged = deep_merge_dict(existing, overlay)
190308
write_json_file(OPENCODE_CONFIG_PATH, merged)
309+
# Plugin is the live/per-request override; opencode.json (above) remains
310+
# the static bootstrap/fallback the plugin fails open to. See #190.
311+
write_auth_plugin(state, _managed_provider_ids(managed_keys))
191312
state = mark_tool_managed(state, "opencode", managed_keys)
192313
save_state(state)
193314
return state, token

src/ucode/cli.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
launch as launch_agent,
2929
)
3030
from ucode.agents.codex import revert_legacy_shared_config
31+
from ucode.agents.opencode import OPENCODE_PLUGIN_PATH
3132
from ucode.agents.pi import PI_SETTINGS_BACKUP_PATH, PI_SETTINGS_PATH
3233
from ucode.config_io import restore_file, set_dry_run
3334
from ucode.databricks import (
@@ -676,6 +677,21 @@ def status() -> int:
676677
return 0
677678

678679

680+
def _remove_opencode_auth_plugin() -> bool:
681+
"""Delete the ucode-managed opencode auth plugin file (see agents/opencode.py).
682+
683+
Unlike opencode.json, this file has no pre-ucode content to restore --
684+
it's entirely ucode-generated -- so revert just deletes it rather than
685+
going through the backup/restore_file machinery (#190)."""
686+
try:
687+
if OPENCODE_PLUGIN_PATH.exists():
688+
OPENCODE_PLUGIN_PATH.unlink()
689+
return True
690+
except OSError:
691+
pass
692+
return False
693+
694+
679695
def revert() -> int:
680696
state = load_state()
681697
managed_configs = state.get("managed_configs") or {}
@@ -690,6 +706,7 @@ def revert() -> int:
690706
pi_settings_restored = restore_file(
691707
PI_SETTINGS_PATH, PI_SETTINGS_BACKUP_PATH, bool(managed_configs.get("pi"))
692708
)
709+
opencode_plugin_removed = _remove_opencode_auth_plugin()
693710
# Older Codex (< 0.134.0) had ucode edit the shared ~/.codex/config.toml in
694711
# place; restoring the per-profile file above does not undo that.
695712
legacy_codex_stripped = revert_legacy_shared_config()
@@ -702,6 +719,7 @@ def revert() -> int:
702719
if legacy_codex_stripped:
703720
print_kv("Codex shared config", "ucode entries removed")
704721
print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged")
722+
print_kv("OpenCode auth plugin", "removed" if opencode_plugin_removed else "unchanged")
705723
for client, spec in MCP_CLIENTS.items():
706724
print_kv(
707725
f"{spec['display']} MCP config",

tests/test_agent_opencode.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,151 @@ def test_config_written_with_correct_model(self, tmp_path, monkeypatch):
412412

413413
written = json.loads(config_file.read_text())
414414
assert written["model"] == "databricks-anthropic/claude-sonnet"
415+
416+
417+
ALL_PROVIDER_IDS = ["databricks-anthropic", "databricks-google", "databricks-oss"]
418+
419+
420+
class TestManagedProviderIds:
421+
def test_picks_out_provider_keys_only(self):
422+
managed_keys = [
423+
["model"],
424+
["provider", "databricks-anthropic"],
425+
["provider", "databricks-oss"],
426+
]
427+
assert opencode._managed_provider_ids(managed_keys) == [
428+
"databricks-anthropic",
429+
"databricks-oss",
430+
]
431+
432+
def test_empty_when_no_provider_keys(self):
433+
assert opencode._managed_provider_ids([["model"]]) == []
434+
435+
436+
class TestRenderAuthPlugin:
437+
def test_contains_chat_headers_hook(self):
438+
js = opencode.render_auth_plugin(WS, None, ALL_PROVIDER_IDS)
439+
assert '"chat.headers"' in js
440+
441+
def test_embeds_workspace_in_auth_token_argv(self):
442+
js = opencode.render_auth_plugin(WS, None, ALL_PROVIDER_IDS)
443+
assert "auth-token" in js
444+
assert "--host" in js
445+
assert WS in js
446+
447+
def test_embeds_profile_flag_when_provided(self):
448+
js = opencode.render_auth_plugin(WS, "my-profile", ALL_PROVIDER_IDS)
449+
assert "--profile" in js
450+
assert "my-profile" in js
451+
452+
def test_omits_profile_flag_when_not_provided(self):
453+
js = opencode.render_auth_plugin(WS, None, ALL_PROVIDER_IDS)
454+
assert "--profile" not in js
455+
456+
def test_embeds_provider_id_guard_set_with_all_providers(self):
457+
js = opencode.render_auth_plugin(WS, None, ALL_PROVIDER_IDS)
458+
for provider_id in ALL_PROVIDER_IDS:
459+
assert provider_id in js
460+
assert "MANAGED_PROVIDER_IDS" in js
461+
462+
def test_embeds_only_configured_subset_of_provider_ids(self):
463+
js = opencode.render_auth_plugin(WS, None, ["databricks-anthropic"])
464+
assert "databricks-anthropic" in js
465+
assert "databricks-google" not in js
466+
assert "databricks-oss" not in js
467+
468+
def test_embeds_ttl_constant(self):
469+
js = opencode.render_auth_plugin(WS, None, ALL_PROVIDER_IDS)
470+
assert "TTL_MS" in js
471+
assert str(opencode.AUTH_PLUGIN_TOKEN_TTL_SECONDS * 1000) in js
472+
473+
def test_exports_plugin_function(self):
474+
js = opencode.render_auth_plugin(WS, None, ALL_PROVIDER_IDS)
475+
assert "export const UcodeDatabricksAuth" in js
476+
477+
def test_provider_scoping_guard_checks_provider_info_id(self):
478+
js = opencode.render_auth_plugin(WS, None, ALL_PROVIDER_IDS)
479+
assert "input.provider?.info?.id" in js
480+
481+
def test_fails_open_on_error(self):
482+
js = opencode.render_auth_plugin(WS, None, ALL_PROVIDER_IDS)
483+
assert "catch" in js
484+
assert "console.error" in js
485+
486+
def test_uses_execfile_not_shell(self):
487+
js = opencode.render_auth_plugin(WS, None, ALL_PROVIDER_IDS)
488+
assert "execFile" in js
489+
assert "node:child_process" in js
490+
491+
492+
class TestWriteAuthPlugin:
493+
def test_writes_plugin_file_containing_hook(self, tmp_path, monkeypatch):
494+
import ucode.agents.opencode as oc_mod
495+
496+
plugin_path = tmp_path / "ucode-databricks-auth.js"
497+
monkeypatch.setattr(oc_mod, "OPENCODE_PLUGIN_PATH", plugin_path)
498+
499+
state = {"workspace": WS, "profile": None}
500+
oc_mod.write_auth_plugin(state, ["databricks-anthropic"])
501+
502+
assert plugin_path.exists()
503+
content = plugin_path.read_text()
504+
assert '"chat.headers"' in content
505+
assert "databricks-anthropic" in content
506+
507+
508+
class TestWriteToolConfigAuthPlugin:
509+
def _write_and_return(self, tmp_path, monkeypatch, opencode_models):
510+
import ucode.agents.opencode as oc_mod
511+
import ucode.config_io as config_io_mod
512+
513+
monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path)
514+
config_file = tmp_path / "opencode.json"
515+
backup_file = tmp_path / "opencode-backup.json"
516+
plugin_file = tmp_path / "ucode-databricks-auth.js"
517+
monkeypatch.setattr(oc_mod, "OPENCODE_CONFIG_PATH", config_file)
518+
monkeypatch.setattr(oc_mod, "OPENCODE_BACKUP_PATH", backup_file)
519+
monkeypatch.setattr(oc_mod, "OPENCODE_PLUGIN_PATH", plugin_file)
520+
521+
state = {
522+
"workspace": WS,
523+
"base_urls": {"opencode": _base_urls()},
524+
"opencode_models": opencode_models,
525+
"managed_configs": {},
526+
}
527+
528+
with (
529+
patch("ucode.agents.opencode.get_databricks_token", return_value="tok"),
530+
patch("ucode.agents.opencode.save_state"),
531+
):
532+
oc_mod.write_tool_config(state, "claude-sonnet", token="tok")
533+
534+
return config_file, plugin_file
535+
536+
def test_writes_plugin_file_to_configured_path(self, tmp_path, monkeypatch):
537+
_config_file, plugin_file = self._write_and_return(
538+
tmp_path, monkeypatch, {"anthropic": ["claude-sonnet"]}
539+
)
540+
541+
assert plugin_file.exists()
542+
assert '"chat.headers"' in plugin_file.read_text()
543+
544+
def test_plugin_scoped_to_configured_providers_only(self, tmp_path, monkeypatch):
545+
_config_file, plugin_file = self._write_and_return(
546+
tmp_path, monkeypatch, {"anthropic": ["claude-sonnet"]}
547+
)
548+
549+
content = plugin_file.read_text()
550+
assert "databricks-anthropic" in content
551+
assert "databricks-google" not in content
552+
assert "databricks-oss" not in content
553+
554+
def test_static_bootstrap_config_still_present_in_opencode_json(self, tmp_path, monkeypatch):
555+
config_file, _plugin_file = self._write_and_return(
556+
tmp_path, monkeypatch, {"anthropic": ["claude-sonnet"]}
557+
)
558+
559+
written = json.loads(config_file.read_text())
560+
anthropic_options = written["provider"]["databricks-anthropic"]["options"]
561+
assert anthropic_options["apiKey"] == "tok"
562+
assert anthropic_options["headers"]["Authorization"] == "Bearer tok"

0 commit comments

Comments
 (0)