mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25:51 +00:00
feat: 支持自托管的 remote-control-server (#214)
* feat: 支持自托管的 remote-control-server (#214) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
31
packages/remote-control-server/src/routes/v1/environments.ts
Normal file
31
packages/remote-control-server/src/routes/v1/environments.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Hono } from "hono";
|
||||
import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment";
|
||||
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
/** POST /v1/environments/bridge — Register an environment */
|
||||
app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const body = await c.req.json();
|
||||
const username = c.get("username");
|
||||
const result = registerEnvironment({ ...body, username });
|
||||
return c.json(result, 200);
|
||||
});
|
||||
|
||||
/** DELETE /v1/environments/bridge/:id — Deregister */
|
||||
app.delete("/bridge/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const envId = c.req.param("id");
|
||||
deregisterEnvironment(envId);
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
|
||||
/** POST /v1/environments/:id/bridge/reconnect — Reconnect */
|
||||
app.post("/:id/bridge/reconnect", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const envId = c.req.param("id");
|
||||
reconnectEnvironment(envId);
|
||||
const { reconnectWorkForEnvironment } = await import("../../services/work-dispatch");
|
||||
await reconnectWorkForEnvironment(envId);
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Hono } from "hono";
|
||||
import { pollWork, ackWork, stopWork, heartbeatWork } from "../../services/work-dispatch";
|
||||
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
import { updatePollTime } from "../../services/environment";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
/** GET /v1/environments/:id/work/poll — Long-poll for work */
|
||||
app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const envId = c.req.param("id");
|
||||
updatePollTime(envId);
|
||||
const result = await pollWork(envId);
|
||||
if (!result) {
|
||||
// Return 204 No Content so the client's axios parses it as null
|
||||
return c.body(null, 204);
|
||||
}
|
||||
return c.json(result, 200);
|
||||
});
|
||||
|
||||
/** POST /v1/environments/:id/work/:workId/ack — Acknowledge work */
|
||||
app.post("/:id/work/:workId/ack", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const workId = c.req.param("workId");
|
||||
ackWork(workId);
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
|
||||
/** POST /v1/environments/:id/work/:workId/stop — Stop work */
|
||||
app.post("/:id/work/:workId/stop", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const workId = c.req.param("workId");
|
||||
stopWork(workId);
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
|
||||
/** POST /v1/environments/:id/work/:workId/heartbeat — Heartbeat */
|
||||
app.post("/:id/work/:workId/heartbeat", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const workId = c.req.param("workId");
|
||||
const result = heartbeatWork(workId);
|
||||
return c.json(result, 200);
|
||||
});
|
||||
|
||||
export default app;
|
||||
119
packages/remote-control-server/src/routes/v1/session-ingress.ts
Normal file
119
packages/remote-control-server/src/routes/v1/session-ingress.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Hono } from "hono";
|
||||
import { createBunWebSocket } from "hono/bun";
|
||||
import { validateApiKey } from "../../auth/api-key";
|
||||
import { verifyWorkerJwt } from "../../auth/jwt";
|
||||
import {
|
||||
handleWebSocketOpen,
|
||||
handleWebSocketMessage,
|
||||
handleWebSocketClose,
|
||||
ingestBridgeMessage,
|
||||
} from "../../transport/ws-handler";
|
||||
import { getSession } from "../../services/session";
|
||||
|
||||
const { upgradeWebSocket, websocket } = createBunWebSocket();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
/** Authenticate via API key or worker JWT in Authorization header or ?token= query param */
|
||||
function authenticateRequest(c: any, label: string, expectedSessionId?: string): boolean {
|
||||
const authHeader = c.req.header("Authorization");
|
||||
const queryToken = c.req.query("token");
|
||||
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
||||
|
||||
// Try API key first
|
||||
if (validateApiKey(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try JWT verification — validate session_id matches if provided
|
||||
if (token) {
|
||||
const payload = verifyWorkerJwt(token);
|
||||
if (payload) {
|
||||
if (expectedSessionId && payload.session_id !== expectedSessionId) {
|
||||
console.log(`[Auth] ${label}: FAILED — JWT session_id mismatch`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Auth] ${label}: FAILED — no valid API key or JWT`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** POST /v2/session_ingress/session/:sessionId/events — HTTP POST (HybridTransport writes) */
|
||||
app.post("/session/:sessionId/events", async (c) => {
|
||||
const sessionId = c.req.param("sessionId")!;
|
||||
|
||||
if (!authenticateRequest(c, `POST session/${sessionId}`, sessionId)) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Invalid auth" } }, 401);
|
||||
}
|
||||
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const events = Array.isArray(body.events) ? body.events : [body];
|
||||
|
||||
let count = 0;
|
||||
for (const msg of events) {
|
||||
if (!msg || typeof msg !== "object") continue;
|
||||
ingestBridgeMessage(sessionId, msg as Record<string, unknown>);
|
||||
count++;
|
||||
}
|
||||
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
|
||||
/** WS /v2/session_ingress/ws/:sessionId — WebSocket transport */
|
||||
app.get(
|
||||
"/ws/:sessionId",
|
||||
upgradeWebSocket(async (c) => {
|
||||
const sessionId = c.req.param("sessionId")!;
|
||||
|
||||
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
ws.close(4003, "unauthorized");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
console.log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
ws.close(4001, "session not found");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[WS] Upgrade accepted: session=${sessionId}`);
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
handleWebSocketOpen(ws as any, sessionId);
|
||||
},
|
||||
onMessage(evt, ws) {
|
||||
const data =
|
||||
typeof evt.data === "string"
|
||||
? evt.data
|
||||
: new TextDecoder().decode(evt.data as ArrayBuffer);
|
||||
handleWebSocketMessage(ws as any, sessionId, data);
|
||||
},
|
||||
onClose(evt, ws) {
|
||||
const closeEvt = evt as unknown as CloseEvent;
|
||||
handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason);
|
||||
},
|
||||
onError(evt, ws) {
|
||||
console.error(`[WS] Error on session=${sessionId}:`, evt);
|
||||
handleWebSocketClose(ws as any, sessionId, 1006, "websocket error");
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
export { websocket };
|
||||
export default app;
|
||||
85
packages/remote-control-server/src/routes/v1/sessions.ts
Normal file
85
packages/remote-control-server/src/routes/v1/sessions.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
createSession,
|
||||
getSession,
|
||||
updateSessionTitle,
|
||||
archiveSession,
|
||||
} from "../../services/session";
|
||||
import { createWorkItem } from "../../services/work-dispatch";
|
||||
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
import { publishSessionEvent } from "../../services/transport";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
/** POST /v1/sessions — Create session */
|
||||
app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const body = await c.req.json();
|
||||
const username = c.get("username");
|
||||
const session = createSession({ ...body, username });
|
||||
|
||||
// Create work item if environment is specified
|
||||
if (body.environment_id) {
|
||||
try {
|
||||
await createWorkItem(body.environment_id, session.id);
|
||||
} catch (err) {
|
||||
console.error(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Publish initial events if provided
|
||||
if (body.events && Array.isArray(body.events)) {
|
||||
for (const evt of body.events) {
|
||||
publishSessionEvent(session.id, evt.type || "init", evt, "outbound");
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(session, 200);
|
||||
});
|
||||
|
||||
/** GET /v1/sessions/:id — Get session */
|
||||
app.get("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const session = getSession(c.req.param("id"));
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
}
|
||||
return c.json(session, 200);
|
||||
});
|
||||
|
||||
/** PATCH /v1/sessions/:id — Update session title */
|
||||
app.patch("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const body = await c.req.json();
|
||||
if (body.title) {
|
||||
updateSessionTitle(c.req.param("id"), body.title);
|
||||
}
|
||||
const session = getSession(c.req.param("id"));
|
||||
return c.json(session, 200);
|
||||
});
|
||||
|
||||
/** POST /v1/sessions/:id/archive — Archive session */
|
||||
app.post("/:id/archive", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
try {
|
||||
archiveSession(c.req.param("id"));
|
||||
} catch {
|
||||
return c.json({ status: "ok" }, 409);
|
||||
}
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
|
||||
/** POST /v1/sessions/:id/events — Send event to session */
|
||||
app.post("/:id/events", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const sessionId = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
|
||||
const events = body.events
|
||||
? Array.isArray(body.events) ? body.events : [body.events]
|
||||
: Array.isArray(body) ? body : [body];
|
||||
const published = [];
|
||||
for (const evt of events) {
|
||||
const result = publishSessionEvent(sessionId, evt.type || "message", evt, "inbound");
|
||||
published.push(result);
|
||||
}
|
||||
|
||||
return c.json({ status: "ok", events: published.length }, 200);
|
||||
});
|
||||
|
||||
export default app;
|
||||
Reference in New Issue
Block a user