From f3fc569ea5725c91b9bb77f4dde9070a8e5494a3 Mon Sep 17 00:00:00 2001 From: Million <15158090088@163.com> Date: Mon, 17 Aug 2026 10:13:57 +0800 Subject: [PATCH] fix(opencode): block unmatched /api/* paths from Web UI proxy The OpenCode TUI calls GET /api/fs/find when completing @-mentions. wolfharness had no route for it, so the request fell through to the Web UI proxy catch-all and was forwarded to app.opencode.ai. The cloud responds 200 with text/html (the SPA index.html), which the SDK parses as a text string (Content-Type text/html;charset=UTF-8 -> parseAs text), so the TUI receives result.data as a string and crashes on result.data.data.map(...). Add 'api/' to the proxy block prefixes and extract the decision into is_proxy_path_blocked() so unmatched /api/* routes now return 404 JSON. The SDK then produces { error }, and the TUI's guard (!result.error) safely skips the crashy code path. --- .../opencode_server/server.py | 55 ++++++++----- .../test_proxy_path_blocking.py | 80 +++++++++++++++++++ 2 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 tests/servers/opencode_server/test_proxy_path_blocking.py diff --git a/src/wolfharness_server/opencode_server/server.py b/src/wolfharness_server/opencode_server/server.py index c1b18fd29..3cb69c17d 100644 --- a/src/wolfharness_server/opencode_server/server.py +++ b/src/wolfharness_server/opencode_server/server.py @@ -56,6 +56,42 @@ def filter_headers(headers: Headers) -> dict[str, str]: return {k: v for k, v in headers.items() if k.lower() not in excluded_headers} +PROXY_BLOCKED_PREFIXES = ( + "api/", + "session/", + "config/", + "agent/", + "model/", + "provider/", + "command/", + "skill/", + "location/", + "integration/", + "file/", + "todo/", + "diff/", + "snapshot/", + "v1/", + "experimental/", +) +"""Unmatched API path prefixes that must NOT be forwarded to the hosted Web UI. + +Requests falling under these prefixes return 404 so the OpenCode SDK/TUI learns +the endpoint doesn't exist instead of receiving a `text/html` SPA page, which +the client would mis-parse and crash on (see `fs.find`). +""" + + +def is_proxy_path_blocked(path: str) -> bool: + """Return whether an unmatched path should 404 instead of proxying. + + Any route under ``api/`` (or a known API prefix) is treated as a missing + endpoint so the OpenCode client gets a structured error rather than the + hosted Web UI's ``index.html`` being forwarded back and crashing the TUI. + """ + return any(path.startswith(prefix) for prefix in PROXY_BLOCKED_PREFIXES) + + class OpenCodeJSONResponse(JSONResponse): """Custom JSON response that excludes None values (like OpenCode does).""" @@ -527,24 +563,7 @@ async def proxy_web_ui(request: Request, path: str) -> Response: """ # Don't proxy API paths — return 404 so the TUI knows the endpoint # doesn't exist rather than receiving cloud data for local sessions. - _api_prefixes = ( - "session/", - "config/", - "agent/", - "model/", - "provider/", - "command/", - "skill/", - "location/", - "integration/", - "file/", - "todo/", - "diff/", - "snapshot/", - "v1/", - "experimental/", - ) - if any(path.startswith(prefix) for prefix in _api_prefixes): + if is_proxy_path_blocked(path): raise HTTPException(status_code=404, detail=f"Endpoint not found: /{path}") import httpx diff --git a/tests/servers/opencode_server/test_proxy_path_blocking.py b/tests/servers/opencode_server/test_proxy_path_blocking.py new file mode 100644 index 000000000..690381392 --- /dev/null +++ b/tests/servers/opencode_server/test_proxy_path_blocking.py @@ -0,0 +1,80 @@ +"""Tests for the OpenCode Web UI proxy path blocking. + +Regression tests for the crash where the OpenCode TUI did ``@``-mention +autocomplete, called ``GET /api/fs/find``, and wolfharness forwarded the +request to the hosted Web UI instead of returning 404. The Web UI responded +with ``index.html`` (``200``, ``Content-Type: text/html;charset=UTF-8``), which +the SDK parsed as ``text``, so the TUI received ``result.data`` as a string and +crashed on ``result.data.data.map(...)`` with + + TypeError: undefined is not an object (evaluating 'e.data.data.map') + +Root cause: the Web UI proxy's API-prefix block list did not include ``api/``, +so unmatched ``/api/*`` routes fell through to the cloud proxy. +""" + +from __future__ import annotations + +import pytest + +from wolfharness_server.opencode_server.server import ( + PROXY_BLOCKED_PREFIXES, + is_proxy_path_blocked, +) + + +pytestmark = pytest.mark.unit + + +class TestIsProxyPathBlocked: + """Tests for :func:`is_proxy_path_blocked`.""" + + @pytest.mark.parametrize( + ("path", "expected"), + [ + # New: all unmatched /api/* routes must 404, never forward + ("api/fs/find", True), + ("api/fs/list", True), + ("api/session", True), + ("api/session/abc123/message", True), + # Existing API prefixes (real API paths always carry the trailing + # slash, e.g. "session/abc123" rather than "session") + ("session/abc", True), + ("config", False), + ("config/path/to/file", True), + ("agent/foo", True), + ("model/foo", True), + ("provider/foo", True), + ("command/foo", True), + ("skill/foo", True), + ("location/foo", True), + ("integration/foo", True), + ("file/content", True), + ("todo/foo", True), + ("diff/foo", True), + ("snapshot/foo", True), + ("v1/metrics", True), + ("experimental/workspace", True), + # Non-API paths must still proxy to the Web UI + ("", False), + ("favicon.ico", False), + ("manifest.json", False), + ("static/app.js", False), + ("some/deep/route", False), + # A bare prefix with no trailing slash does not match (pre-existing + # behavior, kept to avoid changing proxy semantics for odd paths) + ("session", False), + ("file", False), + ], + ) + def test_paths(self, path: str, expected: bool) -> None: + """Assert the blocking decision for each path.""" + assert is_proxy_path_blocked(path) is expected + + def test_api_prefixed_but_not_exact(self) -> None: + """``api`` followed by a non-slash is not an API route.""" + assert is_proxy_path_blocked("api-docs") is False + + def test_prefix_set_contains_api(self) -> None: + """The ``api/`` prefix must be present so unmatched API routes 404.""" + assert "api/" in PROXY_BLOCKED_PREFIXES