Skip to content

Commit 2a3abd4

Browse files
author
SqlRush
committed
Gate remote endpoints by capabilities
1 parent 2cf4f36 commit 2a3abd4

6 files changed

Lines changed: 111 additions & 22 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1488,6 +1488,8 @@ M7 补充:terminal input parser 和 configurable keybinding name parser 现在
14881488

14891489
本轮补充:remote registration 响应会持久化协议版本、能力列表和 lease renew/refresh endpoint,并在 `/status show remote` 中脱敏展示;注册协议版本现在强制校验,只接受空 legacy 版本、`ccr.remote.v1``ccr.remote.v2`,未知版本会把 registration 标为 failed 并清掉可用 endpoint。更完整的云端协议演进策略仍未完成。
14901490

1491+
本轮补充:remote registration 的显式 capabilities/features 会参与 endpoint 启用:非空能力列表缺少 `websocket_protocol` 时忽略 websocket URL,缺少 `lease_renew`/`lease_refresh` 时忽略 renew endpoint,并把 capability warning 写入 registration state 和 `/status show remote`;空能力列表仍按 legacy 兼容处理。更完整的云端能力矩阵仍未完成。
1492+
14911493
本轮补充:daemon remote delivery 会在投递未过期 leased event 前,对注册级同源 lease renew/refresh endpoint 做 best-effort POST,并对 transport error、408/429/5xx 做一次短退避重试,把 renew sent/error 计数写入 pump state、structured result 和 `/status show remote`;完整续期策略和云端协议演进策略仍未完成。
14921494

14931495
本轮补充:remote delivery ack POST 同样会对 transport error、408/429/5xx 做一次短退避重试,保持 delivered/duplicate/failed/expired ack 在瞬时服务端错误下更稳;更完整的远端协议演进策略仍未完成。

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,7 @@ test/parity/ # golden tests against TS/official behavior
747747
- 本轮补充:remote settings 新增 `registrationUrl`/`authToken``advanced.bridge=true` 写出 remote service manifest 后会把 manifest POST 到注册 URL,并将 registered/failed/disabled、HTTP status、远端 session/websocket/poll 信息写入 session-scoped `remote-registration.json``/status show remote` 可审计注册状态且不泄露 token/query。完整 CCR 云端长连接仍未完成。
748748
- 本轮补充:remote registration 响应解析现在会先读取顶层字段,再递归解包 `data``session``remote_session``registration``result``payload` wrapper,兼容云端把 remote session、registration id、websocket/poll endpoint 放在 envelope 内返回。更深的注册协议协商和租约刷新仍未完成。
749749
- 本轮补充:remote registration 响应现在会持久化 `protocolVersion`/`protocol_version``capabilities`/`features``leaseRenewUrl`/`lease_refresh_url` 元数据,并在 `/status show remote` 中以脱敏 URL 暴露协议版本、能力列表和 lease renew endpoint;注册协议版本现在强制校验,只接受空 legacy 版本、`ccr.remote.v1``ccr.remote.v2`,未知版本会把 registration 标为 failed 并清掉可用 endpoint。更完整的云端协议演进策略仍未完成。
750+
- 本轮补充:remote registration 的显式 `capabilities`/`features` 现在会参与 endpoint 启用:非空能力列表缺少 `websocket_protocol` 时会忽略 websocket URL,缺少 `lease_renew`/`lease_refresh` 时会忽略 lease renew endpoint,并把 capability warning 写入 `remote-registration.json``/status show remote`;空能力列表仍按 legacy 兼容处理。更完整的云端能力矩阵和协议演进策略仍未完成。
750751
- 本轮补充:新增 session-scoped `remote-pump.json` 和 daemon remote 消息泵;daemon tick 会在 ScheduleCron due tick 后优先读取 registered `websocket_url`,通过 Bearer auth 建立 WebSocket、读取单帧事件并复用 poll 解码/`RemoteTrigger` 注入路径;无 WebSocket 或 WebSocket 失败且存在 poll URL 时回退到带 cursor 的 poll 拉取。pump 状态会记录 transport、websocket/poll URL 脱敏值、cursor、HTTP status、event/delivered/duplicate/error 计数,`/status show remote` 可审计当前传输。完整 CCR WebSocket 常驻持久 stream 和云端协议 hardening 仍未完成。
751752
- 本轮补充:remote WebSocket pump 现在支持单次 tick 内读取多帧事件,并在握手/读帧失败或非正常 close 时按可配置 backoff 重连;daemon 默认读取最多 8 帧、最多重连 2 次,并把 frame/connect/reconnect 计数和 WebSocket close code 写入 `remote-pump.json``/status show remote`。完整 CCR 云端 WebSocket 常驻持久 stream 与更深协议 hardening 仍未完成。
752753
- 本轮补充:`internal/remote` 新增 callback 型 `StreamWebSocketEvents` primitive,可保持 WebSocket 连接逐帧解码并把事件批次交给调用方,支持 context 取消、可选帧上限、handler 错误传播、异常 close/读错后的 backoff 重连以及 `ReconnectAttempts < 0` 无限重连语义;该能力为 daemon 常驻 stream 托管接线打底。完整云端协议 hardening 仍未完成。

internal/conversation/run.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1855,6 +1855,9 @@ func formatRemoteRegistration(state remotepkg.RegistrationState) []string {
18551855
if state.Error != "" {
18561856
lines = append(lines, "Remote registration error: "+state.Error)
18571857
}
1858+
if len(state.CapabilityWarnings) > 0 {
1859+
lines = append(lines, "Remote registration warnings: "+strings.Join(state.CapabilityWarnings, "; "))
1860+
}
18581861
if state.RegisteredAt != "" {
18591862
lines = append(lines, "Remote registered at: "+state.RegisteredAt)
18601863
}

internal/conversation/run_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2732,6 +2732,21 @@ func TestFormatRemoteRegistrationReportsUnsupportedProtocol(t *testing.T) {
27322732
}
27332733
}
27342734

2735+
func TestFormatRemoteRegistrationReportsCapabilityWarnings(t *testing.T) {
2736+
lines := formatRemoteRegistration(remotepkg.RegistrationState{
2737+
RuntimeState: remotepkg.RegistrationRegistered,
2738+
RemoteSessionID: "remote-limited",
2739+
ProtocolVersion: remotepkg.RemoteProtocolVersionV1,
2740+
Capabilities: []string{"remote_trigger"},
2741+
PollURL: "https://remote.example/poll?token=secret",
2742+
CapabilityWarnings: []string{"websocket url ignored: missing websocket_protocol capability", "lease renew url ignored: missing lease_renew capability"},
2743+
})
2744+
text := strings.Join(lines, "\n")
2745+
if !strings.Contains(text, "Remote registration warnings: websocket url ignored: missing websocket_protocol capability; lease renew url ignored: missing lease_renew capability") || strings.Contains(text, "token=secret") {
2746+
t.Fatalf("registration status = %q", text)
2747+
}
2748+
}
2749+
27352750
func TestRunnerExecutesStatusShowSectionsWithoutQuery(t *testing.T) {
27362751
client := &fakeClient{}
27372752
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())

internal/remote/registration.go

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,24 +34,25 @@ const (
3434
var supportedRemoteProtocolVersions = []string{RemoteProtocolVersionV1, RemoteProtocolVersionV2}
3535

3636
type RegistrationState struct {
37-
SessionID contracts.ID `json:"session_id,omitempty"`
38-
EnvironmentID string `json:"environment_id,omitempty"`
39-
RuntimeState string `json:"runtime_state"`
40-
RegistrationURL string `json:"registration_url,omitempty"`
41-
ManifestPath string `json:"manifest_path,omitempty"`
42-
LastAttemptAt string `json:"last_attempt_at,omitempty"`
43-
RegisteredAt string `json:"registered_at,omitempty"`
44-
StatusCode int `json:"status_code,omitempty"`
45-
RemoteSessionID string `json:"remote_session_id,omitempty"`
46-
RegistrationID string `json:"registration_id,omitempty"`
47-
ProtocolVersion string `json:"protocol_version,omitempty"`
48-
Capabilities []string `json:"capabilities,omitempty"`
49-
WebSocketURL string `json:"websocket_url,omitempty"`
50-
PollURL string `json:"poll_url,omitempty"`
51-
LeaseRenewURL string `json:"lease_renew_url,omitempty"`
52-
Message string `json:"message,omitempty"`
53-
Error string `json:"error,omitempty"`
54-
ManifestServices int `json:"manifest_services,omitempty"`
37+
SessionID contracts.ID `json:"session_id,omitempty"`
38+
EnvironmentID string `json:"environment_id,omitempty"`
39+
RuntimeState string `json:"runtime_state"`
40+
RegistrationURL string `json:"registration_url,omitempty"`
41+
ManifestPath string `json:"manifest_path,omitempty"`
42+
LastAttemptAt string `json:"last_attempt_at,omitempty"`
43+
RegisteredAt string `json:"registered_at,omitempty"`
44+
StatusCode int `json:"status_code,omitempty"`
45+
RemoteSessionID string `json:"remote_session_id,omitempty"`
46+
RegistrationID string `json:"registration_id,omitempty"`
47+
ProtocolVersion string `json:"protocol_version,omitempty"`
48+
Capabilities []string `json:"capabilities,omitempty"`
49+
CapabilityWarnings []string `json:"capability_warnings,omitempty"`
50+
WebSocketURL string `json:"websocket_url,omitempty"`
51+
PollURL string `json:"poll_url,omitempty"`
52+
LeaseRenewURL string `json:"lease_renew_url,omitempty"`
53+
Message string `json:"message,omitempty"`
54+
Error string `json:"error,omitempty"`
55+
ManifestServices int `json:"manifest_services,omitempty"`
5556
}
5657

5758
type RegistrationOptions struct {
@@ -157,6 +158,7 @@ func RegisterManifest(ctx context.Context, options RegistrationOptions) Registra
157158
state.PollURL = ""
158159
state.LeaseRenewURL = ""
159160
}
161+
applyRemoteCapabilityGates(&state)
160162
return state
161163
}
162164

@@ -327,6 +329,40 @@ func validateRemoteProtocolVersion(version string) error {
327329
return fmt.Errorf("unsupported remote protocol version %q; supported: %s", text, strings.Join(supportedRemoteProtocolVersions, ", "))
328330
}
329331

332+
func applyRemoteCapabilityGates(state *RegistrationState) {
333+
if state == nil || len(state.Capabilities) == 0 {
334+
return
335+
}
336+
if strings.TrimSpace(state.WebSocketURL) != "" && !hasRemoteCapability(state.Capabilities, "websocket_protocol", "websocket", "websocket_stream") {
337+
state.WebSocketURL = ""
338+
state.CapabilityWarnings = append(state.CapabilityWarnings, "websocket url ignored: missing websocket_protocol capability")
339+
}
340+
if strings.TrimSpace(state.LeaseRenewURL) != "" && !hasRemoteCapability(state.Capabilities, "lease_renew", "lease_refresh") {
341+
state.LeaseRenewURL = ""
342+
state.CapabilityWarnings = append(state.CapabilityWarnings, "lease renew url ignored: missing lease_renew capability")
343+
}
344+
}
345+
346+
func hasRemoteCapability(capabilities []string, names ...string) bool {
347+
if len(capabilities) == 0 {
348+
return false
349+
}
350+
wanted := make(map[string]bool, len(names))
351+
for _, name := range names {
352+
text := strings.TrimSpace(strings.ToLower(name))
353+
if text != "" {
354+
wanted[text] = true
355+
}
356+
}
357+
for _, capability := range capabilities {
358+
text := strings.TrimSpace(strings.ToLower(capability))
359+
if wanted[text] {
360+
return true
361+
}
362+
}
363+
return false
364+
}
365+
330366
func remoteRegistrationError(status string, body []byte) string {
331367
bodyText := strings.TrimSpace(string(body))
332368
if bodyText == "" {

internal/remote/registration_test.go

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func TestRegisterManifestAcceptsWrappedResponse(t *testing.T) {
7070
"registration":{
7171
"id":"reg-wrapped",
7272
"protocol_version":"ccr.remote.v2",
73-
"features":"remote_trigger lease_refresh",
73+
"features":"websocket_protocol remote_trigger lease_refresh",
7474
"web_socket_url":"wss://remote/wrapped/ws",
7575
"eventsUrl":"https://remote/wrapped/events",
7676
"lease_refresh_url":"https://remote/wrapped/leases/refresh",
@@ -93,13 +93,45 @@ func TestRegisterManifestAcceptsWrappedResponse(t *testing.T) {
9393
if state.RuntimeState != RegistrationRegistered || state.RemoteSessionID != "remote-wrapped" || state.RegistrationID != "reg-wrapped" || state.ProtocolVersion != RemoteProtocolVersionV2 || state.WebSocketURL != "wss://remote/wrapped/ws" || state.PollURL != "https://remote/wrapped/events" || state.LeaseRenewURL != "https://remote/wrapped/leases/refresh" || state.Message != "registered" {
9494
t.Fatalf("registration state = %#v", state)
9595
}
96-
if len(state.Capabilities) != 2 || state.Capabilities[0] != "remote_trigger" || state.Capabilities[1] != "lease_refresh" {
96+
if len(state.Capabilities) != 3 || state.Capabilities[0] != "websocket_protocol" || state.Capabilities[1] != "remote_trigger" || state.Capabilities[2] != "lease_refresh" {
9797
t.Fatalf("registration state = %#v", state)
9898
}
9999
}
100100

101-
func TestRegisterManifestRejectsUnsupportedProtocolVersion(t *testing.T) {
101+
func TestRegisterManifestGatesEndpointsByCapabilities(t *testing.T) {
102102
now := time.Date(2026, 6, 17, 11, 4, 0, 0, time.UTC)
103+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104+
w.Header().Set("content-type", "application/json")
105+
_, _ = w.Write([]byte(`{
106+
"remoteSessionId":"remote-limited",
107+
"protocolVersion":"ccr.remote.v1",
108+
"capabilities":["remote_trigger"],
109+
"websocketUrl":"wss://remote/limited/ws",
110+
"pollUrl":"https://remote/limited/events",
111+
"leaseRenewUrl":"https://remote/limited/leases/renew"
112+
}`))
113+
}))
114+
defer server.Close()
115+
116+
state := RegisterManifest(context.Background(), RegistrationOptions{
117+
RegistrationURL: server.URL + "/register",
118+
Manifest: Manifest{
119+
SessionID: "sess_limited",
120+
EnvironmentID: "env-prod",
121+
Services: []Service{{Name: "daemon", RuntimeState: "running"}},
122+
},
123+
Now: now,
124+
})
125+
if state.RuntimeState != RegistrationRegistered || state.PollURL != "https://remote/limited/events" || state.WebSocketURL != "" || state.LeaseRenewURL != "" {
126+
t.Fatalf("registration state = %#v", state)
127+
}
128+
if len(state.CapabilityWarnings) != 2 || !strings.Contains(state.CapabilityWarnings[0], "websocket url ignored") || !strings.Contains(state.CapabilityWarnings[1], "lease renew url ignored") {
129+
t.Fatalf("capability warnings = %#v", state.CapabilityWarnings)
130+
}
131+
}
132+
133+
func TestRegisterManifestRejectsUnsupportedProtocolVersion(t *testing.T) {
134+
now := time.Date(2026, 6, 17, 11, 5, 0, 0, time.UTC)
103135
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104136
w.Header().Set("content-type", "application/json")
105137
_, _ = w.Write([]byte(`{
@@ -131,7 +163,7 @@ func TestRegisterManifestRejectsUnsupportedProtocolVersion(t *testing.T) {
131163
}
132164

133165
func TestRegisterManifestHandlesDisabledAndFailedState(t *testing.T) {
134-
now := time.Date(2026, 6, 17, 11, 5, 0, 0, time.UTC)
166+
now := time.Date(2026, 6, 17, 11, 6, 0, 0, time.UTC)
135167
manifest := Manifest{SessionID: "sess_remote", EnvironmentID: "env-prod"}
136168
disabled := RegisterManifest(context.Background(), RegistrationOptions{Manifest: manifest, Now: now})
137169
if disabled.RuntimeState != RegistrationDisabled || disabled.Error != "" || disabled.SessionID != "sess_remote" {

0 commit comments

Comments
 (0)