Skip to content

Commit 8b39581

Browse files
solthxchengzifeng
andauthored
fix(remote-control): harden self-hosted session flows (claude-code-best#278)
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
1 parent 42b4081 commit 8b39581

24 files changed

Lines changed: 1249 additions & 159 deletions

docs/features/remote-control-self-hosting.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,19 @@ bun run dist/cli.js
138138
/remote-control
139139
```
140140

141-
CLI 会向 RCS 注册环境,注册成功后在终端显示连接 URL:
141+
环境型 Remote Control(例如 `claude remote-control` 子命令)会向 RCS 注册环境,注册成功后在终端显示连接 URL:
142142

143143
```
144144
https://rcs.example.com/code?bridge=<environmentId>
145145
```
146146

147-
同时支持 QR 码扫码打开。该 URL 即 Web UI 控制面板入口,在浏览器中打开即可远程操控当前会话。
147+
交互式 REPL 方式(`--remote-control``/remote-control`)在某些桥接模式下也可能直接给出会话 URL:
148+
149+
```
150+
https://rcs.example.com/code/session_<id>
151+
```
152+
153+
两种 URL 都可以直接在浏览器打开并远程操控当前会话;只有 environment 模式才会出现在 Web UI 的环境列表中。
148154

149155
若已连接,再次执行 `/remote-control` 会显示对话框,包含以下选项:
150156
- **Disconnect this session** — 断开远程连接
@@ -165,7 +171,7 @@ claude bridge
165171

166172
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能:
167173

168-
- 查看已注册的运行环境
174+
- 查看已注册的运行环境(environment 模式)
169175
- 创建和管理会话
170176
- 实时查看对话消息和工具调用
171177
- 审批 Claude Code 的工具权限请求
@@ -275,4 +281,3 @@ curl https://rcs.example.com/health
275281
| 依赖 | claude.ai 订阅 + OAuth | 仅需 API Key |
276282

277283
自托管模式的核心优势是:设置 `CLAUDE_BRIDGE_BASE_URL` 后,代码自动调用 `isSelfHostedBridge()` 返回 `true`,跳过所有 GrowthBook 和订阅检查,无需 claude.ai 账户即可使用。
278-

packages/remote-control-server/src/__tests__/disconnect-monitor.test.ts

Lines changed: 41 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,18 @@ import {
2525
storeUpdateSession,
2626
storeGetEnvironment,
2727
storeGetSession,
28-
storeListActiveEnvironments,
2928
} from "../store";
29+
import { getEventBus, getAllEventBuses, removeEventBus } from "../transport/event-bus";
30+
import { runDisconnectMonitorSweep } from "../services/disconnect-monitor";
3031

3132
describe("Disconnect Monitor Logic", () => {
3233
beforeEach(() => {
3334
storeReset();
35+
for (const [key] of getAllEventBuses()) {
36+
removeEventBus(key);
37+
}
3438
});
3539

36-
// Test the logic directly rather than the interval-based monitor
37-
// to avoid long-running tests with timers
38-
3940
test("environment times out when lastPollAt is too old", () => {
4041
const env = storeCreateEnvironment({ secret: "s" });
4142
const timeoutMs = 300 * 1000; // 5 minutes
@@ -44,31 +45,15 @@ describe("Disconnect Monitor Logic", () => {
4445
const oldDate = new Date(Date.now() - timeoutMs - 60000);
4546
storeUpdateEnvironment(env.id, { lastPollAt: oldDate });
4647

47-
// Check the timeout logic (same as in disconnect-monitor.ts)
48-
const now = Date.now();
49-
const envs = storeListActiveEnvironments();
50-
for (const e of envs) {
51-
if (e.lastPollAt && now - e.lastPollAt.getTime() > timeoutMs) {
52-
storeUpdateEnvironment(e.id, { status: "disconnected" });
53-
}
54-
}
48+
runDisconnectMonitorSweep();
5549

5650
const updated = storeGetEnvironment(env.id);
5751
expect(updated?.status).toBe("disconnected");
5852
});
5953

6054
test("environment stays active when lastPollAt is recent", () => {
6155
const env = storeCreateEnvironment({ secret: "s" });
62-
const timeoutMs = 300 * 1000;
63-
64-
// lastPollAt is recent (just created)
65-
const now = Date.now();
66-
const envs = storeListActiveEnvironments();
67-
for (const e of envs) {
68-
if (e.lastPollAt && now - e.lastPollAt.getTime() > timeoutMs) {
69-
storeUpdateEnvironment(e.id, { status: "disconnected" });
70-
}
71-
}
56+
runDisconnectMonitorSweep();
7257

7358
const updated = storeGetEnvironment(env.id);
7459
expect(updated?.status).toBe("active");
@@ -77,25 +62,47 @@ describe("Disconnect Monitor Logic", () => {
7762
test("session becomes inactive when updatedAt is too old", () => {
7863
const session = storeCreateSession({});
7964
storeUpdateSession(session.id, { status: "running" });
80-
const timeoutMs = 300 * 1000 * 2; // 2x disconnect timeout
81-
82-
// Simulate updatedAt being older than 2x timeout
83-
// We can't directly set updatedAt, but we can verify the logic
84-
// by checking that recently updated sessions are not marked inactive
85-
const now = Date.now();
8665
const rec = storeGetSession(session.id);
87-
// Session was just updated, should not be inactive
88-
expect(rec?.status).toBe("running");
89-
expect(now - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
66+
expect(rec).toBeTruthy();
67+
if (!rec) return;
68+
69+
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000);
70+
71+
runDisconnectMonitorSweep();
72+
73+
const updated = storeGetSession(session.id);
74+
expect(updated?.status).toBe("inactive");
9075
});
9176

9277
test("session stays running when recently updated", () => {
9378
const session = storeCreateSession({});
9479
storeUpdateSession(session.id, { status: "running" });
9580

96-
const timeoutMs = 300 * 1000 * 2;
81+
runDisconnectMonitorSweep();
82+
83+
const updated = storeGetSession(session.id);
84+
expect(updated?.status).toBe("running");
85+
});
86+
87+
test("session timeout publishes an inactive session_status event", () => {
88+
const session = storeCreateSession({});
89+
storeUpdateSession(session.id, { status: "idle" });
9790
const rec = storeGetSession(session.id);
98-
expect(rec?.status).toBe("running");
99-
expect(Date.now() - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
91+
expect(rec).toBeTruthy();
92+
if (!rec) return;
93+
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000);
94+
95+
const bus = getEventBus(session.id);
96+
const events: Array<{ type: string; payload: { status?: string } }> = [];
97+
bus.subscribe((event) => {
98+
events.push({ type: event.type, payload: event.payload as { status?: string } });
99+
});
100+
101+
runDisconnectMonitorSweep();
102+
103+
expect(events).toContainEqual({
104+
type: "session_status",
105+
payload: { status: "inactive" },
106+
});
100107
});
101108
});

0 commit comments

Comments
 (0)