diff --git a/packages/acp-link/README.md b/packages/acp-link/README.md index 5a3c9d9df..7cd00496a 100644 --- a/packages/acp-link/README.md +++ b/packages/acp-link/README.md @@ -41,6 +41,9 @@ acp-link --https /path/to/agent # Disable authentication (dangerous) acp-link --no-auth /path/to/agent +# Register to RCS with a specific channel group +acp-link --group my-team /path/to/agent + # Pass arguments to the agent (use -- to separate) acp-link /path/to/agent -- --verbose --model gpt-4 ``` @@ -49,7 +52,7 @@ acp-link /path/to/agent -- --verbose --model gpt-4 ``` USAGE - acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] ... + acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] [--group value] ... acp-link --help acp-link --version @@ -59,6 +62,7 @@ FLAGS [--debug] Enable debug logging to file [--no-auth] Disable authentication (dangerous) [--https] Enable HTTPS with self-signed cert + [--group] Channel group ID for RCS registration (letters, digits, hyphens, underscores only) -h --help Print help information and exit -v --version Print version information and exit @@ -84,6 +88,18 @@ ws://localhost:9315/ws?token= Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended). +## RCS Upstream + +acp-link can register to a Remote Control Server (RCS) for remote access. Set the following environment variables: + +| Variable | Description | +|----------|-------------| +| `ACP_RCS_URL` | RCS server URL (e.g. `http://rcs.example.com:3000`) | +| `ACP_RCS_TOKEN` | API token for RCS authentication | +| `ACP_RCS_GROUP` | Channel group ID to lock the agent into (letters, digits, `-`, `_` only) | + +You can also use `--group ` on the CLI. The CLI flag takes priority over the env var. + ## License MIT diff --git a/packages/acp-link/package.json b/packages/acp-link/package.json index 050f54fb5..6eb538467 100644 --- a/packages/acp-link/package.json +++ b/packages/acp-link/package.json @@ -1,6 +1,6 @@ { "name": "acp-link", - "version": "1.0.1", + "version": "1.1.0", "description": "ACP proxy server that bridges WebSocket clients to ACP agents", "author": "claude-code-best", "type": "module", @@ -14,7 +14,7 @@ ], "scripts": { "build": "tsc", - "dev": "bun run src/cli/bin.ts", + "dev": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp", "prepublishOnly": "bun run build" }, "devDependencies": { diff --git a/packages/acp-link/src/cli/command.ts b/packages/acp-link/src/cli/command.ts index a144f8084..4df19cb13 100644 --- a/packages/acp-link/src/cli/command.ts +++ b/packages/acp-link/src/cli/command.ts @@ -40,6 +40,17 @@ export const command = buildCommand({ brief: "Enable HTTPS with auto-generated self-signed certificate", default: false, }, + group: { + kind: "parsed", + parse: (value: string) => { + if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + throw new Error(`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`); + } + return value; + }, + brief: "Channel group ID for RCS registration (env: ACP_RCS_GROUP)", + optional: true, + }, }, positional: { kind: "array", @@ -53,7 +64,7 @@ export const command = buildCommand({ }, func: async function ( this: LocalContext, - flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean }, + flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; group: string | undefined }, ...args: readonly string[] ) { const port = flags.port; @@ -61,6 +72,7 @@ export const command = buildCommand({ const debug = flags.debug; const noAuth = flags["no-auth"]; const https = flags.https; + const group = flags.group; const [command, ...agentArgs] = args; const cwd = process.cwd(); @@ -85,6 +97,6 @@ export const command = buildCommand({ // Import and run the server const { startServer } = await import("../server.js"); - await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https }); + await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https, group }); }, }); diff --git a/packages/acp-link/src/server.ts b/packages/acp-link/src/server.ts index f239eacd9..421bb771e 100644 --- a/packages/acp-link/src/server.ts +++ b/packages/acp-link/src/server.ts @@ -22,6 +22,8 @@ export interface ServerConfig { https?: boolean; /** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */ permissionMode?: string; + /** Channel group ID for RCS registration */ + group?: string; } // Pending permission request @@ -608,11 +610,16 @@ export async function startServer(config: ServerConfig): Promise { // Initialize RCS upstream client if configured const rcsUrl = process.env.ACP_RCS_URL; const rcsToken = process.env.ACP_RCS_TOKEN; + const rcsGroup = config.group || process.env.ACP_RCS_GROUP; + if (rcsGroup && !/^[a-zA-Z0-9_-]+$/.test(rcsGroup)) { + throw new Error(`Invalid ACP_RCS_GROUP "${rcsGroup}": only letters, digits, hyphens, and underscores are allowed`); + } if (rcsUrl) { rcsUpstream = new RcsUpstreamClient({ rcsUrl, apiToken: rcsToken || "", agentName: command, + channelGroupId: rcsGroup || undefined, maxSessions: 1, }); diff --git a/packages/remote-control-server/src/auth/middleware.ts b/packages/remote-control-server/src/auth/middleware.ts index 970589a75..283ff16e4 100644 --- a/packages/remote-control-server/src/auth/middleware.ts +++ b/packages/remote-control-server/src/auth/middleware.ts @@ -90,9 +90,20 @@ export function getUuidFromRequest(c: Context): string | undefined { /** * UUID-based auth for Web UI routes (no-login mode). - * Requires a UUID in query param or header, injects it into context as c.set("uuid"). + * Accepts UUID in query param/header, OR a valid API key via Authorization header. */ export async function uuidAuth(c: Context, next: Next) { + // Try API key auth via Authorization header + const bearer = extractBearerToken(c); + if (bearer && validateApiKey(bearer)) { + // Valid API key — generate a stable UUID from the key for downstream use + const uuid = getUuidFromRequest(c); + c.set("uuid", uuid || bearer); + await next(); + return; + } + + // Fall back to UUID auth const uuid = getUuidFromRequest(c); if (!uuid) { return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401); diff --git a/packages/remote-control-server/src/routes/v1/environments.ts b/packages/remote-control-server/src/routes/v1/environments.ts index c812906ee..c3a84183c 100644 --- a/packages/remote-control-server/src/routes/v1/environments.ts +++ b/packages/remote-control-server/src/routes/v1/environments.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment"; import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware"; +import { storeBindSession } from "../../store"; const app = new Hono(); @@ -9,6 +10,13 @@ app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => { const body = await c.req.json(); const username = c.get("username"); const result = registerEnvironment({ ...body, username }); + // Bind ACP session to the group ID so the web UI can find it by group + if (result.session_id) { + const groupId = body.bridge_id as string | undefined; + if (groupId) { + storeBindSession(result.session_id, groupId); + } + } return c.json(result, 200); }); diff --git a/packages/remote-control-server/src/services/environment.ts b/packages/remote-control-server/src/services/environment.ts index fc127c170..6b57006c3 100644 --- a/packages/remote-control-server/src/services/environment.ts +++ b/packages/remote-control-server/src/services/environment.ts @@ -6,6 +6,7 @@ import { storeUpdateEnvironment, storeListActiveEnvironments, storeListActiveEnvironmentsByUsername, + storeListSessionsByEnvironment, } from "../store"; import type { RegisterEnvironmentRequest, EnvironmentResponse } from "../types/api"; import type { EnvironmentRecord } from "../store"; @@ -20,6 +21,7 @@ function toResponse(row: EnvironmentRecord): EnvironmentResponse { username: row.username, last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null, worker_type: row.workerType, + channel_group_id: row.bridgeId, capabilities: row.capabilities, }; } @@ -41,14 +43,19 @@ export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata }); let sessionId: string | undefined; - // ACP agents: auto-create a session so they appear in the dashboard sessions list + // ACP agents: reuse existing session or create one if (workerType === "acp") { - const session = storeCreateSession({ - environmentId: record.id, - title: req.machine_name || "ACP Agent", - source: "acp", - }); - sessionId = session.id; + const existing = storeListSessionsByEnvironment(record.id); + if (existing.length > 0) { + sessionId = existing[0].id; + } else { + const session = storeCreateSession({ + environmentId: record.id, + title: req.machine_name || "ACP Agent", + source: "acp", + }); + sessionId = session.id; + } } return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId }; diff --git a/packages/remote-control-server/src/store.ts b/packages/remote-control-server/src/store.ts index 8f212023d..c52cd9b59 100644 --- a/packages/remote-control-server/src/store.ts +++ b/packages/remote-control-server/src/store.ts @@ -98,13 +98,14 @@ export function storeDeleteToken(token: string): boolean { // ---------- Environment ---------- -/** Find an active environment by machineName (optionally filtered by workerType) */ +/** Find an active or offline environment by machineName (optionally filtered by workerType). + * Includes "offline" so ACP agents can be reused on reconnect. */ export function storeFindEnvironmentByMachineName( machineName: string, workerType?: string, ): EnvironmentRecord | undefined { for (const rec of environments.values()) { - if (rec.machineName === machineName && rec.status === "active") { + if (rec.machineName === machineName && (rec.status === "active" || rec.status === "offline")) { if (!workerType || rec.workerType === workerType) { return rec; } @@ -313,12 +314,32 @@ export function storeGetSessionOwners(sessionId: string): Set | undefine export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] { const result: SessionRecord[] = []; + const resultIds = new Set(); + + // Collect sessions already owned by this UUID for (const [sessionId, owners] of sessionOwners) { if (owners.has(uuid)) { const session = sessions.get(sessionId); - if (session) result.push(session); + if (session) { + result.push(session); + resultIds.add(sessionId); + } } } + + // Auto-bind orphaned sessions (no owner — typically ACP agent sessions created via REST registration) + for (const [sessionId, session] of sessions) { + if (resultIds.has(sessionId)) continue; + const owners = sessionOwners.get(sessionId); + // No owners map entry at all, or empty owners set + const isOrphaned = !owners || owners.size === 0; + if (isOrphaned) { + storeBindSession(sessionId, uuid); + result.push(session); + resultIds.add(sessionId); + } + } + return result; } diff --git a/packages/remote-control-server/src/types/api.ts b/packages/remote-control-server/src/types/api.ts index 72a0063b2..af3d456c5 100644 --- a/packages/remote-control-server/src/types/api.ts +++ b/packages/remote-control-server/src/types/api.ts @@ -107,6 +107,7 @@ export interface EnvironmentResponse { username: string | null; last_poll_at: number | null; worker_type?: string; + channel_group_id?: string | null; capabilities?: Record | null; } diff --git a/packages/remote-control-server/web/src/App.tsx b/packages/remote-control-server/web/src/App.tsx index 6ab8c1692..f95a0c952 100644 --- a/packages/remote-control-server/web/src/App.tsx +++ b/packages/remote-control-server/web/src/App.tsx @@ -1,9 +1,11 @@ import { useState, useEffect, useCallback, lazy, Suspense } from "react"; import { Navbar } from "./components/Navbar"; import { IdentityPanel } from "./components/IdentityPanel"; +import { TokenManagerDialog } from "./components/TokenManagerDialog"; import { ThemeProvider } from "./lib/theme"; -import { getUuid, setUuid, apiBind } from "./api/client"; +import { getUuid, setUuid, apiBind, setActiveApiToken } from "./api/client"; import { ACPDirectView } from "./components/ACPDirectView"; +import { useTokens } from "./hooks/useTokens"; const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard }))); const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail }))); @@ -11,7 +13,18 @@ const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({ export default function App() { const [currentSessionId, setCurrentSessionId] = useState(null); const [identityOpen, setIdentityOpen] = useState(false); + const [tokenDialogOpen, setTokenDialogOpen] = useState(false); const [acpDirect, setAcpDirect] = useState<{ url: string; token: string } | null>(null); + const { tokens, activeTokenId, activeLabel, activeTokenValue, setActiveTokenId, addToken, removeToken, updateToken } = useTokens(); + + // Sync active token to API client + useEffect(() => { + setActiveApiToken(activeTokenValue); + }, [activeTokenValue]); + + const handleSetActiveToken = useCallback((id: string) => { + setActiveTokenId(id); + }, [setActiveTokenId]); // Simple hash-based router const parseRoute = useCallback(() => { @@ -97,6 +110,8 @@ export default function App() {
setIdentityOpen(true)} + onTokenClick={() => setTokenDialogOpen(true)} + activeTokenLabel={currentSessionId ? undefined : activeLabel} sessionTitle={currentSessionId || (acpDirect ? "ACP" : undefined)} onBack={(currentSessionId || acpDirect) ? navigateToDashboard : undefined} /> @@ -114,6 +129,17 @@ export default function App() { setIdentityOpen(false)} /> + + setTokenDialogOpen(false)} + tokens={tokens} + activeTokenId={activeTokenId} + onSetActive={handleSetActiveToken} + onAdd={addToken} + onRemove={removeToken} + onUpdate={updateToken} + />
); diff --git a/packages/remote-control-server/web/src/api/client.ts b/packages/remote-control-server/web/src/api/client.ts index 7ab6d9853..c13e17df5 100644 --- a/packages/remote-control-server/web/src/api/client.ts +++ b/packages/remote-control-server/web/src/api/client.ts @@ -24,11 +24,35 @@ export function setUuid(uuid: string): void { localStorage.setItem("rcs_uuid", uuid); } +/** Active API token for Authorization header (set by useTokens) */ +let _activeToken: string | null = null; + +export function setActiveApiToken(token: string | null): void { + _activeToken = token; +} + +export function getActiveApiToken(): string | null { + return _activeToken; +} + async function api(method: string, path: string, body?: unknown): Promise { const headers: Record = { "Content-Type": "application/json" }; - const uuid = getUuid(); - const sep = path.includes("?") ? "&" : "?"; - const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`; + + if (_activeToken) { + headers["Authorization"] = `Bearer ${_activeToken}`; + } + + // When using Bearer token auth, backend derives UUID from the token — no need to send query param. + // Otherwise fall back to UUID auth via query param. + let url: string; + if (_activeToken) { + const sep = path.includes("?") ? "&" : "?"; + url = `${BASE}${path}${sep}uuid=${encodeURIComponent(_activeToken)}`; + } else { + const uuid = getUuid(); + const sep = path.includes("?") ? "&" : "?"; + url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`; + } const opts: RequestInit = { method, headers }; if (body !== undefined) opts.body = JSON.stringify(body); diff --git a/packages/remote-control-server/web/src/components/Navbar.tsx b/packages/remote-control-server/web/src/components/Navbar.tsx index 35455ca4a..152814135 100644 --- a/packages/remote-control-server/web/src/components/Navbar.tsx +++ b/packages/remote-control-server/web/src/components/Navbar.tsx @@ -1,14 +1,16 @@ import { cn } from "../lib/utils"; import { ThemeToggle } from "../../components/ui/theme-toggle"; -import { ChevronLeft, LayoutGrid, UserPlus } from "lucide-react"; +import { ChevronLeft, LayoutGrid, UserPlus, KeyRound } from "lucide-react"; interface NavbarProps { onIdentityClick: () => void; + onTokenClick: () => void; + activeTokenLabel?: string | null; sessionTitle?: string; onBack?: () => void; } -export function Navbar({ onIdentityClick, sessionTitle, onBack }: NavbarProps) { +export function Navbar({ onIdentityClick, onTokenClick, activeTokenLabel, sessionTitle, onBack }: NavbarProps) { return (