Skip to content

Commit d2c3638

Browse files
committed
fix(opencode): read provider id directly off input.provider (#190)
The generated chat.headers plugin guarded provider scoping with input.provider?.info?.id, but opencode's real hook input.provider is the runtime Provider record itself (id/name/env/options/source/models) -- id lives directly on input.provider, never under a nested .info. The guard's !providerId check was therefore always true, so the hook returned immediately on every request and never refreshed Authorization for any provider. Axis B was a complete no-op in production despite passing all prior tests, because those tests only asserted on the generated JS's string content, never its behavior against a real hook invocation. Found via an isolated end-to-end test against the real opencode 1.17.10 binary (separate $HOME/XDG dirs, real Databricks auth, no contact with the live ucode install): a well-formed-but-expired JWT baked into the static provider.options.headers.Authorization/apiKey still produced 'Bad Request: Invalid Token' with the plugin loaded and firing -- diagnostic instrumentation showed the guard's providerId was always undefined. Fixed to input.provider?.id and reverified against the same isolated harness: the plugin now fetches a fresh token and the request succeeds despite the stale static config. Adds TestRenderAuthPluginRuntimeBehavior, which shells out to a real node process and invokes the generated plugin's chat.headers hook against the actual runtime input shape (not a mocked one) -- verified to fail against the old input.provider?.info?.id line and pass against the fix, so this class of bug is now covered by an assertion that actually exercises behavior rather than string content.
1 parent e124f26 commit d2c3638

2 files changed

Lines changed: 110 additions & 3 deletions

File tree

src/ucode/agents/opencode.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,13 @@ def render_auth_plugin(workspace: str, profile: str | None, provider_ids: list[s
247247
248248
export const UcodeDatabricksAuth = async (_ctx) => ({{
249249
"chat.headers": async (input, output) => {{
250-
const providerId = input.provider?.info?.id;
250+
// NOTE: the hook's `input.provider` is the runtime Provider record
251+
// itself (id/name/env/options/source/models), not the ProviderContext
252+
// shape ({{source, info, options}}) some type surfaces suggest -- `id`
253+
// lives directly on `input.provider`, not `input.provider.info.id`.
254+
// Confirmed empirically against opencode 1.17.10; verify against a real
255+
// hook invocation before changing this path again (#190 follow-up).
256+
const providerId = input.provider?.id;
251257
if (!providerId || !MANAGED_PROVIDER_IDS.has(providerId)) {{
252258
return;
253259
}}

tests/test_agent_opencode.py

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import json
66
from unittest.mock import patch
77

8+
import pytest
9+
810
from ucode.agents import opencode
911

1012
WS = "https://example.databricks.com"
@@ -474,9 +476,23 @@ def test_exports_plugin_function(self):
474476
js = opencode.render_auth_plugin(WS, None, ALL_PROVIDER_IDS)
475477
assert "export const UcodeDatabricksAuth" in js
476478

477-
def test_provider_scoping_guard_checks_provider_info_id(self):
479+
def test_provider_scoping_guard_reads_provider_id_directly(self):
480+
# `chat.headers`'s `input.provider` is opencode's runtime Provider
481+
# record (id/name/env/options/source/models) -- NOT a nested
482+
# `{source, info, options}` wrapper. `id` lives directly on
483+
# `input.provider`, never `input.provider.info.id`. This was
484+
# confirmed empirically against a real opencode 1.17.10 hook
485+
# invocation (see #190 follow-up) after a version of this plugin
486+
# that read `input.provider?.info?.id` silently no-opped on every
487+
# request -- the guard's `!providerId` was always true, so the
488+
# hook returned immediately and never refreshed the Authorization
489+
# header. See TestRenderAuthPluginRuntimeBehavior below for a live
490+
# Node execution of the generated JS against the real hook shape,
491+
# which is what actually catches a regression here -- a
492+
# string-content assertion alone would not.
478493
js = opencode.render_auth_plugin(WS, None, ALL_PROVIDER_IDS)
479-
assert "input.provider?.info?.id" in js
494+
assert "input.provider?.id" in js
495+
assert "input.provider?.info?.id" not in js
480496

481497
def test_fails_open_on_error(self):
482498
js = opencode.render_auth_plugin(WS, None, ALL_PROVIDER_IDS)
@@ -489,6 +505,91 @@ def test_uses_execfile_not_shell(self):
489505
assert "node:child_process" in js
490506

491507

508+
class TestRenderAuthPluginRuntimeBehavior:
509+
"""Executes the generated plugin under real Node against the actual
510+
opencode `chat.headers` hook input shape, rather than asserting on the
511+
generated JS's string content. A version of this plugin that read
512+
`input.provider?.info?.id` (instead of `input.provider?.id`) passed
513+
every prior test in this file -- because those tests only checked what
514+
string literals appear in the generated source -- while silently
515+
no-opping on every real request (the guard's `!providerId` was always
516+
true). This class exists specifically to catch that class of bug:
517+
a mismatch between our assumed hook-input shape and opencode's real
518+
runtime shape, confirmed via `opencode`'s own `@opencode-ai/plugin`
519+
type surface and an isolated end-to-end run against opencode 1.17.10
520+
(#190 follow-up). Skips if `node` isn't on PATH."""
521+
522+
def _run_plugin(self, tmp_path, provider_id: str | None, fake_token: str = "fresh-token-xyz"):
523+
import shutil
524+
import subprocess
525+
526+
node = shutil.which("node")
527+
if not node:
528+
pytest.skip("`node` is not installed")
529+
530+
fake_ucode = tmp_path / "fake_ucode.py"
531+
fake_ucode.write_text(
532+
"#!/usr/bin/env python3\nimport sys\nprint(sys.argv[-1] if False else "
533+
f"{fake_token!r})\n"
534+
)
535+
fake_ucode.chmod(0o755)
536+
537+
with patch(
538+
"ucode.agents.opencode.build_auth_token_argv",
539+
return_value=["python3", str(fake_ucode)],
540+
):
541+
js = opencode.render_auth_plugin(WS, None, ["databricks-anthropic"])
542+
543+
plugin_path = tmp_path / "plugin.mjs"
544+
plugin_path.write_text(js)
545+
546+
provider_literal = f"{{ id: {provider_id!r} }}" if provider_id else "undefined"
547+
harness = tmp_path / "harness.mjs"
548+
harness.write_text(f"""
549+
import {{ UcodeDatabricksAuth }} from {str(plugin_path.as_posix())!r};
550+
551+
const hooks = await UcodeDatabricksAuth({{}});
552+
const output = {{ headers: {{ Authorization: "Bearer STALE-BOOTSTRAP-TOKEN" }} }};
553+
const input = {{
554+
sessionID: "ses_test",
555+
agent: "build",
556+
model: {{}},
557+
provider: {provider_literal},
558+
message: {{}},
559+
}};
560+
await hooks["chat.headers"](input, output);
561+
console.log(JSON.stringify(output.headers));
562+
""")
563+
564+
result = subprocess.run(
565+
[node, str(harness)],
566+
capture_output=True,
567+
text=True,
568+
timeout=15,
569+
)
570+
return result
571+
572+
def test_managed_provider_id_directly_on_input_provider_refreshes_header(self, tmp_path):
573+
# This is the real opencode runtime shape: `input.provider` IS the
574+
# Provider record, `id` is a direct property.
575+
result = self._run_plugin(tmp_path, provider_id="databricks-anthropic")
576+
assert result.returncode == 0, result.stderr
577+
headers = json.loads(result.stdout.strip().splitlines()[-1])
578+
assert headers["Authorization"] == "Bearer fresh-token-xyz"
579+
580+
def test_unmanaged_provider_id_leaves_static_header_untouched(self, tmp_path):
581+
result = self._run_plugin(tmp_path, provider_id="some-other-provider")
582+
assert result.returncode == 0, result.stderr
583+
headers = json.loads(result.stdout.strip().splitlines()[-1])
584+
assert headers["Authorization"] == "Bearer STALE-BOOTSTRAP-TOKEN"
585+
586+
def test_missing_provider_leaves_static_header_untouched(self, tmp_path):
587+
result = self._run_plugin(tmp_path, provider_id=None)
588+
assert result.returncode == 0, result.stderr
589+
headers = json.loads(result.stdout.strip().splitlines()[-1])
590+
assert headers["Authorization"] == "Bearer STALE-BOOTSTRAP-TOKEN"
591+
592+
492593
class TestWriteAuthPlugin:
493594
def test_writes_plugin_file_containing_hook(self, tmp_path, monkeypatch):
494595
import ucode.agents.opencode as oc_mod

0 commit comments

Comments
 (0)