Skip to content

Commit 2cf4f36

Browse files
author
SqlRush
committed
Reject unsupported remote protocols
1 parent 0bac226 commit 2cf4f36

5 files changed

Lines changed: 83 additions & 9 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,11 +1486,11 @@ M7 补充:terminal input parser 和 configurable keybinding name parser 现在
14861486

14871487
当前状态:已有 Task/TaskOutput/KillTask/SendMessage/TeamCreate/TeamDelete/TeamOutput/TeamSendMessage/TeamDispatch/TeamSchedule/TeamAutoSchedule/TeamCoordinate/ResumeTask/Sleep/Brief/ScheduleCron/RemoteTrigger 入口、sidechain metadata/lifecycle、team manifest、schedule manifest、remote trigger receipt manifest、daemon heartbeat CLI/state/status 审计、ScheduleCron manual trigger/run_due/turn-start due tick、team coordinator_task_id 元数据、TeamOutput coordinator status、TeamSendMessage target routing、TeamDispatch individualized assignments、TeamSchedule deterministic member assignments、TeamAutoSchedule coordinator briefing + member assignments、TeamCoordinate coordinator briefing、structured handoff brief、remote trigger injection/event_id dedupe、remote service manifest、remote registrationUrl/authToken POST 注册状态、remote poll URL/cursor 与 websocket_url 多帧/tick 消息泵、WebSocket 基础重连/backoff 与连接计数审计、bridge direct `/remote-trigger`/`/remote-service` HTTP endpoint、WebSocket `remote_trigger`/`remote_status`/`hello`/`health`/`manifest` action、remote_trigger/remote_service/websocket_protocol manifest capability、task progress event、显式与 settings 默认 owned worktree 创建/清理、sparse/symlink settings 应用、`run:true` subagent nested tool loop、agent permission mode 应用、agent tool allowlist registry/permission pattern 过滤,以及 completed/failed/cancelled 终态 owned worktree 自动清理;完整远端 WebSocket 常驻持久 stream、daemon 托管调度循环、多 agent 后台调度循环和模型驱动团队自动调度仍未完成。
14881488

1489-
本轮补充:remote registration 响应会持久化协议版本、能力列表和 lease renew/refresh endpoint,并在 `/status show remote` 中脱敏展示,为后续租约刷新调用和协议兼容判断提供可审计状态;实际 lease renew/refresh 调用和协议版本强制兼容仍未完成
1489+
本轮补充:remote registration 响应会持久化协议版本、能力列表和 lease renew/refresh endpoint,并在 `/status show remote` 中脱敏展示;注册协议版本现在强制校验,只接受空 legacy 版本、`ccr.remote.v1``ccr.remote.v2`,未知版本会把 registration 标为 failed 并清掉可用 endpoint。更完整的云端协议演进策略仍未完成
14901490

1491-
本轮补充: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`完整续期策略和协议版本强制兼容仍未完成
1491+
本轮补充: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`完整续期策略和云端协议演进策略仍未完成
14921492

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

14951495
### M11: Bridge, LSP, Telemetry, Advanced Integrations
14961496

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -746,14 +746,14 @@ test/parity/ # golden tests against TS/official behavior
746746
- 本轮补充:bridge direct server 新增 `GET /remote-service` discovery endpoint 和 WebSocket `remote_status` action,返回同一份 session-scoped remote service manifest;bridge manifest、direct `/manifest` 响应和 `/status show bridge` 同步暴露 `remote_service` capability,远端控制端可通过 HTTP 或已鉴权 WebSocket 查询 bridge/daemon 服务状态。完整 CCR 云端注册和消息泵仍未完成。
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 内返回。更深的注册协议协商和租约刷新仍未完成。
749-
- 本轮补充:remote registration 响应现在会持久化 `protocolVersion`/`protocol_version``capabilities`/`features``leaseRenewUrl`/`lease_refresh_url` 元数据,并在 `/status show remote` 中以脱敏 URL 暴露协议版本、能力列表和 lease renew endpoint,为后续租约刷新调用提供可审计协议 surface。实际 lease renew/refresh 调用和协议版本强制兼容仍未完成
749+
- 本轮补充: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。更完整的云端协议演进策略仍未完成
750750
- 本轮补充:新增 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 仍未完成。
751751
- 本轮补充: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 仍未完成。
752752
- 本轮补充:`internal/remote` 新增 callback 型 `StreamWebSocketEvents` primitive,可保持 WebSocket 连接逐帧解码并把事件批次交给调用方,支持 context 取消、可选帧上限、handler 错误传播、异常 close/读错后的 backoff 重连以及 `ReconnectAttempts < 0` 无限重连语义;该能力为 daemon 常驻 stream 托管接线打底。完整云端协议 hardening 仍未完成。
753753
- 本轮补充:`--daemon` 常驻模式现在会在初始 tick 后启动 remote WebSocket stream goroutine,按 heartbeat 间隔重试注册状态,复用 `StreamWebSocketEvents``RemoteTrigger` delivery/dedupe,把推送事件实时注入 running team,并在 `remote-pump.json` 中持续更新 `websocket_stream` transport、frame/connect/reconnect、delivered/duplicate/error 计数;daemon heartbeat/tick 在已注册 `websocket_url` 时会跳过短 WebSocket 读取,避免 stream 和 tick 双连接/重复写 pump state,poll-only 注册仍走原 tick 路径;daemon stop/context cancel 会取消 stream,并在 pump state 与 `/status show remote` 中记录 stream start/end/stop reason。完整云端协议 hardening 仍未完成。
754754
- 本轮补充:remote poll/WebSocket 共用解码器现在兼容 `data``event``remote_event``delivery``payload` 包裹的单条事件,以及这些 wrapper 下的 `events/items/messages/deliveries` 列表;云端可以用 envelope 协议携带 cursor 和事件内容,而无需强制把事件字段铺在顶层。更深的鉴权刷新、ack/lease 和服务端协议协商仍未完成。
755755
- 本轮补充:remote poll/WebSocket 事件现在会解析 `ack_url`/`ackUrl`/`acknowledge_url`/`receipt_url``lease_id`/`lease_expires_at``ack`/`lease` nested object 元数据;daemon 会在 delivered/duplicate/failed 后对注册 poll/websocket 同源的 ack URL 做 best-effort POST,带 Bearer auth 和 event/status/sent_count/duplicate/error payload,并对 transport error、408/429/5xx 做一次短退避重试;pump state、structured result 和 `/status show remote` 会记录 ack event/sent/error 与 lease event 计数;非同源 ack URL 会被拒绝且脱敏;已过期 lease 会被跳过投递并 ack `expired`,同时记录 `lease_expired_count`。更深的服务端协议协商仍未完成。
756-
- 本轮补充:daemon remote delivery 现在会对带 `lease_id` 且未过期的事件,在投递前向注册响应提供的同源 `lease_renew_url`/`lease_refresh_url` 做 best-effort POST,携带 Bearer auth、event_id 和 lease_id;renew 对 transport error、408/429/5xx 做一次短退避重试,成功/失败计数会写入 `remote-pump.json`、structured result 和 `/status show remote`完整租约续期策略和协议版本强制兼容仍未完成
756+
- 本轮补充:daemon remote delivery 现在会对带 `lease_id` 且未过期的事件,在投递前向注册响应提供的同源 `lease_renew_url`/`lease_refresh_url` 做 best-effort POST,携带 Bearer auth、event_id 和 lease_id;renew 对 transport error、408/429/5xx 做一次短退避重试,成功/失败计数会写入 `remote-pump.json`、structured result 和 `/status show remote`完整租约续期策略和云端协议演进策略仍未完成
757757

758758
### M11: Bridge 和高级集成
759759

internal/conversation/run_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2718,6 +2718,20 @@ func TestRunnerWritesGatedIntegrationsManifest(t *testing.T) {
27182718
}
27192719
}
27202720

2721+
func TestFormatRemoteRegistrationReportsUnsupportedProtocol(t *testing.T) {
2722+
lines := formatRemoteRegistration(remotepkg.RegistrationState{
2723+
RuntimeState: remotepkg.RegistrationFailed,
2724+
StatusCode: http.StatusOK,
2725+
RemoteSessionID: "remote-future",
2726+
ProtocolVersion: "ccr.remote.v99",
2727+
Error: "unsupported remote protocol version \"ccr.remote.v99\"; supported: ccr.remote.v1, ccr.remote.v2",
2728+
})
2729+
text := strings.Join(lines, "\n")
2730+
if !strings.Contains(text, "Remote registration: failed: status 200: remote session remote-future: protocol ccr.remote.v99") || !strings.Contains(text, "unsupported remote protocol version") {
2731+
t.Fatalf("registration status = %q", text)
2732+
}
2733+
}
2734+
27212735
func TestRunnerExecutesStatusShowSectionsWithoutQuery(t *testing.T) {
27222736
client := &fakeClient{}
27232737
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())

internal/remote/registration.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ const (
2626
RegistrationFailed = "failed"
2727
)
2828

29+
const (
30+
RemoteProtocolVersionV1 = "ccr.remote.v1"
31+
RemoteProtocolVersionV2 = "ccr.remote.v2"
32+
)
33+
34+
var supportedRemoteProtocolVersions = []string{RemoteProtocolVersionV1, RemoteProtocolVersionV2}
35+
2936
type RegistrationState struct {
3037
SessionID contracts.ID `json:"session_id,omitempty"`
3138
EnvironmentID string `json:"environment_id,omitempty"`
@@ -142,6 +149,14 @@ func RegisterManifest(ctx context.Context, options RegistrationOptions) Registra
142149
state.RuntimeState = RegistrationRegistered
143150
state.RegisteredAt = now.UTC().Format(time.RFC3339Nano)
144151
applyRegistrationResponse(&state, body)
152+
if err := validateRemoteProtocolVersion(state.ProtocolVersion); err != nil {
153+
state.RuntimeState = RegistrationFailed
154+
state.RegisteredAt = ""
155+
state.Error = err.Error()
156+
state.WebSocketURL = ""
157+
state.PollURL = ""
158+
state.LeaseRenewURL = ""
159+
}
145160
return state
146161
}
147162

@@ -299,6 +314,19 @@ func cleanStringList(values []string) []string {
299314
return cleaned
300315
}
301316

317+
func validateRemoteProtocolVersion(version string) error {
318+
text := strings.TrimSpace(version)
319+
if text == "" {
320+
return nil
321+
}
322+
for _, supported := range supportedRemoteProtocolVersions {
323+
if text == supported {
324+
return nil
325+
}
326+
}
327+
return fmt.Errorf("unsupported remote protocol version %q; supported: %s", text, strings.Join(supportedRemoteProtocolVersions, ", "))
328+
}
329+
302330
func remoteRegistrationError(status string, body []byte) string {
303331
bodyText := strings.TrimSpace(string(body))
304332
if bodyText == "" {

internal/remote/registration_test.go

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func TestRegisterManifestPostsServiceManifest(t *testing.T) {
4242
},
4343
Now: now,
4444
})
45-
if state.RuntimeState != RegistrationRegistered || state.StatusCode != http.StatusOK || state.RemoteSessionID != "remote-sess" || state.RegistrationID != "reg-1" || state.ProtocolVersion != "ccr.remote.v1" || state.WebSocketURL != "wss://remote/ws" || state.PollURL != "https://remote/events" || state.LeaseRenewURL != "https://remote/leases/renew?token=secret" {
45+
if state.RuntimeState != RegistrationRegistered || state.StatusCode != http.StatusOK || state.RemoteSessionID != "remote-sess" || state.RegistrationID != "reg-1" || state.ProtocolVersion != RemoteProtocolVersionV1 || state.WebSocketURL != "wss://remote/ws" || state.PollURL != "https://remote/events" || state.LeaseRenewURL != "https://remote/leases/renew?token=secret" {
4646
t.Fatalf("registration state = %#v", state)
4747
}
4848
if len(state.Capabilities) != 2 || state.Capabilities[0] != "websocket_protocol" || state.Capabilities[1] != "lease_renew" {
@@ -90,14 +90,46 @@ func TestRegisterManifestAcceptsWrappedResponse(t *testing.T) {
9090
},
9191
Now: now,
9292
})
93-
if state.RuntimeState != RegistrationRegistered || state.RemoteSessionID != "remote-wrapped" || state.RegistrationID != "reg-wrapped" || state.ProtocolVersion != "ccr.remote.v2" || state.WebSocketURL != "wss://remote/wrapped/ws" || state.PollURL != "https://remote/wrapped/events" || state.LeaseRenewURL != "https://remote/wrapped/leases/refresh" || state.Message != "registered" {
93+
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
}
9696
if len(state.Capabilities) != 2 || state.Capabilities[0] != "remote_trigger" || state.Capabilities[1] != "lease_refresh" {
9797
t.Fatalf("registration state = %#v", state)
9898
}
9999
}
100100

101+
func TestRegisterManifestRejectsUnsupportedProtocolVersion(t *testing.T) {
102+
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-future",
107+
"protocolVersion":"ccr.remote.v99",
108+
"capabilities":["websocket_protocol"],
109+
"websocketUrl":"wss://remote/future/ws?token=secret",
110+
"pollUrl":"https://remote/future/events?token=secret",
111+
"leaseRenewUrl":"https://remote/future/leases/renew?token=secret"
112+
}`))
113+
}))
114+
defer server.Close()
115+
116+
state := RegisterManifest(context.Background(), RegistrationOptions{
117+
RegistrationURL: server.URL + "/register",
118+
Manifest: Manifest{
119+
SessionID: "sess_future",
120+
EnvironmentID: "env-prod",
121+
Services: []Service{{Name: "daemon", RuntimeState: "running"}},
122+
},
123+
Now: now,
124+
})
125+
if state.RuntimeState != RegistrationFailed || state.StatusCode != http.StatusOK || state.ProtocolVersion != "ccr.remote.v99" || !strings.Contains(state.Error, "unsupported remote protocol version") || !strings.Contains(state.Error, RemoteProtocolVersionV1) || !strings.Contains(state.Error, RemoteProtocolVersionV2) {
126+
t.Fatalf("registration state = %#v", state)
127+
}
128+
if state.RegisteredAt != "" || state.WebSocketURL != "" || state.PollURL != "" || state.LeaseRenewURL != "" || strings.Contains(state.Error, "token=secret") {
129+
t.Fatalf("unsupported state leaked usable endpoint or secret = %#v", state)
130+
}
131+
}
132+
101133
func TestRegisterManifestHandlesDisabledAndFailedState(t *testing.T) {
102134
now := time.Date(2026, 6, 17, 11, 5, 0, 0, time.UTC)
103135
manifest := Manifest{SessionID: "sess_remote", EnvironmentID: "env-prod"}
@@ -118,7 +150,7 @@ func TestRegisterManifestHandlesDisabledAndFailedState(t *testing.T) {
118150
func TestWriteAndLoadRegistrationState(t *testing.T) {
119151
path := filepath.Join(t.TempDir(), "sess_remote", registrationFileName)
120152
state := DisabledRegistrationState(Manifest{SessionID: "sess_remote"}, "/state/remote-service.json", time.Date(2026, 6, 17, 11, 10, 0, 0, time.UTC))
121-
state.ProtocolVersion = "ccr.remote.v1"
153+
state.ProtocolVersion = RemoteProtocolVersionV1
122154
state.Capabilities = []string{"websocket_protocol", "lease_renew"}
123155
state.LeaseRenewURL = "https://remote/leases/renew"
124156
if err := WriteRegistrationState(path, state); err != nil {
@@ -128,7 +160,7 @@ func TestWriteAndLoadRegistrationState(t *testing.T) {
128160
if err != nil {
129161
t.Fatal(err)
130162
}
131-
if loaded.SessionID != "sess_remote" || loaded.RuntimeState != RegistrationDisabled || loaded.ManifestPath != "/state/remote-service.json" || loaded.ProtocolVersion != "ccr.remote.v1" || loaded.LeaseRenewURL != "https://remote/leases/renew" || len(loaded.Capabilities) != 2 {
163+
if loaded.SessionID != "sess_remote" || loaded.RuntimeState != RegistrationDisabled || loaded.ManifestPath != "/state/remote-service.json" || loaded.ProtocolVersion != RemoteProtocolVersionV1 || loaded.LeaseRenewURL != "https://remote/leases/renew" || len(loaded.Capabilities) != 2 {
132164
t.Fatalf("loaded = %#v", loaded)
133165
}
134166
missing, err := LoadRegistrationState(filepath.Join(t.TempDir(), registrationFileName))

0 commit comments

Comments
 (0)