Skip to content

Commit 840e040

Browse files
author
SqlRush
committed
Add bridge direct server
1 parent 6fc7e11 commit 840e040

4 files changed

Lines changed: 309 additions & 2 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1456,7 +1456,7 @@ M7 补充:terminal input parser 和 configurable keybinding name parser 现在
14561456
- Chrome/computer-use/voice/native integrations。
14571457
- enterprise/gated/platform-specific behavior。
14581458

1459-
当前状态:已新增 `advanced` settings gate 地基,覆盖 bridge/LSP/telemetry/Chrome/voice/computer-use/native integrations 的独立 bool 开关解析、settings merge、headless `/config show advanced` 和 `/config search` 审计;`advanced.telemetry=true` 时会在 session 目录写入安全摘要 JSONL 诊断事件,记录事件类型、session/model、tool/progress keys、token/compact/error 摘要,不写入用户/助手正文或工具结果内容,并已有安全 JSONL 读取、类型/模型过滤、汇总统计和 headless `/status show telemetry` 地基;`advanced.lsp=true` 时才向模型暴露只读 `LSPDiagnostics` 工具,用于读取 session-scoped diagnostics snapshot,并支持 file/severity/limit 过滤,底层已能解析 LSP `textDocument/publishDiagnostics` params/notification payload、按文件替换 snapshot、按 LSP 空 diagnostics 语义清空旧文件诊断,并可通过 headless `/status show lsp` 审计;`advanced.bridge=true` 时会写出 session-scoped bridge manifest,列出 bridge-safe slash/local command 元数据,提供 command/display/alias 安全解析,并可通过 headless `/status show bridge` 审计;bridge direct HTTP handler 已可复用同一 manifest/registry,提供 `/health`、`/manifest`、`/resolve` 和 `/execute` JSON 端点,执行前强制 bridge-safe 白名单并默认不回传展开后的 prompt 正文;`advanced.nativeIntegrations=true` 时会写出 session-scoped native capability manifest,记录平台和安全能力状态,并可通过 headless `/status show native` 审计;任一 `advanced.chrome`/`advanced.voice`/`advanced.computerUse=true` 时会写出 session-scoped integrations manifest,将 Chrome/voice/computer-use 的 enabled 与 `runtime_state` 分开记录,当前启用项明确标为 `not_wired`,并可通过 headless `/status show integrations` 审计;未启用时仍不注册或泄露 gated 工具 schema。实际 bridge listener/websocket、session websocket/direct connect 外部接入、LSP server manager、diagnostic producer、telemetry exporter/tracing、Chrome/voice/computer-use/native runtime 仍未完成。
1459+
当前状态:已新增 `advanced` settings gate 地基,覆盖 bridge/LSP/telemetry/Chrome/voice/computer-use/native integrations 的独立 bool 开关解析、settings merge、headless `/config show advanced` 和 `/config search` 审计;`advanced.telemetry=true` 时会在 session 目录写入安全摘要 JSONL 诊断事件,记录事件类型、session/model、tool/progress keys、token/compact/error 摘要,不写入用户/助手正文或工具结果内容,并已有安全 JSONL 读取、类型/模型过滤、汇总统计和 headless `/status show telemetry` 地基;`advanced.lsp=true` 时才向模型暴露只读 `LSPDiagnostics` 工具,用于读取 session-scoped diagnostics snapshot,并支持 file/severity/limit 过滤,底层已能解析 LSP `textDocument/publishDiagnostics` params/notification payload、按文件替换 snapshot、按 LSP 空 diagnostics 语义清空旧文件诊断,并可通过 headless `/status show lsp` 审计;`advanced.bridge=true` 时会写出 session-scoped bridge manifest,列出 bridge-safe slash/local command 元数据,提供 command/display/alias 安全解析,并可通过 headless `/status show bridge` 审计;bridge direct HTTP handler 已可复用同一 manifest/registry,提供 `/health`、`/manifest`、`/resolve` 和 `/execute` JSON 端点,执行前强制 bridge-safe 白名单并默认不回传展开后的 prompt 正文;bridge loopback-only direct listener 已有可嵌入启动/关闭 API,默认 `127.0.0.1:0`、拒绝非 loopback 地址,并支持可选 bearer/`X-Bridge-Token` token guard;`advanced.nativeIntegrations=true` 时会写出 session-scoped native capability manifest,记录平台和安全能力状态,并可通过 headless `/status show native` 审计;任一 `advanced.chrome`/`advanced.voice`/`advanced.computerUse=true` 时会写出 session-scoped integrations manifest,将 Chrome/voice/computer-use 的 enabled 与 `runtime_state` 分开记录,当前启用项明确标为 `not_wired`,并可通过 headless `/status show integrations` 审计;未启用时仍不注册或泄露 gated 工具 schema。实际 bridge websocket、session websocket/direct connect 外部接入、LSP server manager、diagnostic producer、telemetry exporter/tracing、Chrome/voice/computer-use/native runtime 仍未完成。
14601460

14611461
## Recommended Next Steps
14621462

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@ test/parity/ # golden tests against TS/official behavior
745745
- 每个 gated feature 独立开关测试。
746746
- 不启用 feature 时二进制行为和可见 schema 不泄露 gated 工具/命令。
747747

748-
- 本轮补充:新增 `advanced` settings gate 地基,覆盖 bridge/LSP/telemetry/Chrome/voice/computer-use/native integrations 的独立 bool 开关解析、settings merge、headless `/config show advanced` 和 `/config search` 审计;`advanced.telemetry=true` 时会在 session 目录写入安全摘要 JSONL 诊断事件,记录事件类型、session/model、tool/progress keys、token/compact/error 摘要,不写入用户/助手正文或工具结果内容,并已有安全 JSONL 读取、类型/模型过滤、汇总统计和 headless `/status show telemetry` 地基;`advanced.lsp=true` 时才向模型暴露只读 `LSPDiagnostics` 工具,用于读取 session-scoped diagnostics snapshot,并支持 file/severity/limit 过滤,底层已能解析 LSP `textDocument/publishDiagnostics` params/notification payload、按文件替换 snapshot、按 LSP 空 diagnostics 语义清空旧文件诊断,并可通过 headless `/status show lsp` 审计;`advanced.bridge=true` 时会写出 session-scoped bridge manifest,列出 bridge-safe slash/local command 元数据,提供 command/display/alias 安全解析,并可通过 headless `/status show bridge` 审计;bridge direct HTTP handler 已可复用同一 manifest/registry,提供 `/health`、`/manifest`、`/resolve` 和 `/execute` JSON 端点,执行前强制 bridge-safe 白名单并默认不回传展开后的 prompt 正文;`advanced.nativeIntegrations=true` 时会写出 session-scoped native capability manifest,记录平台和安全能力状态,并可通过 headless `/status show native` 审计;任一 `advanced.chrome`/`advanced.voice`/`advanced.computerUse=true` 时会写出 session-scoped integrations manifest,将 Chrome/voice/computer-use 的 enabled 与 `runtime_state` 分开记录,当前启用项明确标为 `not_wired`,并可通过 headless `/status show integrations` 审计;未启用时仍不注册或泄露 gated 工具 schema。实际 bridge listener/websocket、session websocket/direct connect 外部接入、LSP server manager、diagnostic producer、telemetry exporter/tracing、Chrome/voice/computer-use/native runtime 仍未完成。
748+
- 本轮补充:新增 `advanced` settings gate 地基,覆盖 bridge/LSP/telemetry/Chrome/voice/computer-use/native integrations 的独立 bool 开关解析、settings merge、headless `/config show advanced` 和 `/config search` 审计;`advanced.telemetry=true` 时会在 session 目录写入安全摘要 JSONL 诊断事件,记录事件类型、session/model、tool/progress keys、token/compact/error 摘要,不写入用户/助手正文或工具结果内容,并已有安全 JSONL 读取、类型/模型过滤、汇总统计和 headless `/status show telemetry` 地基;`advanced.lsp=true` 时才向模型暴露只读 `LSPDiagnostics` 工具,用于读取 session-scoped diagnostics snapshot,并支持 file/severity/limit 过滤,底层已能解析 LSP `textDocument/publishDiagnostics` params/notification payload、按文件替换 snapshot、按 LSP 空 diagnostics 语义清空旧文件诊断,并可通过 headless `/status show lsp` 审计;`advanced.bridge=true` 时会写出 session-scoped bridge manifest,列出 bridge-safe slash/local command 元数据,提供 command/display/alias 安全解析,并可通过 headless `/status show bridge` 审计;bridge direct HTTP handler 已可复用同一 manifest/registry,提供 `/health`、`/manifest`、`/resolve` 和 `/execute` JSON 端点,执行前强制 bridge-safe 白名单并默认不回传展开后的 prompt 正文;bridge loopback-only direct listener 已有可嵌入启动/关闭 API,默认 `127.0.0.1:0`、拒绝非 loopback 地址,并支持可选 bearer/`X-Bridge-Token` token guard;`advanced.nativeIntegrations=true` 时会写出 session-scoped native capability manifest,记录平台和安全能力状态,并可通过 headless `/status show native` 审计;任一 `advanced.chrome`/`advanced.voice`/`advanced.computerUse=true` 时会写出 session-scoped integrations manifest,将 Chrome/voice/computer-use 的 enabled 与 `runtime_state` 分开记录,当前启用项明确标为 `not_wired`,并可通过 headless `/status show integrations` 审计;未启用时仍不注册或泄露 gated 工具 schema。实际 bridge websocket、session websocket/direct connect 外部接入、LSP server manager、diagnostic producer、telemetry exporter/tracing、Chrome/voice/computer-use/native runtime 仍未完成。
749749

750750
### M12: Parity hardening
751751

internal/bridge/server.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package bridge
2+
3+
import (
4+
"context"
5+
"crypto/subtle"
6+
"errors"
7+
"fmt"
8+
"net"
9+
"net/http"
10+
"strings"
11+
"time"
12+
)
13+
14+
const defaultDirectListenAddr = "127.0.0.1:0"
15+
16+
type DirectServerOptions struct {
17+
Addr string
18+
Token string
19+
Handler http.Handler
20+
ReadHeaderTimeout time.Duration
21+
}
22+
23+
type DirectServer struct {
24+
server *http.Server
25+
listener net.Listener
26+
errs chan error
27+
url string
28+
}
29+
30+
func StartDirectServer(opts DirectServerOptions) (*DirectServer, error) {
31+
if opts.Handler == nil {
32+
return nil, errors.New("bridge direct handler is required")
33+
}
34+
addr := strings.TrimSpace(opts.Addr)
35+
if addr == "" {
36+
addr = defaultDirectListenAddr
37+
}
38+
if err := validateLoopbackListenAddr(addr); err != nil {
39+
return nil, err
40+
}
41+
listener, err := net.Listen("tcp", addr)
42+
if err != nil {
43+
return nil, err
44+
}
45+
handler := opts.Handler
46+
if token := strings.TrimSpace(opts.Token); token != "" {
47+
handler = DirectTokenGuard(handler, token)
48+
}
49+
timeout := opts.ReadHeaderTimeout
50+
if timeout <= 0 {
51+
timeout = 5 * time.Second
52+
}
53+
server := &http.Server{
54+
Handler: handler,
55+
ReadHeaderTimeout: timeout,
56+
}
57+
direct := &DirectServer{
58+
server: server,
59+
listener: listener,
60+
errs: make(chan error, 1),
61+
url: "http://" + listener.Addr().String(),
62+
}
63+
go func() {
64+
err := server.Serve(listener)
65+
if err != nil && !errors.Is(err, http.ErrServerClosed) {
66+
direct.errs <- err
67+
}
68+
close(direct.errs)
69+
}()
70+
return direct, nil
71+
}
72+
73+
func (s *DirectServer) URL() string {
74+
if s == nil {
75+
return ""
76+
}
77+
return s.url
78+
}
79+
80+
func (s *DirectServer) Addr() net.Addr {
81+
if s == nil || s.listener == nil {
82+
return nil
83+
}
84+
return s.listener.Addr()
85+
}
86+
87+
func (s *DirectServer) Errors() <-chan error {
88+
if s == nil {
89+
ch := make(chan error)
90+
close(ch)
91+
return ch
92+
}
93+
return s.errs
94+
}
95+
96+
func (s *DirectServer) Close(ctx context.Context) error {
97+
if s == nil || s.server == nil {
98+
return nil
99+
}
100+
if ctx == nil {
101+
ctx = context.Background()
102+
}
103+
return s.server.Shutdown(ctx)
104+
}
105+
106+
func DirectTokenGuard(next http.Handler, token string) http.Handler {
107+
token = strings.TrimSpace(token)
108+
if token == "" {
109+
return next
110+
}
111+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+
if directTokenAllowed(r, token) {
113+
next.ServeHTTP(w, r)
114+
return
115+
}
116+
writeDirectError(w, http.StatusUnauthorized, "bridge direct token is required")
117+
})
118+
}
119+
120+
func directTokenAllowed(r *http.Request, token string) bool {
121+
presented := strings.TrimSpace(r.Header.Get("X-Bridge-Token"))
122+
if presented == "" {
123+
presented = bearerToken(r.Header.Get("Authorization"))
124+
}
125+
if presented == "" {
126+
return false
127+
}
128+
return subtle.ConstantTimeCompare([]byte(presented), []byte(token)) == 1
129+
}
130+
131+
func bearerToken(header string) string {
132+
header = strings.TrimSpace(header)
133+
if header == "" {
134+
return ""
135+
}
136+
kind, value, ok := strings.Cut(header, " ")
137+
if !ok || !strings.EqualFold(kind, "Bearer") {
138+
return ""
139+
}
140+
return strings.TrimSpace(value)
141+
}
142+
143+
func validateLoopbackListenAddr(addr string) error {
144+
host, _, err := net.SplitHostPort(addr)
145+
if err != nil {
146+
return fmt.Errorf("bridge direct listen address %q must be host:port: %w", addr, err)
147+
}
148+
host = strings.Trim(host, "[]")
149+
if host == "" {
150+
return fmt.Errorf("bridge direct listen address %q must specify a loopback host", addr)
151+
}
152+
if strings.EqualFold(host, "localhost") {
153+
return nil
154+
}
155+
ip := net.ParseIP(host)
156+
if ip == nil || !ip.IsLoopback() {
157+
return fmt.Errorf("bridge direct listen address %q must use a loopback host", addr)
158+
}
159+
return nil
160+
}

internal/bridge/server_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package bridge
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
"time"
9+
)
10+
11+
func TestStartDirectServerListensOnLoopbackAndServesHealth(t *testing.T) {
12+
server, err := StartDirectServer(DirectServerOptions{
13+
Handler: NewDirectHandler(DirectOptions{
14+
SessionID: "sess_bridge",
15+
Manifest: testDirectManifest(t),
16+
Registry: testDirectRegistry(),
17+
}),
18+
})
19+
if err != nil {
20+
t.Fatal(err)
21+
}
22+
t.Cleanup(func() {
23+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
24+
defer cancel()
25+
if err := server.Close(ctx); err != nil {
26+
t.Fatalf("close direct server: %v", err)
27+
}
28+
})
29+
if server.URL() == "" || server.Addr() == nil {
30+
t.Fatalf("server url=%q addr=%v", server.URL(), server.Addr())
31+
}
32+
resp, err := http.Get(server.URL() + "/health")
33+
if err != nil {
34+
t.Fatal(err)
35+
}
36+
defer resp.Body.Close()
37+
if resp.StatusCode != http.StatusOK {
38+
t.Fatalf("status = %d", resp.StatusCode)
39+
}
40+
var health DirectHealthResponse
41+
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
42+
t.Fatal(err)
43+
}
44+
if !health.OK || health.SessionID != "sess_bridge" {
45+
t.Fatalf("health = %#v", health)
46+
}
47+
}
48+
49+
func TestStartDirectServerRejectsNonLoopbackAddr(t *testing.T) {
50+
_, err := StartDirectServer(DirectServerOptions{
51+
Addr: "0.0.0.0:0",
52+
Handler: NewDirectHandler(DirectOptions{Manifest: testDirectManifest(t), Registry: testDirectRegistry()}),
53+
})
54+
if err == nil {
55+
t.Fatal("StartDirectServer accepted non-loopback address")
56+
}
57+
}
58+
59+
func TestDirectServerTokenGuard(t *testing.T) {
60+
server, err := StartDirectServer(DirectServerOptions{
61+
Token: "secret",
62+
Handler: NewDirectHandler(DirectOptions{
63+
SessionID: "sess_bridge",
64+
Manifest: testDirectManifest(t),
65+
Registry: testDirectRegistry(),
66+
}),
67+
})
68+
if err != nil {
69+
t.Fatal(err)
70+
}
71+
t.Cleanup(func() {
72+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
73+
defer cancel()
74+
if err := server.Close(ctx); err != nil {
75+
t.Fatalf("close direct server: %v", err)
76+
}
77+
})
78+
resp, err := http.Get(server.URL() + "/health")
79+
if err != nil {
80+
t.Fatal(err)
81+
}
82+
resp.Body.Close()
83+
if resp.StatusCode != http.StatusUnauthorized {
84+
t.Fatalf("unauthorized status = %d", resp.StatusCode)
85+
}
86+
87+
req, err := http.NewRequest(http.MethodGet, server.URL()+"/health", nil)
88+
if err != nil {
89+
t.Fatal(err)
90+
}
91+
req.Header.Set("Authorization", "Bearer secret")
92+
resp, err = http.DefaultClient.Do(req)
93+
if err != nil {
94+
t.Fatal(err)
95+
}
96+
defer resp.Body.Close()
97+
if resp.StatusCode != http.StatusOK {
98+
t.Fatalf("authorized status = %d", resp.StatusCode)
99+
}
100+
}
101+
102+
func TestDirectTokenGuardAcceptsBridgeTokenHeader(t *testing.T) {
103+
server, err := StartDirectServer(DirectServerOptions{
104+
Token: "secret",
105+
Handler: NewDirectHandler(DirectOptions{
106+
SessionID: "sess_bridge",
107+
Manifest: testDirectManifest(t),
108+
Registry: testDirectRegistry(),
109+
}),
110+
})
111+
if err != nil {
112+
t.Fatal(err)
113+
}
114+
t.Cleanup(func() {
115+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
116+
defer cancel()
117+
if err := server.Close(ctx); err != nil {
118+
t.Fatalf("close direct server: %v", err)
119+
}
120+
})
121+
req, err := http.NewRequest(http.MethodGet, server.URL()+"/health", nil)
122+
if err != nil {
123+
t.Fatal(err)
124+
}
125+
req.Header.Set("X-Bridge-Token", "secret")
126+
resp, err := http.DefaultClient.Do(req)
127+
if err != nil {
128+
t.Fatal(err)
129+
}
130+
defer resp.Body.Close()
131+
if resp.StatusCode != http.StatusOK {
132+
t.Fatalf("status = %d", resp.StatusCode)
133+
}
134+
}
135+
136+
func TestValidateLoopbackListenAddr(t *testing.T) {
137+
for _, addr := range []string{"127.0.0.1:0", "localhost:0", "[::1]:0"} {
138+
if err := validateLoopbackListenAddr(addr); err != nil {
139+
t.Fatalf("validateLoopbackListenAddr(%q): %v", addr, err)
140+
}
141+
}
142+
for _, addr := range []string{":0", "0.0.0.0:0", "example.com:80", "not-host-port"} {
143+
if err := validateLoopbackListenAddr(addr); err == nil {
144+
t.Fatalf("validateLoopbackListenAddr(%q) accepted", addr)
145+
}
146+
}
147+
}

0 commit comments

Comments
 (0)