Skip to content

Commit c7bc8c8

Browse files
feat: remote control 支持 auto bind 功能 (#300)
* feat: acp-link 支持 --group 参数指定 channel group - 添加 --group CLI flag,校验格式 [a-zA-Z0-9_-]+ - 支持 ACP_RCS_GROUP 环境变量 fallback - 传递 channelGroupId 到 RcsUpstreamClient - 更新 README 文档说明 --group 和相关环境变量 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: RCS 后端 session 复用与 group 绑定 - storeFindEnvironmentByMachineName 匹配 offline 状态,防止重连创建重复 session - registerEnvironment 复用已有 session 而非每次新建 - EnvironmentResponse 返回 channel_group_id 字段 - 注册时将 session 绑定到 group ID,支持 web UI 按 group 查询 - apiKeyAuth 不再设置 uuid,由 uuidAuth 统一处理 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: Web UI Token Manager — 多 token 切换与 session 隔离 - 新增 useTokens hook 管理 localStorage token CRUD - 新增 TokenManagerDialog 弹窗组件(添加/编辑/删除/切换 token) - api client 支持Bearer token 认证,UUID 跟随 token 变化 - Navbar 添加 token 切换按钮 - 切换 token 时自动 reload,实现 session 数据隔离 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复 useTokens useState 初始化函数签名错误 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 673ccd1 commit c7bc8c8

15 files changed

Lines changed: 508 additions & 22 deletions

File tree

packages/acp-link/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ acp-link --https /path/to/agent
4141
# Disable authentication (dangerous)
4242
acp-link --no-auth /path/to/agent
4343

44+
# Register to RCS with a specific channel group
45+
acp-link --group my-team /path/to/agent
46+
4447
# Pass arguments to the agent (use -- to separate)
4548
acp-link /path/to/agent -- --verbose --model gpt-4
4649
```
@@ -49,7 +52,7 @@ acp-link /path/to/agent -- --verbose --model gpt-4
4952

5053
```
5154
USAGE
52-
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
55+
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] [--group value] <command>...
5356
acp-link --help
5457
acp-link --version
5558
@@ -59,6 +62,7 @@ FLAGS
5962
[--debug] Enable debug logging to file
6063
[--no-auth] Disable authentication (dangerous)
6164
[--https] Enable HTTPS with self-signed cert
65+
[--group] Channel group ID for RCS registration (letters, digits, hyphens, underscores only)
6266
-h --help Print help information and exit
6367
-v --version Print version information and exit
6468
@@ -84,6 +88,18 @@ ws://localhost:9315/ws?token=<your-token>
8488

8589
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
8690

91+
## RCS Upstream
92+
93+
acp-link can register to a Remote Control Server (RCS) for remote access. Set the following environment variables:
94+
95+
| Variable | Description |
96+
|----------|-------------|
97+
| `ACP_RCS_URL` | RCS server URL (e.g. `http://rcs.example.com:3000`) |
98+
| `ACP_RCS_TOKEN` | API token for RCS authentication |
99+
| `ACP_RCS_GROUP` | Channel group ID to lock the agent into (letters, digits, `-`, `_` only) |
100+
101+
You can also use `--group <id>` on the CLI. The CLI flag takes priority over the env var.
102+
87103
## License
88104

89105
MIT

packages/acp-link/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "acp-link",
3-
"version": "1.0.1",
3+
"version": "1.1.0",
44
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
55
"author": "claude-code-best",
66
"type": "module",
@@ -14,7 +14,7 @@
1414
],
1515
"scripts": {
1616
"build": "tsc",
17-
"dev": "bun run src/cli/bin.ts",
17+
"dev": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
1818
"prepublishOnly": "bun run build"
1919
},
2020
"devDependencies": {

packages/acp-link/src/cli/command.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ export const command = buildCommand({
4040
brief: "Enable HTTPS with auto-generated self-signed certificate",
4141
default: false,
4242
},
43+
group: {
44+
kind: "parsed",
45+
parse: (value: string) => {
46+
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
47+
throw new Error(`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`);
48+
}
49+
return value;
50+
},
51+
brief: "Channel group ID for RCS registration (env: ACP_RCS_GROUP)",
52+
optional: true,
53+
},
4354
},
4455
positional: {
4556
kind: "array",
@@ -53,14 +64,15 @@ export const command = buildCommand({
5364
},
5465
func: async function (
5566
this: LocalContext,
56-
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean },
67+
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; group: string | undefined },
5768
...args: readonly string[]
5869
) {
5970
const port = flags.port;
6071
const host = flags.host;
6172
const debug = flags.debug;
6273
const noAuth = flags["no-auth"];
6374
const https = flags.https;
75+
const group = flags.group;
6476
const [command, ...agentArgs] = args;
6577
const cwd = process.cwd();
6678

@@ -85,6 +97,6 @@ export const command = buildCommand({
8597

8698
// Import and run the server
8799
const { startServer } = await import("../server.js");
88-
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https });
100+
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https, group });
89101
},
90102
});

packages/acp-link/src/server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface ServerConfig {
2222
https?: boolean;
2323
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
2424
permissionMode?: string;
25+
/** Channel group ID for RCS registration */
26+
group?: string;
2527
}
2628

2729
// Pending permission request
@@ -608,11 +610,16 @@ export async function startServer(config: ServerConfig): Promise<void> {
608610
// Initialize RCS upstream client if configured
609611
const rcsUrl = process.env.ACP_RCS_URL;
610612
const rcsToken = process.env.ACP_RCS_TOKEN;
613+
const rcsGroup = config.group || process.env.ACP_RCS_GROUP;
614+
if (rcsGroup && !/^[a-zA-Z0-9_-]+$/.test(rcsGroup)) {
615+
throw new Error(`Invalid ACP_RCS_GROUP "${rcsGroup}": only letters, digits, hyphens, and underscores are allowed`);
616+
}
611617
if (rcsUrl) {
612618
rcsUpstream = new RcsUpstreamClient({
613619
rcsUrl,
614620
apiToken: rcsToken || "",
615621
agentName: command,
622+
channelGroupId: rcsGroup || undefined,
616623
maxSessions: 1,
617624
});
618625

packages/remote-control-server/src/auth/middleware.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,20 @@ export function getUuidFromRequest(c: Context): string | undefined {
9090

9191
/**
9292
* UUID-based auth for Web UI routes (no-login mode).
93-
* Requires a UUID in query param or header, injects it into context as c.set("uuid").
93+
* Accepts UUID in query param/header, OR a valid API key via Authorization header.
9494
*/
9595
export async function uuidAuth(c: Context, next: Next) {
96+
// Try API key auth via Authorization header
97+
const bearer = extractBearerToken(c);
98+
if (bearer && validateApiKey(bearer)) {
99+
// Valid API key — generate a stable UUID from the key for downstream use
100+
const uuid = getUuidFromRequest(c);
101+
c.set("uuid", uuid || bearer);
102+
await next();
103+
return;
104+
}
105+
106+
// Fall back to UUID auth
96107
const uuid = getUuidFromRequest(c);
97108
if (!uuid) {
98109
return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401);

packages/remote-control-server/src/routes/v1/environments.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Hono } from "hono";
22
import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment";
33
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
4+
import { storeBindSession } from "../../store";
45

56
const app = new Hono();
67

@@ -9,6 +10,13 @@ app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
910
const body = await c.req.json();
1011
const username = c.get("username");
1112
const result = registerEnvironment({ ...body, username });
13+
// Bind ACP session to the group ID so the web UI can find it by group
14+
if (result.session_id) {
15+
const groupId = body.bridge_id as string | undefined;
16+
if (groupId) {
17+
storeBindSession(result.session_id, groupId);
18+
}
19+
}
1220
return c.json(result, 200);
1321
});
1422

packages/remote-control-server/src/services/environment.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
storeUpdateEnvironment,
77
storeListActiveEnvironments,
88
storeListActiveEnvironmentsByUsername,
9+
storeListSessionsByEnvironment,
910
} from "../store";
1011
import type { RegisterEnvironmentRequest, EnvironmentResponse } from "../types/api";
1112
import type { EnvironmentRecord } from "../store";
@@ -20,6 +21,7 @@ function toResponse(row: EnvironmentRecord): EnvironmentResponse {
2021
username: row.username,
2122
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
2223
worker_type: row.workerType,
24+
channel_group_id: row.bridgeId,
2325
capabilities: row.capabilities,
2426
};
2527
}
@@ -41,14 +43,19 @@ export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata
4143
});
4244

4345
let sessionId: string | undefined;
44-
// ACP agents: auto-create a session so they appear in the dashboard sessions list
46+
// ACP agents: reuse existing session or create one
4547
if (workerType === "acp") {
46-
const session = storeCreateSession({
47-
environmentId: record.id,
48-
title: req.machine_name || "ACP Agent",
49-
source: "acp",
50-
});
51-
sessionId = session.id;
48+
const existing = storeListSessionsByEnvironment(record.id);
49+
if (existing.length > 0) {
50+
sessionId = existing[0].id;
51+
} else {
52+
const session = storeCreateSession({
53+
environmentId: record.id,
54+
title: req.machine_name || "ACP Agent",
55+
source: "acp",
56+
});
57+
sessionId = session.id;
58+
}
5259
}
5360

5461
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId };

packages/remote-control-server/src/store.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,14 @@ export function storeDeleteToken(token: string): boolean {
9898

9999
// ---------- Environment ----------
100100

101-
/** Find an active environment by machineName (optionally filtered by workerType) */
101+
/** Find an active or offline environment by machineName (optionally filtered by workerType).
102+
* Includes "offline" so ACP agents can be reused on reconnect. */
102103
export function storeFindEnvironmentByMachineName(
103104
machineName: string,
104105
workerType?: string,
105106
): EnvironmentRecord | undefined {
106107
for (const rec of environments.values()) {
107-
if (rec.machineName === machineName && rec.status === "active") {
108+
if (rec.machineName === machineName && (rec.status === "active" || rec.status === "offline")) {
108109
if (!workerType || rec.workerType === workerType) {
109110
return rec;
110111
}
@@ -313,12 +314,32 @@ export function storeGetSessionOwners(sessionId: string): Set<string> | undefine
313314

314315
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
315316
const result: SessionRecord[] = [];
317+
const resultIds = new Set<string>();
318+
319+
// Collect sessions already owned by this UUID
316320
for (const [sessionId, owners] of sessionOwners) {
317321
if (owners.has(uuid)) {
318322
const session = sessions.get(sessionId);
319-
if (session) result.push(session);
323+
if (session) {
324+
result.push(session);
325+
resultIds.add(sessionId);
326+
}
327+
}
328+
}
329+
330+
// Auto-bind orphaned sessions (no owner — typically ACP agent sessions created via REST registration)
331+
for (const [sessionId, session] of sessions) {
332+
if (resultIds.has(sessionId)) continue;
333+
const owners = sessionOwners.get(sessionId);
334+
// No owners map entry at all, or empty owners set
335+
const isOrphaned = !owners || owners.size === 0;
336+
if (isOrphaned) {
337+
storeBindSession(sessionId, uuid);
338+
result.push(session);
339+
resultIds.add(sessionId);
320340
}
321341
}
342+
322343
return result;
323344
}
324345

packages/remote-control-server/src/types/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export interface EnvironmentResponse {
107107
username: string | null;
108108
last_poll_at: number | null;
109109
worker_type?: string;
110+
channel_group_id?: string | null;
110111
capabilities?: Record<string, unknown> | null;
111112
}
112113

packages/remote-control-server/web/src/App.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
11
import { useState, useEffect, useCallback, lazy, Suspense } from "react";
22
import { Navbar } from "./components/Navbar";
33
import { IdentityPanel } from "./components/IdentityPanel";
4+
import { TokenManagerDialog } from "./components/TokenManagerDialog";
45
import { ThemeProvider } from "./lib/theme";
5-
import { getUuid, setUuid, apiBind } from "./api/client";
6+
import { getUuid, setUuid, apiBind, setActiveApiToken } from "./api/client";
67
import { ACPDirectView } from "./components/ACPDirectView";
8+
import { useTokens } from "./hooks/useTokens";
79

810
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
911
const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail })));
1012

1113
export default function App() {
1214
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
1315
const [identityOpen, setIdentityOpen] = useState(false);
16+
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
1417
const [acpDirect, setAcpDirect] = useState<{ url: string; token: string } | null>(null);
18+
const { tokens, activeTokenId, activeLabel, activeTokenValue, setActiveTokenId, addToken, removeToken, updateToken } = useTokens();
19+
20+
// Sync active token to API client
21+
useEffect(() => {
22+
setActiveApiToken(activeTokenValue);
23+
}, [activeTokenValue]);
24+
25+
const handleSetActiveToken = useCallback((id: string) => {
26+
setActiveTokenId(id);
27+
}, [setActiveTokenId]);
1528

1629
// Simple hash-based router
1730
const parseRoute = useCallback(() => {
@@ -97,6 +110,8 @@ export default function App() {
97110
<div className="flex h-screen flex-col bg-surface-0 text-text-primary">
98111
<Navbar
99112
onIdentityClick={() => setIdentityOpen(true)}
113+
onTokenClick={() => setTokenDialogOpen(true)}
114+
activeTokenLabel={currentSessionId ? undefined : activeLabel}
100115
sessionTitle={currentSessionId || (acpDirect ? "ACP" : undefined)}
101116
onBack={(currentSessionId || acpDirect) ? navigateToDashboard : undefined}
102117
/>
@@ -114,6 +129,17 @@ export default function App() {
114129
</Suspense>
115130

116131
<IdentityPanel open={identityOpen} onClose={() => setIdentityOpen(false)} />
132+
133+
<TokenManagerDialog
134+
open={tokenDialogOpen}
135+
onClose={() => setTokenDialogOpen(false)}
136+
tokens={tokens}
137+
activeTokenId={activeTokenId}
138+
onSetActive={handleSetActiveToken}
139+
onAdd={addToken}
140+
onRemove={removeToken}
141+
onUpdate={updateToken}
142+
/>
117143
</div>
118144
</ThemeProvider>
119145
);

0 commit comments

Comments
 (0)