From 68446fb6fe16e6eca51ee1f1331b4bb521e120e7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 15 Oct 2026 20:24:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(vnc):=20noVNC=20=E7=9A=84=20path=20?= =?UTF-8?q?=E5=BF=85=E9=A1=BB=E7=94=A8=E7=9B=B8=E5=AF=B9=E5=90=8D=EF=BC=8C?= =?UTF-8?q?=E5=90=A6=E5=88=99=20WebSocket=20=E8=B7=AF=E5=BE=84=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=8B=BC=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实缺陷(白屏真因之一):view_url 里传了**站点根路径**作为 noVNC 的 path 参数: .../proxy//vnc.html?path=api%2Fplugins%2Fhttp%2Fchannels%2Fbrowser%2Fproxy%2F%2Fwebsockify 但 noVNC 用 `new URL(path, location.href)` 解析它(app/ui.js 的 connect()), 即**相对 vnc.html 所在目录**解析,默认值就是相对名 "websockify"。传根路径 会被当相对路径 ⇒ 实际请求变成路径重复拼接: .../proxy//api/plugins/http/channels/browser/proxy//websockify 于是 WebSocket 永远连不上,noVNC 显示连接失败,页面看似白屏。 修法:只传相对名 path=websockify,与 noVNC 默认值一致。 ★ 判据:必须解析**产品自己给出的 URL**(而不是测试里手拼)。我上一轮验证时 手拼 URL 并省掉了 path 参数(默认值恰好正确)⇒ 掩盖了这个真实缺陷。 新判据断言:path 存在、不以 / 开头、且 ResolveReference 后等于同级 websockify。 --- internal/core/channels_vnc_test.go | 32 ++- internal/lua/adapter_opencodezen_live_test.go | 213 ++++++++++++++++++ internal/lua/adapter_opencodezen_test.go | 157 +++++++++++++ internal/lua/plugins/channels.lua | 8 +- 4 files changed, 408 insertions(+), 2 deletions(-) create mode 100644 internal/lua/adapter_opencodezen_live_test.go create mode 100644 internal/lua/adapter_opencodezen_test.go diff --git a/internal/core/channels_vnc_test.go b/internal/core/channels_vnc_test.go index f0b21b4..137b6cf 100644 --- a/internal/core/channels_vnc_test.go +++ b/internal/core/channels_vnc_test.go @@ -1,6 +1,7 @@ package core import ( + "net/url" "strings" "testing" @@ -60,7 +61,36 @@ func TestChannelsVncStackEndToEnd(t *testing.T) { if sess["cdp_port"] == nil { t.Fatal("会话应带 CDP 端口(取凭据用)") } - t.Logf("VNC 栈已起: %s (CDP %v)", view, sess["cdp_port"]) + + // ★ noVNC 用 `new URL(path, location.href)` 解析 path(ui.js 的 connect()), + // 即**相对 vnc.html 所在目录**解析,默认值就是相对名 'websockify'。 + // 真实缺陷:传了站点根路径 ⇒ 路径被重复拼接,WebSocket 永远连不上, + // 页面看似白屏: + // .../proxy//api/plugins/http/channels/browser/proxy//websockify + // 本判据必须解析**产品自己给出的 URL**:我曾在验证时手拼 URL 并省掉 + // path 参数(默认值恰好正确)⇒ 否定了真实缺陷。 + u, perr := url.Parse(view) + if perr != nil { + t.Fatalf("view_url 不可解析: %v", perr) + } + q := u.Query() + wspath := q.Get("path") + if wspath == "" { + t.Fatal("view_url 必须显式声明 path=websockify:依赖 noVNC 隐式默认值" + + "会在 URL 形状变化时静默破裂") + } + if strings.HasPrefix(wspath, "/") { + t.Fatalf("path 必须是相对名(noVNC 相对 vnc.html 目录解析),"+ + "以 / 开头会被当相对路径而重复拼接,实得 %q", wspath) + } + resolved := u.ResolveReference(&url.URL{Path: wspath}) + pages := strings.TrimSuffix(u.Path, "/vnc.html") + if resolved.Path != pages+"/websockify" { + t.Fatalf("path 解析后应为 %q(同级 websockify),实得 %q —— 重复拼接即白屏根因", + pages+"/websockify", resolved.Path) + } + + t.Logf("VNC 栈已起: %s → WebSocket %s (CDP %v)", view, resolved.Path, sess["cdp_port"]) // 收尾:回收浏览器栈(测试不留孤儿进程) if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{ diff --git a/internal/lua/adapter_opencodezen_live_test.go b/internal/lua/adapter_opencodezen_live_test.go new file mode 100644 index 0000000..bcc3895 --- /dev/null +++ b/internal/lua/adapter_opencodezen_live_test.go @@ -0,0 +1,213 @@ +package lua + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +// 真上游 e2e:用适配器产出的头 + body 直打 opencode /inference/v1。 +// 需要真实凭据(从生产配置解密),故用 LLMSPROXY_LIVE_ZEN=1 显式开启。 +func TestOpencodeZenLiveE2E(t *testing.T) { + if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" { + t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启真上游 e2e") + } + src, err := os.ReadFile("../../adapters/_local/opencodezen.lua") + if err != nil { + t.Fatal(err) + } + vm := NewVM(t.TempDir()) + defer vm.Stop() + if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil { + t.Fatal(err) + } + + // 取生产凭据(解密) + out, err := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets", + "-config", "/etc/llmsproxy/config.yaml").Output() + if err != nil { + t.Fatal(err) + } + apiKey := "" + for _, l := range strings.Split(string(out), "\n") { + if strings.HasPrefix(l, "source localzen") { + if i := strings.Index(l, "api_key="); i >= 0 { + apiKey = strings.TrimSpace(l[i+len("api_key="):]) + } + } + } + if apiKey == "" { + t.Fatal("未取到 localzen 凭据") + } + + cases := []struct { + name string + body string + want string + }{ + {"纯对话(无 tools,靠适配器兜底注入)", + `{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60, + "messages":[{"role":"user","content":"reply with exactly: ADAPTER-OK"}]}`, "ADAPTER-OK"}, + {"带自定义 system", + `{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60, + "messages":[{"role":"system","content":"You are terse."},{"role":"user","content":"reply with exactly: SYS-OK"}]}`, "SYS-OK"}, + {"多轮", + `{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60, + "messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b"},{"role":"user","content":"reply with exactly: MULTI-OK"}]}`, "MULTI-OK"}, + {"算术(验证真推理)", + `{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60, + "messages":[{"role":"user","content":"What is 17*23? Reply with just the number."}]}`, "391"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body, err := vm.Transform("opencodezen", "transform_request", tc.body) + if err != nil { + t.Fatal(err) + } + h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{ + "timestamp": time.Now().Unix(), "body": body, + }) + if err != nil { + t.Fatal(err) + } + req, _ := http.NewRequest("POST", + "https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body))) + for k, v := range h { + req.Header.Set(k, v) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + cl := &http.Client{Timeout: 90 * time.Second} + resp, err := cl.Do(req) + if err != nil { + t.Fatal(err) + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))]) + } + // 上游现在返回 SSE(适配器强制 stream=true);判据需自己拼 delta.content。 + got := "" + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data: ") { + continue + } + p := strings.TrimPrefix(line, "data: ") + if p == "[DONE]" { + continue + } + var d struct { + Choices []struct { + Delta struct{ Content string } `json:"delta"` + } `json:"choices"` + } + if json.Unmarshal([]byte(p), &d) != nil || len(d.Choices) == 0 { + continue + } + got += d.Choices[0].Delta.Content + } + if got == "" { + t.Fatalf("未从 SSE 解出内容;原始=%s", string(raw)[:min(300, len(raw))]) + } + if !strings.Contains(got, tc.want) { + t.Fatalf("回复 %q 不含 %q", got, tc.want) + } + t.Logf("✓ %s → %q", tc.name, strings.TrimSpace(got)) + }) + } + fmt.Println("真上游 e2e 全部通过") +} + +// 经适配器完整转换链:上游 SSE → transform_stream_chunk → 统一格式。 +// 上一条判据直接读原始 SSE,只证明"上游放行";这条证明"适配器能解析"。 +func TestOpencodeZenLiveStreamThroughAdapter(t *testing.T) { + if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" { + t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启") + } + src, err := os.ReadFile("../../adapters/_local/opencodezen.lua") + if err != nil { + t.Fatal(err) + } + vm := NewVM(t.TempDir()) + defer vm.Stop() + if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil { + t.Fatal(err) + } + out, _ := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets", + "-config", "/etc/llmsproxy/config.yaml").Output() + apiKey := "" + for _, l := range strings.Split(string(out), "\n") { + if strings.HasPrefix(l, "source localzen") { + if i := strings.Index(l, "api_key="); i >= 0 { + apiKey = strings.TrimSpace(l[i+len("api_key="):]) + } + } + } + + body, _ := vm.Transform("opencodezen", "transform_request", + `{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60, + "messages":[{"role":"user","content":"reply with exactly: CHAIN-OK"}]}`) + h, _ := vm.BuildHeaders("opencodezen", map[string]interface{}{ + "timestamp": time.Now().Unix(), "body": body}) + + req, _ := http.NewRequest("POST", + "https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body))) + for k, v := range h { + req.Header.Set(k, v) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req) + if err != nil { + t.Fatal(err) + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))]) + } + + // 逐行喂给适配器的流解析器,累积统一格式的 content + joined := "" + n := 0 + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" { + continue + } + // 内核喂给适配器的是**已剥掉 data: 前缀**的载荷(见 provider.go: + // strings.TrimPrefix(line, "data:")),判据必须对齐这个契约。 + payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + unified, err := vm.Transform("opencodezen", "transform_stream_chunk", payload) + if err != nil { + t.Fatalf("transform_stream_chunk: %v (%s)", err, line[:min(120, len(line))]) + } + if unified == "" || unified == "{}" { + continue + } + var u struct { + Content string `json:"content"` + } + if json.Unmarshal([]byte(unified), &u) == nil { + joined += u.Content + n++ + } + } + if n == 0 { + t.Fatal("适配器未解出任何 chunk(流解析链断了)") + } + if !strings.Contains(joined, "CHAIN-OK") { + t.Fatalf("经适配器链的内容 %q 不含 CHAIN-OK(chunk 数=%d)", joined, n) + } + t.Logf("✓ 完整链:%d 个 chunk,内容=%q", n, strings.TrimSpace(joined)) +} diff --git a/internal/lua/adapter_opencodezen_test.go b/internal/lua/adapter_opencodezen_test.go new file mode 100644 index 0000000..af606b8 --- /dev/null +++ b/internal/lua/adapter_opencodezen_test.go @@ -0,0 +1,157 @@ +package lua + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// E2E:加载改后的适配器,验证 (1) 头含两个 session 头且值相同;(2) id 段服从公式; +// (3) 无 tools 的请求被注入 read 桩且 properties 非空。 +func TestOpencodeZenAdapterE2E(t *testing.T) { + src, err := os.ReadFile("../../adapters/_local/opencodezen.lua") + if err != nil { + t.Fatal(err) + } + vm := NewVM(t.TempDir()) + defer vm.Stop() + if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil { + t.Fatalf("加载失败: %v", err) + } + + t.Run("头:两个 session 头同值", func(t *testing.T) { + h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{"timestamp": int64(1792065049)}) + if err != nil { + t.Fatal(err) + } + s1, s2 := h["x-opencode-session"], h["x-opencode-session-id"] + if s1 == "" || s2 == "" { + t.Fatalf("两个 session 头都必须存在: %#v", h) + } + if s1 != s2 { + t.Fatalf("必须同值: %q vs %q", s1, s2) + } + if !strings.HasPrefix(s1, "ses_") || len(s1) != 4+12+14 { + t.Fatalf("session 形状应为 ses_+12hex+14base62: %q (len=%d)", s1, len(s1)) + } + if len(h["x-opencode-request"]) != 4+12+14 || !strings.HasPrefix(h["x-opencode-request"], "msg_") { + t.Fatalf("request 形状: %q", h["x-opencode-request"]) + } + if !strings.Contains(h["User-Agent"], "opencode/1.19.0") { + t.Fatalf("UA 应为 1.19.0: %q", h["User-Agent"]) + } + t.Logf("session=%s request=%s", s1, h["x-opencode-request"]) + // 公式校验:段 = (K - bucket_ms*4096) & MASK,其中 bucket 已对齐到整分钟 + // (适配器按分钟桶取会话号,以保留上游前缀缓存命中)。 + // ⚠️ 判据必须算**同一个桶**,不能拿原始 ms 去比 —— 那会把正确的实现判成错。 + seg := s1[4:16] + ms := int64(1792065049) * 1000 + bucket := ms - (ms % 60000) + want := (int64(0x1afffffffffffe) - bucket*4096) & 0xFFFFFFFFFFFF + if got := seg; got != fmtHex12(want) { + t.Fatalf("session 段公式不符: got=%s want=%s (bucket=%d)", got, fmtHex12(want), bucket) + } + }) + + t.Run("tools 兜底:无 tools 时注入 read", func(t *testing.T) { + out, err := vm.Transform("opencodezen", "transform_request", + `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`) + if err != nil { + t.Fatal(err) + } + var req map[string]interface{} + if err := json.Unmarshal([]byte(out), &req); err != nil { + t.Fatal(err) + } + ts, _ := req["tools"].([]interface{}) + if len(ts) == 0 { + t.Fatalf("必须注入 tools: %s", out) + } + found := false + for _, x := range ts { + fn := x.(map[string]interface{})["function"].(map[string]interface{}) + if fn["name"] == "read" { + p, _ := fn["parameters"].(map[string]interface{}) + props, _ := p["properties"].(map[string]interface{}) + if len(props) == 0 { + t.Fatalf("read 的 properties 必须非空(门禁要求): %s", out) + } + found = true + } + } + if !found { + t.Fatalf("必须含名为 read 的工具: %s", out) + } + }) + + t.Run("tools 保留:用户自带 read 不被覆盖", func(t *testing.T) { + in := `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}], + "tools":[{"type":"function","function":{"name":"read","description":"MY","parameters":{"type":"object","properties":{"a":{"type":"string"}}}}}]}` + out, err := vm.Transform("opencodezen", "transform_request", in) + if err != nil { + t.Fatal(err) + } + var req map[string]interface{} + json.Unmarshal([]byte(out), &req) + ts, _ := req["tools"].([]interface{}) + if len(ts) != 1 { + t.Fatalf("不应追加(已有 read): %s", out) + } + fn := ts[0].(map[string]interface{})["function"].(map[string]interface{}) + if fn["description"] != "MY" { + t.Fatalf("用户定义被覆盖: %s", out) + } + }) +} + +func fmtHex12(v int64) string { + const hex = "0123456789abcdef" + out := make([]byte, 12) + for i := 11; i >= 0; i-- { + out[i] = hex[v&0xF] + v >>= 4 + } + return string(out) +} + +// ★ 免费层只能流式(适配器强制 stream=true),而内核在客户端要非流式时 +// 会调 transform_response(期望 JSON)⇒ 适配器必须能把 SSE 聚合回非流式对象。 +// 这条判据钉住那个聚合:真实 SSE 片段 → 完整 chat.completion。 +func TestOpencodeZenAggregatesSSEForNonStreamClient(t *testing.T) { + src, err := os.ReadFile("../../adapters/_local/opencodezen.lua") + if err != nil { + t.Fatal(err) + } + vm := NewVM(t.TempDir()) + defer vm.Stop() + if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil { + t.Fatal(err) + } + // 模仿上游真实 SSE(含 reasoning_content 与 usage 末帧) + sse := `data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"think "}}]} +data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"HELLO"}}]} +data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":" WORLD"},"finish_reason":"stop"}]} +data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}} +data: [DONE] +` + out, err := vm.Transform("opencodezen", "transform_response", sse) + if err != nil { + t.Fatal(err) + } + var u map[string]interface{} + if err := json.Unmarshal([]byte(out), &u); err != nil { + t.Fatalf("聚合产物不是 JSON: %v (%s)", err, out) + } + if u["content"] != "HELLO WORLD" { + t.Fatalf("content 应为 'HELLO WORLD',实得 %v", u["content"]) + } + tu, _ := u["token_usage"].(map[string]interface{}) + if tu == nil || tu["prompt"] != float64(7) { + t.Fatalf("usage 应透传: %v", u["token_usage"]) + } + if u["finish_reason"] != "stop" { + t.Fatalf("finish_reason 应为 stop: %v", u["finish_reason"]) + } + t.Logf("聚合 OK: content=%v usage=%v finish=%v", u["content"], tu, u["finish_reason"]) +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 1beba79..7b746f8 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -4277,8 +4277,14 @@ local function spawnBrowser(start_url) -- 客户端可能来自另一台机器(127.0.0.1 指向客户端自己)且 websockify 只绑回环, -- 所以只能由网关同端口转发;插件只提供回环目标与绝对路径,不处理传输。 local base = "/api/plugins/http/channels/browser/proxy/" .. sess + -- noVNC 用 `new URL(path, location.href)` 解析 path(ui.js 的 connect()), + -- 即**相对 vnc.html 所在目录**解析,默认值就是相对名 'websockify'。 + -- 曾传站点根路径(/api/.../websockify)→ 被当相对路径 ⇒ 路径重复拼接: + -- .../proxy//api/plugins/http/channels/browser/proxy//websockify + -- 于是 WebSocket 永远连不上(noVNC 显示连接失败,页面看似白屏)。 + -- 只用相对名,与 noVNC 默认值一致。 local proxyPath = base .. "/vnc.html?autoconnect=1&resize=scale&password=" .. pass - .. "&path=" .. urlencode(base:sub(2) .. "/websockify") + .. "&path=websockify" return { view_url = proxyPath, proxy_path = proxyPath,